e7299b17e9
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)
655 lines
26 KiB
GDScript
655 lines
26 KiB
GDScript
extends Node
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Anti-Cheat Unit Tests
|
|
#
|
|
# Tests the AntiCheat validation pipeline for edge cases:
|
|
# - Sequence validation (overflow, replay, timestamp regression)
|
|
# - Movement validation (teleport, speed, multi-jump)
|
|
# - Aim validation (snap clamping)
|
|
# - Fire rate (interval, ammo consistency)
|
|
# - Command rate (sliding window limits)
|
|
# - Cross-category interactions
|
|
#
|
|
# Run with: godot --headless --script tests/test_anti_cheat.gd
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const AC_SCRIPT = preload("res://server/scripts/anti_cheat.gd")
|
|
const UTILS_SCRIPT = preload("res://tests/test_utils.gd")
|
|
|
|
# Loaded from the AC script in _ready
|
|
enum ViolationCategory {
|
|
INPUT_SEQUENCE = 0,
|
|
MOVEMENT_SPEED = 1,
|
|
MOVEMENT_TELEPORT = 2,
|
|
MOVEMENT_MULTI_JUMP = 3,
|
|
AIM_SNAP = 4,
|
|
FIRE_RATE_INTERVAL = 5,
|
|
FIRE_RATE_AMMO = 6,
|
|
COMMAND_RATE = 7,
|
|
}
|
|
|
|
enum AcLevel {
|
|
OFF = 0,
|
|
LOG = 1,
|
|
CORRECT = 2,
|
|
KICK = 3,
|
|
}
|
|
|
|
var ac
|
|
var tu
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
func _make_input(overrides: Dictionary = {}) -> Dictionary:
|
|
"""Build a standard input packet dict with overrides."""
|
|
var base := {
|
|
"seq": 1,
|
|
"time": 1000.0,
|
|
"movement": Vector3.ZERO,
|
|
"view_angles": Vector2(0.0, 0.0),
|
|
"buttons": 0,
|
|
"ammo": 30,
|
|
"position": Vector3.ZERO,
|
|
"max_speed": 320.0,
|
|
"weapon_fire_rate_ms": 100.0,
|
|
}
|
|
for k in overrides:
|
|
base[k] = overrides[k]
|
|
return base
|
|
|
|
func _register_and_validate(pkt: Dictionary, delta: float = 1.0/128.0) -> Dictionary:
|
|
"""Register peer 1 and validate the packet in one call."""
|
|
if not ac.is_tracking(1):
|
|
ac.register_player(1)
|
|
return ac.validate_input(1, pkt, delta)
|
|
|
|
|
|
# ── Test Suites ────────────────────────────────────────────────────────────
|
|
|
|
func _test_sequence_validation() -> void:
|
|
tu.describe("Sequence Validation — edge cases")
|
|
|
|
# Normal first packet
|
|
var r = _register_and_validate(_make_input({"seq": 1, "time": 1000.0}))
|
|
tu.assert_no_violation(r, "first packet accepted")
|
|
|
|
# Normal second packet — monotonic
|
|
r = ac.validate_input(1, _make_input({"seq": 2, "time": 1000.016}), 0.0078)
|
|
tu.assert_no_violation(r, "monotonic seq accepted")
|
|
|
|
# Replay — same seq
|
|
r = ac.validate_input(1, _make_input({"seq": 2, "time": 1001.0}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.INPUT_SEQUENCE, "replay detected")
|
|
tu.assert_corrected(r, "seq", "replay seq corrected")
|
|
|
|
# Regression — lower seq
|
|
r = ac.validate_input(1, _make_input({"seq": 1, "time": 1002.0}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.INPUT_SEQUENCE, "regression detected")
|
|
|
|
# Timestamp regression
|
|
r = ac.validate_input(1, _make_input({"seq": 5, "time": 999.0}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.INPUT_SEQUENCE, "timestamp regression detected")
|
|
|
|
# Large gap (jump forward) — should be accepted as valid (player may lag)
|
|
r = ac.validate_input(1, _make_input({"seq": 1000, "time": 1010.0}), 0.0078)
|
|
tu.assert_no_violation(r, "large forward jump accepted (possible lag)")
|
|
|
|
# Very large seq number (near overflow boundary)
|
|
r = ac.validate_input(1, _make_input({"seq": 2147483640, "time": 1020.0}), 0.0078)
|
|
tu.assert_no_violation(r, "large seq number accepted")
|
|
|
|
# Overflow wraparound — negative seq after positive is regression
|
|
r = ac.validate_input(1, _make_input({"seq": -5, "time": 1030.0}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.INPUT_SEQUENCE, "negative seq after positive is regression")
|
|
|
|
# First packet always accepted (last_seq was -1)
|
|
ac.unregister_player(2)
|
|
ac.register_player(2)
|
|
r = ac.validate_input(2, _make_input({"seq": 99999, "time": 1.0}), 0.0078)
|
|
tu.assert_no_violation(r, "arbitrary first seq accepted")
|
|
|
|
ac.unregister_player(1)
|
|
|
|
|
|
func _test_movement_teleport() -> void:
|
|
tu.describe("Movement — Teleport Detection Edge Cases")
|
|
|
|
# Reset: register fresh peer 3
|
|
ac.unregister_player(3)
|
|
ac.register_player(3)
|
|
|
|
# First packet at origin → teleport check skipped (init position)
|
|
var r = ac.validate_input(3, _make_input({"seq": 1, "position": Vector3(0, 0, 0), "time": 1000.0}), 0.0078)
|
|
tu.assert_no_violation(r, "first position accepted")
|
|
|
|
# Legitimate small movement
|
|
r = ac.validate_input(3, _make_input({"seq": 2, "position": Vector3(10, 0, 10), "time": 1000.016}), 0.0078)
|
|
tu.assert_no_violation(r, "small movement accepted")
|
|
|
|
# Teleport — large displacement in one tick
|
|
r = ac.validate_input(3, _make_input({"seq": 3, "position": Vector3(1000, 500, 1000), "time": 1000.032}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.MOVEMENT_TELEPORT, "teleport > threshold detected")
|
|
tu.assert_corrected(r, "position", "position corrected to prev")
|
|
|
|
# Reset player and test teleport threshold exactly at boundary
|
|
ac.unregister_player(4)
|
|
ac.register_player(4)
|
|
r = ac.validate_input(4, _make_input({"seq": 1, "position": Vector3(0, 0, 0), "time": 2000.0}), 0.0078)
|
|
tu.assert_no_violation(r, "fresh start")
|
|
# Move just under threshold (sv_ac_teleport_threshold = 100.0)
|
|
r = ac.validate_input(4, _make_input({"seq": 2, "position": Vector3(99.9, 0, 0), "time": 2000.016}), 0.0078)
|
|
tu.assert_no_violation(r, "movement just under teleport threshold")
|
|
|
|
# At exactly threshold (boundary)
|
|
r = ac.validate_input(4, _make_input({"seq": 3, "position": Vector3(199.9, 0, 0), "time": 2000.032}), 0.0078)
|
|
tu.assert_no_violation(r, "exactly at teleport threshold (100.0 units, not over)")
|
|
|
|
# Just over threshold
|
|
r = ac.validate_input(4, _make_input({"seq": 4, "position": Vector3(300.1, 0, 0), "time": 2000.048}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.MOVEMENT_TELEPORT, "just over teleport threshold detected")
|
|
|
|
ac.unregister_player(4)
|
|
|
|
|
|
func _test_movement_speed() -> void:
|
|
tu.describe("Movement — Speed Limit Edge Cases")
|
|
|
|
ac.unregister_player(5)
|
|
ac.register_player(5)
|
|
|
|
# First packet
|
|
var r = ac.validate_input(5, _make_input({"seq": 1, "position": Vector3(0, 0, 0), "time": 3000.0, "max_speed": 320.0}), 0.0078)
|
|
tu.assert_no_violation(r, "first position accepted")
|
|
|
|
# Legitimate max-speed movement: 320 u/s * 1/128 s/tick = ~2.5 u/tick
|
|
# At tolerance 1.2: max_allowed = 320 * 1.2 * 0.0078 = 2.995 u/tick
|
|
r = ac.validate_input(5, _make_input({"seq": 2, "position": Vector3(2.9, 0, 0), "time": 3000.016, "max_speed": 320.0}), 0.0078)
|
|
tu.assert_no_violation(r, "speed within tolerance accepted")
|
|
|
|
# Slight overspeed: 3.1 u/tick > 2.995 = violation
|
|
r = ac.validate_input(5, _make_input({"seq": 3, "position": Vector3(6.0, 0, 0), "time": 3000.032, "max_speed": 320.0}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.MOVEMENT_SPEED, "overspeed detected")
|
|
|
|
# Speed test with player at non-zero prev position
|
|
ac.unregister_player(6)
|
|
ac.register_player(6)
|
|
r = ac.validate_input(6, _make_input({"seq": 1, "position": Vector3(100, 100, 100), "time": 4000.0}), 0.0078)
|
|
# Valid movement from non-zero position
|
|
r = ac.validate_input(6, _make_input({"seq": 2, "position": Vector3(102, 100, 100), "time": 4000.016}), 0.0078)
|
|
tu.assert_no_violation(r, "valid movement from non-zero position")
|
|
|
|
ac.unregister_player(5)
|
|
ac.unregister_player(6)
|
|
|
|
|
|
func _test_multi_jump_detection() -> void:
|
|
tu.describe("Movement — Multi-Jump Detection Edge Cases")
|
|
# Key fix: jump_count must NOT reset on button release while airborne
|
|
|
|
ac.unregister_player(7)
|
|
ac.register_player(7)
|
|
|
|
# Player on ground, jumps
|
|
ac.set_ground_contact(7, true)
|
|
var r = ac.validate_input(7, _make_input({"seq": 1, "buttons": 1, "position": Vector3(0, 0, 0), "time": 5000.0}), 0.0078)
|
|
tu.assert_no_violation(r, "first jump from ground accepted")
|
|
# After physics: player is now airborne
|
|
ac.set_ground_contact(7, false)
|
|
|
|
# Player still holding jump in air — second jump attempt
|
|
r = ac.validate_input(7, _make_input({"seq": 2, "buttons": 1, "position": Vector3(0, 0.1, 0), "time": 5000.016}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.MOVEMENT_MULTI_JUMP, "double jump detected while holding")
|
|
tu.assert_corrected(r, "buttons", "jump flag corrected")
|
|
|
|
# Now test the bypass that was fixed: player releases and re-presses jump mid-air
|
|
ac.unregister_player(8)
|
|
ac.register_player(8)
|
|
ac.set_ground_contact(8, true)
|
|
r = ac.validate_input(8, _make_input({"seq": 1, "buttons": 1, "position": Vector3(0, 0, 0), "time": 6000.0}), 0.0078)
|
|
tu.assert_no_violation(r, "player 8 first jump")
|
|
ac.set_ground_contact(8, false)
|
|
|
|
# Release jump while still airborne
|
|
r = ac.validate_input(8, _make_input({"seq": 2, "buttons": 0, "position": Vector3(0, 0.1, 0), "time": 6000.016}), 0.0078)
|
|
tu.assert_no_violation(r, "button release in air passes (no jump flag)")
|
|
|
|
# Re-press jump in air — should be caught because jump_count did NOT reset
|
|
# (fix: jump_count only resets on ground contact, not button release)
|
|
r = ac.validate_input(8, _make_input({"seq": 3, "buttons": 1, "position": Vector3(0, 0.2, 0), "time": 6000.032}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.MOVEMENT_MULTI_JUMP, "tap-jump bypass prevented")
|
|
|
|
# Release and re-press again to confirm counting continues
|
|
r = ac.validate_input(8, _make_input({"seq": 4, "buttons": 0, "position": Vector3(0, 0.2, 0), "time": 6000.048}), 0.0078)
|
|
tu.assert_no_violation(r, "another release passes")
|
|
r = ac.validate_input(8, _make_input({"seq": 5, "buttons": 1, "position": Vector3(0, 0.3, 0), "time": 6000.064}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.MOVEMENT_MULTI_JUMP, "third aerial tap-jump attempt prevented")
|
|
|
|
# Landing resets the counter
|
|
ac.set_ground_contact(8, true)
|
|
r = ac.validate_input(8, _make_input({"seq": 6, "buttons": 0, "position": Vector3(0, 0, 0), "time": 6001.0}), 0.0078)
|
|
tu.assert_no_violation(r, "ground contact passes")
|
|
r = ac.validate_input(8, _make_input({"seq": 7, "buttons": 1, "position": Vector3(0, 0, 0), "time": 6001.016}), 0.0078)
|
|
tu.assert_no_violation(r, "jump from ground after landing accepted (counter reset)")
|
|
|
|
ac.unregister_player(7)
|
|
ac.unregister_player(8)
|
|
|
|
|
|
func _test_aim_snap() -> void:
|
|
tu.describe("Aim Validation — Snap Detection Edge Cases")
|
|
|
|
ac.unregister_player(9)
|
|
ac.register_player(9)
|
|
|
|
# First packet initialises aim
|
|
var r = ac.validate_input(9, _make_input({"seq": 1, "view_angles": Vector2(0, 0), "time": 7000.0}), 0.0078)
|
|
tu.assert_no_violation(r, "first aim accepted")
|
|
|
|
# Small rotation
|
|
r = ac.validate_input(9, _make_input({"seq": 2, "view_angles": Vector2(5, 10), "time": 7000.016}), 0.0078)
|
|
tu.assert_no_violation(r, "small rotation accepted")
|
|
|
|
# Large snap — 181 degrees > 180 max
|
|
r = ac.validate_input(9, _make_input({"seq": 3, "view_angles": Vector2(0, 190), "time": 7000.032}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.AIM_SNAP, "aim snap > 180° detected")
|
|
tu.assert_corrected(r, "view_angles", "view_angles corrected")
|
|
|
|
# Exactly at boundary (180 degrees)
|
|
ac.unregister_player(10)
|
|
ac.register_player(10)
|
|
r = ac.validate_input(10, _make_input({"seq": 1, "view_angles": Vector2(0, 0), "time": 8000.0}), 0.0078)
|
|
r = ac.validate_input(10, _make_input({"seq": 2, "view_angles": Vector2(0, 180), "time": 8000.016}), 0.0078)
|
|
tu.assert_no_violation(r, "exactly 180° aim change at boundary accepted")
|
|
|
|
# Just over boundary (180.1)
|
|
r = ac.validate_input(10, _make_input({"seq": 3, "view_angles": Vector2(0, 360.1), "time": 8000.032}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.AIM_SNAP, "just over 180° snap detected")
|
|
|
|
ac.unregister_player(9)
|
|
ac.unregister_player(10)
|
|
|
|
|
|
func _test_fire_rate() -> void:
|
|
tu.describe("Fire Rate — Interval Edge Cases")
|
|
|
|
ac.unregister_player(11)
|
|
ac.register_player(11)
|
|
|
|
# First fire always accepted
|
|
var r = ac.validate_input(11, _make_input({
|
|
"seq": 1, "buttons": 2, # fire bitmask = 2
|
|
"time": 9000.0,
|
|
"weapon_fire_rate_ms": 80.0,
|
|
}), 0.0078)
|
|
tu.assert_no_violation(r, "first fire always accepted")
|
|
|
|
# Fire too soon — 40ms < (80-10=70ms)
|
|
r = ac.validate_input(11, _make_input({
|
|
"seq": 2, "buttons": 2,
|
|
"time": 9000.04,
|
|
"weapon_fire_rate_ms": 80.0,
|
|
}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.FIRE_RATE_INTERVAL, "fire too soon detected")
|
|
tu.assert_corrected(r, "buttons", "fire flag corrected")
|
|
|
|
# Fire at exactly min interval (80ms, with 10ms tolerance → 70ms allowed)
|
|
r = ac.validate_input(11, _make_input({
|
|
"seq": 3, "buttons": 2,
|
|
"time": 9000.11,
|
|
"weapon_fire_rate_ms": 80.0,
|
|
}), 0.0078)
|
|
tu.assert_no_violation(r, "fire at exactly min interval after tolerance accepted")
|
|
|
|
# Weapon with very fast fire rate (pistol: 200ms)
|
|
r = ac.validate_input(11, _make_input({
|
|
"seq": 4, "buttons": 2,
|
|
"time": 9000.32,
|
|
"weapon_fire_rate_ms": 200.0,
|
|
}), 0.0078)
|
|
tu.assert_no_violation(r, "fire with pistol rate accepted")
|
|
|
|
ac.unregister_player(11)
|
|
|
|
|
|
func _test_command_rate() -> void:
|
|
tu.describe("Command Rate — Sliding Window Edge Cases")
|
|
|
|
ac.unregister_player(12)
|
|
ac.register_player(12)
|
|
|
|
# Rapid commands to fill the window
|
|
var base_time = 10000.0
|
|
var r: Dictionary
|
|
var violations_found = false
|
|
var rapid_seq = 0
|
|
for i in range(150):
|
|
rapid_seq += 1
|
|
var t = base_time + i * 0.004 # 250 Hz command rate
|
|
r = ac.validate_input(12, _make_input({"seq": rapid_seq, "time": t}), 0.0078)
|
|
if r.get("violations", []).size() > 0:
|
|
violations_found = true
|
|
break
|
|
|
|
tu.assert_true(violations_found, "command rate limit kicks in after ~128+ commands in window")
|
|
|
|
# Burst allowance for new connections
|
|
ac.unregister_player(13)
|
|
ac.register_player(13)
|
|
# Player 13 connected within last 3 seconds — has burst allowance
|
|
var burst_violations = false
|
|
rapid_seq = 0
|
|
for i in range(140):
|
|
rapid_seq += 1
|
|
var t = 11000.0 + i * 0.004
|
|
r = ac.validate_input(13, _make_input({"seq": rapid_seq, "time": t}), 0.0078)
|
|
if r.get("violations", []).size() > 0:
|
|
burst_violations = true
|
|
break
|
|
tu.assert_true(burst_violations, "burst allowance exceeded")
|
|
|
|
ac.unregister_player(12)
|
|
ac.unregister_player(13)
|
|
|
|
|
|
func _test_ammo_consistency() -> void:
|
|
tu.describe("Ammo Consistency — Edge Cases")
|
|
|
|
ac.unregister_player(14)
|
|
ac.register_player(14)
|
|
|
|
# First ammo report
|
|
var r = ac.validate_input(14, _make_input({
|
|
"seq": 1, "buttons": 0, "ammo": 30, "time": 12000.0
|
|
}), 0.0078)
|
|
tu.assert_no_violation(r, "first ammo report accepted")
|
|
|
|
# Fire weapon: ammo decreases by 1
|
|
r = ac.validate_input(14, _make_input({
|
|
"seq": 2, "buttons": 2, "ammo": 29, "time": 12000.1
|
|
}), 0.0078)
|
|
tu.assert_no_violation(r, "ammo decreased by 1 after fire accepted")
|
|
|
|
# Ammo increases while firing — cheat
|
|
r = ac.validate_input(14, _make_input({
|
|
"seq": 3, "buttons": 2, "ammo": 30, "time": 12000.2
|
|
}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.FIRE_RATE_AMMO, "ammo increase while firing detected")
|
|
|
|
# Ammo doesn't change while firing (should decrease by burst_count=1)
|
|
r = ac.validate_input(14, _make_input({
|
|
"seq": 4, "buttons": 2, "ammo": 30, "time": 12000.3,
|
|
"burst_count": 1,
|
|
}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.FIRE_RATE_AMMO, "ammo not decreasing while firing detected")
|
|
|
|
ac.unregister_player(14)
|
|
|
|
|
|
func _test_enforcement_levels() -> void:
|
|
tu.describe("Enforcement — Level Interaction Edge Cases")
|
|
|
|
# Save and set to OFF
|
|
var saved_level = ac.sv_ac_level
|
|
ac.sv_ac_level = AcLevel.OFF
|
|
|
|
ac.unregister_player(15)
|
|
ac.register_player(15)
|
|
|
|
# Should return no violations at OFF level
|
|
var r = ac.validate_input(15, _make_input({
|
|
"seq": 1, "position": Vector3(0, 0, 0), "time": 13000.0
|
|
}), 0.0078)
|
|
tu.assert_no_violation(r, "no violations at OFF level")
|
|
|
|
# Even blatant cheats should pass
|
|
r = ac.validate_input(15, _make_input({
|
|
"seq": 99999, "position": Vector3(9999, 9999, 9999), "time": 13000.1
|
|
}), 0.0078)
|
|
tu.assert_no_violation(r, "blatant cheat passes at OFF level")
|
|
|
|
ac.unregister_player(15)
|
|
ac.sv_ac_level = saved_level
|
|
|
|
|
|
func _test_concurrent_players() -> void:
|
|
tu.describe("Concurrent Players — State Isolation")
|
|
|
|
# Register 3 simultaneous players
|
|
ac.register_player(20)
|
|
ac.register_player(21)
|
|
ac.register_player(22)
|
|
|
|
ac.set_ground_contact(20, true)
|
|
ac.set_ground_contact(21, true)
|
|
ac.set_ground_contact(22, true)
|
|
|
|
# All send first packets
|
|
var r20 = ac.validate_input(20, _make_input({"seq": 1, "time": 14000.0}), 0.0078)
|
|
var r21 = ac.validate_input(21, _make_input({"seq": 1, "time": 14000.0}), 0.0078)
|
|
var r22 = ac.validate_input(22, _make_input({"seq": 1, "time": 14000.0}), 0.0078)
|
|
tu.assert_no_violation(r20, "player 20 first")
|
|
tu.assert_no_violation(r21, "player 21 first")
|
|
tu.assert_no_violation(r22, "player 22 first")
|
|
|
|
# Player 20 jumps while airborne
|
|
ac.set_ground_contact(20, false)
|
|
r20 = ac.validate_input(20, _make_input({"seq": 2, "buttons": 1, "position": Vector3(0, 1, 0), "time": 14000.016}), 0.0078)
|
|
r21 = ac.validate_input(21, _make_input({"seq": 2, "position": Vector3(2, 0, 0), "time": 14000.016}), 0.0078)
|
|
r22 = ac.validate_input(22, _make_input({"seq": 2, "position": Vector3(0, 0, 2), "time": 14000.016}), 0.0078)
|
|
tu.assert_no_violation(r21, "player 21 clean")
|
|
tu.assert_no_violation(r22, "player 22 clean")
|
|
|
|
# Player 20 jumps again while airborne (3rd tick with jump button)
|
|
r20 = ac.validate_input(20, _make_input({"seq": 3, "buttons": 1, "position": Vector3(0, 2, 0), "time": 14000.032}), 0.0078)
|
|
tu.assert_violation(r20, ViolationCategory.MOVEMENT_MULTI_JUMP, "player 20 multi-jump detected independently")
|
|
|
|
# Verify violation counts are isolated
|
|
var v20_count = ac.get_player_violation_count(20)
|
|
var v21_count = ac.get_player_violation_count(21)
|
|
var v22_count = ac.get_player_violation_count(22)
|
|
tu.assert_true(v20_count > 0, "player 20 has violations")
|
|
tu.assert_eq(v21_count, 0, "player 21 has zero violations")
|
|
tu.assert_eq(v22_count, 0, "player 22 has zero violations")
|
|
|
|
ac.unregister_player(20)
|
|
ac.unregister_player(21)
|
|
ac.unregister_player(22)
|
|
|
|
|
|
func _test_missing_data_fields() -> void:
|
|
tu.describe("Missing/Null Data — Graceful Handling")
|
|
|
|
ac.unregister_player(30)
|
|
ac.register_player(30)
|
|
|
|
# Packet missing 'seq' entirely
|
|
var r = ac.validate_input(30, {"time": 15000.0, "buttons": 0}, 0.0078)
|
|
tu.assert_no_violation(r, "missing seq field handled gracefully")
|
|
|
|
# Packet missing 'position'
|
|
r = ac.validate_input(30, {"seq": 2, "time": 15000.016, "buttons": 0}, 0.0078)
|
|
tu.assert_no_violation(r, "missing position handled gracefully")
|
|
|
|
# Packet missing 'view_angles'
|
|
r = ac.validate_input(30, {"seq": 3, "time": 15000.032, "buttons": 0, "position": Vector3(0, 0, 0)}, 0.0078)
|
|
tu.assert_no_violation(r, "missing view_angles handled gracefully")
|
|
|
|
# Packet missing 'time'
|
|
r = ac.validate_input(30, {"seq": 4, "position": Vector3.ZERO, "buttons": 0}, 0.0078)
|
|
tu.assert_no_violation(r, "missing time handled gracefully")
|
|
|
|
# Packet with wrong types
|
|
r = ac.validate_input(30, {"seq": "not_an_int", "time": "not_a_float", "buttons": null, "position": "wrong_type"}, 0.0078)
|
|
tu.assert_no_violation(r, "wrong types handled gracefully (no crash)")
|
|
|
|
ac.unregister_player(30)
|
|
|
|
|
|
func _test_reset_player() -> void:
|
|
tu.describe("Reset Player State — State Cleanup")
|
|
|
|
ac.register_player(40)
|
|
ac.set_ground_contact(40, true)
|
|
var r = ac.validate_input(40, _make_input({"seq": 1, "time": 16000.0}), 0.0078)
|
|
tu.assert_no_violation(r, "initial validation ok")
|
|
|
|
# Reset
|
|
ac.reset_player_violations(40)
|
|
tu.assert_eq(ac.get_player_violation_count(40), 0, "violations reset to 0")
|
|
tu.assert_eq(ac.get_player_violations(40).size(), 0, "violations log cleared")
|
|
|
|
ac.unregister_player(40)
|
|
|
|
|
|
func _test_clear_all() -> void:
|
|
tu.describe("Clear All — Global State Cleanup")
|
|
|
|
ac.register_player(50)
|
|
ac.register_player(51)
|
|
ac.register_player(52)
|
|
tu.assert_true(ac.is_tracking(50), "player 50 tracked")
|
|
tu.assert_true(ac.is_tracking(51), "player 51 tracked")
|
|
tu.assert_true(ac.is_tracking(52), "player 52 tracked")
|
|
|
|
ac.clear_all()
|
|
tu.assert_false(ac.is_tracking(50), "player 50 cleared")
|
|
tu.assert_false(ac.is_tracking(51), "player 51 cleared")
|
|
tu.assert_false(ac.is_tracking(52), "player 52 cleared")
|
|
|
|
# Should still work after clearing
|
|
ac.register_player(50)
|
|
tu.assert_true(ac.is_tracking(50), "re-register after clear works")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# NEW: Edge Case Tests (Phase 6 bug bash)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
func _test_aim_yaw_wraparound() -> void:
|
|
tu.describe("Aim — Yaw Wraparound (359° → 1° should NOT flag)")
|
|
|
|
ac.unregister_player(60)
|
|
ac.register_player(60)
|
|
|
|
# First packet initialises aim at yaw=359
|
|
var r = ac.validate_input(60, _make_input({"seq": 1, "view_angles": Vector2(0, 359), "time": 17000.0}), 0.0078)
|
|
tu.assert_no_violation(r, "first aim at 359° accepted")
|
|
|
|
# Small rotation around the 0° boundary: 359→1 = 2° delta (shortest path)
|
|
# Without yaw wraparound fix, this would be |1-359| = 358° → false snap flag
|
|
r = ac.validate_input(60, _make_input({"seq": 2, "view_angles": Vector2(0, 1), "time": 17000.016}), 0.0078)
|
|
tu.assert_no_violation(r, "359° → 1° (2° shortest path) not flagged as snap")
|
|
|
|
# Full wraparound: 1→359 = 2° delta backward (shortest path)
|
|
r = ac.validate_input(60, _make_input({"seq": 3, "view_angles": Vector2(0, 359), "time": 17000.032}), 0.0078)
|
|
tu.assert_no_violation(r, "1° → 359° (2° shortest path, reverse) not flagged as snap")
|
|
|
|
# Legitimate large snap still caught: 359→200 = 159° which is still less than 180°
|
|
r = ac.validate_input(60, _make_input({"seq": 4, "view_angles": Vector2(0, 200), "time": 17000.048}), 0.0078)
|
|
tu.assert_no_violation(r, "359° → 200° (159° delta, under 180° max) accepted")
|
|
|
|
# True snap that wraps: 200→10 = 190° shortest path → should flag
|
|
r = ac.validate_input(60, _make_input({"seq": 5, "view_angles": Vector2(0, 10), "time": 17000.064}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.AIM_SNAP, "200° → 10° (190° shortest path) flagged as snap")
|
|
|
|
ac.unregister_player(60)
|
|
|
|
|
|
func _test_movement_first_position_origin() -> void:
|
|
tu.describe("Movement — First Position at Origin (Vector3.ZERO)")
|
|
|
|
# Bug: If a player starts at position (0,0,0), the old code skipped
|
|
# the second tick movement validation entirely, allowing undetected teleport.
|
|
|
|
ac.unregister_player(61)
|
|
ac.register_player(61)
|
|
|
|
# First packet at origin
|
|
var r = ac.validate_input(61, _make_input({"seq": 1, "position": Vector3(0, 0, 0), "time": 18000.0}), 0.0078)
|
|
tu.assert_no_violation(r, "first position at origin accepted")
|
|
|
|
# Second packet — teleport far away (should be flagged now with the fix)
|
|
r = ac.validate_input(61, _make_input({"seq": 2, "position": Vector3(999, 999, 999), "time": 18000.016}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.MOVEMENT_TELEPORT, "teleport from origin on tick 2 flagged (bypass fixed)")
|
|
|
|
ac.unregister_player(61)
|
|
|
|
|
|
func _test_movement_second_packet_speed_check() -> void:
|
|
tu.describe("Movement — Speed check on second tick after origin")
|
|
|
|
ac.unregister_player(62)
|
|
ac.register_player(62)
|
|
|
|
# First packet
|
|
var r = ac.validate_input(62, _make_input({"seq": 1, "position": Vector3.ZERO, "time": 19000.0}), 0.0078)
|
|
tu.assert_no_violation(r, "first position accepted")
|
|
|
|
# Second packet: valid speed (within tolerance from origin)
|
|
# At max_speed=320, tol=1.2, delta=1/128: max this tick = 320*1.2*0.0078 ≈ 2.995
|
|
r = ac.validate_input(62, _make_input({"seq": 2, "position": Vector3(2.5, 0, 0), "time": 19000.016, "max_speed": 320.0}), 0.0078)
|
|
tu.assert_no_violation(r, "valid speed at tick 2 from origin accepted")
|
|
|
|
# Third packet: overspeed at 6.0 units from 2.5 = 3.5 u > 2.995 max
|
|
r = ac.validate_input(62, _make_input({"seq": 3, "position": Vector3(6.0, 0, 0), "time": 19000.032, "max_speed": 320.0}), 0.0078)
|
|
tu.assert_violation(r, ViolationCategory.MOVEMENT_SPEED, "overspeed on tick 3 flagged")
|
|
|
|
ac.unregister_player(62)
|
|
|
|
|
|
# ── Main Entry Point ──────────────────────────────────────────────────────
|
|
|
|
func _ready() -> void:
|
|
"""Run all tests and exit."""
|
|
print("\n══════════════════════════════════════════════")
|
|
print(" Anti-Cheat Unit Tests")
|
|
print("══════════════════════════════════════════════\n")
|
|
|
|
# Instantiate anti-cheat from preload
|
|
ac = AC_SCRIPT.new()
|
|
add_child(ac)
|
|
|
|
# Force specific values for deterministic tests
|
|
ac.sv_ac_level = AcLevel.KICK
|
|
ac.sv_ac_speed_tolerance = 1.2
|
|
ac.sv_ac_max_aim_snap = 180.0
|
|
ac.sv_ac_fire_tolerance_ms = 10
|
|
ac.sv_ac_command_rate_max = 128
|
|
ac.sv_ac_teleport_threshold = 100.0
|
|
ac.sv_ac_kick_threshold = 5
|
|
ac.sv_ac_check_multi_jump = true
|
|
ac.sv_ac_check_ammo = true
|
|
|
|
tu = UTILS_SCRIPT.new()
|
|
add_child(tu)
|
|
|
|
# Run all test suites
|
|
_test_sequence_validation()
|
|
_test_movement_teleport()
|
|
_test_movement_speed()
|
|
_test_multi_jump_detection()
|
|
_test_aim_snap()
|
|
_test_fire_rate()
|
|
_test_command_rate()
|
|
_test_ammo_consistency()
|
|
_test_enforcement_levels()
|
|
_test_concurrent_players()
|
|
_test_missing_data_fields()
|
|
_test_reset_player()
|
|
_test_clear_all()
|
|
|
|
# Phase 6 bug bash edge case tests
|
|
_test_aim_yaw_wraparound()
|
|
_test_movement_first_position_origin()
|
|
_test_movement_second_packet_speed_check()
|
|
|
|
# Summary
|
|
print("══════════════════════════════════════════════")
|
|
var total = tu._passed + tu._failed
|
|
print(" Total: %d passed, %d failed out of %d" % [tu._passed, tu._failed, total])
|
|
print("══════════════════════════════════════════════\n")
|
|
|
|
get_tree().quit(tu.exit_code())
|