t_p4_anticheat: add basic server-side anti-cheat controller
- Rate limiting: flags players exceeding 130Hz input RPC rate - Speed hacks: validates position deltas against max speed * 1.5 - Aimbot snap: flags look-direction changes >45° in one tick - Suspicion level tracking (0.0-1.0) with decay over time - Escalating thresholds: 0.3=warn(log), 0.6=kick(signal), 0.8=ban(signal, 1h temp) - Configurable max speed via set_max_speed() - Ban handler Callable for caller-defined disconnect logic - Design: generous tolerances, near-zero false positives, Phase-4 basic hardening
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
## AntiCheat — Server-side anti-cheat controller.
|
||||
##
|
||||
## Provides basic integrity checks against obvious exploits:
|
||||
## - Rate limiting (input flood / RPC spam above 130 Hz)
|
||||
## - Speed hacks (position deltas exceeding max movement × 1.5)
|
||||
## - Aimbot snap (look-direction changes >45° within one tick)
|
||||
##
|
||||
## Suspicion accumulates per player (0.0 – 1.0). Escalating actions:
|
||||
## 0.3 → warn (server log)
|
||||
## 0.6 → kick (disconnect)
|
||||
## 0.8 → ban (temp ban, 1 hour)
|
||||
##
|
||||
## Design philosophy:
|
||||
## - All checks use generous tolerances (1.5× – 2× expected max) to account
|
||||
## for network jitter and edge cases.
|
||||
## - When in doubt, do NOT flag. False-positive rate must be near zero.
|
||||
## - This is Phase-4 basic hardening, NOT a competitive anti-cheat.
|
||||
##
|
||||
## Usage (inside GameServer._ready()):
|
||||
## var ac = AntiCheat.new()
|
||||
## add_child(ac)
|
||||
## ac.set_max_speed(ServerConfig.movement_sprint_speed)
|
||||
## # Wire a ban handler:
|
||||
## ac.ban_handler = func(p_id, reason):
|
||||
## multiplayer.disconnect_peer(p_id)
|
||||
##
|
||||
## Receive reports from gameplay systems:
|
||||
## ac.report_input_rate(player_id, 150.0)
|
||||
## ac.report_movement(player_id, Vector3(12, 0, 0), 1)
|
||||
## ac.report_look_snap(player_id, from_dir, to_dir)
|
||||
|
||||
class_name AntiCheat
|
||||
extends Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Emitted when a player's suspicion level changes.
|
||||
signal player_suspicion_changed(player_id: int, level: float)
|
||||
|
||||
## Emitted when a player is kicked for anti-cheat violations.
|
||||
## The caller (GameServer) should disconnect the peer.
|
||||
signal player_kicked(player_id: int, reason: String)
|
||||
|
||||
## Emitted when a player is temp-banned for anti-cheat violations.
|
||||
## The caller should enforce the ban (disconnect + track expiry).
|
||||
signal player_banned(player_id: int, reason: String)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants — thresholds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Maximum input RPC rate (Hz). Margin above the 128 Hz sim tick.
|
||||
const DEFAULT_RATE_LIMIT_HZ: float = 130.0
|
||||
|
||||
## Tolerance multiplier applied to expected max movement speed.
|
||||
const SPEED_TOLERANCE: float = 1.5
|
||||
|
||||
## Maximum allowed look-angle change per tick, in degrees.
|
||||
const AIMBOT_SNAP_DEG: float = 45.0
|
||||
|
||||
## Default maximum player speed (sprint speed from default config).
|
||||
const DEFAULT_MAX_SPEED: float = 7.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants — suspicion values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Suspicion added per rate-limit violation.
|
||||
const SUS_INPUT_RATE: float = 0.08
|
||||
|
||||
## Suspicion added per speed-hack violation.
|
||||
const SUS_SPEED_HACK: float = 0.12
|
||||
|
||||
## Suspicion added per aimbot snap.
|
||||
const SUS_AIMBOT: float = 0.10
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants — thresholds & timing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const WARN_THRESHOLD: float = 0.3
|
||||
const KICK_THRESHOLD: float = 0.6
|
||||
const BAN_THRESHOLD: float = 0.8
|
||||
|
||||
## Suspicion decay amount per DECAY_INTERVAL seconds (rewards clean play).
|
||||
const DECAY_AMOUNT: float = 0.02
|
||||
|
||||
## How often (seconds) suspicion decays for clean players.
|
||||
const DECAY_INTERVAL: float = 2.0
|
||||
|
||||
## Temporary ban duration in seconds (1 hour).
|
||||
const BAN_DURATION: float = 3600.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Max player speed in units/sec. Set via set_max_speed() to match server
|
||||
## config. Defaults to DEFAULT_MAX_SPEED.
|
||||
var _max_speed: float = DEFAULT_MAX_SPEED
|
||||
|
||||
## Current suspicion per player, 0.0 – 1.0.
|
||||
var _suspicion: Dictionary = {} # player_id (int) → level (float)
|
||||
|
||||
## Tracks which players have already triggered each action level.
|
||||
var _warned: Dictionary = {} # player_id (int) → true
|
||||
var _kicked: Dictionary = {} # player_id (int) → true
|
||||
var _banned: Dictionary = {} # player_id (int) → ban_expiry_time (float)
|
||||
|
||||
## Accumulator for suspicion decay timer.
|
||||
var _decay_timer: float = 0.0
|
||||
|
||||
## Optional kick/ban handler Callable. If set, it is invoked with
|
||||
## (player_id, reason) when a player is kicked or banned. The caller is
|
||||
## responsible for actually disconnecting or tracking the ban.
|
||||
var ban_handler: Callable = Callable()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
pass
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_decay_timer += delta
|
||||
if _decay_timer >= DECAY_INTERVAL:
|
||||
_decay_timer = 0.0
|
||||
_decay_suspicion()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Set the max player speed (should match the server's sprint speed or
|
||||
## the fastest any legitimate player can move).
|
||||
func set_max_speed(speed: float) -> void:
|
||||
_max_speed = max(0.1, speed)
|
||||
|
||||
## Return the current max-speed value used for speed-hack checks.
|
||||
func get_max_speed() -> float:
|
||||
return _max_speed
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — report methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Report a player's input RPC rate. Flags if rate_hz exceeds the limit.
|
||||
func report_input_rate(player_id: int, rate_hz: float) -> void:
|
||||
if _is_banned(player_id):
|
||||
return
|
||||
|
||||
if rate_hz > DEFAULT_RATE_LIMIT_HZ:
|
||||
_add_suspicion(
|
||||
player_id,
|
||||
SUS_INPUT_RATE,
|
||||
"Input rate %.1f Hz exceeds limit (max %.1f Hz)" % [rate_hz, DEFAULT_RATE_LIMIT_HZ]
|
||||
)
|
||||
|
||||
|
||||
## Report a player's position delta over a given number of ticks.
|
||||
## delta_pos: the Vector3 displacement since the last valid report.
|
||||
## tick_delta: number of simulation ticks over which the movement occurred
|
||||
## (typically 1 for per-tick checks, larger for infrequent reports).
|
||||
func report_movement(player_id: int, delta_pos: Vector3, tick_delta: int) -> void:
|
||||
if _is_banned(player_id):
|
||||
return
|
||||
|
||||
if tick_delta <= 0:
|
||||
return
|
||||
|
||||
var distance: float = delta_pos.length()
|
||||
var tick_time: float = 1.0 / 128.0 # simulation tick duration
|
||||
## Max distance a player could legitimately travel in tick_delta ticks.
|
||||
var max_expected: float = _max_speed * SPEED_TOLERANCE * tick_time * tick_delta
|
||||
|
||||
if distance > max_expected:
|
||||
_add_suspicion(
|
||||
player_id,
|
||||
SUS_SPEED_HACK,
|
||||
"Movement %.2f units exceeds expected max %.2f (%d ticks)" %
|
||||
[distance, max_expected, tick_delta]
|
||||
)
|
||||
|
||||
|
||||
## Report a player's look-direction snap.
|
||||
## from: the previous tick's look direction (normalised Vector3).
|
||||
## to: the current tick's look direction (normalised Vector3).
|
||||
## Flags if the angle between them exceeds AIMBOT_SNAP_DEG.
|
||||
func report_look_snap(player_id: int, from: Vector3, to: Vector3) -> void:
|
||||
if _is_banned(player_id):
|
||||
return
|
||||
|
||||
# Avoid normalisation issues with zero vectors
|
||||
var f_len_sq: float = from.length_squared()
|
||||
var t_len_sq: float = to.length_squared()
|
||||
if f_len_sq < 0.0001 or t_len_sq < 0.0001:
|
||||
return
|
||||
|
||||
var dot: float = from.dot(to) / (sqrt(f_len_sq) * sqrt(t_len_sq))
|
||||
var angle_deg: float = rad_to_deg(acos(clampf(dot, -1.0, 1.0)))
|
||||
|
||||
if angle_deg > AIMBOT_SNAP_DEG:
|
||||
_add_suspicion(
|
||||
player_id,
|
||||
SUS_AIMBOT,
|
||||
"Look snap %.1f° exceeds max %.1f°" % [angle_deg, AIMBOT_SNAP_DEG]
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — state queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Return the current suspicion level for a player (0.0 – 1.0).
|
||||
func get_suspicion(player_id: int) -> float:
|
||||
return _suspicion.get(player_id, 0.0)
|
||||
|
||||
## Return true if the player is currently banned (ban still in effect).
|
||||
func is_banned(player_id: int) -> bool:
|
||||
return _is_banned(player_id)
|
||||
|
||||
## Remove all tracking data for a player (on disconnect / map change).
|
||||
func unregister_player(player_id: int) -> void:
|
||||
_suspicion.erase(player_id)
|
||||
_warned.erase(player_id)
|
||||
_kicked.erase(player_id)
|
||||
# NOTE: _banned entries persist so reconnecting within the ban window
|
||||
# still rejects. Use clear_bans() on map change or explicit unban.
|
||||
|
||||
## Clear all ban records (e.g., on server restart or map change).
|
||||
func clear_bans() -> void:
|
||||
_banned.clear()
|
||||
|
||||
## Clear all suspicion tracking (full reset).
|
||||
func reset_all() -> void:
|
||||
_suspicion.clear()
|
||||
_warned.clear()
|
||||
_kicked.clear()
|
||||
_banned.clear()
|
||||
_decay_timer = 0.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal — suspicion management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Add suspicion for a player and take action if thresholds are crossed.
|
||||
## reason is a human-readable string for log/kick/ban messages.
|
||||
func _add_suspicion(player_id: int, amount: float, reason: String) -> void:
|
||||
var current: float = _suspicion.get(player_id, 0.0)
|
||||
current = min(1.0, current + amount)
|
||||
_suspicion[player_id] = current
|
||||
|
||||
player_suspicion_changed.emit(player_id, current)
|
||||
|
||||
# Check thresholds in ascending order — only act on the highest newly
|
||||
# reached threshold to avoid spam (no re-warn on same violation).
|
||||
if current >= BAN_THRESHOLD and not _banned.has(player_id):
|
||||
_apply_ban(player_id, reason)
|
||||
elif current >= KICK_THRESHOLD and not _kicked.has(player_id):
|
||||
_apply_kick(player_id, reason)
|
||||
elif current >= WARN_THRESHOLD and not _warned.has(player_id):
|
||||
_apply_warn(player_id, reason)
|
||||
|
||||
|
||||
func _apply_warn(player_id: int, reason: String) -> void:
|
||||
_warned[player_id] = true
|
||||
var msg: String = "[AntiCheat] WARN — player %d: %s" % [player_id, reason]
|
||||
print(msg)
|
||||
# No disconnect; just a server-side log warning.
|
||||
|
||||
|
||||
func _apply_kick(player_id: int, reason: String) -> void:
|
||||
_kicked[player_id] = true
|
||||
var msg: String = "[AntiCheat] KICK — player %d: %s" % [player_id, reason]
|
||||
print(msg)
|
||||
player_kicked.emit(player_id, reason)
|
||||
|
||||
if ban_handler.is_valid():
|
||||
ban_handler.call(player_id, reason)
|
||||
|
||||
|
||||
func _apply_ban(player_id: int, reason: String) -> void:
|
||||
var expiry: float = Time.get_unix_time_from_system() + BAN_DURATION
|
||||
_banned[player_id] = expiry
|
||||
var msg: String = "[AntiCheat] BAN — player %d: %s (until UNIX %d)" % \
|
||||
[player_id, reason, int(expiry)]
|
||||
print(msg)
|
||||
player_banned.emit(player_id, reason)
|
||||
|
||||
if ban_handler.is_valid():
|
||||
ban_handler.call(player_id, reason)
|
||||
|
||||
|
||||
## Decay suspicion for all non-banned players.
|
||||
func _decay_suspicion() -> void:
|
||||
for player_id in _suspicion.keys():
|
||||
if _is_banned(player_id):
|
||||
continue
|
||||
var current: float = _suspicion[player_id] - DECAY_AMOUNT
|
||||
if current <= 0.0:
|
||||
_suspicion.erase(player_id)
|
||||
_warned.erase(player_id)
|
||||
# Keep _kicked so they don't get a second chance immediately
|
||||
# after decay; only explicit unregister resets kicks.
|
||||
else:
|
||||
_suspicion[player_id] = current
|
||||
player_suspicion_changed.emit(player_id, current)
|
||||
|
||||
|
||||
## Check if a player is currently banned (ban recorded and still in effect).
|
||||
func _is_banned(player_id: int) -> bool:
|
||||
if not _banned.has(player_id):
|
||||
return false
|
||||
var expiry: float = _banned[player_id]
|
||||
if Time.get_unix_time_from_system() >= expiry:
|
||||
# Ban expired — clean up
|
||||
_banned.erase(player_id)
|
||||
return false
|
||||
return true
|
||||
Reference in New Issue
Block a user