From 222dcaebb3ba2167f10dc214ccc77692457635dc Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 1 Jul 2026 20:37:20 -0400 Subject: [PATCH] t_p4_anticheat: add basic server-side anti-cheat controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/scripts/anticheat/anti_cheat.gd | 321 +++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 server/scripts/anticheat/anti_cheat.gd diff --git a/server/scripts/anticheat/anti_cheat.gd b/server/scripts/anticheat/anti_cheat.gd new file mode 100644 index 0000000..a7bf183 --- /dev/null +++ b/server/scripts/anticheat/anti_cheat.gd @@ -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