222dcaebb3
- 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
322 lines
12 KiB
GDScript
322 lines
12 KiB
GDScript
## 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
|