mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 18:13:42 +00:00
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:
+23
-7
@@ -4,7 +4,7 @@ Working document for the online multiplayer effort. `TODO.md` points here.
|
||||
|
||||
Everything below is written so an agent (or a person) can pick up a single numbered task, do it, verify it against a stated acceptance criterion, and stop. Sections 1–6 are the decisions those tasks assume; read them before picking up work in Phase 2 or later.
|
||||
|
||||
**Status: Phase 4's correctness gates are green; sign-off waits on a human playtest. Phase 3 needed two real fixes to get there (task 4.13).** The client now has local-ship delta-rebase reconciliation, client-only ball touch prediction, adaptive input-depth signalling, and experimental remote present-time visuals; all server simulation, bot action/observation behaviour, collision resources, and tick rate remain unchanged. The action-sequence-correctness gap that blocked Phase 4 was a mislabelled prediction history, now fixed and permanently gated (task 4.11). An adversarial review of that fix then found two Phase 3 bugs that were silently killing a connected player's input — periodically on a clean LAN, and permanently after any ~2 s host hitch — both now fixed with verified controls (task 4.13). What remains is not a measurement: nobody has played it at ~100 ms RTT to judge feel, which is what the milestone actually asks. See §7 for the implemented work, evidence, and the one open architectural question (a contact-cohort-only shadow world).
|
||||
**Status: Phase 5's tasks are all implemented and individually verified at 1v1; its 3v3 phase gate has not been run. Phase 4's correctness gates are green and its sign-off waits on a human playtest.** The client now has local-ship delta-rebase reconciliation, client-only ball touch prediction, adaptive input-depth signalling, and experimental remote present-time visuals; all server simulation, bot action/observation behaviour, collision resources, and tick rate remain unchanged. The action-sequence-correctness gap that blocked Phase 4 was a mislabelled prediction history, now fixed and permanently gated (task 4.11). An adversarial review of that fix then found two Phase 3 bugs that were silently killing a connected player's input — periodically on a clean LAN, and permanently after any ~2 s host hitch — both now fixed with verified controls (task 4.13). What remains is not a measurement: nobody has played it at ~100 ms RTT to judge feel, which is what the milestone actually asks. See §7 for the implemented work, evidence, and the one open architectural question (a contact-cohort-only shadow world).
|
||||
|
||||
---
|
||||
|
||||
@@ -969,11 +969,11 @@ Note the free-flight p99 **improved** (0.170/0.176/0.184 → 0.141/0.168/0.154)
|
||||
| 5.3 `[D:5.1]` | **DONE.** `kickoff` RPC carrying resulting transforms (never a seed, per §1), deferred freeze, `reset_gen` bump, countdown from `server_tick`, late-arrival skip | Real two-process run: `LOADING -> WARMUP -> PLAYING`, countdown ticks match `WARMUP_TICKS` exactly; a kickoff past its own resume tick unfreezes immediately and emits `0` |
|
||||
| 5.4 `[D:5.1]` | **DONE.** `goal_scored(scoring_team, score, goal_tick, resume_tick)`, freeze on the goal tick, reset moved out of the sensor path into the kickoff at `resume_tick`; cinematic is presentation-only | `PLAYING -> GOAL_PAUSE -> WARMUP -> PLAYING` observed on the client; bodies stay where the goal left them for the whole window; `Engine.time_scale` untouched |
|
||||
| 5.5 `[D:5.1]` `[P]` | **DONE.** Clock expiry -> `FULL_TIME` -> sudden death on a draw or `RESULTS`, golden goal in overtime, then `LOBBY` on both peers. `get_tree().paused` is never used in the networked path | Full run observed end to end: `LOADING -> WARMUP -> PLAYING -> FULL_TIME -> OVERTIME_WARMUP -> OVERTIME -> GOAL_PAUSE -> RESULTS -> LOBBY`, both peers returning to the lobby scene |
|
||||
| 5.6 `[D:5.1]` `[P]` | Disconnect → controller swap; 30 s identity-keyed slot reservation and reconnect; `--fill-bots` / `--no-fill-bots`; `stalled` flag and nameplate | A disconnect never despawns a ship; reconnect within 30 s restores the slot |
|
||||
| 5.7 `[D:5.6]` | Null `MatchNet`'s controller reference in the same transaction as the swap, and `is_instance_valid`-guard every use | No freed-object access on repeated disconnect/reconnect |
|
||||
| 5.8 `[D:5.1]` `[P]` | Spectators and late join; spectator-safe `HUDController` path; camera target cycling | A spectator can watch a live match and cycle targets |
|
||||
| 5.9 `[D:5.3]` `[P]` | Server-only `_respawn_escaped_bodies()` with a `reset_gen` bump | Clients hard-snap on an escape respawn instead of fighting it |
|
||||
| 5.10 `[D:5.1]` `[P]` | **Server replay log**: append-only binary `(tick, inputs received, snapshot sent)` | A recorded match replays deterministically enough to reproduce a reported snap |
|
||||
| 5.6 `[D:5.1]` `[P]` | **DONE.** Controller swap on disconnect (ship never despawned), 30 s identity-keyed reservation, reclaim on reconnect, `--fill-bots`/`--no-fill-bots`, `stalled` set immediately for the nameplate | Real 3-process run: ship survived, controller valid, slot reserved, reclaimed by name, same ship instance |
|
||||
| 5.7 `[D:5.6]` | **DONE.** `_swap_slot_controller()` rebinds in the same transaction; `slot.controller` retyped to the base `ShipController`; every use `is_instance_valid`-guarded | The disconnect test caught the real bug: the narrower `RLShipController` type made the swap assignment fail, leaving a freed reference |
|
||||
| 5.8 `[D:5.1]` `[P]` | **DONE.** A slotless peer spectates (no ship spawned, same snapshot stream), `HUDController.spectator_mode` keeps clock/score/celebration and hides only the ship cluster, camera cycles ships then ball, `--max-spectators` cap | Spectator path exercised by the mid-match joiner; HUD no longer `push_error`s and bails with a dead HUD |
|
||||
| 5.9 `[D:5.3]` `[P]` | **DONE.** 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 unaffected (base is a no-op) |
|
||||
| 5.10 `[D:5.1]` `[P]` | **DONE.** `scripts/replay_log.gd`, `--replay-log=<path>`, storing wire bytes verbatim in both directions | Live 6 s match recorded 1115 records (557 inputs / 558 snapshots); a stored snapshot decodes back to `server_tick=100 match_state=WARMUP bodies=2`; 6 unit tests incl. truncation and foreign-file rejection |
|
||||
|
||||
> `Ship.set_controller` (`ship.gd:213-218`) calls `queue_free()` on the outgoing controller. Task 5.7 exists because the takeover path in 5.6 otherwise leaves `MatchNet` holding a freed reference — the exact class of bug that surfaces as a random server crash weeks later.
|
||||
|
||||
@@ -1000,7 +1000,23 @@ godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=cl
|
||||
|
||||
Verified against a control: hardcoding the snapshot byte back to `0` fails both the byte assertion and the transition-legality assertion. That control is why the gate asserts the wire byte separately from the RPC at all — everything else in the check is RPC-driven and would pass identically with a dead byte, which is exactly how Phase 4's mislabelled history survived every gate (gotcha 47).
|
||||
|
||||
**Phase gate:** a full 3v3 start-to-finish including a mid-match disconnect and a late joiner.
|
||||
#### Phase 5 notes
|
||||
|
||||
**Task ordering caught three ordering bugs of the same shape**, all found by a failing run rather than by review, and all worth remembering as a class: *a value consumed by one per-tick updater and cleared by another is order-dependent.* `_update_kickoff_countdown()` clears the `_kickoff_resume_tick` that `_update_match_state()` reads to leave `WARMUP` (match froze forever); `_apply_match_state()` resets `_state_deadline_tick` on every transition, so a `GOAL_PAUSE` deadline assigned *before* `_set_match_state` was wiped (match never resumed); and a `set_deferred("freeze", true)` landed before the queued kickoff teleport could apply, stranding every body where the goal left it.
|
||||
|
||||
**Freezing is asymmetric between server and client, and this is not optional.** On the server every body is a real dynamic simulation and all of them freeze. On a client, `freeze` is *already* load-bearing for something else: remote ships and the ball are permanently `FREEZE_MODE_KINEMATIC` and driven by transform writes, with only the local ship unfrozen for prediction. Freezing "all bodies" on a client therefore **unfreezes the remote ones on the way back out** — they fall under gravity while the interpolator fights them for the transform. Measured: 210 hard snaps and an infinite p99. A client freezes only the one body it actually simulates.
|
||||
|
||||
**Prediction is suspended while the match is not live.** During a countdown or goal pause the local ship is frozen on both peers, so there is nothing to predict — but the reconciler still ran delta transport and visual-offset maths over those frozen states and produced a p95 position error of **2.4e10 m** while the instantaneous error stayed small. Input keeps flowing so the server's jitter buffer does not starve into `stalled`.
|
||||
|
||||
**§6.4's two rules conflict and the reservation has to win.** "Reserve a departed player's slot for 30 s" and "abort to the lobby once the last human leaves" applied naively means the abort fires instantly in a 1v1 — the moment the only player drops, the match is torn down and the reservation can never be redeemed, making the reconnect path unreachable exactly when it matters (one player whose connection blipped). Abort now waits until nobody is connected **and** no reservation is outstanding.
|
||||
|
||||
**Task 5.7's bug was real and the test found it.** `SlotInfo.controller` was declared `RLShipController`, but §6.4's takeover swaps in an `AIShipController` or the base controller — a narrower declared 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 disconnect run. The per-tick `slot.controller.action` write is now also gated on `is RLShipController`: a disconnected slot's bot drives itself, and overwriting its action from a permanently-starving buffer would pin it to the departed player's last input.
|
||||
|
||||
**`--check-only --script` is the only thing that catches a parse error in `networked_match.gd`.** The unit runner never loads it, so `bot_model_path` being undefined (and later `ReplayLog` being unregistered) both passed 81/87 unit tests while breaking every two-process run. Validate touched scripts directly. A newly added `class_name` also needs `godot --headless --path Game --import` before anything can resolve it.
|
||||
|
||||
**New/changed test surface:** `--exercise-match-state` (both roles; host forces a goal, client validates the whole observed sequence and the wire byte), `--role=host-disconnect` for the 5.6/5.7 three-process scenario, `--match-length=<s>` to reach `FULL_TIME` in a short run, `--replay-log=<path>`, `--fill-bots`/`--no-fill-bots`, `--max-spectators=<n>`. The ball-contact scenario now **steers at the ball with closed-loop real input** instead of a hand-tuned fixed-heading burst, which 5.3 broke by adding `KICKOFF_YAW_JITTER` (0 contacts in 3/3 runs); it thrusts while turning rather than hovering to aim, which took it from 2/3 to 5/5.
|
||||
|
||||
**Phase gate:** a full 3v3 start-to-finish including a mid-match disconnect and a late joiner. **Not yet run** — every scenario above was verified at 1v1 (plus a two-bot CI match). The 3v3 gate needs a real multi-client session and is the outstanding item for this phase, alongside Phase 4's own un-run human playtest.
|
||||
|
||||
### Phase 6 — Dedicated server productionisation
|
||||
|
||||
|
||||
Reference in New Issue
Block a user