Files
Josh Creek 7b150ef72e feat(multiplayer): task 2.8 net_sim.gd, close out Phase 2
New NetSim autoload: seeded, CLI-driven (--net-sim-latency/-jitter/-loss/-dup)
latency/jitter/loss/duplicate decorator, a true no-op passthrough unless a
flag is set. Wraps MatchSim.send_input/send_snapshot per the design doc's
scope, plus NetworkManager's ping/pong so the already-tested RTT/clock
measurement becomes the acceptance signal for "raises observed RTT" without
waiting on Phase 3's per-peer snapshot echo.

Two real bugs found while building and verifying this against Phase 2's own
milestone gate (a real match under --net-sim-latency 80 --net-sim-jitter
20, not just LAN): a timestamp captured inside a delayed RPC closure
silently ate that side's own added delay out of the round-trip
measurement instead of adding to it; and a delayed send whose target
disconnected (or whose own process had already shut down) during the hold
threw RPC errors, since the existing get_peers() filtering only checked
validity at schedule time. Fixed by capturing timestamps before handing
off to NetSim, and by having NetSim re-validate the target at fire time.

Phase 2's milestone gate now passes for real: a full 1v1 under simulated
80ms latency / 20ms jitter still shows clean server-authoritative
movement and zero RPC errors. Full Phase 1 + Phase 2 regression suite
re-verified clean with NetSim present but inactive.
2026-08-20 08:50:47 +01:00

112 lines
4.3 KiB
GDScript

extends Node
# Manual two-process smoke test for NetSim (task 2.8 acceptance:
# "--net-sim-latency 80 measurably raises observed RTT"). Deliberately not
# part of the pure-function suite — needs two real processes and real wall
# time to observe a delayed pong.
#
# NetSim reads its own --net-sim-* flags directly from OS.get_cmdline_user_args()
# (see net_sim.gd) — this driver only needs --role= and passes any
# --net-sim-* flags straight through untouched. The host is where the
# _pong reply gets delayed, so --net-sim-latency=/--net-sim-loss= belong on
# the HOST invocation; the client just observes NetworkManager.rtt_ms.
#
# Usage:
# godot --headless --path Game res://tests/net_sim_smoke.tscn -- --role=host --net-sim-latency=80
# godot --headless --path Game res://tests/net_sim_smoke.tscn -- --role=client --min-rtt=70
#
# For the loss scenario:
# godot --headless --path Game res://tests/net_sim_smoke.tscn -- --role=host --net-sim-loss=1.0
# godot --headless --path Game res://tests/net_sim_smoke.tscn -- --role=client-loss
const DEFAULT_PORT := 7810
const TIMEOUT_SECONDS := 12.0
const HOST_LIFETIME_SECONDS := 8.0
# Loose ceiling, not a tight bound: real localhost jitter plus one full
# PING_INTERVAL_SEC of scheduling slack is possible before the first sample
# lands, so this only needs to catch a badly broken (e.g. no-op) NetSim.
const MAX_RTT_SLACK_MS := 400.0
var _role := ""
var _port := DEFAULT_PORT
var _min_rtt_ms := 0.0
var _finished := false
func _ready() -> void:
for arg: String in OS.get_cmdline_user_args():
if arg.begins_with("--role="):
_role = arg.substr("--role=".length())
elif arg.begins_with("--port="):
_port = int(arg.substr("--port=".length()))
elif arg.begins_with("--min-rtt="):
_min_rtt_ms = arg.substr("--min-rtt=".length()).to_float()
if _role == "host":
var err := NetworkManager.host(_port)
if err != OK:
_finish(false, "host() failed: %s" % error_string(err))
return
print("SMOKE: hosting on port %d (net-sim latency=%.1fms jitter=%.1fms loss=%.2f)" % [
_port, NetSim.latency_ms, NetSim.jitter_ms, NetSim.loss_fraction
])
get_tree().create_timer(HOST_LIFETIME_SECONDS).timeout.connect(func() -> void:
_finish(true, "host ran for %.1fs" % HOST_LIFETIME_SECONDS))
elif _role == "client":
NetworkManager.clock_updated.connect(_on_clock_updated)
var err := NetworkManager.join("127.0.0.1", _port)
if err != OK:
_finish(false, "join() failed: %s" % error_string(err))
return
print("SMOKE: joining 127.0.0.1:%d, expecting rtt >= %.1fms ..." % [_port, _min_rtt_ms])
elif _role == "client-loss":
NetworkManager.clock_updated.connect(_on_unexpected_clock_updated)
var err := NetworkManager.join("127.0.0.1", _port)
if err != OK:
_finish(false, "join() failed: %s" % error_string(err))
return
print("SMOKE: joining 127.0.0.1:%d, expecting NO rtt sample (100%% loss) ..." % _port)
get_tree().create_timer(HOST_LIFETIME_SECONDS - 1.0).timeout.connect(func() -> void:
_finish(NetworkManager.rtt_ms < 0.0, "rtt_ms=%.1f after %.1fs (expected -1, no pong ever arrived)" % [
NetworkManager.rtt_ms, HOST_LIFETIME_SECONDS - 1.0
]))
else:
_finish(false, "missing or unrecognised --role= (expected host|client|client-loss)")
return
get_tree().create_timer(TIMEOUT_SECONDS).timeout.connect(_on_timeout)
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _on_clock_updated(rtt_ms: float, _offset_ms: float) -> void:
var ceiling := _min_rtt_ms * 4.0 + MAX_RTT_SLACK_MS
var ok := rtt_ms >= _min_rtt_ms and rtt_ms <= ceiling
print("SMOKE INFO: observed rtt_ms=%.2f (want >= %.1f, <= %.1f)" % [rtt_ms, _min_rtt_ms, ceiling])
_finish(ok, "client observed rtt_ms=%.2f against min=%.1f" % [rtt_ms, _min_rtt_ms])
func _on_unexpected_clock_updated(rtt_ms: float, _offset_ms: float) -> void:
_finish(false, "client received a pong (rtt_ms=%.2f) despite --net-sim-loss=1.0 on the host" % rtt_ms)
func _on_timeout() -> void:
if not _finished:
_finish(false, "timed out waiting for a clock sample")
func _finish(success: bool, message: String) -> void:
if _finished:
return
_finished = true
print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message])
await get_tree().create_timer(0.3).timeout
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)