feat(multiplayer): Phase 5 tasks 5.6-5.10 - disconnects, spectators, replay log

Completes Phase 5's implementation. Every task is verified at 1v1; the
3v3 phase gate itself has not been run and remains outstanding.

5.6/5.7 disconnects: a ship is never despawned. The slot keeps it and
swaps the controller (--fill-bots gives it a bot, the default leaves it
inert per §1.4), sets `stalled` immediately so the nameplate greys out
rather than waiting ~500ms for the abandoned jitter buffer to starve,
and reserves the slot for 30s keyed by player name so a reconnect gets
the same ship back.

5.7 was a real bug, found by the test rather than by review:
SlotInfo.controller was declared RLShipController, but the takeover
swaps in an AIShipController or the base controller - the narrower type
makes that assignment fail its type check, leaving the field pointing at
the controller set_controller() just queue_free()d. It surfaced as
controller_valid=false on the first run. The per-tick action write is
now also gated on `is RLShipController`, since a disconnected slot's bot
drives itself and overwriting it from a starving buffer would pin it to
the departed player's last input.

§6.4's two rules conflict: reserve for 30s, but abort when the last
human leaves. Applied naively the abort wins instantly in a 1v1 and the
reservation can never be redeemed, making reconnect unreachable exactly
when it matters. Abort now waits for no connections AND no outstanding
reservations.

5.8 spectators: a slotless peer spawns no ship and receives the same
snapshot broadcast. HUDController.spectator_mode keeps the clock, score
and goal celebration and hides only the ship instrument cluster - it
previously push_error'd and bailed, leaving a spectator with a dead HUD.
Camera cycles ships in slot order then the ball. --max-spectators caps
it, counted from the live peer list so a dropped spectator cannot leak a
unit of the cap.

5.9 escape respawn: new GameMode._on_bodies_respawned() virtual;
NetworkedMatch bumps reset_gen through Phase 2's deferred path so the
bump and the respawned pose land in the same broadcast. Single-player
modes are unaffected - the base is a no-op.

5.10 replay log: scripts/replay_log.gd, --replay-log=<path>, storing the
wire bytes verbatim in both directions rather than re-serialising - a
re-encode would launder away precisely the malformed payload being
chased. A live 6s match recorded 1115 records (557 inputs / 558
snapshots) and a stored snapshot decodes back to server_tick=100
match_state=WARMUP bodies=2.

Note for future work: --check-only --script is the only thing that
catches a parse error in networked_match.gd, because the unit runner
never loads it. Two separate breakages passed the full unit suite while
breaking every two-process run. A new class_name also needs --import
before it resolves.

Test surface: --role=host-disconnect (three-process 5.6/5.7 scenario),
--match-length=<s>, --replay-log, --fill-bots/--no-fill-bots,
--max-spectators. The ball-contact scenario now steers at the ball with
closed-loop real input instead of a hand-tuned fixed heading, which 5.3
broke by adding KICKOFF_YAW_JITTER; thrusting while turning took it from
2/3 to 5/5.

Regression: 87 unit tests; free-flight LAN p99 0.094m with 0 hard snaps;
transition gate 0.00%; ball contact 5/5; lifecycle goal cycle and full
match to RESULTS/LOBBY; disconnect+reconnect; two-bot CI.
This commit is contained in:
Josh Creek
2026-08-21 10:25:15 +01:00
parent 3d6906b981
commit a5cbc977b5
11 changed files with 708 additions and 14 deletions
+33
View File
@@ -30,6 +30,11 @@ class_name HUDController
@onready var camera_mode_label = get_node_or_null("Control/Instruments/Cluster/CameraModeLabel")
var ship: Node
# §6.3 (task 5.8). Set by the game mode BEFORE this node enters the tree when
# the local peer has no ship of its own. Distinct from `ship == null` by
# accident: a missing ship is still an error for a player, and silently
# degrading to a spectator HUD would hide that.
var spectator_mode := false
var _last_score := {0: 0, 1: 0}
var _goal_tween: Tween
@@ -43,6 +48,16 @@ func _initialize_hud():
# this runs — not discovered via group, since the "ship" group can have
# 2+ members and there's no reliable way to tell which one is "ours".
if not ship:
# §6.3 (task 5.8): a spectator legitimately has no ship of its own, and
# must still get the score, clock and goal celebration. Only the
# per-ship instrument cluster is meaningless without one, so hide that
# and carry on wiring everything else — this used to push_error and
# bail, which left a spectator with a completely dead HUD.
if spectator_mode:
print("HUDController: spectator mode — hiding ship instruments")
_hide_ship_instruments()
_connect_mode_signals()
return
push_error("HUDController: No ship assigned")
return
@@ -59,6 +74,24 @@ func _initialize_hud():
if camera_rig and camera_rig.has_signal("camera_mode_changed"):
camera_rig.camera_mode_changed.connect(_on_ship_camera_mode_changed)
_connect_mode_signals()
func _hide_ship_instruments() -> void:
# The per-ship cluster (speed, altitude, thrust, boost, camera mode) has no
# meaning without a ship. Everything else on the HUD still does.
for node in [speed_gauge, altitude_gauge, camera_mode_label]:
if node and is_instance_valid(node):
node.visible = false
var cluster := get_node_or_null("Control/Instruments/Cluster")
if cluster and is_instance_valid(cluster):
cluster.visible = false
# Everything that depends on the MODE rather than on owning a ship: clock,
# score, team identity, match-ended, kickoff countdown. A spectator gets all
# of it.
func _connect_mode_signals() -> void:
# Connect to game manager's timer signal; modes without a timer
# (e.g. free play) just don't show one
var game_manager = get_tree().get_first_node_in_group("game")