# Tests — Tactical Shooter Netcode Bug Bash This directory contains tests and tools for the Phase 6 netcode bug bash: netcode edge cases, packet loss simulation, and high ping testing. ## Quick Start Run all tests headlessly: ```bash # Option A: Direct test runner script ./tests/RUN_TESTS.sh # Option B: Manual Godot invocation godot --headless --script tests/test_anti_cheat.gd godot --headless --script tests/test_net_sim.gd godot --headless --script tests/test_rcon_edge_cases.gd ``` Run OS-level network simulation (requires root): ```bash # Apply a network profile to the game server port sudo ./tests/netem_profiles.sh apply cellular # Test under extreme conditions sudo ./tests/netem_profiles.sh apply warzone # Remove all netem rules sudo ./tests/netem_profiles.sh off # Show current tc qdisc configuration sudo ./tests/netem_profiles.sh status ``` ## Test Files | File | What It Tests | |------|---------------| | `test_utils.gd` | Minimal assertion framework for headless Godot tests | | `test_anti_cheat.gd` | Anti-cheat validation pipeline: sequence, movement, aim (including yaw wraparound), fire rate, command rate, ammo edge cases, first-position-at-origin bypass | | `test_net_sim.gd` | Network condition simulator: packet loss, latency, jitter, reordering, duplication, burst loss, bandwidth + 6 new edge case tests (jitter > latency, 25%+500ms combined, burst+reorder, 10k rapid sequence, bandwidth+loss, zero-condition passthrough) | | `test_rcon_edge_cases.gd` | RCON protocol edge cases: auth state machine, buffer overflow, malformed commands | ## Network Simulator (`server/scripts/net_sim.gd`) Wraps any `MultiplayerPeer` (ENet, WebSocket, etc.) to inject simulated adverse network conditions. Use it to test gameplay under real-world network scenarios without needing actual bad connections. ### Quick Profiles ```gdscript var sim = NetSim.new() sim.set_profile("lan") # 0% loss, 1ms latency sim.set_profile("dsl") # 0.5% loss, 20ms ±5ms sim.set_profile("cellular") # 2% loss, 60ms ±20ms sim.set_profile("satellite") # 1% loss, 600ms ±50ms sim.set_profile("congested") # 5% loss, 100ms ±40ms, reorder sim.set_profile("warzone") # 10% loss, 200ms ±60ms, burst loss ``` ### Manual Configuration ```gdscript sim.packet_loss = 0.05 # 5% packet loss sim.latency_ms = 100 # 100ms one-way delay sim.jitter_ms = 30 # ±30ms variable delay sim.reorder_window = 4 # shuffle groups of 4 sim.duplicate_rate = 0.01 # 1% duplicate packets sim.bandwidth_limit = 50000 # 50KB/s cap sim.burst_loss_count = 3 # drop 3 consecutive sim.burst_loss_interval = 50 # every 50 packets ``` ### Usage with ENet ```gdscript # Create real peer var real_peer = ENetMultiplayerPeer.new() real_peer.create_server(7777, 16) # Wrap with simulator var sim = NetSim.new() # IMPORTANT: You must modify game code to call sim.send_packet()/sim.receive_packet() # instead of the real peer directly. See net_sim.gd for the API. ``` ### Debug Stats ```gdscript print(sim.get_stats_string()) # Example output: # Sent: 1500 # Lost (loss): 75 # Lost (burst): 12 # Duplicated: 8 # Effective loss: 5.1% # Config: loss=5.0% lat=100ms jitter=±30ms ... ``` ## Bugs Found & Fixed (Phase 6 Bug Bash) ### Critical: NetSim packet delivery pipeline broken (`net_sim.gd`) **Root cause**: `_deliver_to_game()` was a no-op (`pass`). When `_process()` ran every frame, it found packets whose delay had expired, removed them from the queue, and called `_deliver_to_game()` which did nothing. The packets vanished into the void. Additionally, `_send_to_real_peer()` was defined but never called — packets were queued internally but never forwarded to the real ENet peer. **Fix**: - `_send_to_real_peer()` is now called from `_process()` when outbound delay expires - `_deliver_to_game()` removed; the outbound pipeline is now: `send_packet` → queue → `_process` → `_send_to_real_peer` → real peer's `put_packet()` - Separated outbound and inbound queues (`_outbound_queue` for game→network, `_inbound_queue` for network→game). Previously everything used `_inbound_queue`. - `receive_packet()` and `has_packet()` now fall through to the real peer when the inbound simulation queue is empty - Added `_queue_delayed_outbound()` and `_queue_delayed_inbound()` as dedicated queue functions - Removed dead `_queue_delayed_packet()` function ### Bug: `_outbound_queue` dead code (`net_sim.gd`) **Root cause**: `var _outbound_queue` was declared (line 82) but never written to. All delayed packets went into `_inbound_queue`, mixing outbound and inbound traffic in the same queue with no directional separation. **Fix**: `send_packet()` now queues in `_outbound_queue`. `_process()` forwards expired outbound packets to the real peer. `_inbound_queue` is now truly for inbound packets read from the real peer. ### Bug: Anti-cheat aim yaw wraparound false positives (`anti_cheat.gd`) **Root cause**: `_validate_aim()` computed `(angles - state.last_view_angles).abs()` without handling yaw circular wraparound. If a player's last yaw was 359° and they turned right 2° to 1°, the delta was `|1 - 359| = 358°`, triggering a false positive aim snap violation. **Fix**: Added shortest-angular-distance calculation using `fmod(raw_delta, 360.0)` with wraparound correction: values > 180° are subtracted from 360° to get the true shortest path. Applied to both violation detection and aim angle clamping. ### Bug: First-position-at-origin teleport bypass (`anti_cheat.gd`) **Root cause**: `_validate_movement()` had this early-return guard: ``` if prev_pos == Vector3.ZERO and state.last_seq != -1: if state.violation_count == 0: return # SKIPS ALL MOVEMENT VALIDATION ``` If a player spawned at `Vector3(0,0,0)` (a valid map origin), the second tick's movement validation was entirely skipped, allowing undetected teleport on tick 2. **Fix**: Removed the entire guard. The first packet is still protected by the `state.last_seq != -1` check in the teleport/speed tests (which correctly skips the first-ever packet). All subsequent packets are validated normally regardless of position value. ### Bug: Companion file `net_sim_peer.gd` referenced but missing **Root cause**: `net_sim.gd` line 254 references a companion file `net_sim_peer.gd` for the "full MultiplayerPeer adapter," but this file does not exist anywhere in the project. **Status**: Documented. The architecture relies on game code calling `send_packet()` and `receive_packet()` directly. A transparent `MultiplayerPeer` wrapper would be needed for drop-in replacement without game code changes. ### Missing Gitea Issues (to be created manually) 1. `net_sim.gd`: Packet delivery pipeline broken — packets never reach real peer 2. `anti_cheat.gd`: Aim validation yaw wraparound causes false positives 3. `anti_cheat.gd`: First position at origin bypasses movement validation on tick 2 4. `net_sim.gd`: `_outbound_queue` dead code (minor) 5. `net_sim.gd`: Bandwidth check order — random loss reduces effective bandwidth ## Bugs Found & Fixed ### Bug: Multi-jump detection bypass (anti_cheat.gd) **Root cause**: `jump_count` was reset to 0 every time the jump button was released, regardless of whether the player was airborne. This let players tap-jump repeatedly while mid-air, bypassing the single-jump limit. **Fix**: `jump_count` now only resets on ground contact (`was_on_ground = true`). Button release while airborne no longer resets the counter. ### Bug: Sequence number not corrected in output (anti_cheat.gd) **Root cause**: When `_validate_sequence` detected a non-monotonic seq number, the corrected output packet was never updated — it still contained the invalid seq. **Fix**: On seq regression, `corrected["seq"]` is now set to `state.last_seq + 1`. ### Bug: Duplicate cleanup loop (anti_cheat.gd) **Root cause**: The command rate sliding window cleanup had two identical `while state.command_times.size() > limit + 8` loops, the second being dead code. **Fix**: Removed the duplicate loop. One cleanup pass is sufficient. ## Adding New Tests 1. Create a `.gd` file in this directory 2. Extend `Node` and implement `_ready()` to run tests 3. Use `TestUtils` for assertions: ```gdscript var tu = TestUtils.new() add_child(tu) tu.describe("My Feature") tu.assert_eq(result, expected, "what we're checking") tu.finish() get_tree().quit(tu.exit_code()) ``` 4. Add to `RUN_TESTS.sh`