Fresh start: replace with naxIO/netfox-cs-sample foundation

Complete replacement of the tactical-shooter project with the
netfox-cs-sample (MIT) — a CS 1.6 inspired multiplayer FPS built
with Godot 4 and netfox.

## What's new
- Full CS-style gameplay: teams (T/CT), rounds, economy, buy menu
- 6 weapons: Knife, Glock, USP, AK-47, M4A1, AWP
- Bomb plant/defuse with 2 bombsites
- Flashbang & smoke grenades
- Proper netfox rollback netcode at 64 tick
- Network popup UI for host/join
- HUD, crosshair, round timer, scoreboard
- All netfox singletons registered as autoloads (works in exported builds)

## Architecture
- Listen-server (host from client, no dedicated server binary)
- Multiplayer-fps game lives at examples/multiplayer-fps/
- Netfox addons registered as autoloads for exported build compat
- Godot 4.7 with Forward+ renderer

## Removed
- Old headless-server architecture (client_main, server_main, player.gd, etc.)
- Custom netfox bootstrap with ENet fallback
- Old ChaffGames FPS template (2,420 lines, 844 KB)
- SimulationServer GDExtension stub
- Godot-jolt physics (netfox sample uses default Godot physics)
- Duplicate weapon_data.gd, anti_cheat.gd, round_manager.gd, etc.
- Server browser API Python venv (87 MB)
- test_range map and modular assets

## Preserved
- Git history
- Server config at config/default_server_config.cfg
- Windows export preset
- Build directory (gitignored)

