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