Phase 7: netfox + godot-jolt stack upgrade
Stack installed: - netfox v1.35.3 (core + extras + noray + internals) - godot-jolt v0.16.0-stable Architecture: - Server: ENet transport (works headless, no netfox deps) - Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator) New/modified: - docs/migration-netfox-plan.md — migration architecture - scripts/network/network_manager.gd — netfox-aware ENet fallback - scripts/network/player.gd — clean base player - client/characters/player_netfox.gd — rollback player w/ WeaponManager - client/characters/input/player_net_input.gd — BaseNetInput subclass - client/characters/character/fps_character_controller.gd — netfox input feed - client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager - client/scripts/round_replicator.gd — client-side round state bridge - server/scripts/round_manager.gd — improved state machine - server/scripts/plugin_api/plugin_manager.gd — refined plugin system - config: enemy_tag, ally_tag for meatball targeting Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
This commit is contained in:
@@ -0,0 +1,677 @@
|
||||
extends Node
|
||||
class_name AntiCheat
|
||||
|
||||
# ═════════════════════════════════════════════════════════
|
||||
# Server-authoritative anti-cheat validation coordinator
|
||||
#
|
||||
# Per-player state tracking and input validation pipeline.
|
||||
# Called from the server's _physics_process per player per tick.
|
||||
#
|
||||
# Validation pipeline order:
|
||||
# 1. Input sequence (timestamp monotonic, seq# dedup)
|
||||
# 2. Movement (speed limit, teleport, multi-jump)
|
||||
# 3. Aim (max snap rotation per tick)
|
||||
# 4. Fire rate (interval between shots, ammo consistency)
|
||||
# 5. Command rate (commands/second limit)
|
||||
# 6. Enforcement (log / correct / warn / kick)
|
||||
#
|
||||
# Designed to be loaded once on the server and called per tick
|
||||
# from the game's player state update loop.
|
||||
# ═════════════════════════════════════════════════════════
|
||||
|
||||
# ─── Enforcement Levels ──────────────────────────────────
|
||||
enum AcLevel {
|
||||
OFF = 0, # Disabled — no validation performed
|
||||
LOG = 1, # Log violations to server console only
|
||||
CORRECT = 2, # Correct invalid input + warn player (default)
|
||||
KICK = 3, # Kick player on threshold breach
|
||||
}
|
||||
|
||||
# ─── Violation Categories ────────────────────────────────
|
||||
enum ViolationCategory {
|
||||
INPUT_SEQUENCE, # Bad sequence / replay / timestamp
|
||||
MOVEMENT_SPEED, # Exceeded speed limit
|
||||
MOVEMENT_TELEPORT, # Instant position change
|
||||
MOVEMENT_MULTI_JUMP, # Jump without ground contact
|
||||
AIM_SNAP, # View-angle delta exceeds limit
|
||||
FIRE_RATE_INTERVAL, # Shots too close together
|
||||
FIRE_RATE_AMMO, # Ammo delta inconsistent with shot
|
||||
COMMAND_RATE, # > max_commands/sec from client
|
||||
}
|
||||
|
||||
# ─── Cvars (exported for inspector; override via config system) ──
|
||||
|
||||
## Enforcement level (0=off, 1=log, 2=correct, 3=kick)
|
||||
@export_range(0, 3, 1) var sv_ac_level: int = 2
|
||||
|
||||
## Speed multiplier before flagging (1.2 = 20% over max)
|
||||
@export_range(1.0, 3.0, 0.05) var sv_ac_speed_tolerance: float = 1.2
|
||||
|
||||
## Max view-angle delta in degrees per tick
|
||||
@export_range(10.0, 360.0, 1.0) var sv_ac_max_aim_snap: float = 180.0
|
||||
|
||||
## Fire interval tolerance in ms above weapon's fire rate
|
||||
@export_range(0, 100, 1) var sv_ac_fire_tolerance_ms: int = 10
|
||||
|
||||
## Max commands from one client per second
|
||||
@export_range(64, 254, 1) var sv_ac_command_rate_max: int = 128
|
||||
|
||||
## Max distance units per tick before flagged as teleport
|
||||
@export_range(10.0, 500.0, 1.0) var sv_ac_teleport_threshold: float = 100.0
|
||||
|
||||
## Violations before kick (only at AcLevel.KICK)
|
||||
@export_range(1, 50, 1) var sv_ac_kick_threshold: int = 5
|
||||
|
||||
## Enable/disable multi-jump check
|
||||
@export var sv_ac_check_multi_jump: bool = true
|
||||
|
||||
## Enable/disable ammo consistency check
|
||||
@export var sv_ac_check_ammo: bool = true
|
||||
|
||||
# ─── Signals ─────────────────────────────────────────────
|
||||
|
||||
## Emitted when a violation triggers a kick decision.
|
||||
signal player_kicked(peer_id: int, reason: String, violation_count: int)
|
||||
|
||||
## Emitted on every validated violation (at all enforcement levels).
|
||||
signal violation_recorded(peer_id: int, category: int, details: String)
|
||||
|
||||
## Emitted when the anti-cheat corrects an input field.
|
||||
signal input_corrected(peer_id: int, field: String, original_value, clamped_value)
|
||||
|
||||
# ─── Per-Player Tracked State ────────────────────────────
|
||||
|
||||
class PlayerState:
|
||||
var peer_id: int
|
||||
var last_seq: int = -1
|
||||
var last_timestamp: float = 0.0
|
||||
var last_position: Vector3 = Vector3.ZERO
|
||||
var last_view_angles: Vector2 = Vector2.ZERO
|
||||
var last_fire_time: float = 0.0
|
||||
var last_ammo: int = -1
|
||||
var on_ground: bool = true # server-authoritative (set via set_ground_contact)
|
||||
var jump_count: int = 0 # consecutive airborne jumps
|
||||
var last_fire_pressed: bool = false
|
||||
var _aim_initialized: bool = false # has aim been recorded at least once?
|
||||
var command_times: Array[float] = []
|
||||
var violation_count: int = 0
|
||||
var violations_log: Array[Dictionary] = []
|
||||
var connected_at: float = 0.0
|
||||
|
||||
func _init(id: int, time: float):
|
||||
peer_id = id
|
||||
connected_at = time
|
||||
|
||||
func reset(time: float) -> void:
|
||||
last_seq = -1
|
||||
last_timestamp = 0.0
|
||||
last_position = Vector3.ZERO
|
||||
last_view_angles = Vector2.ZERO
|
||||
last_fire_time = 0.0
|
||||
last_ammo = -1
|
||||
on_ground = true
|
||||
jump_count = 0
|
||||
last_fire_pressed = false
|
||||
_aim_initialized = false
|
||||
command_times.clear()
|
||||
violation_count = 0
|
||||
violations_log.clear()
|
||||
connected_at = time
|
||||
|
||||
# ─── Internal State ──────────────────────────────────────
|
||||
|
||||
var _player_states: Dictionary = {} # peer_id → PlayerState
|
||||
|
||||
# ─── Public API ──────────────────────────────────────────
|
||||
|
||||
func register_player(peer_id: int) -> void:
|
||||
"""Register a new player for anti-cheat tracking.
|
||||
|
||||
Call when a player fully connects to the server.
|
||||
The player's state is initialized fresh.
|
||||
"""
|
||||
var state = PlayerState.new(peer_id, Time.get_ticks_usec() / 1_000_000.0)
|
||||
_player_states[peer_id] = state
|
||||
|
||||
|
||||
func unregister_player(peer_id: int) -> void:
|
||||
"""Remove a player from anti-cheat tracking.
|
||||
|
||||
Call on player disconnect to clean up state.
|
||||
"""
|
||||
_player_states.erase(peer_id)
|
||||
|
||||
|
||||
func is_tracking(peer_id: int) -> bool:
|
||||
"""Returns true if peer_id is being tracked."""
|
||||
return _player_states.has(peer_id)
|
||||
|
||||
|
||||
func validate_input(peer_id: int, input_packet: Dictionary, delta: float) -> Dictionary:
|
||||
"""Run full validation pipeline on a player's input packet.
|
||||
|
||||
Parameters:
|
||||
peer_id: Network peer ID of the player
|
||||
input_packet: Dict with keys:
|
||||
seq (int) — monotonic sequence number
|
||||
time (float) — client timestamp (seconds)
|
||||
movement (Vector3) — movement direction
|
||||
view_angles (Vector2) — camera pitch/yaw
|
||||
buttons (int) — bitmask: 1=jump, 2=fire, 4=crouch, 8=use
|
||||
ammo (int) — current weapon ammo (-1 if unknown/melee)
|
||||
position (Vector3) — player world position
|
||||
max_speed (float, optional) — current max player speed
|
||||
weapon_fire_rate_ms (float, optional) — current fire interval
|
||||
burst_count (int, optional) — ammo per burst (default: 1)
|
||||
delta: Physics tick delta (e.g. 1/128 ≈ 0.0078)
|
||||
|
||||
Integration note:
|
||||
After calling validate_input() and applying corrected input
|
||||
to physics, call set_ground_contact(peer_id, on_ground) to
|
||||
furnish server-authoritative ground-state for the next tick's
|
||||
multi-jump validation.
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
corrected (Dictionary) — cleaned input packet (may have clamped fields)
|
||||
violations (Array[Dictionary]) — violations detected this tick
|
||||
action_taken (String) — "" / "warned" / "kicked"
|
||||
"""
|
||||
var result = {
|
||||
"corrected": input_packet.duplicate(),
|
||||
"violations": [],
|
||||
"action_taken": "",
|
||||
}
|
||||
|
||||
if sv_ac_level == AcLevel.OFF or not _player_states.has(peer_id):
|
||||
return result
|
||||
|
||||
var state: PlayerState = _player_states[peer_id]
|
||||
|
||||
# ── Pipeline ──
|
||||
|
||||
# 1. Input sequence validation (always runs)
|
||||
_result_seq(_validate_sequence(input_packet, state, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# 2. Movement validation
|
||||
_result_mv(_validate_movement(input_packet, state, delta, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# 3. Aim validation
|
||||
_result_aim(_validate_aim(input_packet, state, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# 4. Fire rate validation
|
||||
_result_fire(_validate_fire_rate(input_packet, state, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# 5. Command rate validation
|
||||
_result_cmd(_validate_command_rate(input_packet, state), peer_id, state, result)
|
||||
|
||||
# 6. Ammo validation
|
||||
if sv_ac_check_ammo:
|
||||
_result_ammo(_validate_ammo(input_packet, state, result["corrected"]), peer_id, state, result)
|
||||
|
||||
# ── Enforcement (post-pipeline) ──
|
||||
if state.violation_count > 0 and result["violations"].size() > 0:
|
||||
# Set warned flag at any level above OFF
|
||||
if sv_ac_level >= AcLevel.CORRECT:
|
||||
result["action_taken"] = "warned"
|
||||
|
||||
# Kick decision — at KICK level when threshold breached
|
||||
if sv_ac_level >= AcLevel.KICK and state.violation_count >= sv_ac_kick_threshold:
|
||||
var reason: String = "Anti-cheat violation threshold reached (%d/%d)" % [
|
||||
state.violation_count, sv_ac_kick_threshold
|
||||
]
|
||||
player_kicked.emit(peer_id, reason, state.violation_count)
|
||||
result["action_taken"] = "kicked"
|
||||
|
||||
# ── Post-validation bookkeeping ──
|
||||
if input_packet.has("position") and typeof(input_packet.position) == TYPE_VECTOR3:
|
||||
state.last_position = result["corrected"].get("position", input_packet.position)
|
||||
|
||||
if input_packet.has("time"):
|
||||
state.last_timestamp = input_packet.time
|
||||
|
||||
if input_packet.has("view_angles"):
|
||||
state.last_view_angles = result["corrected"].get("view_angles", input_packet.view_angles)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func get_player_violations(peer_id: int) -> Array:
|
||||
"""Return a copy of the violation log for a player."""
|
||||
if not _player_states.has(peer_id):
|
||||
return []
|
||||
return _player_states[peer_id].violations_log.duplicate()
|
||||
|
||||
|
||||
func get_player_violation_count(peer_id: int) -> int:
|
||||
"""Return the total violation count for a player."""
|
||||
if not _player_states.has(peer_id):
|
||||
return 0
|
||||
return _player_states[peer_id].violation_count
|
||||
|
||||
|
||||
func reset_player_violations(peer_id: int) -> void:
|
||||
"""Reset violation counter for a player (e.g. after admin pardon)."""
|
||||
if _player_states.has(peer_id):
|
||||
var s: PlayerState = _player_states[peer_id]
|
||||
s.violation_count = 0
|
||||
s.violations_log.clear()
|
||||
|
||||
|
||||
func clear_all() -> void:
|
||||
"""Remove all tracked players."""
|
||||
_player_states.clear()
|
||||
|
||||
|
||||
func set_ground_contact(peer_id: int, on_ground: bool) -> void:
|
||||
"""Set the server-authoritative ground-contact state for a player.
|
||||
|
||||
Must be called by the game server AFTER physics simulation each tick,
|
||||
so the anti-cheat uses authoritative ground state for multi-jump
|
||||
detection on the next tick.
|
||||
"""
|
||||
if _player_states.has(peer_id):
|
||||
_player_states[peer_id].on_ground = on_ground
|
||||
|
||||
|
||||
# ─── Individual Validators ───────────────────────────────
|
||||
|
||||
func _validate_sequence(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
|
||||
"""Check sequence monotonicity and timestamp monotonicity.
|
||||
|
||||
Returns violation Dictionaries or null.
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
var seq: int = packet.get("seq", -1)
|
||||
var time: float = packet.get("time", 0.0)
|
||||
|
||||
# First packet always accepted
|
||||
if state.last_seq == -1:
|
||||
state.last_seq = seq
|
||||
state.last_timestamp = time
|
||||
return {"violations": violations, "invalid": false}
|
||||
|
||||
var invalid := false
|
||||
|
||||
# Sequence must increase monotonically
|
||||
if seq <= state.last_seq:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.INPUT_SEQUENCE,
|
||||
"Non-monotonic sequence: %d → %d" % [state.last_seq, seq],
|
||||
))
|
||||
invalid = true
|
||||
|
||||
# Timestamp must increase monotonically
|
||||
if time < state.last_timestamp:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.INPUT_SEQUENCE,
|
||||
"Timestamp regression: %.4f → %.4f" % [state.last_timestamp, time],
|
||||
))
|
||||
invalid = true
|
||||
|
||||
# Update state on valid (or lightly correct)
|
||||
if not invalid:
|
||||
state.last_seq = seq
|
||||
state.last_timestamp = time
|
||||
else:
|
||||
# Correct: bump seq forward so we don't accumulate stale state
|
||||
if seq <= state.last_seq:
|
||||
corrected["seq"] = state.last_seq + 1
|
||||
|
||||
return {"violations": violations, "invalid": invalid}
|
||||
|
||||
|
||||
func _validate_movement(packet: Dictionary, state: PlayerState, delta: float, corrected: Dictionary) -> Dictionary:
|
||||
"""Check speed limit, teleport, and multi-jump.
|
||||
|
||||
The player's max speed is defined by the movement controller.
|
||||
We use a configurable tolerance multiplier.
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
if not packet.has("position") or typeof(packet.position) != TYPE_VECTOR3:
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var pos: Vector3 = packet.position
|
||||
var prev_pos: Vector3 = state.last_position
|
||||
|
||||
var displacement: Vector3 = pos - prev_pos
|
||||
var distance: float = displacement.length()
|
||||
|
||||
# ── Teleport check ──
|
||||
if distance > sv_ac_teleport_threshold and state.last_seq != -1:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.MOVEMENT_TELEPORT,
|
||||
"Teleport: moved %.1f units in one tick (threshold %.1f)" % [distance, sv_ac_teleport_threshold],
|
||||
{"distance": distance, "threshold": sv_ac_teleport_threshold},
|
||||
))
|
||||
# Correct: reset position to previous
|
||||
corrected["position"] = prev_pos
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
# ── Speed check ──
|
||||
# Max speed varies by weapon/game state; use a configurable tolerance
|
||||
# over the assumed base speed. The actual game should pass its max_speed
|
||||
# to validate_input(...) or we use a cvars-driven estimate.
|
||||
var max_speed_units_per_sec: float = _resolve_max_speed(packet)
|
||||
var max_speed_this_tick: float = max_speed_units_per_sec * sv_ac_speed_tolerance * delta
|
||||
|
||||
var speed: float = distance / max(delta, 0.001)
|
||||
|
||||
if distance > max_speed_this_tick and state.last_seq != -1:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.MOVEMENT_SPEED,
|
||||
"Speed: %.1f u/s (max %.1f u/s × %.1f tolerance)" % [speed, max_speed_units_per_sec, sv_ac_speed_tolerance],
|
||||
{"speed": speed, "max_allowed": max_speed_this_tick / delta},
|
||||
))
|
||||
|
||||
# Correct: clamp displacement to max permitted
|
||||
if distance > 0.001:
|
||||
var clamped_displacement: Vector3 = displacement.normalized() * min(distance, max_speed_this_tick)
|
||||
corrected["position"] = prev_pos + clamped_displacement
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
# ── Multi-jump check ──
|
||||
# jump_count only resets on ground contact, NOT on button release,
|
||||
# to prevent tap-jump bypass (pressing jump repeatedly while airborne).
|
||||
if sv_ac_check_multi_jump:
|
||||
var jump_pressed: bool = (packet.get("buttons", 0) & 1) != 0
|
||||
var was_on_ground: bool = state.on_ground
|
||||
|
||||
if was_on_ground:
|
||||
state.jump_count = 0
|
||||
elif jump_pressed:
|
||||
state.jump_count += 1
|
||||
|
||||
var max_jumps: int = 1 # only one jump allowed without ground contact
|
||||
if not was_on_ground and state.jump_count > max_jumps:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.MOVEMENT_MULTI_JUMP,
|
||||
"Multi-jump: %d jumps without ground contact" % state.jump_count,
|
||||
{"jump_count": state.jump_count, "max_jumps": max_jumps},
|
||||
))
|
||||
# Correct: suppress the jump flag
|
||||
corrected["buttons"] = packet.get("buttons", 0) & ~1
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
|
||||
func _validate_aim(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
|
||||
"""Check view-angle delta per tick doesn't exceed max snap.
|
||||
|
||||
view_angles is Vector2(pitch, yaw) in degrees.
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
if not packet.has("view_angles"):
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var angles: Vector2 = packet.view_angles
|
||||
|
||||
# First tick — just record (no aim-initialized state yet)
|
||||
if not state._aim_initialized:
|
||||
state._aim_initialized = true
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
# Compute shortest angular distance per axis, handling yaw wraparound
|
||||
var delta_angles := Vector2()
|
||||
for axis in [0, 1]:
|
||||
var raw_delta: float = angles[axis] - state.last_view_angles[axis]
|
||||
var wrapped_delta: float = fmod(raw_delta, 360.0)
|
||||
if wrapped_delta > 180.0:
|
||||
wrapped_delta -= 360.0
|
||||
elif wrapped_delta < -180.0:
|
||||
wrapped_delta += 360.0
|
||||
delta_angles[axis] = abs(wrapped_delta)
|
||||
var max_delta: float = max(delta_angles.x, delta_angles.y)
|
||||
|
||||
if max_delta > sv_ac_max_aim_snap and state.last_seq != -1:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.AIM_SNAP,
|
||||
"Aim snap: %.1f° in one tick (max %.1f°)" % [max_delta, sv_ac_max_aim_snap],
|
||||
{"delta": max_delta, "max_allowed": sv_ac_max_aim_snap},
|
||||
))
|
||||
|
||||
# Correct: clamp delta per axis, handling yaw wraparound
|
||||
var clamped_angles := state.last_view_angles
|
||||
for axis in [0, 1]:
|
||||
var diff: float = angles[axis] - state.last_view_angles[axis]
|
||||
# Shortest-path diff, handling wraparound
|
||||
var wrapped_diff: float = fmod(diff, 360.0)
|
||||
if wrapped_diff > 180.0:
|
||||
wrapped_diff -= 360.0
|
||||
elif wrapped_diff < -180.0:
|
||||
wrapped_diff += 360.0
|
||||
# If the shortest path is within snap limit, allow it; otherwise clamp raw diff
|
||||
if abs(wrapped_diff) > sv_ac_max_aim_snap:
|
||||
var clamped_diff: float = clamp(diff, -sv_ac_max_aim_snap, sv_ac_max_aim_snap)
|
||||
clamped_angles[axis] = state.last_view_angles[axis] + clamped_diff
|
||||
|
||||
corrected["view_angles"] = clamped_angles
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
|
||||
func _validate_fire_rate(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
|
||||
"""Check minimum interval between shots.
|
||||
|
||||
Weapon fire rate is represented as min_interval_ms.
|
||||
If the game provides it in the packet, great; otherwise use a
|
||||
reasonable minimum (based on fastest weapon in the game: ~100ms = 600rpm).
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
var buttons: int = packet.get("buttons", 0)
|
||||
var fire_pressed: bool = (buttons & 2) != 0
|
||||
|
||||
if not fire_pressed:
|
||||
state.last_fire_pressed = false
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var now: float = packet.get("time", Time.get_ticks_usec() / 1_000_000.0)
|
||||
var elapsed_ms: float = (now - state.last_fire_time) * 1000.0
|
||||
|
||||
# If weapon provides its fire interval, use it; else use a reasonable min
|
||||
var weapon_min_interval_ms: float = packet.get("weapon_fire_rate_ms", 80.0)
|
||||
var allowed_interval_ms: float = weapon_min_interval_ms - sv_ac_fire_tolerance_ms
|
||||
|
||||
# First fire always accepted
|
||||
if state.last_fire_time <= 0.0:
|
||||
state.last_fire_time = now
|
||||
state.last_fire_pressed = true
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
if elapsed_ms < allowed_interval_ms:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.FIRE_RATE_INTERVAL,
|
||||
"Fire rate: %.1fms interval (min allowed %.1fms)" % [elapsed_ms, allowed_interval_ms],
|
||||
{"elapsed_ms": elapsed_ms, "min_allowed_ms": allowed_interval_ms},
|
||||
))
|
||||
|
||||
# Correct: suppress fire button
|
||||
corrected["buttons"] = buttons & ~2
|
||||
state.last_fire_pressed = false
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
state.last_fire_time = now
|
||||
state.last_fire_pressed = true
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
|
||||
func _validate_command_rate(packet: Dictionary, state: PlayerState) -> Dictionary:
|
||||
"""Limit commands per second from a client.
|
||||
|
||||
Commands include any client→server packet. We track a sliding
|
||||
window of recent command timestamps.
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
var now: float = packet.get("time", Time.get_ticks_usec() / 1_000_000.0)
|
||||
var window_start: float = now - 1.0 # 1 second window
|
||||
|
||||
# Prune old entries
|
||||
var i: int = 0
|
||||
while i < state.command_times.size():
|
||||
if state.command_times[i] < window_start:
|
||||
state.command_times.remove_at(i)
|
||||
else:
|
||||
i += 1
|
||||
|
||||
state.command_times.append(now)
|
||||
|
||||
# Allow a small burst at the start of a connection
|
||||
var is_new_connection: bool = (now - state.connected_at) < 3.0
|
||||
var burst_allowed: int = 4
|
||||
var limit: int = sv_ac_command_rate_max + (burst_allowed if is_new_connection else 0)
|
||||
|
||||
if state.command_times.size() > limit:
|
||||
# Log one violation per excess over the limit
|
||||
var excess: int = state.command_times.size() - limit
|
||||
if excess > 0:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.COMMAND_RATE,
|
||||
"Command rate: %d/s (max %d/s)" % [state.command_times.size(), sv_ac_command_rate_max],
|
||||
{"actual_rate": state.command_times.size(), "max_rate": sv_ac_command_rate_max},
|
||||
))
|
||||
|
||||
# Keep window bounded — single cleanup pass
|
||||
while state.command_times.size() > limit + 8:
|
||||
state.command_times.remove_at(0)
|
||||
|
||||
return {"violations": violations}
|
||||
|
||||
|
||||
func _validate_ammo(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary:
|
||||
"""Check ammo delta consistency.
|
||||
|
||||
If the player fired last tick, ammo should decrease by at most 1
|
||||
(or by burst count if provided).
|
||||
"""
|
||||
var violations: Array[Dictionary] = []
|
||||
|
||||
if not packet.has("ammo"):
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var ammo: int = packet.ammo
|
||||
if state.last_ammo == -1:
|
||||
state.last_ammo = ammo
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
var buttons: int = packet.get("buttons", 0)
|
||||
var fire_this: bool = (buttons & 2) != 0
|
||||
|
||||
var delta_ammo: int = ammo - state.last_ammo
|
||||
|
||||
# Expected: negative if fired, 0 if not fired, positive if picking up ammo
|
||||
if state.last_fire_time > 0 and fire_this:
|
||||
# Should have decreased by 1 (or burst count)
|
||||
var expected_delta: int = -packet.get("burst_count", 1)
|
||||
if delta_ammo > 0:
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.FIRE_RATE_AMMO,
|
||||
"Ammo increased while firing: %d → %d" % [state.last_ammo, ammo],
|
||||
{"last_ammo": state.last_ammo, "new_ammo": ammo},
|
||||
))
|
||||
# Correct: keep old ammo value
|
||||
corrected["ammo"] = state.last_ammo
|
||||
return {"violations": violations, "corrected": true}
|
||||
elif delta_ammo > expected_delta:
|
||||
# Less ammo used than expected (e.g., 0 instead of -1)
|
||||
violations.append(_make_violation(
|
||||
ViolationCategory.FIRE_RATE_AMMO,
|
||||
"Ammo mismatch while firing: delta %d (expected %d)" % [delta_ammo, expected_delta],
|
||||
{"delta": delta_ammo, "expected": expected_delta},
|
||||
))
|
||||
corrected["ammo"] = max(0, state.last_ammo + expected_delta)
|
||||
return {"violations": violations, "corrected": true}
|
||||
|
||||
state.last_ammo = ammo
|
||||
return {"violations": violations, "corrected": false}
|
||||
|
||||
|
||||
# ─── Enforcement Helpers ────────────────────────────────
|
||||
|
||||
func _make_violation(category: int, details: String, extra: Dictionary = {}) -> Dictionary:
|
||||
var v := {
|
||||
"category": category,
|
||||
"category_name": ViolationCategory.keys()[category],
|
||||
"details": details,
|
||||
"time": Time.get_ticks_usec() / 1_000_000.0,
|
||||
"extra": extra,
|
||||
}
|
||||
return v
|
||||
|
||||
|
||||
func _record_violation(state: PlayerState, v: Dictionary) -> void:
|
||||
state.violation_count += 1
|
||||
state.violations_log.append(v)
|
||||
|
||||
# Keep log bounded
|
||||
if state.violations_log.size() > 100:
|
||||
state.violations_log.pop_front()
|
||||
|
||||
|
||||
func _resolve_max_speed(packet: Dictionary) -> float:
|
||||
"""Get max player speed from packet or default.
|
||||
|
||||
The game should set 'max_speed' in the input packet.
|
||||
If not provided, assume a standard FPS walking speed.
|
||||
"""
|
||||
if packet.has("max_speed"):
|
||||
return packet.max_speed
|
||||
return 320.0 # Default: CS-style max speed
|
||||
|
||||
|
||||
# ─── Pipeline Result Handlers ──────────────────────────
|
||||
|
||||
func _result_seq(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
|
||||
|
||||
func _result_mv(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
if r.get("corrected", false):
|
||||
input_corrected.emit(peer_id, "position", null, null)
|
||||
|
||||
|
||||
func _result_aim(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
if r.get("corrected", false):
|
||||
input_corrected.emit(peer_id, "view_angles", null, null)
|
||||
|
||||
|
||||
func _result_fire(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
if r.get("corrected", false):
|
||||
input_corrected.emit(peer_id, "fire", null, null)
|
||||
|
||||
|
||||
func _result_cmd(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
|
||||
|
||||
func _result_ammo(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void:
|
||||
for v in r.get("violations", []):
|
||||
_record_violation(state, v)
|
||||
result["violations"].append(v)
|
||||
violation_recorded.emit(peer_id, v.category, v.details)
|
||||
if r.get("corrected", false):
|
||||
input_corrected.emit(peer_id, "ammo", null, null)
|
||||
Reference in New Issue
Block a user