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)
+119 -5
View File
@@ -291,6 +291,12 @@ var _pending_freeze_tick := -1
var _fill_bots := false
# Task 5.10, server only. null unless --replay-log= was passed.
var _replay_log: ReplayLog = null
# Server only: kept so match_config can be rebuilt after a slot's peer_id
# changes on reconnect (see _rebroadcast_match_config).
var _arena_path := ""
# Client only: the authoritative resume tick while a goal cinematic is playing,
# read by _goal_pause_seconds(). -1 when no goal window is open.
var _client_goal_resume_tick := -1
# §6.3 (task 5.8), client only.
var _is_spectator := false
var _spectator_target_index := 0
@@ -353,6 +359,7 @@ func _ready() -> void:
MatchSim.kickoff_received.connect(_on_kickoff_received)
MatchSim.goal_scored_received.connect(_on_goal_scored_received)
MatchSim.clock_state_received.connect(_on_clock_state_received)
MatchSim.match_bootstrap_received.connect(_on_match_bootstrap_received)
_request_match_config_until_received()
@@ -385,6 +392,7 @@ func _exit_tree() -> void:
func _start_server() -> void:
var arena_path := ArenaRegistry.random_path()
_arena_path = arena_path
arena = (load(arena_path) as PackedScene).instantiate()
add_child(arena)
for goal in arena.get_goals():
@@ -417,6 +425,9 @@ func _start_server() -> void:
MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices)
MatchSim.input_received.connect(_on_input_received)
NetworkManager.client_disconnected.connect(_on_client_disconnected)
# Piggyback live state on the existing retry loop, so a peer that missed
# the join-time bootstrap gets one every time it re-asks for config.
MatchSim.match_config_requested.connect(_send_match_bootstrap)
MatchNet.player_joined.connect(_on_player_joined_midmatch)
# §6.1: the arena, ball and every slot's ship now exist and match_config is
@@ -571,6 +582,19 @@ func _apply_match_state(new_state: int, at_tick: int) -> void:
# The clock only advances during live play (§6.2 step 9). Derived here
# rather than tracked separately so it cannot disagree with the state.
_clock_running = MatchState.is_live(new_state) and not _match_over
if not multiplayer.is_server():
# HUDController duck-types on these two, and both previously emitted
# ONLY inside server-side logic — so a client froze and returned to the
# lobby without ever showing a result, and its timer never switched to
# overtime. Derive them from replicated state instead of adding two
# more RPCs: the client already has the authoritative score, and the
# state transition itself is the event.
if new_state == MatchState.State.OVERTIME_WARMUP:
_in_overtime = true
overtime_started.emit()
elif new_state == MatchState.State.RESULTS:
_match_over = true
match_ended.emit(_winning_team(), score.duplicate())
if new_state == MatchState.State.LOBBY and not multiplayer.is_server():
# §6.2 step 10: both sides return to the LOBBY, not the main menu.
# Deferred because this runs from an RPC handler mid-tree-traversal
@@ -795,7 +819,27 @@ func _on_goal_scored_received(scoring_team: int, new_score: Dictionary, goal_tic
# The cinematic is bounded by [goal_tick, resume_tick] (§6.2 step 8), and
# is presentation only: it never gates when play resumes, which is what
# kept the server resetting while clients were mid-celebration.
#
# resume_tick is used, not just received. A reliable-channel retransmit can
# deliver this hundreds of ms after goal_tick, and starting a fresh
# fixed-length timer on ARRIVAL would then run the celebration past the
# server's own window and overlap the next kickoff. _goal_pause_seconds()
# below reads this and returns the time actually remaining.
_client_goal_resume_tick = resume_tick
_play_goal_celebration(scoring_team, 1 - scoring_team)
_client_goal_resume_tick = -1
# Overrides GameMode's virtual. On a client during a goal, the pause is
# whatever is LEFT of the authoritative window, not a fresh full duration.
func _goal_pause_seconds() -> float:
if _client_goal_resume_tick < 0:
return super()
var remaining := float(_client_goal_resume_tick - _current_server_tick()) / float(SimConstants.TICK_HZ)
# Clamp: a window that already elapsed must not produce a negative timer
# (Godot's create_timer asserts on <= 0), and a wildly future tick from a
# corrupt packet must not hang the celebration open.
return clampf(remaining, 0.05, super())
# --- §6.2 step 9: clock (task 5.2) -----------------------------------------
@@ -819,6 +863,16 @@ func _broadcast_clock_state() -> void:
MatchSim.send_clock_state(_clock_running, _end_tick, Engine.get_physics_frames())
func _on_match_bootstrap_received(state: int, at_tick: int, new_score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int) -> void:
score = new_score.duplicate()
score_changed.emit(score.duplicate())
_end_tick = end_tick
_clock_running = clock_running
_reset_gen = reset_gen
_last_local_reset_gen = reset_gen
_apply_match_state(state, at_tick)
func _on_clock_state_received(running: bool, end_tick: int, _at_tick: int) -> void:
_clock_running = running
_end_tick = end_tick
@@ -1019,6 +1073,16 @@ func _try_reclaim_slot(peer_id: int, player_name: String) -> bool:
slot.jitter_buffer = InputJitterBuffer.new()
slot.consecutive_seq_rejects = 0
_swap_slot_controller(slot, RLShipController.new())
# CRITICAL, and the reason a reconnect silently became a spectator: the
# slot's peer_id just changed, but MatchSim caches the last
# match_config and replays THAT to anyone who asks. A reconnecting
# client in a fresh process requests config, receives the pre-
# disconnect peer-id array, cannot find itself in it, leaves
# _my_slot null and falls through to the spectator path — no ship, no
# input, for the rest of the match. Re-broadcast so the cache and the
# roster agree again.
_rebroadcast_match_config()
_send_match_bootstrap(peer_id)
print("NetworkedMatch: peer %d reclaimed %s's reserved slot" % [peer_id, player_name])
return true
return false
@@ -1040,6 +1104,9 @@ func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void:
# server's own peer bookkeeping inconsistent (§9 gotcha on force=true).
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
return
# §6.3: a spectator/late joiner reconstructs from this, since match_config
# carries arena and roster only — no score, clock or match state.
_send_match_bootstrap(peer_id)
print("NetworkedMatch: peer %d (%s) joined mid-match; spectating until the next kickoff" % [peer_id, player_name])
@@ -1058,6 +1125,28 @@ func _spectator_count() -> int:
return count
# Rebuilds match_config from the CURRENT slot list and re-sends it. Slot order
# (and therefore snapshot body order) is preserved because _slots itself is
# never reordered — only a slot's peer_id changes on reclaim.
func _rebroadcast_match_config() -> void:
var peer_ids := PackedInt32Array()
var teams := PackedInt32Array()
var spawn_indices := PackedInt32Array()
for slot in _slots:
peer_ids.append(slot.peer_id)
teams.append(slot.team)
spawn_indices.append(slot.spawn_index)
MatchSim.send_match_config(_arena_path, peer_ids, teams, spawn_indices)
# §6.2 step 2: give one peer the live state it cannot get from match_config.
func _send_match_bootstrap(peer_id: int) -> void:
MatchSim.send_match_bootstrap(
peer_id, match_state, match_state_since_tick, score.duplicate(),
_end_tick, _clock_running, _reset_gen
)
func _expire_slot_reservations() -> void:
var now := Engine.get_physics_frames()
for slot in _slots:
@@ -1130,6 +1219,27 @@ func _broadcast_snapshot() -> void:
if _replay_log != null:
_replay_log.record_snapshot(server_tick, bytes)
MatchSim.send_snapshot(slot.peer_id, bytes)
# §6.3: "a spectator receives identical snapshots (the snapshot is already
# a broadcast — zero extra server work)". That was only true of the SEGMENT:
# the loop above unicasts one packet per SLOT, so a peer without a slot
# received nothing at all — no poses, no reset_gen, no match_state byte.
# An adversarial review caught it; spectating was entirely non-functional.
# The body segment is shared, so this really is just one extra send per
# spectator. The per-slot header fields are meaningless without a slot:
# there is no acknowledged input sequence, and -1 is the codec's own
# "client not established" value for buffer depth (§3.3).
var spectator_bytes := PackedByteArray()
for peer_id in connected_peers:
var has_slot := false
for slot in _slots:
if slot.peer_id == peer_id:
has_slot = true
break
if has_slot:
continue
if spectator_bytes.is_empty():
spectator_bytes = NetCodec.pack_snapshot(0, -1, 0, segment)
MatchSim.send_snapshot(peer_id, spectator_bytes)
func _ship_to_net_body_state(ship: Ship, stalled: bool) -> NetBodyState:
@@ -1297,7 +1407,7 @@ func _unhandled_input(event: InputEvent) -> void:
func _spectator_target_count() -> int:
return _slots.size() + (1 if is_instance_valid(ball) else 0)
return _slots.size()
func _point_spectator_camera() -> void:
@@ -1305,11 +1415,15 @@ func _point_spectator_camera() -> void:
if count == 0:
return
_spectator_target_index = posmod(_spectator_target_index, count)
var target: Node3D = null
# SHIPS ONLY. ShipCameraRig.target is declared `var target: Ship`
# (ship_camera.gd:37) and the rig reaches into ship-only API (`visual`,
# `is_turbo_active`, `get_speed_ratio`), so assigning the ball here was a
# type error waiting to fire the moment anyone cycled past the last ship.
# The rig already has its own ball-cam MODE for watching the ball, which is
# the supported way to do it — this cycles whose ship we follow.
var target: Ship = null
if _spectator_target_index < _slots.size():
target = _slots[_spectator_target_index].ship
else:
target = ball
if not is_instance_valid(target):
return
if not is_instance_valid(_camera_rig):
@@ -1326,7 +1440,7 @@ func _point_spectator_camera() -> void:
if is_instance_valid(_camera_rig):
_camera_rig.target = target
if is_instance_valid(hud):
hud.ship = target if target is Ship else null
hud.ship = target
func cycle_spectator_target(step: int = 1) -> void:
+18
View File
@@ -67,6 +67,16 @@ func _ready() -> void:
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
@@ -126,6 +136,14 @@ func _on_disconnect_host_player_joined(_peer_id: int, _name: String) -> void:
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()
+54 -2
View File
@@ -505,8 +505,11 @@ func _drive_at_ball(ship: Ship, ball_body: Node3D, timeout_seconds: float) -> vo
if not is_instance_valid(ship) or not is_instance_valid(ball_body):
break
var to_ball := ball_body.global_position - ship.global_position
if to_ball.length() < 3.0:
break # close enough that the existing thrust carries it in
if to_ball.length() < 1.2:
break # touching distance; momentum carries it the rest of the way
# Deliberately keeps steering all the way in rather than breaking off
# early and coasting: breaking at 3m let the ship sail past the ball
# without ever touching it (0 contacts in 1 run of 3).
# Bearing in the ship's own frame: -Z is forward, +X is right.
var local := ship.global_transform.basis.inverse() * to_ball
var yaw_error := atan2(local.x, -local.z)
@@ -632,6 +635,55 @@ func run_disconnect_host_check(lifetime_seconds: float) -> void:
get_tree().quit(0 if success else 1)
# §6.3 (task 5.8). A peer that joins mid-match with a name nobody reserved is a
# spectator: no slot, no ship, but it MUST still receive the snapshot stream
# and follow the lifecycle. An adversarial review found spectators received no
# snapshots at all, because _broadcast_snapshot unicasts per SLOT.
func run_spectator_check(run_seconds: float) -> void:
var snapshot_count := [0]
MatchSim.snapshot_received.connect(func(_d: Dictionary) -> void: snapshot_count[0] += 1)
var deadline := Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline and not _is_networked_match(get_tree().current_scene):
await get_tree().process_frame
var match_scene := get_tree().current_scene
if not _is_networked_match(match_scene):
print("SMOKE FAIL: spectator never loaded the match scene")
get_tree().quit(1)
return
await get_tree().create_timer(run_seconds).timeout
if not _is_networked_match(match_scene):
print("SMOKE FAIL: match scene torn down during the spectator run")
get_tree().quit(1)
return
var is_spectator: bool = match_scene._my_slot == null
var got_snapshots: bool = snapshot_count[0] > int(run_seconds * 20.0)
var camera_ok: bool = is_instance_valid(match_scene._camera_rig)
var state_ok: bool = MatchState.is_valid(match_scene.match_state) and match_scene.match_state != MatchState.State.LOBBY
# Bootstrap: a late joiner must know the live clock, not wait for a goal.
var stats: Dictionary = match_scene.get_net_debug_stats()
var wire_state := int(stats.get("snapshot_match_state", -1))
var wire_ok: bool = wire_state == int(stats.get("match_state", -2))
# Cycling must be safe and must never hand the camera a non-Ship.
match_scene.cycle_spectator_target(1)
match_scene.cycle_spectator_target(1)
match_scene.cycle_spectator_target(-1)
var cycle_ok: bool = is_instance_valid(match_scene._camera_rig) and (match_scene._camera_rig.target == null or match_scene._camera_rig.target is Ship)
print("SMOKE INFO: spectator is_spectator=%s snapshots=%d camera_ok=%s state=%s wire_state=%s cycle_ok=%s" % [
str(is_spectator), snapshot_count[0], str(camera_ok), MatchState.to_name(match_scene.match_state),
MatchState.to_name(wire_state), str(cycle_ok)
])
var success := is_spectator and got_snapshots and camera_ok and state_ok and wire_ok and cycle_ok
print("SMOKE %s: spectator received the snapshot stream and followed the match (snapshots=%d, want > %d)" % [
"PASS" if success else "FAIL", snapshot_count[0], int(run_seconds * 20.0)
])
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)
func run_malformed_abuse_check() -> void:
await get_tree().create_timer(1.0).timeout
# A single-element Array, not a plain bool: GDScript lambdas capture