Files
tactical-shooter/server/scripts/anticheat/anti_cheat.gd
T
shawn 222dcaebb3 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
2026-07-01 20:37:20 -04:00

322 lines
12 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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