phase: 06_realtime_websocket_protocol
All green. Final verification complete — writing the report.
**Final pass report — phase 06 `realtime_websocket_protocol`**
- Verified: all 6 task files present in `complete/`; this was the final verification pass (no implementation work outstanding)
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors
- `uv run undercooked-levels validate` → content OK; `uv run pytest` (full suite) → **1751 passed, 0 failed, coverage 97.30% (>90% floor)**; slow sim stage (`-m slow tests/sim --no-cov`) → 2 passed; repo hygiene clean
- App launches: `/healthz` 200, `/` 200, `/api/levels`, `/api/config`, `POST /api/rooms` (201), `/api/stats` all verified live on an ephemeral port (server cleaned up)
- Criteria: scripted client room→hello→ready→start→countdown→≥20 snaps→round — PASS (`test_ws_round_two_clients.py`); identical `round` payloads both clients — PASS (`round_a == round_b`); version mismatch → `err protocol_mismatch` + close `4400` — PASS; 64 KB refused / 4 KB boundary, 500-burst → `rate_limited` → `4404` with bounded queue, 2000 malformed + 200 handshakes within memory bound — PASS; snap ≤ 1.5 KB (4p/4o/3h) and 64×64 ≤ 4 KB, downlink ≤ 60 KB/s over 100 ticks — PASS; `docs/PROTOCOL.md` model-sync test-enforced in both directions + full-round sequence diagram present — PASS
- Docs: README.md had 3 stale spots — status section (claimed 01–05 complete, 06 pending → now 01–06 complete, 07 pending), "until phases 06/07 wire the transport" wording, and the `PROTOCOL.md` layout note ("phase 06 completes it"). All fixed. AGENTS.md verified consistent — no changes needed
- No defects found in completed phases; no live infrastructure touched
Next pending phase: `07_canvas_client_and_hud`.
This commit is contained in:
+41
-13
@@ -76,6 +76,8 @@ already `CLOSED`) and stops the tick task.
|
||||
| `last_result` | the `sim.round_state.result_payload` of the last finished round (`verdict`, `round_no`, `score`, `time_left`, `deadline_share`) |
|
||||
| `round_no` / `round_seed` | the round counter (1, 2, …) and its seed: round 1 plays the base seed, every later round steps the LCG cursor — a fresh, and from one base pinned, seed |
|
||||
| `events_since_snapshot` / `take_events` | the sim events (scored, wasted, burnt, …) since the last client snapshot; drained, never accumulated |
|
||||
| `_queued_inputs` / `queue_input` | the per-tick input queue (capacity `INPUT_QUEUE_PER_SEAT x len(seats)`; see "One 20 Hz tick task", the `Inputs` paragraph) |
|
||||
| `stats()` / `note_input_stat` | the room's diagnostics census: the lobby/round counters plus the input counters (`inputs_received`, `inputs_dropped`, `stale_seqs`, `clamped_moves`, `large_seq_gaps`) |
|
||||
| `snapshot_seq` | the room's snapshot counter (the phase 06 transport stamps it on frames) |
|
||||
|
||||
## One 20 Hz tick task, deadline-scheduled
|
||||
@@ -102,8 +104,15 @@ deterministic test (100 ticks over exactly 5.0 s, no drift).
|
||||
at zero, fire `countdown_done` and build the round.
|
||||
- **`PLAYING`** — `sim.step(sim_state, dt, queued, recipe_book)`: the kernel's
|
||||
own `STEP_DT` substeps, `dt` clamped to `[0, MAX_DT]` by the sim (a jump
|
||||
costs at most two substeps). The per-tick input queue is drained into the
|
||||
step (the sim stamps `received_at_tick`); the sim's events are appended to
|
||||
costs at most two substeps). The per-tick input queue is drained into
|
||||
the step (the frames carry their `received_at_tick` stamp from
|
||||
`queue_input`) — **unless the tick advances no substep** (a wall-clock
|
||||
`dt` too short to fill a `STEP_DT` once the accumulator is counted):
|
||||
then the queue is *kept* and the step is not called, so a frame is never
|
||||
dropped without being applied (a 20 Hz wall-clock loop jitters 48-51 ms;
|
||||
without the guard a queued frame would be lost on a good fraction of
|
||||
ticks). A settled state (`result` set) still drains: the kernel ignores
|
||||
its inputs there by design. The sim's events are appended to
|
||||
`events_since_snapshot`. When the sim's `result` lands, the room opens the
|
||||
**settle** — `sim.round_state.SETTLE_S` (1.0 s) of wall time, the sim in its
|
||||
frozen stage-7 machine in between — and only then captures
|
||||
@@ -118,10 +127,20 @@ deterministic test (100 ticks over exactly 5.0 s, no drift).
|
||||
floored at 0, so a backwards `Clock` never un-runs the sim.
|
||||
|
||||
Inputs: `queue_input` accepts a `sim.model.PlayerInput` only while `PLAYING`
|
||||
and for a seated token (an unknown token is an `err` event — the room's only
|
||||
in-band error channel; a dropped input outside playing is a silent no-op, the
|
||||
client is not at fault). The queue is per-tick: drained into the step, cleared
|
||||
after it, never carried between ticks.
|
||||
and for a seated, connected token: an unknown token is an `err` event with
|
||||
`reason=not_seated` (the player is not a seat at all) or `reason=unknown_player`
|
||||
(the token is a seat but the sim id is not its) — the room's only in-band
|
||||
error channel — while a `DISCONNECTED` seat's frame is a **silent no-op** (the
|
||||
client is not at fault, so no error is queued) and `last_seq` on the
|
||||
connection is left untouched. The queue is per-tick with a capacity of
|
||||
`INPUT_QUEUE_PER_SEAT x len(seats)` (4 x the seated players): at capacity,
|
||||
the *oldest* frame of the arriving player is dropped (the newest intent
|
||||
wins; `inputs_dropped` bumped) — the queue never grows unbounded. The
|
||||
queue is drained by the next tick that advances a substep (a zero-substep
|
||||
tick keeps it — see the `PLAYING` bullet) and cleared then; `stats()`
|
||||
counts the input traffic (`inputs_received`, `inputs_dropped`, `stale_seqs`,
|
||||
`clamped_moves` — the clamp itself is the transport's, `ws.to_player_input` —
|
||||
and `large_seq_gaps`, the tolerated-loss sibling).
|
||||
|
||||
## Crash isolation
|
||||
|
||||
@@ -189,13 +208,22 @@ The three ways a room leaves the registry, and the windows that govern each
|
||||
`join`s while `CLOSED` (the window before the drop) is a named 410
|
||||
`room_closed`.
|
||||
|
||||
**Disconnected seats.** A dropped connection marks the seat
|
||||
`connected=false`; the seat is *kept* for the rejoin path (a token
|
||||
`join`s its own room again, the seat is marked connected, the sim keeps
|
||||
its player). The grace that drops such a seat is
|
||||
`UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (45 s) — that rule lands with phase 12
|
||||
(reconnect resilience); until then a disconnected seat lingers until the
|
||||
room empties and the seat grace runs.
|
||||
**Disconnected seats (task 05).** A dropped connection marks the seat
|
||||
`connected=false`; the seat is *kept* for the rejoin path. The park
|
||||
comes first: after `PARK_AFTER_S` (3 s) the cook's carried item is
|
||||
released (onto the station on the tile, or `counters["lost_items"]`),
|
||||
and the sim's cook sits in `disconnected`. The grace that drops the
|
||||
seat is `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (45 s), enforced by the
|
||||
room's own tick in every state (`SeatAbandoned`): the seat is emptied
|
||||
(one `ghost_off` event, a host transfer, `empty_since` for the close
|
||||
rules) and any still-carried item is released at once. A rejoin inside
|
||||
the grace reclaims the seat (`welcome.rejoined`; the snapshot stream
|
||||
resumes within one tick); after it, the spectate/join rules apply. The
|
||||
emptied room closes through the 30 s seat grace above (the room's own
|
||||
check in `LOBBY`/`ROUND_END`; the GC in any state), and a closing room
|
||||
closes its sockets with `err room_gone` + close `4401` — the client's
|
||||
"the room died" signal (the contract in `docs/PROTOCOL.md`,
|
||||
"Heartbeats, drops, and seat grace").
|
||||
|
||||
**The crash policy** is the crash-isolation section above, restated as
|
||||
lifecycle: a crashed room fires `close` from inside its own tick (it
|
||||
|
||||
Reference in New Issue
Block a user