Files
CosmicClash/Game/tests/networked_match_smoke.gd
T
Josh Creek 75f485667b feat(multiplayer): Phase 4 prediction correctness + two input-death fixes
Closes Phase 4's outstanding action-sequence-correctness invariant, then
fixes two server-side bugs an adversarial review of that work uncovered.
Server simulation, bot observations, collision resources and tick rate are
unchanged: the server_physics_parity trace is byte-for-byte identical to
HEAD across 360 ticks including both ships' full observation vectors.

4.11 - prediction history filed under the ISSUING sequence

_send_local_input filed each post-step predicted state under the timeline's
estimate of the sequence the server would consume this tick, trailing
issuance by input_lead. The body had integrated the intent issued under
_input_seq, so predicted[S] held "state after the intent from now" while
the server's authority for S is "state after action(S)". They agree only
while the stick is still. Filing under _input_seq costs nothing: which
action the ship uses is decided in LocalNetShipController.get_action() and
is untouched.

Every prior Phase 4 gate held its input steady, and a steady input cannot
falsify a sequence label - the 60s runs honestly reported marker=0/3784.
New --exercise-input-transitions role toggles thrust every 6 ticks; it is
the only gate that can catch a label regression. Verified non-vacuous: the
old label fails it at 50%.

4.12 - issued-but-unsimulated sequences, and the release path

An attack (delta > 1) issues and sends several sequences for one local
physics step. Those gap sequences had no recorded prediction, so a server
ack of one reported missing_not_recorded - indistinguishable from ring
loss, costing a teleport and resync suppression several times a minute.
They are now recorded stateless via record_unsimulated() and answered with
a new "skip" decision mode. Free-flight hard snaps: 25/8/4 -> 0/0/0.

A release (delta == 0) re-recorded at the unchanged _input_seq, filing the
current intent under a sequence that went out carrying a different action;
LocalInputTimeline deliberately refuses to mutate an issued sequence, so
the ring contradicted the wire. Recording is now skipped on release ticks.

4.13 - two Phase 3 bugs silently killing player input

(a) InputJitterBuffer.consume() advanced last_applied_seq on every tick
including a starve. Since ingest() discards seq <= last_applied_seq, one
starve on a sequence the client had not sent yet stranded the stream one
ahead of arrivals permanently - both sides advancing in lockstep, every
honest packet discarded on arrival. The client's own input_lead release is
enough to trigger it, so input died for ~30 ticks roughly every 6.5s on a
clean LAN. Now only gives up on a sequence once strictly newer data proves
it lost. Silent-client stall and ring-overflow resync are unchanged.

(b) The seq-range guard bounded incoming seq against highest_ingested_seq,
which only advances inside ingest(), which that guard gates. After a ~2s
host hitch every packet was rejected forever with no diagnostic (600+
consecutive rejections reproduced via SIGSTOP). Third iteration of this
guard; each previous version bounded against a value only the accepted
path could advance. Adds an escape after 10 consecutive rejections, which
grants an attacker nothing the rate limiter does not already bound.

(c) The transitions gate reported PASS at 3.76% while input was completely
dead, because suppression stops _record_metrics - a worse outage yields
fewer samples and a LOWER rate. Now scales the required sample count with
run length and asserts the wire's server_stalled bit. Reverting both fixes
makes it fail at samples 292/600, server_stalled=true, input_lead=12.

Fixing (a) also explained a residual the review had already traced: 151 of
151 action-marker mismatches were the server repeating a stale action on a
starve, not a prediction defect. Marker is now 0.00% in all three
conditions (was 1.7-2.5%), and free-flight p99 improved to
0.141/0.168/0.154m from 0.170/0.176/0.184m.

Two pre-existing test defects fixed alongside: the ball gate asserted
RTT-masking on a link with no RTT (flaked 2 in 5; now asserted only at
rtt >= 20ms, 5/5 under latency), and the two-bot CI compared scores across
a 3-5s window (now polls the scores the server actually held; note
score_changed is emitted only on the client path).

QA: 72 unit tests; 60s free-flight at LAN/80+-20ms/5% loss; transition
gate in all three; 2.0s and 3.5s host-freeze recovery; ball contact x5;
two-bot CI x3; all three abuse roles; net/match_net/clock/lobby smokes.

Phase 4 sign-off still pending a human playtest at ~100ms RTT - the
milestone asks how it feels, which no gate here answers.
2026-08-21 09:17:19 +01:00

119 lines
4.8 KiB
GDScript

