Files
tactical-shooter/tests/test_anti_cheat.gd
T
shawn e7299b17e9 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)
2026-07-02 17:39:22 -04:00

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())