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:
@@ -0,0 +1,55 @@
|
||||
# Phase 06 — Realtime WebSocket protocol
|
||||
|
||||
**Story:** `US-02_join_by_code`, `US-03_ready_up_and_start`, `US-05_orders_score_and_result` (delivery), `US-09_disconnect_and_rejoin` (partial)
|
||||
**Context:** `.agents/PLAN.md` §7 (protocol), §4 (data flow, snapshot), A3, A4, A14
|
||||
|
||||
## Objective
|
||||
Put the tested room core on the wire: `/ws/{code}` with a versioned JSON protocol, strict
|
||||
inbound validation (shape, size, rate, per-state legality), input ingestion with
|
||||
sequence numbers, and snapshot/event broadcasting at the configured rate — so two browsers
|
||||
can already play a round. This is the last phase before the game becomes visible.
|
||||
|
||||
## Dependencies
|
||||
- `05_anonymous_lobbies_and_room_lifecycle` (complete) — supervisor, `Room`, lobby
|
||||
payloads, session store
|
||||
- `04_kitchen_pipeline_and_scoring` (complete) — sim state and result payloads to encode
|
||||
|
||||
## Tasks
|
||||
1. `01_protocol_models_and_wire_contract.md` — `server/protocol.py`, message models, `docs/PROTOCOL.md`, protocol version
|
||||
2. `02_ws_endpoint_and_handshake.md` — `/ws/{code}`, origin/token checks, caps, error codes, single-socket-per-token
|
||||
3. `03_input_ingestion_and_validation.md` — `input` handling, seq discipline, clamping, per-state whitelist
|
||||
4. `04_snapshot_and_event_encoding.md` — `server/encoding.py`, quantized snapshots, event batching, `SNAPSHOT_HZ` pacing, size budget
|
||||
5. `05_heartbeat_disconnect_and_seat_grace.md` — heartbeats, seat grace on drop, ghost parking, rejoin within the window
|
||||
6. `06_websocket_integration_suite.md` — two-socket full round, malformed flood, rate limit, size cap, latency injection flag
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: every protocol model's accept/reject surface; encoder quantization and size;
|
||||
whitelist per room state; seq stale/rollback rules.
|
||||
- Integration (real WebSockets via `TestClient`/`websockets` against a live uvicorn on an
|
||||
ephemeral port): handshake, welcome shape, `ready`→`start`→`countdown`→`snap` stream,
|
||||
two clients both reach the same `round` payload, unknown message types, oversized frame,
|
||||
message flood, wrong protocol version close code `4400`, second socket evicts first,
|
||||
abrupt close keeps the seat for the grace window and a reconnect resumes snapshots.
|
||||
- Budget tests: encoded snapshot ≤ 1.5 KB for a 4-player level-1 state; downlink ≤ 60 KB/s
|
||||
per player over a measured 5 s window.
|
||||
- Coverage: **>90%** on new/modified code; `server/protocol.py`, `server/encoding.py` ≥ 95%.
|
||||
- The phase is "Complete" only when the **entire** project test suite passes 100%.
|
||||
|
||||
## Documentation & Consistency
|
||||
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
|
||||
stands after this phase — fixing every inconsistency found, **including pre-existing
|
||||
ones not caused by this phase**.
|
||||
- `docs/PROTOCOL.md` is the deliverable contract: message tables, close codes, error codes,
|
||||
size/rate limits, versioning policy, and a sequence diagram of a full round. Every field
|
||||
in code must appear in the doc (a test compares model field sets to the doc tables).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] a Python `websockets` client can: create room over HTTP → connect `/ws/{code}` → `hello` → `ready` → host `start` → receive `countdown` then ≥ 20 `snap` frames → drive a scripted input to `round`
|
||||
- [ ] two clients in one room receive identical `round` payloads
|
||||
- [ ] protocol version mismatch closes with code `4400` and a clean client message
|
||||
- [ ] oversized (> `max_message_bytes`) and flood (> `max_inputs_per_s`) cases are refused with the documented error/close codes and no memory growth
|
||||
- [ ] snapshot ≤ 1.5 KB and downlink ≤ 60 KB/s measured by tests
|
||||
- [ ] `docs/PROTOCOL.md` matches the implemented models (test-enforced) and includes the round sequence diagram
|
||||
- [ ] full test suite **100% passing**, coverage >90%
|
||||
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
|
||||
- [ ] no behavior change in completed phases
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# Task 01 — Protocol models and wire contract
|
||||
|
||||
**Phase:** `06_realtime_websocket_protocol` · **Story:** `US-02_join_by_code`, `US-03_ready_up_and_start`
|
||||
|
||||
## Objective
|
||||
Define the wire contract in code and in prose before any socket exists, so the client and
|
||||
server are built against one unambiguous spec.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/protocol.py`:
|
||||
- `PROTOCOL_VERSION = 1`; `MIN_SUPPORTED_VERSION = 1`.
|
||||
- Client→server models (Pydantic, `extra="forbid"`, frozen): `HelloMsg(p, token?,
|
||||
name?)`, `ReadyMsg`, `SetLevelMsg(level)`, `StartMsg`, `InputMsg(seq, move: Vec2Msg,
|
||||
act, face)`, `EmoteMsg(id)`, `ToLobbyMsg`, `NextLevelMsg`, `PingMsg(t)`.
|
||||
- Server→client models: `WelcomeMsg(you, room, state, level, seats)`, `LobbyMsg`,
|
||||
`CountdownMsg(n)`, `SnapMsg(raw: dict)` (encoder-owned, see task 04), `RoundMsg`,
|
||||
`EvMsg(events)`, `ErrMsg(code, msg, seq?)`, `PongMsg(t, now)`.
|
||||
- `ClientMsgType` / `ServerMsgType` literal unions; `parse_client_message(raw: bytes)
|
||||
-> ClientMessage | ProtocolError` that never raises, returns a typed error with a
|
||||
stable `code`, and enforces `p` on `hello`.
|
||||
- `ErrorCode` constants: `unknown_type`, `bad_json`, `bad_field`, `too_big`,
|
||||
`rate_limited`, `not_seated`, `not_host`, `level_locked`, `wrong_state`,
|
||||
`room_full`, `room_gone`, `protocol_mismatch`, `evicted`, `internal`.
|
||||
- `CloseCode` constants: `4400` protocol mismatch, `4401` room gone, `4402` room full,
|
||||
`4403` evicted, `4404` abuse (flood/oversize repeat), `1000` normal.
|
||||
- `MESSAGE_WHITELIST: Mapping[RoomState, frozenset[ClientMsgType]]` — the single source
|
||||
of "what is legal when" (imported by the endpoint, never duplicated).
|
||||
2. `docs/PROTOCOL.md`:
|
||||
- handshake + full-round sequence diagram (ASCII), C→S and S→C tables with every field,
|
||||
type, and unit, `snap` frame structure with quantization rules, error/close code
|
||||
tables, size/rate limits, and the versioning policy (patch-compatible fields vs.
|
||||
`PROTOCOL_VERSION` bump; unknown-field rejection is intentional, so the version is
|
||||
bumped on any field change).
|
||||
3. `tests/unit/test_protocol_parse.py` — every message type parses; unknown type, bad JSON,
|
||||
wrong types, missing fields, extra fields, `p` mismatch, `move` magnitude > 1, `seq`
|
||||
negative/non-int, `act` non-bool, unknown `face` — each yields the documented `code`.
|
||||
4. `tests/unit/test_protocol_doc_sync.py` — extract field names from the doc tables and
|
||||
compare with `model_fields` for every message model in both directions (no undocumented
|
||||
field, no doc field that does not exist).
|
||||
5. `tests/unit/test_protocol_whitelist.py` — asserts the whitelist covers all room states ×
|
||||
all client types (totality) and pins the expected matrix literal.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: parse accept/reject surface, doc sync, whitelist totality.
|
||||
- Coverage: **>90%** on `protocol.py` (target 95%+).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_protocol_parse.py tests/unit/test_protocol_doc_sync.py tests/unit/test_protocol_whitelist.py -q` → green
|
||||
- [ ] adding a field to a message model without documenting it fails `test_protocol_doc_sync` (verify once by hand, then revert)
|
||||
- [ ] `parse_client_message(b"{")` returns a `ProtocolError(code="bad_json")` and does not raise
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
# Task 02 — WebSocket endpoint, handshake, and connection guardrails
|
||||
|
||||
**Phase:** `06_realtime_websocket_protocol` · **Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`
|
||||
|
||||
## Objective
|
||||
Open the socket: `/ws/{code}` with authentication by token, origin and size/rate
|
||||
guardrails, a `hello`→`welcome` handshake, and clean close codes.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/ws.py`:
|
||||
- `Connection` dataclass: `ws`, `token`, `player_id`, `seat_index | None`,
|
||||
`role (player|spectator)`, `last_seq`, `rate: TokenBucket`, `bytes_in`,
|
||||
`deadlines`, `send_queue: asyncio.Queue`, `alive`.
|
||||
- `async def websocket_endpoint(ws: WebSocket, code: str)` registered in
|
||||
`create_app()`:
|
||||
1. `websocket.accept()` **only after** checks: normalized code exists (else accept +
|
||||
close `4401` — never close before accept in a way that looks like a network error),
|
||||
`max_message_bytes` passed to `accept(max_size=…)`, origin check against
|
||||
`settings.allowed_origins` (empty setting = same-host check; never `*` in
|
||||
production mode),
|
||||
2. wait for `hello` with a 5 s timeout (else close `4400`),
|
||||
3. seat/spectate via the supervisor (`rejoin` when the token already holds a seat,
|
||||
spectate when the room is `PLAYING`, refuse `4402` when the room is full and the
|
||||
token is new),
|
||||
4. send `welcome` then the current `lobby`, then start two tasks: `pump_out` (drains
|
||||
`send_queue`, batching ≤ 4 frames per `send`) and `read_in`.
|
||||
- `TokenBucket(rate=max_inputs_per_s, burst=max_inputs_per_s)`; on empty →
|
||||
`ErrMsg(rate_limited)` and, after 3 consecutive violations inside 10 s, close `4404`.
|
||||
- Single socket per token: a second `hello` with the same token sends `ErrMsg(evicted)`
|
||||
+ close `4403` to the *old* connection and marks it dead (no ghost sends).
|
||||
- Inbound loop: `websocket.receive_text()`; `bytes_in` accounting; unknown type →
|
||||
`ErrMsg(unknown_type)`; after 5 violations close `4404`; per-state whitelist from
|
||||
`protocol.MESSAGE_WHITELIST` → `ErrMsg(wrong_state)`.
|
||||
- Outbound helper `send(model)` that never blocks the game loop (queue put with
|
||||
`put_nowait`, dropping the oldest snapshot when the queue exceeds 8 — snapshots are
|
||||
droppable, `lobby`/`round`/`err` are not).
|
||||
- `finally`: notify the room (`on_disconnect`) exactly once, cancel tasks, release.
|
||||
2. `src/undercooked/server/app.py` — register the route; add
|
||||
`GET /api/health`-style room stats (`{rooms, players, sockets, tick_hz}`) for ops; keep
|
||||
`/healthz` cheap and unchanged.
|
||||
3. `tests/integration/test_ws_handshake.py` (live uvicorn on an ephemeral port via a
|
||||
`uvicorn.Server` task fixture in `tests/integration/conftest.py`):
|
||||
- happy handshake → `welcome` + `lobby`, seat index/host flag correct;
|
||||
- unknown code → close `4401`; full room new token → `4402`;
|
||||
- no `hello` within the timeout → `4400` (with the timeout shortened via settings);
|
||||
- disallowed origin → rejected; same-host origin → allowed;
|
||||
- second socket same token evicts the first with `4403` and the first stops receiving;
|
||||
- oversized frame → refused; message flood → `rate_limited` then `4404`;
|
||||
- abrupt `ws.close(transport=True)` → room sees one `on_disconnect`, no double release,
|
||||
no exception in the server log.
|
||||
4. `tests/unit/test_send_queue_policy.py` — snapshot droppability under a slow reader,
|
||||
non-droppable frames preserved in order, no unbounded queue growth.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: real sockets for every guardrail path.
|
||||
- Unit: queue policy, token bucket arithmetic.
|
||||
- Coverage: **>90%** on `ws.py`; every guardrail branch exercised.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_ws_handshake.py -q` → green
|
||||
- [ ] `websocat`-equivalent manual step in `docs/PROTOCOL.md` reproduces a handshake against `uv run undercooked`
|
||||
- [ ] the flood test shows bounded memory (no per-connection list that grows with message count)
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Task 03 — Input ingestion, sequence discipline, and clamping
|
||||
|
||||
**Phase:** `06_realtime_websocket_protocol` · **Story:** `US-04_move_and_cook`, `US-06_coop_shared_kitchen`
|
||||
|
||||
## Objective
|
||||
Turn client `input` messages into sim `PlayerInput`s safely: reject stale and replayed
|
||||
sequences, clamp magnitudes server-side, and never let a hostile client move a cook at
|
||||
impossible speed.
|
||||
|
||||
## Work
|
||||
1. In `src/undercooked/server/ws.py` + `room.py`:
|
||||
- `Connection.last_seq` handling: `seq` must be `> last_seq`; equal → silently dropped
|
||||
(idempotent resend), smaller → `ErrMsg(bad_field, seq)` + `resync` event (the client
|
||||
hard-snapshots); gaps of ≤ 3 are tolerated (UDP-ish loss), larger gaps accepted but
|
||||
logged in room stats.
|
||||
- `to_player_input(conn, msg, tick)` — clamp `move` to length ≤ 1, quantize to 3
|
||||
decimals; `face` must be a known value (else derived from `move`); `act` coerced to
|
||||
bool; attach `received_at_tick` (server truth) for contested arbitration.
|
||||
- Room input queue is per tick, capacity `4 × len(seats)`; overflow drops the **oldest**
|
||||
entry for that player and bumps `stats().input_drops` (never grows unbounded).
|
||||
- Actions from a `DISCONNECTED` seat are ignored; from a spectator → `not_seated`.
|
||||
- `stats()` gains `inputs_received`, `inputs_dropped`, `stale_seqs`, `clamped_moves`.
|
||||
2. Anti-cheat invariants to assert (and enforce, not just log):
|
||||
- a cook cannot exceed `MAX_SPEED` because the sim integrates velocity from intents —
|
||||
a test that sends `move = {x: 1000, y: 1000}` every tick proves the distance per tick
|
||||
stays within `MAX_SPEED × dt × 1.01`;
|
||||
- teleport attempts (a client sending a position field) are impossible: the protocol has
|
||||
no position field, and `extra="forbid"` rejects it (test).
|
||||
3. `tests/integration/test_ws_input.py`:
|
||||
- monotonic seq accepted, equal seq ignored, backwards seq → `err` + resync event,
|
||||
tolerated gap, huge gap accepted with stat bump;
|
||||
- clamp test above; face derivation; `act` non-bool rejected upstream;
|
||||
- spectator input → `not_seated`; disconnected seat input ignored;
|
||||
- queue overflow drops oldest and the sim keeps stepping.
|
||||
4. `docs/PROTOCOL.md` — "Input rules": seq semantics, resync contract, clamping, why no
|
||||
position is ever client-sent.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: every seq path, clamping under a hostile client, spectator/disconnected rules.
|
||||
- Coverage: **>90%** on the input path.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_ws_input.py -q` → green
|
||||
- [ ] the hostile-speed test proves the cook's per-tick distance bound holds
|
||||
- [ ] sending a `pos` field is rejected by the schema (asserted)
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Task 04 — Snapshot and event encoding
|
||||
|
||||
**Phase:** `06_realtime_websocket_protocol` · **Story:** `US-05_orders_score_and_result`, `US-06_coop_shared_kitchen`
|
||||
|
||||
## Objective
|
||||
Compress the sim into the small, interpolable frame the client consumes, and pace it:
|
||||
quantized snapshots at `SNAPSHOT_HZ` for players, half that for spectators, with events
|
||||
batched onto frames.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/encoding.py`:
|
||||
- `encode_snapshot(state, prev: SnapshotMeta | None) -> dict` producing exactly the
|
||||
PLAN §4 shape: `t`, `p[]` (`i,x,y,f,h,s,pi`), `st[]` (`k,x,y,g,q,p,s`), `o[]`
|
||||
(`id,r,l,pts`), `s`, `tgt`, `tl`, `hz[]`, `e[]`; positions quantized with
|
||||
`Vec2.quantized(2)`, progress/quality to 2 decimals, enums as short strings.
|
||||
- Optional field-level delta mode: when `prev` is supplied, emit `d` with only changed
|
||||
entities plus a `full` flag every `FULL_SNAPSHOT_EVERY_TICKS = 20` (so a client can
|
||||
always resync cheaply). Delta mode is enabled per connection and documented; if a
|
||||
client misses a frame with no `full`, the server sends a full frame on the next tick.
|
||||
- `encode_events(events) -> list[dict]` with the stable `k` names from `EventKind` and
|
||||
coordinates; events are appended to the frame the tick they occurred in, capped at
|
||||
`MAX_EVENTS_PER_FRAME = 24` with `stats().events_dropped` on overflow.
|
||||
- `snapshot_size(frame) -> int` (UTF-8 length) — the budget hook.
|
||||
- No `pickle`/`dataclasses.asdict` reflexes: an explicit field map per type (greps
|
||||
clean, keeps the wire shape stable, and prevents accidentally leaking internal ids —
|
||||
a test asserts station internal ids appear only as `k`+coordinates).
|
||||
2. Pacing in `room.py` / `ws.py`:
|
||||
- `Room` emits an encode pass every `tick_hz / snapshot_hz` ticks for players and every
|
||||
`2 ×` for spectators; encoding happens once per (frame, audience) pair and is reused
|
||||
across identical-audience connections (encode-once, fan-out-many).
|
||||
- `UNDERCOOKED_SIM_LATENCY_MS` applies a server-side send delay (asyncio queue delay)
|
||||
so netcode tests can simulate RTT without a proxy; documented in README config table
|
||||
(already listed) and `docs/PROTOCOL.md`.
|
||||
3. `tests/integration/test_encoding.py`:
|
||||
- exact key-set equality for a level-1 frame against a literal expectation,
|
||||
- quantization (1.23456 → 1.23), delta mode shrinks a typical frame by ≥ 40 % versus
|
||||
full and produces identical decoded state,
|
||||
- snapshot ≤ 1.5 KB with 4 players / 4 orders / 3 hazards on level 1 (and a generous
|
||||
4 KB assert for a 64-tile level to catch pathological growth),
|
||||
- downlink ≤ 60 KB/s per player measured over 100 ticks,
|
||||
- events cap and `full` cadence.
|
||||
4. `tests/unit/test_encoding_no_leak.py` — a frame contains no `token`, no
|
||||
`display_name`, no `seed`, no station internal `id`, no `rng_state` (recursively).
|
||||
5. `docs/PROTOCOL.md` — the `snap` section rewritten from the implementation, with a real
|
||||
captured example frame and the delta rules.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: frame shape, size, rate, delta correctness, latency flag.
|
||||
- Unit: no-leak recursion, quantization helpers.
|
||||
- Coverage: **>90%** on `encoding.py` (target 95%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_encoding.py tests/unit/test_encoding_no_leak.py -q` → green
|
||||
- [ ] the 4-player level-1 frame is ≤ 1.5 KB (assert) and the measured downlink ≤ 60 KB/s
|
||||
- [ ] delta-mode decode equivalence test passes against full frames
|
||||
- [ ] `docs/PROTOCOL.md` shows a captured frame that the test also produces (same keys)
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Task 05 — Heartbeats, disconnect handling, and seat grace
|
||||
|
||||
**Phase:** `06_realtime_websocket_protocol` · **Story:** `US-09_disconnect_and_rejoin`
|
||||
|
||||
## Objective
|
||||
Make the socket honest about liveness and make dropping out survivable: heartbeats, a
|
||||
grace window that keeps the seat, ghost presentation for the others, and clean teardown.
|
||||
|
||||
## Work
|
||||
1. In `ws.py` + `room.py`:
|
||||
- client `ping{t}` → `pong{t, now}`; server-side `last_seen` per connection;
|
||||
`HEARTBEAT_DEAD_S = 12` — no inbound traffic for that long ⇒ the server closes `1000`
|
||||
and treats it as a drop (also catches half-open TCP).
|
||||
- `Room.on_disconnect(seat, now)` — mark `connected=False`, keep the seat, emit
|
||||
`GHOST_ON` event; the sim's player switches to `DISCONNECTED` state and parks after
|
||||
`PARK_AFTER_S = 3` (carried item released onto the current tile's station, or dropped
|
||||
into `counters["lost_items"]` if there is none — never deleted silently).
|
||||
- `guest_idle_timeout_s` (config) expiry ⇒ `SeatAbandoned`: the seat empties, others get
|
||||
`GHOST_OFF`, `all_seats_empty` may close the room (phase 05 GC).
|
||||
- Rejoin inside the grace window (`hello` with the same token) ⇒ `welcome` with
|
||||
`rejoined: true`, seat restored, `connected=True`, `GHOST_OFF` broadcast, snapshot
|
||||
stream resumes on the next tick.
|
||||
- Rejoin after expiry ⇒ `err(room_gone)` for that seat and spectate/join rules apply.
|
||||
- Close-code policy: normal leave `1000`, abuse `4404`, room closed `4401` — and the
|
||||
client must be able to tell "you left" from "the room died".
|
||||
- Test-only seam `POST /api/test/kill_socket/{code}/{token}` (registered **only** when
|
||||
`UNDERCOOKED_TEST_MODE=1`) that force-closes a connection server-side, so e2e tests
|
||||
can simulate a hard drop. Documented as test-only in `docs/PROTOCOL.md`.
|
||||
2. `docs/PROTOCOL.md` — heartbeat interval, grace semantics, ghost events, close-code
|
||||
decision table, and the rejoin handshake.
|
||||
3. `tests/integration/test_ws_disconnect.py`:
|
||||
- abrupt close keeps the seat for the grace window (`FakeClock` + manual `tick_once`),
|
||||
`GHOST_ON` emitted once, cook parks after 3 s, carried item lands on a station;
|
||||
- rejoin inside grace restores the seat and the color, resumes snapshots within one
|
||||
tick, and emits `GHOST_OFF` once;
|
||||
- rejoin after grace → seat given up, room may close, stale token rejected with
|
||||
`room_gone`;
|
||||
- heartbeat timeout closes cleanly and is indistinguishable (to the room) from a drop;
|
||||
- no double-release when both `finally` and the timeout fire;
|
||||
- `UNDERCOOKED_TEST_MODE=0` ⇒ the test seam route does not exist (assert 404).
|
||||
4. `tests/unit/test_ghost_parking.py` — park timing, item release placement, counters.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: every disconnect/rejoin path with a fake clock.
|
||||
- Unit: ghost/parking rules.
|
||||
- Coverage: **>90%** on the disconnect paths; the test seam itself is excluded only if it
|
||||
cannot be covered — prefer covering it.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_ws_disconnect.py tests/unit/test_ghost_parking.py -q` → green
|
||||
- [ ] rejoin inside grace resumes the snapshot stream within 1 tick (asserted) and does not create a second seat
|
||||
- [ ] the test-mode seam is absent by default (`curl` on a normal server → 404)
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Task 06 — WebSocket integration suite and protocol budget
|
||||
|
||||
**Phase:** `06_realtime_websocket_protocol` · **Story:** `US-02_join_by_code`, `US-03_ready_up_and_start`, `US-05_orders_score_and_result`
|
||||
|
||||
## Objective
|
||||
Play a complete round over real sockets with two clients, and lock the abuse-resistance
|
||||
and bandwidth budgets so later phases cannot silently regress them.
|
||||
|
||||
## Work
|
||||
1. `tests/integration/conftest.py` — `live_server` fixture: uvicorn on an ephemeral port
|
||||
in-process, `UNDERCOOKED_LEVELS_DIR` pointed at shipped levels, `tick_hz` and
|
||||
`snapshot_hz` overridable, plus a `ws_client(code, token=None)` helper returning a tiny
|
||||
scripted client (`send_model`, `expect(type, timeout)`, `collect(type, n)`).
|
||||
2. `tests/integration/test_ws_round_two_clients.py`:
|
||||
- A creates the room, B joins; both send `ready`; A sends `start`;
|
||||
- both receive `countdown` 3→1 then ≥ 20 `snap` frames in increasing `t`;
|
||||
- both drive inputs; both observe the same `SERVED` event and the same final `score`;
|
||||
- the round ends and both receive byte-identical `round` payloads (JSON-compared);
|
||||
- a third connection mid-round arrives as a spectator and receives snapshots but its
|
||||
`input` is refused;
|
||||
- after `round`, `to_lobby` returns both to `LOBBY` with seats preserved.
|
||||
3. `tests/integration/test_ws_abuse.py`:
|
||||
- 2 000 malformed messages → server stays healthy, connection closed at the violation
|
||||
limit, other connections unaffected, `/healthz` still 200;
|
||||
- 500 messages in a burst from one client → `rate_limited` then `4404`, no queue growth
|
||||
(assert `stats().send_queue_max` stays within the documented bound);
|
||||
- a 64 KB frame is refused; a 4 KB boundary frame is accepted exactly at the cap;
|
||||
- flood of `start`/`set_level`/`emote` cannot bypass the whitelist or host check;
|
||||
- 200 concurrent connections that only handshake (rooms not started) stay within
|
||||
`stats().memory_mb` bounds and complete handshakes < 1 s p95.
|
||||
4. `tests/integration/test_ws_budgets.py` — the numbers PLAN §10 promises, re-measured here
|
||||
as the authoritative gate: mean tick cost ≤ 2 ms for a 4-player room (measured inside
|
||||
`tick_once` over 400 ticks), snapshot ≤ 1.5 KB, downlink ≤ 60 KB/s, input→snapshot echo
|
||||
≤ 120 ms on localhost. Mark the expensive ones `slow` so `validate.sh` runs them
|
||||
deliberately.
|
||||
5. `docs/PROTOCOL.md` — "Testing the protocol" section: how to run the abuse suite, the
|
||||
test seam, and the budgets table with the measured values from this run.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: full two-client round, abuse resistance, budgets.
|
||||
- Coverage: **>90%** on new/modified code; this task should push `ws.py` to ≥ 95%.
|
||||
- Full suite green; `bash .agents/validate.sh` exits 0.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_ws_round_two_clients.py -q` → green with byte-identical `round` payloads
|
||||
- [ ] `uv run pytest tests/integration/test_ws_abuse.py -q` → green, server healthy afterward
|
||||
- [ ] `uv run pytest -m slow tests/integration/test_ws_budgets.py -q` → all four budgets met, numbers recorded in the report
|
||||
- [ ] a manual `uv run undercooked` + two browser tabs reaches `ROUND_END` (record what you saw in the phase report)
|
||||
- [ ] full test suite **100% passing**, coverage >90%
|
||||
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
|
||||
- [ ] no behavior change in completed phases
|
||||
Reference in New Issue
Block a user