Files
CosmicClash/Game/tests/networked_match_smoke.gd
T
Josh Creek b5e9dff33c fix(multiplayer): Phase 5 adversarial review fixes - reconnect, spectators
An adversarial review found five real defects in the Phase 5 lifecycle
work. Two were critical and both were verified against controls.

CRITICAL - a reconnecting client silently became a spectator.
_try_reclaim_slot() swapped slot.peer_id, but MatchSim caches the last
match_config and replays THAT to whoever asks. A reconnecting client in
a fresh process requested config, received the pre-disconnect peer-id
array, could not find itself, left _my_slot null and fell through to the
spectator path - no ship, no input, for the rest of the match. The
evidence was already in my own disconnect-test logs ("no slot for this
peer - spectating", my_slot_ok=false) and I dismissed it: the host-side
check only asserted the SERVER reclaimed the slot, never that the
returning client owned it. Config is now rebroadcast on reclaim.
Verified: my_slot_ok=false -> true.

CRITICAL - spectators received no snapshots at all. §6.3 says a
spectator "receives identical snapshots (the snapshot is already a
broadcast - zero extra server work)". That was only ever true of the
body SEGMENT: _broadcast_snapshot unicasts one packet per SLOT, so a
peer without a slot got nothing - no poses, no reset_gen, no
match_state byte. Spectating was entirely non-functional. The segment is
still shared, so this is one extra send per spectator. Verified against
a control: 0 snapshots and state stuck at LOADING before, 361 snapshots
and PLAYING after.

HIGH - cycling the spectator camera to the ball was a type error.
ShipCameraRig.target is declared `var target: Ship` and the rig reaches
into ship-only API, so it would have fired the moment anyone cycled past
the last ship. Cycling is ships-only; the rig already has its own
ball-cam mode for watching the ball.

MEDIUM - clients never received match_ended or overtime_started. Both
emitted only inside server-side logic, so a client froze and returned to
the lobby without a result and its timer never switched to overtime.
Derived from replicated state instead of adding two more RPCs: the
client already has the authoritative score, and the transition is the
event.

MEDIUM - the goal cinematic ignored its authoritative window. goal_tick
and resume_tick arrived and were unused; the client started a fresh
fixed-length timer on RPC receipt, so a reliable retransmit could run
the celebration past the server's window and into the next kickoff.
_goal_pause_seconds() now returns the time actually remaining, clamped
so an elapsed window cannot produce a non-positive timer.

Also added: a match_bootstrap RPC carrying state, score, clock and
reset_gen to one peer. match_config alone carries arena and roster only,
so a late joiner or reconnecting player had no score or clock until the
next goal happened to fire. It is sent on join AND on every
request_match_config retry - the join-time send has exactly the same
race match_config already had (the server sends it before the peer has
loaded the match scene and connected its listeners), which the control
run exposed: state was reaching PLAYING via the snapshot byte, not the
bootstrap.

New test: --role=client-spectator asserts a slotless peer receives the
snapshot stream, follows the lifecycle, agrees with the wire byte, and
can cycle targets without ever handing the camera a non-Ship. Verified
non-vacuous. The ball-contact steering now closes all the way to 1.2m
instead of coasting from 3m, which was missing the ball outright in
roughly 1 run in 4.

Not fixed, and still open: the 30s slot reservation is keyed on the
player's display name, so any peer can claim a departed player's ship by
choosing their name. §6.2 step 1 reserves auth_ticket for Phase 7; this
needs a real identity token, not a name.

Regression: 87 unit tests; free-flight LAN; transition gate 0.00%; ball
contact 4/4; goal cycle; full match to RESULTS/LOBBY; disconnect and
reconnect; spectator; two-bot CI.
2026-08-21 11:34:48 +01:00

157 lines
6.5 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 _exercise_match_state := 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 == "--exercise-match-state":
_exercise_match_state = true
elif arg.begins_with("--warmup-seconds="):
_warmup_seconds = maxf(0.0, arg.get_slice("=", 1).to_float())
match _role:
"host-disconnect":
var derr := NetworkManager.host(PORT)
if derr != OK:
print("SMOKE FAIL: host() failed: %s" % error_string(derr))
get_tree().quit(1)
return
print("SMOKE: hosting (disconnect/reconnect scenario) on port %d ..." % PORT)
MatchNet.player_joined.connect(_on_disconnect_host_player_joined)
"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-spectator":
# A name nobody reserved, so the server has no slot for it.
MatchNet.local_player_name = "Watcher"
var serr := NetworkManager.join("127.0.0.1", PORT)
if serr != OK:
print("SMOKE FAIL: join() failed: %s" % error_string(serr))
get_tree().quit(1)
return
print("SMOKE: joining as a spectator ...")
MatchNet.welcomed.connect(_on_spectator_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, _exercise_match_state)
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, _exercise_match_state)
func _on_disconnect_host_player_joined(_peer_id: int, _name: String) -> void:
MatchNet.player_joined.disconnect(_on_disconnect_host_player_joined)
print("SMOKE: host loading networked_match.tscn (disconnect scenario) ...")
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_disconnect_host_check.call_deferred(_drive_seconds)
func _on_spectator_welcomed() -> void:
MatchNet.welcomed.disconnect(_on_spectator_welcomed)
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_spectator_check.call_deferred(_drive_seconds)
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()