extends Node
# Manual two-process smoke test for Phase 2 (tasks 2.1-2.5): match_config,
# server-authoritative simulation, snapshot broadcast, client interpolation.
# Not part of tests/test_runner.tscn — needs real ENet peers and a real
# physics-driven ship. Run:
#
# godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=host
# godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=client
const PORT := 7812
const DEFAULT_SETTLE_SECONDS := 2.0 # time to let match_config + a few snapshots land before checking spawn state
const DEFAULT_DRIVE_SECONDS := 2.0 # time to hold forward thrust and let the ship actually move
var _role := ""
var _settle_seconds := DEFAULT_SETTLE_SECONDS
var _drive_seconds := DEFAULT_DRIVE_SECONDS
var _exercise_ball_contact := false
var _exercise_free_flight := false
var _exercise_input_transitions := false
var _warmup_seconds := 0.0
func _ready() -> void:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--role="):
_role = arg.substr("--role=".length())
elif arg.begins_with("--settle-seconds="):
_settle_seconds = maxf(0.5, arg.get_slice("=", 1).to_float())
elif arg.begins_with("--drive-seconds="):
_drive_seconds = maxf(0.5, arg.get_slice("=", 1).to_float())
elif arg == "--exercise-ball-contact":
_exercise_ball_contact = true
elif arg == "--exercise-free-flight":
_exercise_free_flight = true
elif arg == "--exercise-input-transitions":
_exercise_input_transitions = true
elif arg.begins_with("--warmup-seconds="):
_warmup_seconds = maxf(0.0, arg.get_slice("=", 1).to_float())
match _role:
"host":
var err := NetworkManager.host(PORT)
if err != OK:
print("SMOKE FAIL: host() failed: %s" % error_string(err))
get_tree().quit(1)
return
print("SMOKE: hosting on port %d, waiting for a client to join the roster..." % PORT)
MatchNet.player_joined.connect(_on_host_player_joined)
"client":
MatchNet.local_player_name = "NetTest"
var err := NetworkManager.join("127.0.0.1", PORT)
if err != OK:
print("SMOKE FAIL: join() failed: %s" % error_string(err))
get_tree().quit(1)
return
print("SMOKE: joining ...")
MatchNet.welcomed.connect(_on_client_welcomed)
"client-abuse-malformed", "client-abuse-flood", "client-abuse-flood-dutycycle":
# task 3.4's disconnect-abusive-peer paths: joins normally (so
# it's a real connected peer, exactly like a hostile custom
# client would be — the validation doesn't get to assume
# anything about who's on the other end of an authenticated
# connection), then deliberately abuses MatchSim._recv_input
# directly rather than going through networked_match.gd's own
# honest encoder.
MatchNet.local_player_name = "Abuser"
var err := NetworkManager.join("127.0.0.1", PORT)
if err != OK:
print("SMOKE FAIL: join() failed: %s" % error_string(err))
get_tree().quit(1)
return
print("SMOKE: joining to abuse (%s) ..." % _role)
MatchNet.welcomed.connect(_on_abuser_welcomed)
_:
print("SMOKE FAIL: missing or unrecognised --role=")
get_tree().quit(1)
return
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _on_host_player_joined(_peer_id: int, _name: String) -> void:
MatchNet.player_joined.disconnect(_on_host_player_joined)
print("SMOKE: host loading networked_match.tscn ...")
get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn")
var hooks := preload("res://tests/networked_match_test_hooks.gd").new()
get_tree().root.add_child.call_deferred(hooks)
# The host must outlive client settle + drive, plus connection/shutdown
# slack. This keeps --drive-seconds useful for sustained prediction QA.
hooks.run_host_check.call_deferred(_settle_seconds + _warmup_seconds + _drive_seconds + 4.0)
func _on_client_welcomed() -> void:
MatchNet.welcomed.disconnect(_on_client_welcomed)
print("SMOKE: client loading networked_match.tscn ...")
get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn")
var hooks := preload("res://tests/networked_match_test_hooks.gd").new()
get_tree().root.add_child.call_deferred(hooks)
hooks.run_client_check.call_deferred(_settle_seconds, _drive_seconds, _exercise_ball_contact, _exercise_free_flight, _warmup_seconds, _exercise_input_transitions)
func _on_abuser_welcomed() -> void:
MatchNet.welcomed.disconnect(_on_abuser_welcomed)
var hooks := preload("res://tests/networked_match_test_hooks.gd").new()
get_tree().root.add_child.call_deferred(hooks)
if _role == "client-abuse-malformed":
hooks.run_malformed_abuse_check.call_deferred()
elif _role == "client-abuse-flood-dutycycle":
hooks.run_duty_cycle_flood_abuse_check.call_deferred()
else:
hooks.run_rate_limit_abuse_check.call_deferred()