feat(multiplayer): Phase 5 task 5.1 - match lifecycle state machine

Adds the §6.1 state machine, its broadcast, and the client side that
follows it. Physics, freezing and input are deliberately NOT gated on
state yet - 5.3 and 5.4 own freeze/unfreeze at kickoff and goal, and
doing it here would change the conditions every Phase 4 prediction gate
was measured under.

scripts/match_state.gd holds the enum and transition table as pure data
with no scene or RPC dependency, so the table is checked exhaustively
rather than by example: every state reachable, every state has an exit,
no self-transitions, abort-to-LOBBY from anywhere per §6.4, illegal
shortcuts rejected, unknown values refused rather than coerced. The enum
values are the wire format - match_state has been a u8 in the snapshot
header since §2.4 - so a test pins them; only append, never renumber.

The server validates every transition and push_errors an illegal one
rather than following it. Clients deliberately do NOT enforce the table:
authoritative state must be accepted, and a late joiner legitimately
jumps straight to PLAYING.

Two channels carry the state. state_change (reliable, channel 0) is
prompt and carries an absolute at_tick, never a duration. The snapshot's
match_state byte is the catch-up path for a client not yet sent a
transition - a late joiner, or the window between scene load and the
first RPC.

The byte needs a tick guard, and this was found the hard way. Snapshots
are unreliable_ordered on channel 2 and ordering holds only within a
channel, so a state_change for tick N routinely arrives before an
in-flight snapshot from tick N-2. Without the guard the client applies
the new state then gets dragged back by the older byte, oscillating on
every transition - observed directly as LOADING -> WARMUP -> LOBBY ->
PLAYING -> LOBBY while running a deliberately-broken-byte control. Only
a byte at least as new as match_state_since_tick is accepted.

WARMUP_TICKS/GOAL_PAUSE_TICKS are honest placeholders so 5.1 drives real
transitions to verify against; 5.3 and 5.4 replace them. The server also
leaves LOADING immediately rather than waiting for scene_ready, which
does not exist yet.

New smoke flag --exercise-match-state, passed to both roles: the host
forces a goal to drive a GOAL_PAUSE cycle, the client records the
sequence and asserts every consecutive pair is legal, that ticks are
monotonic, and that the wire byte agrees with its own state. Observed
LOADING -> WARMUP -> PLAYING -> GOAL_PAUSE -> WARMUP with tick deltas
matching the configured durations exactly.

Verified against a control: hardcoding the snapshot byte back to 0 fails
both the byte assertion and the transition-legality assertion. The byte
is asserted separately from the RPC precisely because everything else in
the check is RPC-driven and would pass with a dead byte - the same gap
that hid the Phase 4 label bug (gotcha 47).

