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.
This commit is contained in:
Josh Creek
2026-08-21 11:34:48 +01:00
parent a5cbc977b5
commit b5e9dff33c
4 changed files with 223 additions and 7 deletions
+32
View File
@@ -30,6 +30,7 @@ signal state_change_received(state: int, at_tick: int) # §6.1 MatchState.Stat
signal kickoff_received(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int)
signal goal_scored_received(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int)
signal clock_state_received(running: bool, end_tick: int, at_tick: int)
signal match_bootstrap_received(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int)
# Input validation (multiplayer-todo.md §3.1 steps 2-3, task 3.4). Deliberately
# lives here rather than in NetworkedMatch: framing/rate abuse is a protocol-
@@ -151,6 +152,15 @@ func send_match_config(arena_path: String, peer_ids: PackedInt32Array, teams: Pa
_match_config.rpc(arena_path, peer_ids, teams, spawn_indices)
# Also the client's cue to ask for live match state — see
# NetworkedMatch._on_match_config_requested. A late joiner's bootstrap has the
# SAME race match_config has: the server sends it when the peer joins the
# roster, which is before that peer has loaded the match scene and connected
# its listeners, so a one-shot send is simply missed. Delivery has to be
# "ask until you get it" for both.
signal match_config_requested(peer_id: int)
func request_match_config() -> void:
_request_match_config.rpc_id(1)
@@ -201,6 +211,19 @@ func send_clock_state(running: bool, end_tick: int, at_tick: int) -> void:
_clock_state.rpc(running, end_tick, at_tick)
# §6.2 step 2 / §6.3: everything a peer needs to reconstruct the CURRENT match
# on arrival, sent to one peer rather than broadcast.
#
# match_config alone is not enough and never was: it carries arena and roster
# only, so a late joiner or a reconnecting player had no score, no clock, and
# no match state until the next goal or transition happened to fire. An
# adversarial review caught that; §6.2 step 2's `welcome` is specified to carry
# exactly this set, so this is that message under a name that does not clash
# with MatchNet's own lobby-level welcome.
func send_match_bootstrap(peer_id: int, state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int) -> void:
_match_bootstrap.rpc_id(peer_id, state, at_tick, score, end_tick, clock_running, reset_gen)
@rpc("authority", "call_remote", "reliable", 0)
func _match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
match_config_received.emit(arena_path, peer_ids, teams, spawn_indices)
@@ -215,6 +238,7 @@ func _request_match_config() -> void:
peer_id, _last_match_config["arena_path"], _last_match_config["peer_ids"],
_last_match_config["teams"], _last_match_config["spawn_indices"]
)
match_config_requested.emit(peer_id)
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
@@ -326,6 +350,14 @@ func _clock_state(running: bool, end_tick: int, at_tick: int) -> void:
clock_state_received.emit(running, end_tick, at_tick)
@rpc("authority", "call_remote", "reliable", 0)
func _match_bootstrap(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int) -> void:
if not MatchState.is_valid(state):
push_warning("MatchSim: ignoring bootstrap with unknown match_state %d" % state)
return
match_bootstrap_received.emit(state, at_tick, score, end_tick, clock_running, reset_gen)
@rpc("authority", "call_remote", "reliable", 0)
func _score_update(score: Dictionary) -> void:
score_update_received.emit(score)