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:
-217
@@ -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`
|
||||
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
uid://c0a04532axyqo
|
||||
@@ -1 +0,0 @@
|
||||
uid://d3gfib3iijduv
|
||||
@@ -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 +0,0 @@
|
||||
uid://tenyf8ht7hfy
|
||||
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
uid://ch5ajnsrenxrf
|
||||
@@ -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: 800–1200 (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 +0,0 @@
|
||||
uid://klpd4bulsbph
|
||||
@@ -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 +0,0 @@
|
||||
uid://b0tj3p7e8wuya
|
||||
@@ -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 +0,0 @@
|
||||
uid://y10gj1e3o0cw
|
||||
@@ -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 +0,0 @@
|
||||
uid://cpxbr1puyub5d
|
||||
Reference in New Issue
Block a user