Co-authored-by: naxIO <naxIO@users.noreply.github.com>
This commit is contained in:
2026-07-02 20:55:20 -04:00
parent ce39b237c3
commit b0c83af092
4416 changed files with 57418 additions and 902676 deletions
-217
View File
@@ -1,217 +0,0 @@
# 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`
-124
View File
@@ -1,124 +0,0 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════════════
# Tactical Shooter — Netcode Bug Bash Test Runner
#
# Runs all test suites in headless Godot and reports results.
# Usage: ./tests/RUN_TESTS.sh
#
# Requirements:
# - Godot 4+ executable in PATH, or set GODOT_BIN env var
# ═══════════════════════════════════════════════════════════════════════════
set -euo pipefail
# Config
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
GODOT="${GODOT_BIN:-godot}"
# ANSI colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
BOLD='\033[1m'
TESTS=(
"test_anti_cheat.gd"
"test_net_sim.gd"
"test_rcon_edge_cases.gd"
)
PASS_COUNT=0
FAIL_COUNT=0
SKIP_COUNT=0
RESULTS=()
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Tactical Shooter — Netcode Bug Bash Runner${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
echo " Project: $PROJECT_DIR"
echo " Godot: $(which "$GODOT" 2>/dev/null || echo 'NOT FOUND')"
echo ""
# Check godot is available
if ! command -v "$GODOT" &>/dev/null; then
echo -e "${RED}ERROR: Godot executable '$GODOT' not found in PATH.${NC}"
echo " Set GODOT_BIN env var to the godot binary path, or install Godot."
echo ""
echo " Example:"
echo " export GODOT_BIN=/usr/local/bin/godot"
echo " $0"
echo ""
exit 1
fi
# Run each test
for test_file in "${TESTS[@]}"; do
test_path="$SCRIPT_DIR/$test_file"
test_name="${test_file%.gd}"
if [ ! -f "$test_path" ]; then
echo -e "${YELLOW} [SKIP] $test_name — file not found${NC}"
SKIP_COUNT=$((SKIP_COUNT + 1))
RESULTS+=("SKIP|$test_name|file not found")
continue
fi
echo -e "${BOLD}Running: $test_name${NC}"
echo " $GODOT --headless --script $test_path"
echo ""
# Run the test — capture output and exit code
set +e
output=$("$GODOT" --headless --script "$test_path" 2>&1)
exit_code=$?
set -e
# Print output (indented)
echo "$output" | sed 's/^/ /'
if [ $exit_code -eq 0 ]; then
echo -e "${GREEN} ✓ PASS ($test_name)${NC}"
PASS_COUNT=$((PASS_COUNT + 1))
RESULTS+=("PASS|$test_name|")
else
# Extract failure info from output
fail_lines=$(echo "$output" | grep -c "\[FAIL\]" || true)
echo -e "${RED} ✗ FAIL ($test_name) — $fail_lines assertion(s) failed${NC}"
FAIL_COUNT=$((FAIL_COUNT + 1))
RESULTS+=("FAIL|$test_name|$fail_lines failures")
fi
echo ""
echo "──────────────────────────────────────"
echo ""
done
# Summary
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Test Run Complete${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
for result in "${RESULTS[@]}"; do
IFS='|' read -r status name info <<< "$result"
case "$status" in
PASS) echo -e " ${GREEN}${NC} $name" ;;
FAIL) echo -e " ${RED}${NC} $name$info" ;;
SKIP) echo -e " ${YELLOW}${NC} $name ($info)" ;;
esac
done
echo ""
echo -e " ${BOLD}${GREEN}$PASS_COUNT passed${NC}, ${BOLD}${RED}$FAIL_COUNT failed${NC}, ${YELLOW}$SKIP_COUNT skipped${NC}"
echo ""
if [ $FAIL_COUNT -gt 0 ]; then
exit 1
fi
exit 0
-304
View File
@@ -1,304 +0,0 @@
#!/usr/bin/env godot
# tests/benchmarks/bench_128hz.gd
# 128Hz Load Test Benchmark for GDExtension Simulation Core
#
# Tests SimulationServer at N=8,16,32,64 bots running at 128Hz for
# BENCH_DURATION virtual-seconds each. Measures achieved tick rate,
# frame time (GDScript + tick overhead), C++ tick time (from get_stats()),
# memory delta, and missed ticks.
#
# Usage:
# godot --headless --script tests/benchmarks/bench_128hz.gd
#
# Exit code: 0 = benchmark complete, 1 = GDExtension not loadable
extends SceneTree
const TICK_HZ := 128
const BENCH_DURATION_SEC := 10.0
const POPULATION_LEVELS := [8, 16, 32, 64]
const TICK_INTERVAL := 1.0 / TICK_HZ
const EXPECTED_TICKS := int(TICK_HZ * BENCH_DURATION_SEC)
var _results := []
# ---------------------------------------------------------------------------
# Entry
# ---------------------------------------------------------------------------
func _init() -> void:
print("")
print("=".repeat(70))
print(" 128Hz Load Test Benchmark — GDExtension Simulation Server")
print("=".repeat(70))
print("")
var vi = Engine.get_version_info()
print(" Engine: Godot %d.%d.%d" % [vi.major, vi.minor, vi.patch])
print(" Platform: %s" % OS.get_name())
print(" Headless: %s" % str(DisplayServer.get_name() == "headless"))
print(" Tick Rate: %d Hz" % TICK_HZ)
print(" Duration: %.1f sec per level" % BENCH_DURATION_SEC)
print(" Expected ticks: %d" % EXPECTED_TICKS)
print("")
# ---- Diagnostics: is the GDExtension loadable? ----
if not ClassDB.class_exists("SimulationServer"):
printerr("")
printerr(" [FATAL] SimulationServer class is NOT registered in ClassDB.")
printerr("")
printerr(" Diagnostic:")
printerr(" The GDExtension shared library could not be loaded.")
printerr("")
printerr(" Expected configuration:")
printerr(" .gdextension: res://gdextension/simulation.gdextension")
printerr(" Linux .so: res://gdextension/simulation/gdextension/bin/linux/libsimulation.so")
printerr("")
printerr(" Possible causes:")
printerr(" 1. The .so has not been compiled — run: cd gdextension/simulation && scons -j$(nproc)")
printerr(" 2. The .gdextension file points to the wrong path")
printerr(" 3. The .so is incompatible with this Godot version (need 4.2+ godot-cpp)")
printerr(" 4. Missing symbol — rebuild with a clean build")
printerr("")
quit(1)
return
print(" [OK] SimulationServer class is registered in ClassDB")
print("")
# Run benchmarks for each population level
for pop in POPULATION_LEVELS:
_run_benchmark(pop)
# Print summary table
_print_summary()
quit(0)
# ---------------------------------------------------------------------------
# Single population-level benchmark
# ---------------------------------------------------------------------------
func _run_benchmark(pop: int) -> void:
print("".repeat(70))
print(" Benchmark: %d bots" % pop)
print("".repeat(70))
# Create a fresh SimulationServer for each population level
# Use ClassDB.instantiate because SimulationServer is a GDExtension
# class that isn't known to the parser at compile time.
var server = ClassDB.instantiate("SimulationServer")
if server == null:
printerr(" [ERROR] Failed to create SimulationServer instance!")
return
# Configure and populate
server.set_tick_rate(TICK_HZ)
server.populate_bots(pop)
server.start()
server.reset_stats()
var entity_count = server.get_entity_count()
print(" Entities spawned: %d" % entity_count)
assert(entity_count == pop, "Entity count should match requested population")
# ---- Warm-up: a few ticks to stabilise caches / allocators ----
for _i in range(10):
if server.can_tick(TICK_INTERVAL):
server.tick()
server.reset_stats()
# Memory snapshot BEFORE
var mem_before := OS.get_static_memory_usage()
# ---- Main benchmark loop (EXPECTED_TICKS iterations) ----
var tick_count := 0
var frame_times := []
var bench_start := Time.get_ticks_usec()
for i in range(EXPECTED_TICKS):
var frame_start := Time.get_ticks_usec()
if server.can_tick(TICK_INTERVAL):
var snap = server.tick()
tick_count += 1
var frame_end := Time.get_ticks_usec()
frame_times.append(frame_end - frame_start)
var bench_end := Time.get_ticks_usec()
# Memory snapshot AFTER
var mem_after := OS.get_static_memory_usage()
var mem_delta := mem_after - mem_before
# Internal C++ timing (from get_stats)
var stats = server.get_stats()
var cpp_avg_usec := float(stats["avg_tick_usec"])
var cpp_peak_usec := float(stats["peak_tick_usec"])
var cpp_tick_count := int(stats["tick_count"])
# Wall-clock timing
var wall_usec := bench_end - bench_start
var wall_sec := wall_usec / 1_000_000.0
var achieved_hz := float(tick_count) / wall_sec if wall_sec > 0.0 else 0.0
# Frame-time statistics
var avg_frame_usec := 0.0
var min_frame_usec := 0
var max_frame_usec := 0
if frame_times.size() > 0:
var total := 0
min_frame_usec = frame_times[0]
max_frame_usec = frame_times[0]
for ft in frame_times:
total += ft
if ft < min_frame_usec:
min_frame_usec = ft
if ft > max_frame_usec:
max_frame_usec = ft
avg_frame_usec = float(total) / float(frame_times.size())
# Missed ticks (process fewer than expected)
var missed_ticks: int = max(0, EXPECTED_TICKS - tick_count)
# ---- Print per-level results ----
print("")
print(" ┌─ Results ──────────────────────────────────────────────┐")
print(" │ Wall clock: %8.3f sec │" % wall_sec)
print(" │ Total ticks: %d / %d (expected) │" % [tick_count, EXPECTED_TICKS])
print(" │ Missed ticks: %d" % missed_ticks)
print(" │ Achieved tick rate: %.1f Hz │" % achieved_hz)
print(" │ │")
print(" │ Frame time (GDScript + tick overhead): │")
print(" │ Avg: %8.1f µs │" % avg_frame_usec)
print(" │ Min: %8d µs │" % min_frame_usec)
print(" │ Max: %8d µs │" % max_frame_usec)
print(" │ │")
print(" │ Tick time (C++ internal, from get_stats()): │")
print(" │ Avg: %8.1f µs │" % cpp_avg_usec)
print(" │ Peak: %8.1f µs │" % cpp_peak_usec)
print(" │ Count: %d" % cpp_tick_count)
print(" │ │")
print(" │ Memory: │")
print(" │ Before: %s" % _pad(_fmt_bytes(mem_before), 12))
print(" │ After: %s" % _pad(_fmt_bytes(mem_after), 12))
print(" │ Delta: %s" % _pad(_fmt_bytes(mem_delta), 12))
print(" └─────────────────────────────────────────────────────────┘")
print("")
# Save for summary
_results.append({
"pop": pop,
"achieved_hz": achieved_hz,
"avg_frame_usec": avg_frame_usec,
"min_frame_usec": min_frame_usec,
"max_frame_usec": max_frame_usec,
"cpp_avg_usec": cpp_avg_usec,
"cpp_peak_usec": cpp_peak_usec,
"wall_sec": wall_sec,
"total_ticks": tick_count,
"expected_ticks": EXPECTED_TICKS,
"missed_ticks": missed_ticks,
"mem_before": mem_before,
"mem_after": mem_after,
"mem_delta": mem_delta,
})
# Cleanup
server.stop()
server = null
# ---------------------------------------------------------------------------
# Summary table
# ---------------------------------------------------------------------------
func _print_summary() -> void:
print("")
print("=".repeat(70))
print(" BENCHMARK SUMMARY")
print("=".repeat(70))
print("")
# Table header
print(" %-8s %-12s %-10s %-10s %-8s %-10s" % [
"Players", "Tick Rate", "Frame Avg", "C++ Tick", "Missed", "Mem Δ",
])
print(" %-8s %-12s %-10s %-10s %-8s %-10s" % [
"", "(Hz)", "(µs)", "Avg (µs)", "Ticks", "",
])
print(" " + "".repeat(60))
for r in _results:
var rate_str := "%.1f/%d" % [r["achieved_hz"], TICK_HZ]
var missed_str := "%d/%d" % [r["missed_ticks"], r["expected_ticks"]]
print(" %-8s %-12s %-10.1f %-10.1f %-8s %-10s" % [
str(r["pop"]),
rate_str,
r["avg_frame_usec"],
r["cpp_avg_usec"],
missed_str,
_fmt_bytes(r["mem_delta"]),
])
print(" " + "".repeat(60))
print("")
print(" Legend:")
print(" Players: number of simulated bots")
print(" Tick Rate: achieved Hz / target Hz (128)")
print(" Frame Avg: average frame time incl. GDScript + tick overhead")
print(" C++ Tick: average tick processing time inside C++ (get_stats)")
print(" Missed: ticks short of expected / total expected ticks")
print(" Mem Δ: static memory delta before/after the benchmark")
print("")
if _results.size() > 0:
var all_zero_missed := true
var all_above_target := true
for r in _results:
if r["missed_ticks"] > 0:
all_zero_missed = false
if r["achieved_hz"] < TICK_HZ:
all_above_target = false
if all_zero_missed and all_above_target:
print(" ✓ ALL BENCHMARKS PASSED — simulation maintains 128 Hz at all population levels")
elif all_above_target:
print(" ⚠ All levels achieved ≥128 Hz but some missed ticks recorded (check timing)")
else:
print(" ✗ Some levels FAILED to maintain 128 Hz — simulation may need optimisation")
print(" for the higher population levels.")
print("")
print("=".repeat(70))
print(" Benchmark complete.")
print("=".repeat(70))
print("")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
static func _fmt_bytes(bytes: int) -> String:
if bytes < 0:
return "-" + _fmt_bytes(-bytes)
if bytes < 1024:
return "%d B" % bytes
elif bytes < 1024 * 1024:
return "%.1f KB" % (float(bytes) / 1024.0)
else:
return "%.2f MB" % (float(bytes) / (1024.0 * 1024.0))
# Left-pad a string to at least `width` characters by adding spaces.
# Godot 4 GDScript doesn't have rjust(), so we roll our own.
static func _pad(s: String, width: int) -> String:
var needed := width - s.length()
if needed <= 0:
return s
var buf := ""
for _i in range(needed):
buf += " "
return buf + s
-1
View File
@@ -1 +0,0 @@
uid://c0a04532axyqo
-1
View File
@@ -1 +0,0 @@
uid://d3gfib3iijduv
-734
View File
@@ -1,734 +0,0 @@
## Netcode Bug Bash — Automated Netcode Test Suite
##
## Tests connection lifecycle, prediction & reconciliation, round lifecycle,
## economy & weapons, and RCON functionality — all without real ENet traffic
## by calling internal APIs directly.
##
## Usage:
## godot --headless --script tests/netcode/netcode_bugbash.gd
##
## Exit code: 0 = all pass, 1 = any failure
extends Node
# Preload TeamData for enum access (it's a class_name Resource, not an autoload)
const TeamData = preload("res://scripts/teams/team_data.gd")
# ---------------------------------------------------------------------------
# Test counters
# ---------------------------------------------------------------------------
var _passed: int = 0
var _failed: int = 0
var _total: int = 0
# ---------------------------------------------------------------------------
# Test infrastructure references
# ---------------------------------------------------------------------------
var _game_server: Node = null
var _world_3d: World3D = null
var _net_mgr: Node = null
# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------
func check_pass(name: String) -> void:
_passed += 1
_total += 1
print(" ✓ PASS: %s" % name)
func check_fail(name: String, reason: String) -> void:
_failed += 1
_total += 1
print(" ✗ FAIL: %s%s" % [name, reason])
func assert_eq(name: String, got, expected, msg: String = "") -> void:
if got == expected:
check_pass(name)
else:
var extra: String = ": " + msg if msg else ""
check_fail(name, "expected %s, got %s%s" % [str(expected), str(got), extra])
func assert_true(name: String, cond: bool, msg: String = "") -> void:
if cond:
check_pass(name)
else:
var extra: String = ": " + msg if msg else ""
check_fail(name, "condition was false%s" % [extra])
func assert_false(name: String, cond: bool, msg: String = "") -> void:
if not cond:
check_pass(name)
else:
var extra: String = ": " + msg if msg else ""
check_fail(name, "condition was true%s" % [extra])
# ---------------------------------------------------------------------------
# Infrastructure setup
# ---------------------------------------------------------------------------
func _setup_infrastructure() -> void:
# Create NetworkManager manually (autoloads not loaded in --script mode)
var nm_script = load("res://scripts/network/network_manager.gd")
_net_mgr = nm_script.new()
add_child(_net_mgr)
print(" Created NetworkManager node")
# Create a 3D physics world so physics-dependent subsystems work
_world_3d = World3D.new()
get_window().world_3d = _world_3d
# Add a static body so raycasts have something to hit
var sb := StaticBody3D.new()
var col := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(10, 10, 10)
col.shape = box
sb.add_child(col)
add_child(sb)
sb.global_position = Vector3(0, 0, -20)
# Create GameServer with all subsystems
var gs_script = load("res://server/scripts/game_server.gd")
_game_server = gs_script.new()
add_child(_game_server)
# Manually ensure the simulation server is running
if _game_server.has_method("start_simulation"):
_game_server.start_simulation()
print(" Setup complete: GameServer + all subsystems created")
print("")
# ---------------------------------------------------------------------------
# Main entry
# ---------------------------------------------------------------------------
func _ready() -> void:
print("")
print("=".repeat(64))
print(" Netcode Bug Bash — Automated Test Suite")
print("=".repeat(64))
print("")
var vi = Engine.get_version_info()
print(" Engine: Godot %d.%d.%d" % [vi.major, vi.minor, vi.patch])
print(" Platform: %s" % OS.get_name())
print(" Headless: %s" % str(DisplayServer.get_name() == "headless"))
print("")
_setup_infrastructure()
# === GROUP 1: Connection Lifecycle (5 tests) ===
print("".repeat(64))
print(" GROUP 1: Connection Lifecycle")
print("".repeat(64))
test_conn_client_connects()
test_conn_client_disconnect_cleanup()
test_conn_client_reconnect_fresh()
test_conn_full_server_rejected()
test_conn_invalid_data_no_crash()
# === GROUP 2: Prediction & Reconciliation (4 tests) ===
print("")
print("".repeat(64))
print(" GROUP 2: Prediction & Reconciliation")
print("".repeat(64))
test_pred_client_input_processed()
test_pred_server_snapshot_reconcile()
test_pred_lag_compensation_rewind()
test_pred_packet_loss_recovery()
# === GROUP 3: Round Lifecycle (3 tests) ===
print("")
print("".repeat(64))
print(" GROUP 3: Round Lifecycle")
print("".repeat(64))
test_round_transitions()
test_round_bomb_explosion()
test_round_bomb_defuse()
# === GROUP 4: Economy & Weapons (3 tests) ===
print("")
print("".repeat(64))
print(" GROUP 4: Economy & Weapons")
print("".repeat(64))
test_econ_kill_reward()
test_econ_round_win_loss()
test_econ_weapon_purchase()
# === GROUP 5: RCON (3 tests) ===
print("")
print("".repeat(64))
print(" GROUP 5: RCON")
print("".repeat(64))
test_rcon_auth_command()
test_rcon_invalid_password()
test_rcon_start_match_end_round()
# === SUMMARY ===
_print_summary()
get_tree().quit(0 if _failed == 0 else 1)
func _print_summary() -> void:
print("")
print("=".repeat(64))
print(" RESULTS: %d / %d passed, %d failed" % [_passed, _total, _failed])
print("=".repeat(64))
if _failed == 0:
print(" ALL TESTS PASSED")
else:
print(" SOME TESTS FAILED — see above for details")
print("=".repeat(64))
# ===================================================================
# GROUP 1: Connection Lifecycle
# ===================================================================
func test_conn_client_connects() -> void:
if not _net_mgr or not _net_mgr.has_signal("player_connected"):
check_fail("conn_01_client_connects", "NetworkManager not available")
return
var err = _net_mgr.start_server(0)
if err != OK:
check_fail("conn_01_client_connects", "Failed to start server: " + error_string(err))
return
assert_true("conn_01_server_started", _net_mgr.is_server,
"NetworkManager should be in server mode after start_server")
_net_mgr.stop()
func test_conn_client_disconnect_cleanup() -> void:
if _game_server == null:
check_fail("conn_02_disconnect_cleanup", "GameServer not available")
return
var gs = _game_server
if not gs.is_running:
gs.start_simulation()
var entity_id: int = gs.spawn_player_entity(1001, Vector3(2, 0, 3))
assert_true("conn_02_spawned", entity_id >= 0, "Player should spawn successfully")
var peer = gs.get_peer_for_entity(entity_id)
assert_eq("conn_02_entity_to_peer_mapped", peer, 1001,
"entity_to_peer should map to peer_id=1001")
# Despawn (simulating disconnect)
gs.despawn_player_entity(entity_id)
var peer_after = gs.get_peer_for_entity(entity_id)
assert_eq("conn_02_entity_cleaned", peer_after, -1,
"entity_to_peer should be cleared after despawn")
func test_conn_client_reconnect_fresh() -> void:
if _game_server == null:
check_fail("conn_03_reconnect_fresh", "GameServer not available")
return
var gs = _game_server
var eid1: int = gs.spawn_player_entity(2001, Vector3(5, 0, 5))
gs.despawn_player_entity(eid1)
var eid2: int = gs.spawn_player_entity(2001, Vector3(10, 0, 10))
assert_true("conn_03_re_spawned", eid2 >= 0, "Re-spawning should succeed")
assert_true("conn_03_new_entity_id", eid2 != eid1, "Re-spawn should get a new entity_id")
var old_peer = gs.get_peer_for_entity(eid1)
assert_eq("conn_03_old_entity_cleaned", old_peer, -1, "Old entity_id should be unmapped")
gs.despawn_player_entity(eid2)
func test_conn_full_server_rejected() -> void:
if not _net_mgr:
check_fail("conn_04_full_server", "NetworkManager not available")
return
_net_mgr.max_clients = 1
var err = _net_mgr.start_server(0)
if err != OK:
check_fail("conn_04_full_server", "Failed to start server: " + error_string(err))
return
assert_true("conn_04_max_clients_set", _net_mgr.max_clients == 1,
"max_clients should be 1")
_net_mgr.stop()
func test_conn_invalid_data_no_crash() -> void:
if _game_server == null:
check_fail("conn_05_invalid_data", "GameServer not available")
return
var gs = _game_server
var sim = gs.get_simulation_server()
if sim and sim.has_method("apply_input"):
sim.apply_input(-999, {"move_direction": "not_a_vector"})
check_pass("conn_05_invalid_entity_input")
var eid = gs.spawn_player_entity(3001, Vector3.ZERO)
sim.apply_input(eid, {})
check_pass("conn_05_empty_input_dict")
gs.despawn_player_entity(eid)
else:
check_fail("conn_05_invalid_data", "Simulation server missing apply_input method")
var bad_eid = gs.spawn_player_entity(3002, Vector3(NAN, NAN, NAN))
gs.despawn_player_entity(bad_eid)
check_pass("conn_05_nan_position")
# ===================================================================
# GROUP 2: Prediction & Reconciliation
# ===================================================================
func test_pred_client_input_processed() -> void:
if _game_server == null:
check_fail("pred_01_input_processed", "GameServer not available")
return
var gs = _game_server
var sim = gs.get_simulation_server()
# Test simulation_server.apply_input directly
if sim and sim.has_method("apply_input"):
var eid = gs.spawn_player_entity(4001, Vector3(0, 0, 0))
sim.apply_input(eid, {"move_direction": Vector3(1, 0, 0), "sprint": false})
check_pass("pred_01_apply_input_direct")
gs.despawn_player_entity(eid)
else:
check_fail("pred_01_input_processed", "Simulation server missing apply_input")
func test_pred_server_snapshot_reconcile() -> void:
var snapshot_script = load("res://scripts/network/snapshot.gd")
var snap = snapshot_script.new()
snap.timestamp = 42
snap.position = Vector3(10, 5, 3)
snap.rotation = Quaternion.IDENTITY
snap.velocity = Vector3(2, 0, 1)
snap.grounded = true
var data = snap.to_dict()
assert_eq("pred_02_serialize_timestamp", data.get("timestamp"), 42)
assert_eq("pred_02_serialize_position", data.get("position"), [10.0, 5.0, 3.0])
assert_eq("pred_02_serialize_grounded", data.get("grounded"), true)
var restored = snapshot_script.from_dict(data)
assert_eq("pred_02_deserialize_timestamp", restored.timestamp, 42)
assert_eq("pred_02_deserialize_position", restored.position, Vector3(10, 5, 3))
assert_eq("pred_02_deserialize_grounded", restored.grounded, true)
# Test ClientPrediction ring buffer
var cp_script = load("res://scripts/network/client_prediction.gd")
var cp = cp_script.new()
cp._snapshot_buffer.resize(64)
var test_snap = snapshot_script.new()
test_snap.timestamp = 5
test_snap.position = Vector3(1, 2, 3)
cp._snapshot_buffer[5 % 64] = test_snap
cp._pending_inputs[5] = {"move_direction": Vector3(0, 0, -1)}
assert_true("pred_02_buffer_has_snapshot",
cp._snapshot_buffer[5 % 64] != null,
"Snapshot should be stored in ring buffer")
check_pass("pred_02_buffer_and_serialization_ok")
func test_pred_lag_compensation_rewind() -> void:
if _game_server == null:
check_fail("pred_03_lag_comp", "GameServer not available")
return
var lc = _game_server.lag_compensation
if lc == null:
check_fail("pred_03_lag_comp", "LagCompensation not wired in GameServer")
return
var test_node := Node3D.new()
test_node.global_position = Vector3(10, 0, 20)
add_child(test_node)
var entity_id = 9999
lc.register_player_node(entity_id, test_node)
lc.record_tick(0)
test_node.global_position = Vector3(30, 0, 40)
lc.record_tick(1)
# Move node away for rewind test
test_node.global_position = Vector3(50, 0, 60)
var origin := Vector3(0, 0, 0)
var direction := Vector3(0, 0, -1).normalized()
var result = lc.rewind_and_raycast(0, origin, direction, 100.0, [])
assert_true("pred_03_rewind_no_crash", true,
"rewind_and_raycast completed without error")
var pos_after: Vector3 = test_node.global_position
assert_eq("pred_03_node_restored", pos_after, Vector3(50, 0, 60),
"Node position should be restored after rewind")
lc.unregister_player_node(entity_id)
test_node.queue_free()
func test_pred_packet_loss_recovery() -> void:
var cp_script = load("res://scripts/network/client_prediction.gd")
var snapshot_script = load("res://scripts/network/snapshot.gd")
var cp = cp_script.new()
cp._snapshot_buffer.resize(64)
for t in range(5):
var snap = snapshot_script.new()
snap.timestamp = t
snap.position = Vector3(t * 2.0, 0, 0)
snap.rotation = Quaternion.IDENTITY
snap.velocity = Vector3(2, 0, 0)
snap.grounded = true
cp._snapshot_buffer[t % 64] = snap
cp._pending_inputs[t] = {"move_direction": Vector3(1, 0, 0), "input_sequence": t}
cp._last_confirmed_tick = -1
var pending_after: Array[int] = []
for tick in cp._pending_inputs.keys():
if tick > 2:
pending_after.append(tick)
pending_after.sort()
assert_eq("pred_04_pending_count", pending_after.size(), 2,
"Should have 2 pending inputs after tick 2 (ticks 3, 4)")
assert_eq("pred_04_pending_tick_3", pending_after[0], 3,
"First pending tick should be 3")
assert_eq("pred_04_pending_tick_4", pending_after[1], 4,
"Second pending tick should be 4")
check_pass("pred_04_packet_loss_recovery_logic")
# ===================================================================
# GROUP 3: Round Lifecycle
# ===================================================================
func test_round_transitions() -> void:
if _game_server == null:
check_fail("round_01_transitions", "GameServer not available")
return
var rm = _game_server.round_manager
if rm == null:
check_fail("round_01_transitions", "RoundManager not available")
return
assert_eq("round_01_initial_warmup", rm.get_phase(), rm.RoundPhase.WARMUP,
"Initial phase should be WARMUP")
rm.start_match()
assert_eq("round_01_after_start_match", rm.get_phase(), rm.RoundPhase.PREP,
"After start_match, phase should be PREP")
assert_eq("round_01_round_1", rm.get_round_num(), 1,
"Round number should be 1")
assert_true("round_01_match_started", rm.is_match_started(),
"Match should be started")
rm._phase_timer = 0.001
rm._advance_timer(0.1)
assert_eq("round_01_live", rm.get_phase(), rm.RoundPhase.LIVE,
"After PREP timer expires, phase should be LIVE")
rm.end_round(TeamData.Team.COUNTER_TERRORIST, "test")
assert_eq("round_01_post", rm.get_phase(), rm.RoundPhase.POST,
"After end_round, phase should be POST")
var ct_score = rm.team_manager.get_team_score(TeamData.Team.COUNTER_TERRORIST)
assert_eq("round_01_score_ct", ct_score, 1,
"CT should have 1 point after winning")
check_pass("round_01_transition_sequence_ok")
func test_round_bomb_explosion() -> void:
if _game_server == null:
check_fail("round_02_bomb_explosion", "GameServer not available")
return
var rm = _game_server.round_manager
if rm == null:
check_fail("round_02_bomb_explosion", "RoundManager not available")
return
var tm = _game_server.team_manager
if tm == null:
check_fail("round_02_bomb_explosion", "TeamManager not available")
return
rm.start_match()
rm.start_live()
rm.end_round(TeamData.Team.TERRORIST, "bomb_exploded")
assert_eq("round_02_phase", rm.get_phase(), rm.RoundPhase.POST,
"Phase should be POST after bomb explosion")
var t_score = tm.get_team_score(TeamData.Team.TERRORIST)
assert_eq("round_02_t_score", t_score, 1,
"Terrorist should have 1 point")
check_pass("round_02_bomb_explosion_ok")
func test_round_bomb_defuse() -> void:
if _game_server == null:
check_fail("round_03_bomb_defuse", "GameServer not available")
return
var rm = _game_server.round_manager
if rm == null:
check_fail("round_03_bomb_defuse", "RoundManager not available")
return
var tm = _game_server.team_manager
if tm == null:
check_fail("round_03_bomb_defuse", "TeamManager not available")
return
rm.start_round(2)
rm.start_live()
rm.end_round(TeamData.Team.COUNTER_TERRORIST, "bomb_defused")
assert_eq("round_03_phase", rm.get_phase(), rm.RoundPhase.POST,
"Phase should be POST after bomb defuse")
var ct_score = tm.get_team_score(TeamData.Team.COUNTER_TERRORIST)
assert_eq("round_03_ct_score_updated", ct_score, 2,
"CT should have 2 points (1 from prev + 1 from defuse)")
check_pass("round_03_bomb_defuse_ok")
# ===================================================================
# GROUP 4: Economy & Weapons
# ===================================================================
func test_econ_kill_reward() -> void:
if _game_server == null:
check_fail("econ_01_kill_reward", "GameServer not available")
return
var em = _game_server.economy_manager
if em == null:
check_fail("econ_01_kill_reward", "EconomyManager not available")
return
em.register_player(5001)
var initial_money = em.get_money(5001)
assert_eq("econ_01_initial_money", initial_money, 800,
"Starting money should be 800")
em.award_kill_reward(5001)
var after_kill = em.get_money(5001)
assert_eq("econ_01_after_kill", after_kill, 1100,
"Money should increase by 300 (kill reward)")
var signal_data: Dictionary = {}
var signal_conn = func(pid, old_amt, new_amt, reason):
signal_data = {"player_id": pid, "old": old_amt, "new": new_amt, "reason": reason}
em.money_changed.connect(signal_conn)
em.award_kill_reward(5001)
assert_eq("econ_01_signal_reason", signal_data.get("reason", ""), "kill",
"Signal reason should be 'kill'")
assert_eq("econ_01_signal_new_amount", signal_data.get("new", 0), 1400,
"Signal should report new balance after kill reward")
em.money_changed.disconnect(signal_conn)
check_pass("econ_01_kill_reward_ok")
func test_econ_round_win_loss() -> void:
if _game_server == null:
check_fail("econ_02_win_loss", "GameServer not available")
return
var em = _game_server.economy_manager
var tm = _game_server.team_manager
if em == null or tm == null:
check_fail("econ_02_win_loss", "EconomyManager or TeamManager not available")
return
em.register_player(6001)
em.register_player(6002)
tm.assign_player(6001, TeamData.Team.COUNTER_TERRORIST)
tm.assign_player(6002, TeamData.Team.TERRORIST)
em.set_money(6001, 0, "admin")
em.set_money(6002, 0, "admin")
var ct_players = [6001]
em.award_round_win(TeamData.Team.COUNTER_TERRORIST, ct_players)
assert_eq("econ_02_ct_after_win", em.get_money(6001), 3250,
"CT player should get 3250 round win bonus")
var t_players = [6002]
em.award_round_loss(TeamData.Team.TERRORIST, t_players)
assert_eq("econ_02_t_after_first_loss", em.get_money(6002), 1900,
"T player should get 1900 base loss money on first loss")
# Second consecutive loss with escalated bonus
em.set_money(6002, 0, "admin")
em.award_round_loss(TeamData.Team.TERRORIST, t_players)
assert_eq("econ_02_t_after_second_loss", em.get_money(6002), 2400,
"T player should get 1900 + 500 = 2400 on second consecutive loss")
check_pass("econ_02_win_loss_ok")
# Clean up
tm.unregister_player(6001)
tm.unregister_player(6002)
em.unregister_player(6001)
em.unregister_player(6002)
func test_econ_weapon_purchase() -> void:
if _game_server == null:
check_fail("econ_03_purchase", "GameServer not available")
return
var em = _game_server.economy_manager
var ws = _game_server.weapon_server
if em == null:
check_fail("econ_03_purchase", "EconomyManager not available")
return
em.register_player(7001)
em.set_money(7001, 5000, "admin")
assert_true("econ_03_can_afford_rifle", em.can_afford(7001, 2700),
"Player with 5000 should afford a 2700 rifle")
assert_true("econ_03_can_afford_pistol", em.can_afford(7001, 500),
"Player should afford a 500 pistol")
var success = em.spend_money(7001, 2700)
assert_true("econ_03_spend_rifle", success, "Spending 2700 should succeed")
assert_eq("econ_03_after_rifle", em.get_money(7001), 2300,
"After spending 2700, balance should be 2300")
var fail_spend = em.spend_money(7001, 99999)
assert_false("econ_03_insufficient_funds", fail_spend,
"Spending more than balance should fail")
if ws:
ws.give_weapon(7001, "rifle")
var can_fire = ws.can_fire(7001, "rifle")
assert_true("econ_03_can_fire_rifle", can_fire,
"Player should be able to fire rifle after purchasing it")
var ammo_info = ws.get_ammo_info(7001, "rifle")
assert_eq("econ_03_rifle_ammo", ammo_info.get("ammo", 0), 30,
"Rifle should start with full magazine (30 rounds)")
else:
check_fail("econ_03_purchase", "WeaponServer not available")
em.unregister_player(7001)
if ws:
ws.unregister_player(7001)
# ===================================================================
# GROUP 5: RCON
# ===================================================================
func test_rcon_auth_command() -> void:
if _game_server == null:
check_fail("rcon_01_auth_command", "GameServer not available")
return
var rcon_script = load("res://server/scripts/rcon_server.gd")
var rcon = rcon_script.new()
rcon.password = "testpass"
rcon.enabled = false
add_child(rcon)
var handler = null
for child in rcon.get_children():
if child.has_method("handle_command"):
handler = child
break
if handler == null:
var handler_path = "res://server/scripts/rcon_command_handler.gd"
if ResourceLoader.exists(handler_path):
handler = load(handler_path).new()
add_child(handler)
if handler:
var response = handler.handle_command("echo", ["hello"])
assert_true("rcon_01_echo_response", "hello" in response,
"Echo command should return the argument")
response = handler.handle_command("help", [])
assert_true("rcon_01_help_response", "Commands" in response or "help" in response,
"Help command should return command list")
response = handler.handle_command("echo", [])
assert_true("rcon_01_ping_response", "Pong" in response,
"Echo without args should return 'Pong'")
response = handler.handle_command("status", [])
assert_true("rcon_01_status_response",
"Status" in response or "Server" in response,
"Status command should return server info")
else:
check_fail("rcon_01_auth_command", "Command handler not created")
rcon.queue_free()
if handler and handler.get_parent():
handler.queue_free()
func test_rcon_invalid_password() -> void:
var rcon_script = load("res://server/scripts/rcon_server.gd")
var rcon = rcon_script.new()
rcon.password = "secretpass"
rcon.enabled = false
add_child(rcon)
assert_eq("rcon_02_password_set", rcon.password, "secretpass",
"Password should be set correctly")
# Test IP ban logic
rcon._banned_ips["127.0.0.1"] = Time.get_unix_time_from_system() + 5.0
assert_true("rcon_02_ip_banned", rcon._is_ip_banned("127.0.0.1"),
"IP should be banned after 3 failures")
rcon.queue_free()
check_pass("rcon_02_bad_password_rejected")
func test_rcon_start_match_end_round() -> void:
if _game_server == null:
check_fail("rcon_03_start_match_end_round", "GameServer not available")
return
var rm = _game_server.round_manager
if rm == null:
check_fail("rcon_03_start_match_end_round", "RoundManager not available")
return
rm.start_warmup()
var handler_path = "res://server/scripts/rcon_command_handler.gd"
if not ResourceLoader.exists(handler_path):
check_fail("rcon_03_start_match_end_round", "RconCommandHandler not found")
return
var handler = load(handler_path).new()
add_child(handler)
if handler.has_signal("rcon_command"):
handler.rcon_command.connect(_game_server._on_rcon_command)
var response = handler.handle_command("start_match", [])
assert_true("rcon_03_start_match_response",
"Command" in response or "dispatched" in response,
"start_match command should be dispatched")
assert_true("rcon_03_match_started", rm.is_match_started(),
"Match should be started via RCON")
rm.start_live()
response = handler.handle_command("end_round", ["ct", "bomb_defused"])
assert_true("rcon_03_end_round_response",
"Command" in response or "dispatched" in response,
"end_round command should be dispatched")
assert_eq("rcon_03_phase_post", rm.get_phase(), rm.RoundPhase.POST,
"Phase should be POST after end_round via RCON")
handler.queue_free()
check_pass("rcon_03_start_match_end_round_ok")
-1
View File
@@ -1 +0,0 @@
uid://tenyf8ht7hfy
-142
View File
@@ -1,142 +0,0 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════════════
# tc/netem Network Profile Manager for Tactical Shooter
#
# Applies tc qdisc netem rules to simulate real-world network conditions
# on the game server's port. Requires root (sudo).
#
# Usage:
# sudo ./netem_profiles.sh apply lan # 0% loss, near-zero latency
# sudo ./netem_profiles.sh apply dsl # 0.5% loss, 20ms ±5ms
# sudo ./netem_profiles.sh apply cellular # 2% loss, 60ms ±20ms
# sudo ./netem_profiles.sh apply satellite # 1% loss, 600ms ±50ms
# sudo ./netem_profiles.sh apply congested # 5% loss, 100ms ±40ms, reorder
# sudo ./netem_profiles.sh apply warzone # 10% loss, 200ms ±60ms, burst
# sudo ./netem_profiles.sh apply extreme # 25% loss, 500ms ±100ms
# sudo ./netem_profiles.sh apply edge # 50% loss, 1000ms ±200ms (catastrophic)
# sudo ./netem_profiles.sh off # Remove all netem rules
# sudo ./netem_profiles.sh status # Show current rules
#
# Requires: iproute2 (tc), root, IFACE (default: eth0), PORT (default: 7777)
# ═══════════════════════════════════════════════════════════════════════════
set -euo pipefail
# ─── Config ────────────────────────────────────────────────────────────────
IFACE="${NETEM_IFACE:-eth0}"
PORT="${NETEM_PORT:-7777}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ─── Color ─────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# ─── Help / Usage ──────────────────────────────────────────────────────────
usage() {
echo "Usage: $0 {apply <profile>|off|status}"
echo ""
echo "Profiles:"
echo " lan 0% loss, 1ms latency"
echo " dsl 0.5% loss, 20ms ± 5ms jitter"
echo " cellular 2% loss, 60ms ±20ms jitter"
echo " satellite 1% loss, 600ms ±50ms jitter"
echo " congested 5% loss, 100ms ±40ms jitter, reorder 5%"
echo " warzone 10% loss, 200ms ±60ms jitter, 1% dupe, reorder 2%"
echo " extreme 25% loss, 500ms ±100ms jitter, reorder 10%"
echo " edge 50% loss, 1000ms ±200ms jitter (catastrophic)"
exit 1
}
# ─── Profile definitions ───────────────────────────────────────────────────
declare -A PROFILES
# Format: loss lat jitter reorder reorder_corr dup dup_corr
# loss: percentage (0-100)
# lat: one-way latency in ms
# jitter: ±ms
# reorder: percentage of packets reordered
# reorder_corr: correlation factor for reorder (0-100)
# dup: duplicate percentage
# dup_corr: correlation factor for duplicate
PROFILES["lan"]="loss 0% delay 1ms 0ms"
PROFILES["dsl"]="loss 0.5% delay 20ms 5ms"
PROFILES["cellular"]="loss 2% delay 60ms 20ms"
PROFILES["satellite"]="loss 1% delay 600ms 50ms"
PROFILES["congested"]="loss 5% delay 100ms 40ms reorder 5% 50%"
PROFILES["warzone"]="loss 10% delay 200ms 60ms 25% duplicate 1% 25%"
PROFILES["extreme"]="loss 25% delay 500ms 100ms reorder 10% 50%"
PROFILES["edge"]="loss 50% delay 1000ms 200ms"
# ─── Apply netem to a specific port via iptables + tc mirror ───────────────
apply_netem() {
local profile_name="$1"
local profile_params="${PROFILES[$profile_name]:-}"
if [ -z "$profile_params" ]; then
echo -e "${RED}Unknown profile: $profile_name${NC}"
usage
fi
echo -e "${CYAN}Applying netem profile: ${YELLOW}$profile_name${NC}"
echo -e " Interface: $IFACE Port: $PORT"
echo -e " Parameters: $profile_params"
echo ""
# Remove existing qdisc on the root
sudo tc qdisc del dev "$IFACE" root 2>/dev/null || true
# Apply netem to ALL traffic on the interface (targeted filtering is complex)
# To target specific ports, you'd use iptables + ifb mirroring, but for
# dev/testing, applying to the full interface is sufficient when run on
# a dedicated test machine.
sudo tc qdisc add dev "$IFACE" root netem $profile_params
echo -e "${GREEN}✓ Profile '$profile_name' applied${NC}"
show_status
}
# ─── Remove all netem rules ────────────────────────────────────────────────
remove_netem() {
echo -e "${YELLOW}Removing all netem rules on $IFACE...${NC}"
sudo tc qdisc del dev "$IFACE" root 2>/dev/null || true
echo -e "${GREEN}✓ Netem rules removed (interface returned to normal)${NC}"
}
# ─── Show current status ───────────────────────────────────────────────────
show_status() {
echo ""
echo -e "${CYAN}Current qdisc on $IFACE:${NC}"
sudo tc qdisc show dev "$IFACE" 2>/dev/null || echo " (no qdisc configured — interface is clean)"
echo ""
echo -e "${CYAN}Filter by port $PORT (iptables):${NC}"
sudo iptables -L netem -n 2>/dev/null || echo " (no netem iptables chain)"
}
# ─── Main ──────────────────────────────────────────────────────────────────
if [ $# -lt 1 ]; then
usage
fi
case "$1" in
apply)
if [ $# -lt 2 ]; then
echo -e "${RED}Error: 'apply' requires a profile name${NC}"
echo ""
usage
fi
apply_netem "$2"
;;
off|remove|clear)
remove_netem
;;
status)
show_status
;;
*)
usage
;;
esac
-654
View File
@@ -1,654 +0,0 @@
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())
-1
View File
@@ -1 +0,0 @@
uid://ch5ajnsrenxrf
-648
View File
@@ -1,648 +0,0 @@
extends Node
# ═══════════════════════════════════════════════════════════════════════════
# Network Simulator — Unit & Integration Tests
#
# Tests the NetSim network condition simulator for correctness:
# - Packet loss rate (statistical, within confidence)
# - Latency simulation (timing)
# - Jitter distribution
# - Packet reordering
# - Duplication rate
# - Burst loss pattern
# - Bandwidth throttling
# - Silent passthrough with all conditions at 0
# - Profile presets
# - Multi-peer isolation
#
# Run with: godot --headless --script tests/test_net_sim.gd
# ═══════════════════════════════════════════════════════════════════════════
const SIM_SCRIPT = preload("res://server/scripts/net_sim.gd")
const UTILS_SCRIPT = preload("res://tests/test_utils.gd")
var sim # NetSim — loaded via preload
var tu # TestUtils — loaded via preload
# ─── Test Suites ──────────────────────────────────────────────────────────
func _test_silent_passthrough() -> void:
tu.describe("Silent Passthrough — zero conditions, no side effects")
sim.packet_loss = 0.0
sim.latency_ms = 0
sim.jitter_ms = 0
sim.reorder_window = 0
sim.duplicate_rate = 0.0
sim.bandwidth_limit = 0
sim.burst_loss_count = 0
sim.burst_loss_interval = 0
var sent = 100
var received = 0
for i in range(sent):
var pkt = SIM_SCRIPT.make_test_packet(i, "test")
if sim.send_packet(pkt, SIM_SCRIPT.CHANNEL_UNRELIABLE):
pass
# All should be in inbound queue (no loss)
received = sim._outbound_queue.size()
tu.assert_eq(received, sent, "all packets queued with no conditions")
# No dropped stats
tu.assert_eq(sim._total_lost, 0, "zero loss")
tu.assert_eq(sim._total_burst_dropped, 0, "zero burst drops")
tu.assert_eq(sim._total_bandwidth_dropped, 0, "zero bw drops")
# Drain queue
sim._outbound_queue.clear()
func _test_packet_loss_rate() -> void:
tu.describe("Packet Loss — statistical rate verification")
sim.packet_loss = 0.2 # 20% loss
sim.latency_ms = 0
sim.jitter_ms = 0
sim.reorder_window = 0
sim.duplicate_rate = 0.0
sim.bandwidth_limit = 0
sim.burst_loss_count = 0
sim.burst_loss_interval = 0
sim.reset_stats()
var total = 5000
for i in range(total):
var pkt = SIM_SCRIPT.make_test_packet(i, "payload_%d" % i)
sim.send_packet(pkt)
# With 20% loss on 5000 packets: expected ~1000 lost
# Acceptable range: 8001200 (within 4 sigma of binomial)
var lost = sim._total_lost
tu.assert_true(lost >= 700 and lost <= 1300,
"loss count %d in acceptable range [700, 1300] for 20%% on %d" % [lost, total])
sim.reset_stats()
sim.packet_loss = 0.0
func _test_latency_timing() -> void:
tu.describe("Latency — timing accuracy")
sim.packet_loss = 0.0
sim.latency_ms = 100 # 100ms one-way
sim.jitter_ms = 0
sim.reorder_window = 0
sim.duplicate_rate = 0.0
sim.burst_loss_count = 0
sim.burst_loss_interval = 0
sim.reset_stats()
var pkt = SIM_SCRIPT.make_test_packet(1, "latency_test")
var before = Time.get_ticks_msec()
sim.send_packet(pkt)
# Packet should be in queue with delay
tu.assert_eq(sim._outbound_queue.size(), 1, "one packet queued")
var queued = sim._outbound_queue[0]
var expected_delivery = before + 100
var actual_delivery = queued.time
var diff = actual_delivery - before
tu.assert_true(diff >= 95 and diff <= 105,
"latency %dms within [95, 105]ms tolerance (got %dms)" % [100, diff])
sim.reset_stats()
func _test_jitter_distribution() -> void:
tu.describe("Jitter — distribution within range")
sim.packet_loss = 0.0
sim.latency_ms = 50
sim.jitter_ms = 30 # ±30ms -> effective range: [20, 80]
sim.reset_stats()
var delays: Array[float] = []
var samples = 200
var before = Time.get_ticks_msec()
for i in range(samples):
var pkt = SIM_SCRIPT.make_test_packet(i, "jitter_test")
sim.send_packet(pkt)
var queued = sim._outbound_queue[i]
delays.append(queued.time - before)
before = Time.get_ticks_msec() # time moves forward naturally
# Check bounds: each delay should be latency ± jitter
# Min: max(0, 50-30) = 20, Max: 50+30 = 80
var within_bounds = true
var min_seen = 999.0
var max_seen = 0.0
for d in delays:
if d < 20.0 or d > 80.0:
within_bounds = false
min_seen = min(min_seen, d)
max_seen = max(max_seen, d)
tu.assert_true(within_bounds,
"all jittered delays within [20, 80]ms (min=%.1f max=%.1f)" % [min_seen, max_seen])
sim.reset_stats()
func _test_packet_duplication() -> void:
tu.describe("Duplication — statistical rate verification")
sim.packet_loss = 0.0
sim.latency_ms = 0
sim.duplicate_rate = 0.15 # 15%
sim.reset_stats()
var total = 2000
for i in range(total):
var pkt = SIM_SCRIPT.make_test_packet(i, "dupe_test")
sim.send_packet(pkt)
var dupe = sim._total_duplicated
tu.assert_true(dupe >= 150 and dupe <= 450,
"duplicate count %d in acceptable range [150, 450] for 15%% on %d" % [dupe, total])
sim.duplicate_rate = 0.0
sim.reset_stats()
func _test_burst_loss() -> void:
tu.describe("Burst Loss — pattern verification")
sim.packet_loss = 0.0
sim.latency_ms = 0
sim.burst_loss_count = 5 # drop 5 consecutive packets
sim.burst_loss_interval = 10 # every 10 packets
sim.reset_stats()
var total = 50
for i in range(total):
var pkt = SIM_SCRIPT.make_test_packet(i, "burst")
sim.send_packet(pkt)
# With burst_loss_interval=10 and burst_loss_count=5:
# Packets 10-14 dropped, 20-24 dropped, 30-34 dropped, 40-44 dropped = 20 drops
var burst_drops = sim._total_burst_dropped
tu.assert_eq(burst_drops, 20, "expected 20 burst drops out of 50 (5/10 pattern = 20)")
# The sum of inbound + burst_dropped should equal total
var queued = sim._outbound_queue.size()
tu.assert_eq(queued + burst_drops, total, "queued %d + burst_dropped %d = total %d" % [queued, burst_drops, total])
sim.burst_loss_count = 0
sim.burst_loss_interval = 0
sim.reset_stats()
func _test_bandwidth_throttling() -> void:
tu.describe("Bandwidth — throttling verification")
sim.packet_loss = 0.0
sim.latency_ms = 0
var pkt_size = 100 # bytes per test packet
sim.bandwidth_limit = 2000 # 2000 bytes/s
sim.reset_stats()
var total = 100
for i in range(total):
var data = PackedByteArray()
data.resize(pkt_size)
data[0] = i
sim.send_packet(data)
# At 2000 bytes/s with 100-byte packets: max ~20 packets should pass
var bw_drops = sim._total_bandwidth_dropped
tu.assert_true(bw_drops >= 70,
"at least 70 of 100 packets dropped by bandwidth limit (got %d)" % bw_drops)
tu.assert_true(sim._outbound_queue.size() <= 30,
"at most 30 packets pass bandwidth limit (got %d)" % sim._outbound_queue.size())
sim.bandwidth_limit = 0
sim.reset_stats()
func _test_reorder_window() -> void:
tu.describe("Packet Reordering — window flipping")
sim.packet_loss = 0.0
sim.latency_ms = 1
sim.jitter_ms = 0
sim.reorder_window = 5
sim.reset_stats()
# Send 20 packets
for i in range(20):
var pkt = SIM_SCRIPT.make_test_packet(i, "reorder")
sim.send_packet(pkt)
# Flush the reorder buffer
sim._flush_reorder_buffer()
# The reorder buffer shuffles groups of 5.
# This doesn't guarantee reorder happened (shuffle may produce same order),
# but it should have triggered the reorder path
tu.assert_true(sim._total_reordered > 0,
"reordering occurred (>0 packets shuffled)")
sim.reorder_window = 0
sim.reset_stats()
func _test_profile_presets() -> void:
tu.describe("Profile Presets — quick-set correctness")
tu.assert_true(sim.set_profile("lan"), "lan profile set")
tu.assert_eq(sim.packet_loss, 0.0, "lan: no loss")
tu.assert_eq(sim.latency_ms, 1, "lan: 1ms latency")
tu.assert_true(sim.set_profile("cellular"), "cellular profile set")
tu.assert_eq(sim.packet_loss, 0.02, "cellular: 2% loss")
tu.assert_eq(sim.latency_ms, 60, "cellular: 60ms latency")
tu.assert_true(sim.set_profile("satellite"), "satellite profile set")
tu.assert_eq(sim.packet_loss, 0.01, "satellite: 1% loss")
tu.assert_eq(sim.latency_ms, 600, "satellite: 600ms latency")
tu.assert_true(sim.set_profile("warzone"), "warzone profile set")
tu.assert_eq(sim.packet_loss, 0.10, "warzone: 10% loss")
tu.assert_eq(sim.latency_ms, 200, "warzone: 200ms latency")
tu.assert_eq(sim.burst_loss_count, 3, "warzone: burst loss 3")
# Unknown profile should fail
tu.assert_false(sim.set_profile("nonexistent"), "unknown profile fails")
func _test_stats_report() -> void:
tu.describe("Stats Report — format correctness")
sim.packet_loss = 0.1
sim.reset_stats()
for i in range(100):
var pkt = SIM_SCRIPT.make_test_packet(i, "stats")
sim.send_packet(pkt)
var report = sim.get_stats_string()
tu.assert_true(report.contains("Sent:"), "report contains Sent:")
tu.assert_true(report.contains("Config:"), "report contains Config:")
tu.assert_true(report.contains("Effective loss:"), "report contains Effective loss:")
func _test_high_loss_extreme() -> void:
tu.describe("Extreme Conditions — 100% packet loss")
sim.packet_loss = 1.0
sim.latency_ms = 0
sim.reset_stats()
for i in range(100):
var pkt = SIM_SCRIPT.make_test_packet(i, "all_lost")
sim.send_packet(pkt)
tu.assert_eq(sim._total_lost, 100, "all 100 packets lost at 100% loss")
tu.assert_eq(sim._outbound_queue.size(), 0, "no packets in queue at 100% loss")
sim.packet_loss = 0.0
sim.reset_stats()
func _test_zero_bandwidth() -> void:
tu.describe("Edge: zero bandwidth limit")
sim.packet_loss = 0.0
sim.latency_ms = 0
sim.bandwidth_limit = 0 # 0 = unlimited
sim.reset_stats()
for i in range(10):
var pkt = SIM_SCRIPT.make_test_packet(i, "no_bw_limit")
sim.send_packet(pkt)
# Should not drop any — 0 means unlimited
tu.assert_eq(sim._total_bandwidth_dropped, 0, "zero bandwidth limit treated as unlimited")
tu.assert_eq(sim._outbound_queue.size(), 10, "all 10 packets queued with unlimited bw")
sim.reset_stats()
func _test_complex_profile() -> void:
tu.describe("Complex Profile — combined conditions")
# Simulate a realistic bad network: 3% loss, 80ms latency, 25ms jitter
sim.packet_loss = 0.03
sim.latency_ms = 80
sim.jitter_ms = 25
sim.duplicate_rate = 0.01
sim.reorder_window = 0
sim.bandwidth_limit = 0
sim.burst_loss_count = 0
sim.reset_stats()
var total = 1000
for i in range(total):
var pkt = SIM_SCRIPT.make_test_packet(i, "complex")
sim.send_packet(pkt)
var queued = sim._outbound_queue.size()
var lost = sim._total_lost
var duped = sim._total_duplicated
var delayed = sim._total_delayed
# At 3% loss: ~30 lost (range: 10-60)
tu.assert_true(lost >= 5 and lost <= 80,
"loss %d in range for 3%% on %d" % [lost, total])
# At 1% dupe: ~10 dupes
tu.assert_true(duped >= 0 and duped <= 40,
"dupes %d in range for 1%% on %d" % [duped, total])
# All non-lost packets should be delayed (latency > 0)
tu.assert_true(delayed >= queued,
"all queued packets marked as delayed (%d >= %d)" % [delayed, queued])
sim.reset_stats()
# ═══════════════════════════════════════════════════════════════════════════
# NEW: Edge Case Tests (Phase 6 bug bash)
# ═══════════════════════════════════════════════════════════════════════════
func _test_jitter_exceeds_latency() -> void:
tu.describe("Edge: Jitter exceeds latency — negative delay clamping")
# When jitter > latency, the per-packet delay can go negative.
# max(0, delay) clamps to 0. Test that all packets still get some delay
# and no crashes occur.
sim.packet_loss = 0.0
sim.latency_ms = 30
sim.jitter_ms = 100 # ±100ms on 30ms base → effective range [-70, +130] → clamped to [0, 130]
sim.reset_stats()
var total = 500
for i in range(total):
var pkt = SIM_SCRIPT.make_test_packet(i, "neg_jitter")
sim.send_packet(pkt)
# All packets should be queued (no loss)
var queued = sim._outbound_queue.size()
tu.assert_eq(queued, total, "all %d packets queued despite negative jitter" % total)
# All should be marked as delayed (some have 0ms due to clamping but
# the _total_delayed counter counts packets that got any non-zero delay)
tu.assert_true(sim._total_delayed > 0,
"at least some packets received non-zero delay (got %d)" % sim._total_delayed)
# Verify no negative delivery times
for entry in sim._outbound_queue:
tu.assert_true(entry.time >= 0, "all delivery times >= 0 (no wrap)")
sim.reset_stats()
func _test_combined_high_loss_latency() -> void:
tu.describe("Edge: High loss (25%) + high latency (500ms) combined")
# Simulate extreme network conditions: 25% loss, 500ms latency, 100ms jitter
sim.packet_loss = 0.25
sim.latency_ms = 500
sim.jitter_ms = 100
sim.duplicate_rate = 0.0
sim.bandwidth_limit = 0
sim.burst_loss_count = 0
sim.reset_stats()
var total = 2000
for i in range(total):
var pkt = SIM_SCRIPT.make_test_packet(i, "extreme")
sim.send_packet(pkt)
var lost = sim._total_lost
var queued = sim._outbound_queue.size()
# At 25% loss on 2000: expected ~500 lost (range: 350-650)
tu.assert_true(lost >= 300 and lost <= 700,
"loss %d in acceptable range [300, 700] for 25%% on %d" % [lost, total])
# Queued = total - lost
tu.assert_eq(queued, total - lost,
"queued %d + lost %d = total %d" % [queued, lost, total])
# All queued packets should have delay near 500ms
var min_delay = 99999.0
var max_delay = 0.0
var now = Time.get_ticks_msec()
for entry in sim._outbound_queue:
var effective = entry.time - now
min_delay = min(min_delay, effective)
max_delay = max(max_delay, effective)
# With 500ms latency + 100ms jitter: effective range [400, 600]
tu.assert_true(min_delay >= 0,
"minimum effective delay >= 0 (got %.1f)" % min_delay)
tu.assert_true(max_delay <= 700,
"maximum effective delay <= 700 (got %.1f)" % max_delay)
sim.reset_stats()
func _test_burst_with_reorder() -> void:
tu.describe("Edge: Burst loss + reorder window combined")
sim.packet_loss = 0.0
sim.latency_ms = 5
sim.jitter_ms = 0
sim.burst_loss_count = 3
sim.burst_loss_interval = 10
sim.reorder_window = 4
sim.reset_stats()
var total = 60
for i in range(total):
var pkt = SIM_SCRIPT.make_test_packet(i, "burst_reorder")
sim.send_packet(pkt)
# Flush reorder buffer
sim._flush_reorder_buffer()
# With interval=10, count=3: ~6 bursts = 18 drops
var burst_drops = sim._total_burst_dropped
var queued = sim._outbound_queue.size()
var triggered = sim._total_reordered
tu.assert_eq(queued + burst_drops, total,
"queued %d + burst_drops %d = total %d" % [queued, burst_drops, total])
tu.assert_true(triggered > 0,
"reordering was triggered (%d packets reordered)" % triggered)
sim.burst_loss_count = 0
sim.burst_loss_interval = 0
sim.reorder_window = 0
sim.reset_stats()
func _test_rapid_sequence_10k() -> void:
tu.describe("Performance: 10,000 rapid packets — timing precision")
# Simulate rapid-fire packet generation at 128 tickrate
sim.packet_loss = 0.02
sim.latency_ms = 50
sim.jitter_ms = 10
sim.duplicate_rate = 0.005
sim.reset_stats()
var total = 10000
for i in range(total):
var pkt = SIM_SCRIPT.make_test_packet(i, "rapid_%d" % i)
sim.send_packet(pkt)
var lost = sim._total_lost
var duped = sim._total_duplicated
var queued = sim._outbound_queue.size()
tu.assert_eq(queued + lost, total,
"queued %d + lost %d = total %d (no phantom packets)" % [queued, lost, total])
# At 2% loss on 10000: ~200 lost (range: 100-400)
tu.assert_true(lost >= 50 and lost <= 500,
"loss %d in range for 2%% on %d" % [lost, total])
# At 0.5% dupe: ~50 dupes
tu.assert_true(duped >= 5 and duped <= 200,
"dupes %d in range for 0.5%% on %d" % [duped, total])
# Verify no negative delivery times
for entry in sim._outbound_queue:
tu.assert_true(entry.time >= 0, "all delivery times >= 0 (no integer overflow)")
sim.reset_stats()
func _test_bandwidth_with_loss() -> void:
tu.describe("Edge: Bandwidth limit combined with packet loss")
# Loss removes some packets before bandwidth check has a chance to drop them.
# Effective throughput = min(bandwidth adjusted, surviving packets).
sim.packet_loss = 0.1 # 10% loss
sim.latency_ms = 0
sim.bandwidth_limit = 5000 # 5000 bytes/s
sim.reset_stats()
var pkt_size = 100
var total = 200
for i in range(total):
var data = PackedByteArray()
data.resize(pkt_size)
data[0] = i
sim.send_packet(data)
var lost_loss = sim._total_lost
var bw_drops = sim._total_bandwidth_dropped
var queued = sim._outbound_queue.size()
# 10% of 200 = ~20 lost to random loss (range: 10-30)
tu.assert_true(lost_loss >= 5 and lost_loss <= 40,
"loss %d in range for 10%% on %d" % [lost_loss, total])
# Remaining 180 packets compete for 5000/100 = 50 packet slots
# But since bandwidth is checked after loss, at most ~50-60 should pass
tu.assert_true(bw_drops >= 100,
"at least 100 packets dropped by bandwidth limit (got %d)" % bw_drops)
tu.assert_true(queued + bw_drops + lost_loss == total,
"accounting: queued %d + bw %d + lost %d = total %d" % [queued, bw_drops, lost_loss, total])
sim.bandwidth_limit = 0
sim.reset_stats()
func _test_zero_packet_loss_timing() -> void:
tu.describe("Edge: Zero loss + zero latency — no side effects")
sim.packet_loss = 0.0
sim.latency_ms = 0
sim.jitter_ms = 0
sim.reorder_window = 0
sim.duplicate_rate = 0.0
sim.bandwidth_limit = 0
sim.burst_loss_count = 0
sim.burst_loss_interval = 0
sim.reset_stats()
var total = 500
var seq = 0
for i in range(total):
var pkt = SIM_SCRIPT.make_test_packet(i, "zero")
var ok = sim.send_packet(pkt)
tu.assert_true(ok, "packet %d send returned true" % i)
# All should queue with 0 delay (delivery at now + 0ms)
var queued = sim._outbound_queue.size()
tu.assert_eq(queued, total, "all %d packets queued" % total)
tu.assert_eq(sim._total_lost, 0, "zero loss")
tu.assert_eq(sim._total_duplicated, 0, "zero dupes")
tu.assert_eq(sim._total_burst_dropped, 0, "zero burst drops")
tu.assert_eq(sim._total_bandwidth_dropped, 0, "zero bw drops")
# Delivery times should all be at or near current time
var now = Time.get_ticks_msec()
for entry in sim._outbound_queue:
tu.assert_true(entry.time >= now - 5 and entry.time <= now + 5,
"zero-delay packet delivery at current time (time=%d, now=%d)" % [entry.time, now])
sim.reset_stats()
# ── Main Entry Point ──────────────────────────────────────────────────────
func _ready() -> void:
"""Run all tests and exit."""
print("\n══════════════════════════════════════════════")
print(" Network Simulator — Unit & Integration Tests")
print("══════════════════════════════════════════════\n")
sim = SIM_SCRIPT.new()
add_child(sim)
# Use deterministic seed for reproducibility
sim.seed_value = 42
sim._rng.seed = 42
tu = UTILS_SCRIPT.new()
add_child(tu)
# Run all test suites
_test_silent_passthrough()
_test_packet_loss_rate()
_test_latency_timing()
_test_jitter_distribution()
_test_packet_duplication()
_test_burst_loss()
_test_bandwidth_throttling()
_test_reorder_window()
_test_profile_presets()
_test_stats_report()
_test_high_loss_extreme()
_test_zero_bandwidth()
_test_complex_profile()
# Phase 6 bug bash edge case tests
_test_jitter_exceeds_latency()
_test_combined_high_loss_latency()
_test_burst_with_reorder()
_test_rapid_sequence_10k()
_test_bandwidth_with_loss()
_test_zero_packet_loss_timing()
# 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())
-1
View File
@@ -1 +0,0 @@
uid://klpd4bulsbph
-361
View File
@@ -1,361 +0,0 @@
extends Node
# ═══════════════════════════════════════════════════════════════════════════
# RCON Edge Case Tests
#
# Tests the RCON server for edge cases in protocol handling:
# - Auth state machine (password mismatch, 3-strike penalty)
# - Buffer overflow protection
# - Malformed commands
# - Empty/whitespace-only input
# - Concurrent connections
# - Large payloads
# - Extreme inputs
#
# NOTE: These tests require a TCPServer and StreamPeerTCP, which means
# they need the Godot engine to run. Some tests use the actual RCON
# server node with a local loopback connection.
#
# Run with: godot --headless --script tests/test_rcon_edge_cases.gd
# ═══════════════════════════════════════════════════════════════════════════
const RCON_SCRIPT = preload("res://server/scripts/rcon_server.gd")
const RCON_CMD_SCRIPT = preload("res://server/scripts/rcon_command_handler.gd")
const UTILS_SCRIPT = preload("res://tests/test_utils.gd")
var rcon # RconServer
var handler # RconCommandHandler
var tu # TestUtils
const LOCALHOST = "127.0.0.1"
const TEST_PORT = 28962 # Different from default (28960) to avoid conflicts
# ─── Suite: Auth State Machine ───────────────────────────────────────────
func _test_auth_correct_password() -> void:
tu.describe("Auth — correct password")
rcon.password = "testpass123"
rcon.enabled = true
await get_tree().create_timer(0.1).timeout # let server start
var client = StreamPeerTCP.new()
var err = client.connect_to_host(LOCALHOST, TEST_PORT)
tu.assert_eq(err, OK, "tcp connect succeeds")
await get_tree().create_timer(0.1).timeout
# Send password
client.put_data("testpass123\n".to_utf8_buffer())
await get_tree().create_timer(0.1).timeout
# Process the pending data (rcon_server._process needs to run)
rcon._process(0.1)
# Read response — should be auth_ok
if client.get_available_bytes() > 0:
var res = client.get_data(client.get_available_bytes())
if res[0] == OK:
var response_text = res[1].get_string_from_utf8()
tu.assert_true(response_text.contains("auth_ok"), "auth response contains 'auth_ok'")
else:
tu.assert_true(false, "read response failed")
else:
tu.assert_true(false, "no response from server")
client.disconnect_from_host()
rcon.enabled = false
await get_tree().create_timer(0.1).timeout
func _test_auth_wrong_password() -> void:
tu.describe("Auth — wrong password (3-strike penalty)")
rcon.password = "secret"
rcon.port = TEST_PORT + 1
rcon.enabled = true
await get_tree().create_timer(0.1).timeout
var client = StreamPeerTCP.new()
var err = client.connect_to_host(LOCALHOST, TEST_PORT + 1)
tu.assert_eq(err, OK, "tcp connect succeeds")
await get_tree().create_timer(0.2).timeout
# Send wrong password 3 times
for attempt in range(3):
client.put_data("wrongpass\n".to_utf8_buffer())
await get_tree().create_timer(0.05).timeout
rcon._process(0.1)
# Drain response
if client.get_available_bytes() > 0:
var res = client.get_data(client.get_available_bytes())
if res[0] == OK:
var text = res[1].get_string_from_utf8()
if attempt < 2:
tu.assert_true(text.contains("auth_fail"), "attempt %d gets auth_fail" % (attempt + 1))
else:
# Third attempt should disconnect
pass
# After 3 fails, IP should be banned
var is_banned = rcon._is_ip_banned(LOCALHOST)
tu.assert_true(is_banned, "IP banned after 3 failed auth attempts")
client.disconnect_from_host()
rcon.enabled = false
await get_tree().create_timer(0.1).timeout
func _test_auth_empty_password() -> void:
tu.describe("Auth — empty password")
rcon.password = ""
rcon.port = TEST_PORT + 2
rcon.enabled = true
await get_tree().create_timer(0.1).timeout
var client = StreamPeerTCP.new()
var err = client.connect_to_host(LOCALHOST, TEST_PORT + 2)
tu.assert_eq(err, OK, "tcp connect succeeds")
await get_tree().create_timer(0.2).timeout
# Empty password should match
client.put_data("\n".to_utf8_buffer())
await get_tree().create_timer(0.1).timeout
rcon._process(0.1)
if client.get_available_bytes() > 0:
var res = client.get_data(client.get_available_bytes())
if res[0] == OK:
var text = res[1].get_string_from_utf8()
tu.assert_true(text.contains("auth_ok"), "empty password auth ok")
else:
tu.assert_true(false, "read failed")
else:
tu.assert_true(false, "no response")
client.disconnect_from_host()
rcon.enabled = false
await get_tree().create_timer(0.1).timeout
# ─── Suite: Buffer Overflow ──────────────────────────────────────────────
func _test_buffer_overflow() -> void:
tu.describe("Buffer — overflow protection")
rcon.password = "test"
rcon.port = TEST_PORT + 3
rcon.enabled = true
await get_tree().create_timer(0.1).timeout
var client = StreamPeerTCP.new()
var err = client.connect_to_host(LOCALHOST, TEST_PORT + 3)
tu.assert_eq(err, OK, "tcp connect succeeds")
await get_tree().create_timer(0.2).timeout
# Send a very large payload (above MAX_BUFFER_SIZE=4096)
var big_data = PackedByteArray()
big_data.resize(5000)
for i in range(5000):
big_data[i] = 65 + (i % 26) # A-Z repeating
big_data[4999] = 10 # newline at end
client.put_data(big_data)
await get_tree().create_timer(0.1).timeout
rcon._process(0.1)
# Server should disconnect due to buffer overflow
var status = client.get_status()
tu.assert_eq(status, StreamPeerTCP.STATUS_NONE, "client disconnected after buffer overflow")
client.disconnect_from_host()
rcon.enabled = false
await get_tree().create_timer(0.1).timeout
# ─── Suite: Command Handling ─────────────────────────────────────────────
func _test_command_empty_lines() -> void:
tu.describe("Commands — empty/whitespace-only lines")
rcon.password = "test"
rcon.port = TEST_PORT + 4
rcon.enabled = true
await get_tree().create_timer(0.1).timeout
var client = StreamPeerTCP.new()
var err = client.connect_to_host(LOCALHOST, TEST_PORT + 4)
tu.assert_eq(err, OK, "tcp connect succeeds")
await get_tree().create_timer(0.2).timeout
# Auth first
client.put_data("test\n".to_utf8_buffer())
await get_tree().create_timer(0.1).timeout
rcon._process(0.1)
# Drain auth response
if client.get_available_bytes() > 0:
client.get_data(client.get_available_bytes())
# Send empty line (should be ignored)
client.put_data("\n".to_utf8_buffer())
await get_tree().create_timer(0.1).timeout
rcon._process(0.1)
# Should have no error state
tu.assert_eq(client.get_status(), StreamPeerTCP.STATUS_CONNECTED, "still connected after empty line")
# Send whitespace line
client.put_data(" \t \n".to_utf8_buffer())
await get_tree().create_timer(0.1).timeout
rcon._process(0.1)
tu.assert_eq(client.get_status(), StreamPeerTCP.STATUS_CONNECTED, "still connected after whitespace line")
# Send valid command to make sure still working
client.put_data("echo hello\n".to_utf8_buffer())
await get_tree().create_timer(0.1).timeout
rcon._process(0.1)
if client.get_available_bytes() > 0:
var res = client.get_data(client.get_available_bytes())
if res[0] == OK:
var text = res[1].get_string_from_utf8()
tu.assert_true(text.contains("hello"), "echo command works after empty whitespace lines")
else:
tu.assert_true(false, "read failed")
else:
tu.assert_true(false, "no echo response")
client.disconnect_from_host()
rcon.enabled = false
await get_tree().create_timer(0.1).timeout
func _test_command_unknown() -> void:
tu.describe("Commands — unknown command handling")
rcon.password = "test"
rcon.port = TEST_PORT + 5
rcon.enabled = true
await get_tree().create_timer(0.1).timeout
var client = StreamPeerTCP.new()
var err = client.connect_to_host(LOCALHOST, TEST_PORT + 5)
tu.assert_eq(err, OK, "connect ok")
await get_tree().create_timer(0.2).timeout
# Auth
client.put_data("test\n".to_utf8_buffer())
await get_tree().create_timer(0.1).timeout
rcon._process(0.1)
if client.get_available_bytes() > 0:
client.get_data(client.get_available_bytes())
# Unknown command
client.put_data("nonexistent_command_xyz\n".to_utf8_buffer())
await get_tree().create_timer(0.1).timeout
rcon._process(0.1)
if client.get_available_bytes() > 0:
var res = client.get_data(client.get_available_bytes())
if res[0] == OK:
var text = res[1].get_string_from_utf8()
tu.assert_true(text.contains("Unknown command"), "unknown command returns error message")
else:
tu.assert_true(false, "read failed")
else:
tu.assert_true(false, "no response to unknown command")
client.disconnect_from_host()
rcon.enabled = false
await get_tree().create_timer(0.1).timeout
func _test_command_status_output() -> void:
tu.describe("Commands — status output format")
rcon.password = "test"
rcon.port = TEST_PORT + 6
rcon.enabled = true
await get_tree().create_timer(0.1).timeout
var client = StreamPeerTCP.new()
var err = client.connect_to_host(LOCALHOST, TEST_PORT + 6)
tu.assert_eq(err, OK, "connect ok")
await get_tree().create_timer(0.2).timeout
client.put_data("test\n".to_utf8_buffer())
await get_tree().create_timer(0.1).timeout
rcon._process(0.1)
if client.get_available_bytes() > 0:
client.get_data(client.get_available_bytes())
client.put_data("status\n".to_utf8_buffer())
await get_tree().create_timer(0.1).timeout
rcon._process(0.1)
if client.get_available_bytes() > 0:
var res = client.get_data(client.get_available_bytes())
if res[0] == OK:
var text = res[1].get_string_from_utf8()
tu.assert_true(text.contains("Server Status"), "status response contains header")
tu.assert_true(text.contains("FPS:") || text.contains("Engine:"), "status response contains metrics")
tu.assert_false(text.contains("null"), "status response has no null values")
else:
tu.assert_true(false, "read failed")
else:
tu.assert_true(false, "no response to status command")
client.disconnect_from_host()
rcon.enabled = false
await get_tree().create_timer(0.1).timeout
# ─── Main Entry Point ─────────────────────────────────────────────────────
func _ready() -> void:
"""Run all tests and exit."""
print("\n══════════════════════════════════════════════")
print(" RCON Edge Case Tests")
print("══════════════════════════════════════════════\n")
print(" NOTE: RCON tests require a TCP loopback connection.")
print(" These tests will be SKIPPED if the platform doesn't support TCP.")
print("")
tu = UTILS_SCRIPT.new()
add_child(tu)
# Create RCON server and handler
rcon = RCON_SCRIPT.new()
rcon.name = "TestRconServer"
add_child(rcon)
handler = RCON_CMD_SCRIPT.new()
handler.name = "TestRconHandler"
rcon.add_child(handler)
# Helpers for TestUtils (since some tests use `await`)
set_process(false)
set_physics_process(false)
# Run test suites
# NOTE: RCON tests need the engine to process between packets.
# The `await` calls work in _process-free mode by using scene tree timers.
# Skipping due to complexity of async GDScript testing — these are placeholders
# for integration testing against a real Godot instance.
print("\n [SKIP] RCON edge case tests require headless Godot runtime with TCP support.")
print(" Run manually: godot --headless --script tests/test_rcon_edge_cases.gd\n")
# Still run the tests but mark them as informational
tu.describe("RCON Tests")
print(" Tests skipped — use Godot headless runner (see README)\n")
print("══════════════════════════════════════════════")
print(" RCON tests: 0 passed, 0 failed (all skipped)")
print("==============================================")
print("")
get_tree().quit(0)
-1
View File
@@ -1 +0,0 @@
uid://b0tj3p7e8wuya
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env godot --headless -s
extends SceneTree
func _init():
print("=== Singleton Test ===")
var names = Engine.get_singleton_list()
for n in names:
print(" singleton: ", n)
print("---")
print("RoundManager:", Engine.has_singleton("RoundManager"))
print("ServerConfig:", Engine.has_singleton("ServerConfig"))
print("NetworkManager:", Engine.has_singleton("NetworkManager"))
print("RoundManager singleton only:", Engine.has_singleton("RoundManager"))
quit()
-1
View File
@@ -1 +0,0 @@
uid://y10gj1e3o0cw
-111
View File
@@ -1,111 +0,0 @@
extends Node
class_name TestUtils
# ═══════════════════════════════════════════════════════════════════════════
# TestUtils — Minimal assertion framework for headless Godot unit tests
#
# Usage:
# var tu = TestUtils.new()
# add_child(tu)
# tu.describe("Movement validation")
# tu.assert_eq(result.x, 5, "x should be 5")
# tu.assert_true(valid, "should be valid")
# tu.finish() # prints summary
# ═══════════════════════════════════════════════════════════════════════════
var _passed: int = 0
var _failed: int = 0
var _current_suite: String = ""
var _assertions_in_suite: int = 0
func describe(name: String) -> void:
"""Start a named test suite/section."""
_current_suite = name
_assertions_in_suite = 0
print("\n === " + name + " ===")
func assert_eq(got, expected, msg: String = "") -> void:
_assertions_in_suite += 1
if got == expected:
_passed += 1
else:
_failed += 1
var detail = "got '%s', expected '%s'" % [str(got), str(expected)]
print(" [FAIL] " + msg + "" + detail)
func assert_ne(got, unexpected, msg: String = "") -> void:
_assertions_in_suite += 1
if got != unexpected:
_passed += 1
else:
_failed += 1
print(" [FAIL] " + msg + " — got '%s' which matches unexpected value" % str(got))
func assert_true(cond: bool, msg: String = "") -> void:
_assertions_in_suite += 1
if cond:
_passed += 1
else:
_failed += 1
print(" [FAIL] " + msg + " — expected true, got false")
func assert_false(cond: bool, msg: String = "") -> void:
_assertions_in_suite += 1
if not cond:
_passed += 1
else:
_failed += 1
print(" [FAIL] " + msg + " — expected false, got true")
func assert_almost_eq(got: float, expected: float, tolerance: float = 0.001, msg: String = "") -> void:
_assertions_in_suite += 1
if abs(got - expected) <= tolerance:
_passed += 1
else:
_failed += 1
var detail = "got %.4f, expected %.4f ± %.4f" % [got, expected, tolerance]
print(" [FAIL] " + msg + "" + detail)
func assert_violation(result: Dictionary, category: int, msg: String = "") -> void:
"""Assert the validation result contains at least one violation of the given category."""
_assertions_in_suite += 1
for v in result.get("violations", []):
if v.get("category", -1) == category:
_passed += 1
return
_failed += 1
print(" [FAIL] " + msg + " — expected violation category " + str(category) + ", none found")
func assert_no_violation(result: Dictionary, msg: String = "") -> void:
"""Assert the validation result has zero violations."""
_assertions_in_suite += 1
var count = result.get("violations", []).size()
if count == 0:
_passed += 1
else:
_failed += 1
print(" [FAIL] " + msg + " — expected 0 violations, got " + str(count) + ": " + str(result["violations"]))
func assert_corrected(result: Dictionary, field: String, msg: String = "") -> void:
"""Assert the result's corrected dict has a different value for `field` than the original."""
_assertions_in_suite += 1
if result.get("corrected", {}).has(field):
_passed += 1
else:
_failed += 1
print(" [FAIL] " + msg + " — expected correction in field '%s', none found" % field)
func finish() -> void:
"""Print test summary and return exit code via OS."""
var total = _passed + _failed
print("\n ──────────────────────────────────────")
print(" Suite: " + _current_suite)
print(" Passed: %d / %d" % [_passed, total])
if _failed > 0:
print(" FAILED: %d test(s)" % _failed)
else:
print(" All passed.")
print("")
func exit_code() -> int:
return 0 if _failed == 0 else 1
-1
View File
@@ -1 +0,0 @@
uid://cpxbr1puyub5d