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:
2026-07-01 20:37:20 -04:00
parent 0aa29d33e4
commit 222dcaebb3
+321
View File
@@ -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