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
+75 -3
View File
@@ -159,7 +159,7 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball
# ball every time (0 contacts in 3/3 runs). Closing the loop on the
# actual bearing keeps this exercising the real input path while being
# indifferent to how the kickoff happened to orient the ship.
await _drive_at_ball(my_slot.ship, match_scene.ball, 3.0)
await _drive_at_ball(my_slot.ship, match_scene.ball, 8.0)
# Leave a >150ms observation window before the normal drive so a
# subsequent goal reset cannot mask blend-back.
Input.action_release("move_forward")
@@ -514,9 +514,14 @@ func _drive_at_ball(ship: Ship, ball_body: Node3D, timeout_seconds: float) -> vo
Input.action_release("turn_right")
if absf(yaw_error) > ALIGNED_RADIANS:
Input.action_press("turn_right" if yaw_error > 0.0 else "turn_left")
Input.action_release("move_forward")
else:
# Thrust whenever the ball is anywhere ahead, not only once perfectly
# aligned. Cutting thrust to turn made the ship hover and burn the
# window without closing distance, which is why this reached the ball
# only 2 runs in 3; turning under power converges much faster.
if absf(yaw_error) < PI * 0.5:
Input.action_press("move_forward")
else:
Input.action_release("move_forward")
# Vertical alignment matters too — the ball sits above the floor and a
# ship that is climbing sails straight over it.
Input.action_release("move_up")
@@ -560,6 +565,73 @@ func _run_free_flight_trace(ship: Ship, start_position: Vector3, duration_second
# honest encoder — this IS what a hostile custom client sending raw ENet
# packets would look like, so bypassing the normal send path is the point,
# not a shortcut.
# §6.4 (tasks 5.6/5.7), host side. Watches its own slots across a client's
# disconnect and reconnect and asserts the documented contract: the ship is
# never despawned, the controller is swapped rather than left dangling, the
# slot is reserved by identity, and a returning player gets it back.
func run_disconnect_host_check(lifetime_seconds: float) -> void:
await get_tree().create_timer(2.0).timeout
var match_scene := get_tree().current_scene
if not _is_networked_match(match_scene):
print("SMOKE FAIL: host scene is not NetworkedMatch")
get_tree().quit(1)
return
var slots_before: int = match_scene._slots.size()
if slots_before == 0:
print("SMOKE FAIL: host has no slots — the client never made it into the roster")
NetworkManager.shutdown()
get_tree().quit(1)
return
var ship_before = match_scene._slots[0].ship
var name_before: String = match_scene._slots[0].player_name
print("SMOKE INFO: host has %d slot(s), player_name=%s" % [slots_before, name_before])
# Wait for the client to drop. Guarded on the scene still existing: §6.4's
# abort can tear the match down underneath this loop.
var drop_deadline := Time.get_ticks_msec() + int(lifetime_seconds * 1000.0)
while Time.get_ticks_msec() < drop_deadline and _is_networked_match(match_scene) and not match_scene._slots[0].disconnected:
await get_tree().physics_frame
if not _is_networked_match(match_scene):
print("SMOKE FAIL: match aborted during the disconnect window — the reservation should have held it open")
NetworkManager.shutdown()
get_tree().quit(1)
return
var saw_disconnect: bool = match_scene._slots[0].disconnected
var ship_survived: bool = match_scene._slots.size() == slots_before and is_instance_valid(match_scene._slots[0].ship) and match_scene._slots[0].ship == ship_before
var controller_valid: bool = is_instance_valid(match_scene._slots[0].controller)
var reserved: bool = match_scene._slots[0].reserved_until_tick > Engine.get_physics_frames()
print("SMOKE INFO: after disconnect saw_disconnect=%s ship_survived=%s controller_valid=%s reserved=%s" % [
str(saw_disconnect), str(ship_survived), str(controller_valid), str(reserved)
])
# Then for it to come back and reclaim the slot.
var back_deadline := Time.get_ticks_msec() + int(lifetime_seconds * 1000.0)
while Time.get_ticks_msec() < back_deadline and _is_networked_match(match_scene) and match_scene._slots[0].disconnected:
await get_tree().physics_frame
if not _is_networked_match(match_scene):
print("SMOKE FAIL: match aborted before the player could reconnect")
NetworkManager.shutdown()
get_tree().quit(1)
return
var reclaimed: bool = not match_scene._slots[0].disconnected
var same_ship: bool = is_instance_valid(match_scene._slots[0].ship) and match_scene._slots[0].ship == ship_before
# Ticking on past the swap proves task 5.7: _physics_process writes
# slot.controller.action every tick, so a dangling reference from
# set_controller()'s queue_free() would have crashed by now.
for i in 60:
if not _is_networked_match(match_scene):
break
await get_tree().physics_frame
var success := saw_disconnect and ship_survived and controller_valid and reserved and reclaimed and same_ship and is_instance_valid(match_scene._slots[0].controller)
print("SMOKE %s: disconnect kept the ship and the reconnect reclaimed the slot (disconnect=%s ship_kept=%s reserved=%s reclaimed=%s same_ship=%s)" % [
"PASS" if success else "FAIL", str(saw_disconnect), str(ship_survived), str(reserved), str(reclaimed), str(same_ship)
])
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