Regression: 81 unit tests; 60s free-flight LAN (p99 0.148m, 0 hard
snaps, marker 0/3364); transition gate 0.00%; ball contact; two-bot CI.
This commit is contained in:
Josh Creek
2026-08-21 09:31:22 +01:00
parent 75f485667b
commit 9f28c02488
9 changed files with 485 additions and 7 deletions
+22 -1
View File
@@ -964,7 +964,7 @@ Note the free-flight p99 **improved** (0.170/0.176/0.184 → 0.141/0.168/0.154)
| # | Task | Acceptance |
|---|---|---|
| 5.1 `[D:2.1]` | Server state machine, `state_change` broadcast, `match_state` snapshot byte | Clients follow every transition |
| 5.1 `[D:2.1]` | **DONE.** `scripts/match_state.gd` (enum + validated transition table, pure/unit-testable), server-driven machine in `NetworkedMatch`, `state_change` RPC on reliable channel 0 carrying an absolute `at_tick`, and the snapshot `match_state` byte populated for real | Client observed `LOADING -> WARMUP -> PLAYING -> GOAL_PAUSE -> WARMUP` with monotonic ticks in a real two-process run; every consecutive pair legal; wire byte asserted independently of the RPC |
| 5.2 `[D:5.1]` | Tick-derived clock replacing the `Timer` + `_process` polling; `clock_state` RPC; goal-time freeze as `end_tick += (resume_tick - goal_tick)` | Clocks agree across peers to within a tick; no float drift across 10 goals |
| 5.3 `[D:5.1]` | `kickoff` RPC with broadcast transforms, freeze/unfreeze, `reset_gen`, countdown derived from `server_tick`, **and the specified late-arrival behaviour** | A `kickoff` delayed past `resume_tick` applies immediately without a negative countdown |
| 5.4 `[D:5.1]` | `goal_scored` RPC; server-side pause window via `_goal_pause_seconds()` and `_set_frozen()` (**never `Engine.time_scale`**); client cinematic split from timing | Server reset no longer fires while clients are mid-celebration |
@@ -979,6 +979,27 @@ Note the free-flight p99 **improved** (0.170/0.176/0.184 → 0.141/0.168/0.154)
> Task 5.10 is the highest-value debuggability investment here. The packets are already flat bytes, so it is ~50 lines. Without it, "my ship snapped" is permanently unreproducible from a field report — the CI gate catches regressions, but it cannot debug a player's bad night.
#### Task 5.1 notes
`scripts/match_state.gd` holds the enum and the §6.1 transition table as pure data with no scene/RPC dependency — the same reason `net_codec.gd` and `input_jitter_buffer.gd` are standalone — so the table is checked exhaustively (every state reachable, every state has an exit, no self-transitions, abort-to-LOBBY from anywhere, illegal shortcuts rejected) rather than by example. **The enum's integer values are the wire format**, pinned by a test: `match_state` has been a `u8` in the snapshot header since §2.4, so renumbering an existing state silently reinterprets packets from an older peer. Only append.
The server validates every transition and `push_error`s an illegal one rather than following it, because the symptom otherwise — clients faithfully following into a state the server's own code never meant to reach — is near-impossible to diagnose from a field report.
**Two channels carry the state, deliberately.** `state_change` (reliable, channel 0) is prompt and carries the absolute `at_tick`; the snapshot's `match_state` byte is the catch-up path for a client that has not been sent a transition yet — a late joiner (§6.3), or the window between scene load and the first RPC. **The byte needs a tick guard**: snapshots are `unreliable_ordered` on channel 2 and ordering holds only *within* a channel, so a `state_change` for tick N routinely arrives before an in-flight snapshot from tick N-2. Without the guard the client applies the new state and is immediately dragged back by the older byte, oscillating on every transition — observed directly (`LOADING -> WARMUP -> LOBBY -> PLAYING -> LOBBY -> ...`) while running a deliberately-broken-byte control. Only a byte at least as new as `match_state_since_tick` is accepted.
The client deliberately does **not** enforce the transition table — authoritative state must be accepted, and a late joiner legitimately jumps straight to `PLAYING`. The table is a server-side invariant. The smoke test asserts legality of what the client *observes*, seeding its first sample from whatever state the client converged to rather than counting that as a transition, so late-loading clients (seen seeding at `WARMUP` rather than `LOADING`) still pass.
**5.1 does not gate physics, freezing or input on state.** Tasks 5.3 and 5.4 own freeze/unfreeze at kickoff and goal; doing it here would both duplicate that work and change the conditions every Phase 4 prediction gate was measured under. `MatchState.is_live()` exists for them to use. `WARMUP_TICKS`/`GOAL_PAUSE_TICKS` are honest placeholders so 5.1 drives *real* transitions to verify against — 5.3 replaces the first with the broadcast kickoff (reset transforms + countdown from `server_tick`), 5.4 the second with `_goal_pause_seconds()` and the client-cinematic split. The server also leaves `LOADING` immediately rather than waiting for `scene_ready`, which does not exist yet (5.3).
New smoke flag `--exercise-match-state` (pass to **both** roles — the host forces a goal to drive a `GOAL_PAUSE` cycle, the client records and validates the sequence):
```
godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=host --drive-seconds=6 --exercise-match-state
godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=client --drive-seconds=6 --exercise-match-state
```
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 6 — Dedicated server productionisation