Scaffold project and author 14-phase roadmap
Browser co-op kitchen game: FastAPI + native WebSockets, server-authoritative 20 Hz simulation, in-memory anonymous lobbies, data-driven YAML levels, Canvas 2D vanilla ES-module client with locally authored SVG art and no external CDNs. .agents/PLAN.md records the LOCKED decisions; .agents/user_stories/ holds the 12 flow contracts; .agents/phases/todo/ breaks them into 14 sequential, test-gated phases (71 task files), each leaving the app launchable and the suite green. LLM-Generated: true
This commit is contained in:
+486
@@ -0,0 +1,486 @@
|
||||
# PLAN.md — Undercooked (master design, LOCKED)
|
||||
|
||||
**Type:** real-time multiplayer web game (in-browser, cooperative)
|
||||
**Working title:** Undercooked — an independent Overcooked-style homage
|
||||
**Status:** design locked, implementation begins at phase 01
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
One browser tab is enough to start playing: open the site, create a lobby, read the
|
||||
5-character code out loud to your friends, hit *Start*, and cook together. No signup,
|
||||
no download, no install, no email. Each player's phone or laptop is one cook; the
|
||||
kitchen is shared and authoritative on the server. A campaign of ~10 hand-authored
|
||||
levels ratchets the pressure — more recipes, fewer stations, hostile floors — and an
|
||||
endless mode keeps the escalation going after the campaign ends.
|
||||
|
||||
**The one thing a user does on day one:** create/join a lobby by code and finish
|
||||
level 1 with 2+ other people, seeing each other's cooks move with no perceptible lag.
|
||||
|
||||
### Success criteria (v1)
|
||||
|
||||
1. 4 players on 4 devices complete level 1 of a lobby in one sitting, no page reloads.
|
||||
2. Creating a lobby, sharing the code, and joining takes < 20 seconds end to end.
|
||||
3. A player who closes their laptop and returns mid-round rejoins and keeps playing.
|
||||
4. All 10 campaign levels load, validate as solvable, and are measurably harder than
|
||||
their predecessor by the difficulty metrics in §8.
|
||||
5. Full test suite passes; the game survives a 200-room stress run on one node.
|
||||
|
||||
### Non-goals for v1
|
||||
|
||||
Accounts, auth, payments, persistent accounts/friend lists, matchmaking with
|
||||
strangers, cross-node scaling, mobile-native apps, voice/chat (text-free emotes only),
|
||||
level editor UI, gamepads, internationalization, ranked/online leaderboards.
|
||||
|
||||
---
|
||||
|
||||
## 2. Assumptions
|
||||
|
||||
1. **Sessions are short** (a round is 60–180 s) and **rooms are small** (≤ 4 players),
|
||||
so a single asyncio process can hold thousands of rooms with a 20 Hz tick each.
|
||||
2. **Anonymous is a feature, not a fallback.** Losing progress on server restart is
|
||||
acceptable; therefore no database is required (user-approved, §3).
|
||||
3. **Browser is the only client.** Desktop keyboard and touch are both first-class;
|
||||
the client must degrade gracefully on a 360 px-wide phone.
|
||||
4. **Art is ours.** Simple vector shapes/sprites generated in-repo; no third-party
|
||||
asset packs, no CDN fonts.
|
||||
5. **Trust model:** players are guests on our server, potentially hostile. The server
|
||||
validates everything; nothing client-reported is authoritative.
|
||||
6. **Python is fast enough** for a 20 Hz tick over ≤ 4 players + ≤ 60 entities
|
||||
(≈ 0.5–2 ms CPU per room per tick). The perf budget in §10 protects this; if a room
|
||||
misses budget, it drops to a lower tick rate rather than changing the stack.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architectural Anchors (LOCKED DECISIONS)
|
||||
|
||||
| # | Component | Decision | Rationale | Status |
|
||||
|---|---|---|---|---|
|
||||
| A1 | Language / tooling | Python ≥ 3.12, `uv` for env + run | Project standard; `uv sync`/`uv run` everywhere | LOCKED |
|
||||
| A2 | Web framework | FastAPI + uvicorn (`uvicorn[standard]`) | Async-native, first-class WebSocket support, Pydantic integration | LOCKED |
|
||||
| A3 | Realtime transport | **Native WebSocket** (`/ws/{room_code}`), JSON frames | Lowest-latency simple option; no polling fallback needed for a game. Socket.IO rejected: extra abstraction, no benefit | LOCKED |
|
||||
| A4 | Simulation authority | **Server-authoritative fixed-tick** sim at 20 Hz; snapshots out, inputs in; client interpolation + local prediction | Single source of truth (anti-cheat, no drift), reconnectable, testable headless | LOCKED |
|
||||
| A5 | Sim purity | `src/undercooked/sim/` has **zero** I/O, asyncio, wall-clock, or unseeded `random`; `step(state, dt)` is a pure function | Enables headless determinism tests and frame-rate-independent logic | LOCKED |
|
||||
| A6 | Persistence | **None.** Rooms live in the supervisor's memory; results/session scoreboards in memory. No Postgres, no SQLite, no ORM, no Alembic, no Redis | Anonymous, ephemeral by design; user explicitly chose this over the default Postgres rigour | LOCKED (user-approved deviation) |
|
||||
| A7 | Cache / queue | None (single process, in-process asyncio tasks) | Multi-node fan-out is a v2 concern; adding Valkey now buys nothing | LOCKED |
|
||||
| A8 | Content format | **Data-driven** levels + recipes as YAML in `levels/`, parsed into Pydantic models and validated at startup | No hard-coded content lists; new levels require no Python change | LOCKED |
|
||||
| A9 | Client rendering | Canvas 2D, `requestAnimationFrame`, DPR-aware | Smooth motion between snapshots; DOM tiles stutter with many moving entities | LOCKED |
|
||||
| A10 | Client code | Vanilla **ES modules**, no bundler, no framework | Zero build step, debuggable in the browser, satisfies the no-CDN rule trivially | LOCKED |
|
||||
| A11 | Art | In-repo generated **SVG** tileset/sprites + CSS; system font stack | Portable, tiny, no external fonts/CDN | LOCKED |
|
||||
| A12 | No external CDNs | Every asset served from `app/static/`; CSP `default-src 'self' ws: wss:`; enforced by a test | Project rule; also removes a privacy and availability risk | LOCKED |
|
||||
| A13 | Test stack | pytest (+`pytest-asyncio`) for unit/integration/sim; **Playwright** for E2E, **one suite per user story**, multi-context for multiplayer | Stories are the delivery unit; two browser contexts = two real players | LOCKED |
|
||||
| A14 | Protocol | Versioned JSON messages (`p` protocol int, `type` discriminator) documented in `docs/PROTOCOL.md`; server rejects unknown versions/major mismatch | Debuggable, testable, forward-compatible | LOCKED |
|
||||
| A15 | Lobby codes | 5 chars, Crockford base32 (no `I L O U`), server-generated, collision-checked, ~33 M space | Shoutable out loud, quick to type on a phone | LOCKED |
|
||||
| A16 | Player identity | `secrets.token_urlsafe(24)` token per browser tab in `sessionStorage`; display name optional, sanitized, non-unique | Anonymous, rejoinable, no PII | LOCKED |
|
||||
| A17 | Config | Env vars (`UNDERCOOKED_*`) + `.env` via `python-dotenv`; typed `config.py`; no hard-coded paths/ports | Deployable, testable | LOCKED |
|
||||
| A18 | Debugging | `debugpy` gated by `DEBUGPY=1` (non-blocking listen on `DEBUGPY_PORT`) | Real-time server is hard to debug by printing | LOCKED |
|
||||
| A19 | Packaging | Multi-stage `Containerfile` (slim runtime, non-root) + `compose.yaml` (app only, no DB) | Single-artifact deploy | LOCKED |
|
||||
| A20 | Quality gate | ruff + pyright + pytest with **>90%** coverage on new/changed code; full suite green before any phase completes | Project standard | LOCKED |
|
||||
| A21 | Determinism harness | Input scripts + seed replay to a golden state hash (`tests/sim/test_replay_*.py`) | Proves A4/A5 and makes balance regressions cheap to catch | LOCKED |
|
||||
| A22 | Lint/format targets | `pyproject.toml` `[tool.ruff]`, `[tool.pyright]`, `[tool.pytest.ini_options]` as scaffolded | One source of truth for checks | LOCKED |
|
||||
|
||||
Anything not in this table is fair game to choose *within* an existing anchor. Using a
|
||||
new dependency, service, protocol, or framework requires explicit user permission,
|
||||
recorded in the phase's `00_phase.md` and appended to this table by the user.
|
||||
|
||||
---
|
||||
|
||||
## 4. High-level architecture
|
||||
|
||||
```
|
||||
browser (player) server (one asyncio process)
|
||||
┌───────────────────────────────────┐ WS ┌──────────────────────────────────────┐
|
||||
│ app/static/js │ │ server/app.py FastAPI app │
|
||||
│ app.js screen state mach. │═══════▶ │ ├─ / index.html │
|
||||
│ net/connection.js WS + backoff │ JSON │ ├─ /static/* ES modules, SVG │
|
||||
│ net/predict.js local predict │◀═══════ │ ├─ /healthz /api/levels /api/rooms │
|
||||
│ net/snapshots.js buffer+lerp │ frames │ └─ /ws/{code} ws endpoint │
|
||||
│ input/keyboard.js|touch.js │ │ │ │
|
||||
│ render/canvas.js layers, camera │ │ server/protocol.py msg models+valid. │
|
||||
│ render/hud.js score/time/orders│ │ server/room.py Room + FSM │
|
||||
│ ui/… lobby/result DOM│ │ │ 20 Hz tick task │
|
||||
└───────────────────────────────────┘ │ server/rooms.py RoomSupervisor │
|
||||
│ server/sessions.py in-memory runs │
|
||||
│ │ │
|
||||
│ sim/* pure simulation kernel │
|
||||
│ levels/*.yaml → sim/level.py │
|
||||
└──────────────────────────────────────┘
|
||||
(no database, no external calls)
|
||||
```
|
||||
|
||||
### Component responsibilities
|
||||
|
||||
| Component | Owns | Never does |
|
||||
|---|---|---|
|
||||
| `sim/` | game truth: state, `step()`, interactions, scoring, hazards | network, HTTP, time, randomness outside round seed |
|
||||
| `sim/level.py` + `sim/validation.py` | YAML → typed `LevelDef`; reachability/solvability validation | simulation stepping |
|
||||
| `server/room.py` | one match: player seats, FSM, tick loop, snapshot/event emission, rejoin seats | routing, HTTP, cross-room logic |
|
||||
| `server/rooms.py` | room registry, code issuance/collision, capacity, GC of idle rooms | game rules |
|
||||
| `server/protocol.py` | message models, per-state whitelists, inbound validation, framing caps | game rules |
|
||||
| `server/sessions.py` | in-memory records: completed rounds, best scores per session token | durable storage |
|
||||
| `server/app.py` | FastAPI factory, static files, headers/CSP, startup level validation | game rules |
|
||||
| client `net/` | connection lifecycle, snapshot buffer, interpolation, prediction/reconciliation | deciding anything |
|
||||
| client `render/` | drawing, camera, HUD, effects | mutating game state |
|
||||
|
||||
### Data flow (one tick, 20 Hz)
|
||||
|
||||
1. Each connected client sends at most one `input` per frame: `{seq, move:{x,y}, act, face}`
|
||||
— only on change, plus a keep-alive at ≥ 5 Hz.
|
||||
2. `protocol.py` validates shape, size, rate, and legality for the room's state;
|
||||
stale `seq` is dropped; illegal moves are clamped, never trusted.
|
||||
3. `room.tick()` applies queued inputs to the sim, then `sim.step(state, dt)` advances
|
||||
physics, station timers, order spawning, hazards, and win/loss checks.
|
||||
4. The sim returns `(state, events)`; `events` are transient FX/log lines
|
||||
(`chopped`, `burnt`, `served`, `order_missed`, `fire_started`, `score`, …).
|
||||
5. The room builds a **snapshot** (compacted: entities quantized to 1/64 tile,
|
||||
station arrays, order list, score/timer) and pushes `snap` frames to players at
|
||||
`SNAPSHOT_HZ` and to spectators at half that; events ride along with the frame.
|
||||
6. Client buffers snapshots and renders at `now − 100 ms`, interpolating between the
|
||||
two bracketing snapshots; own player is additionally dead-reckoned from local input
|
||||
and reconciled when a snapshot contradicts it.
|
||||
|
||||
### Snapshot content (contract, compact)
|
||||
|
||||
```jsonc
|
||||
{ "t": 4821, "p": [ { "i":"ab12", "x":12.34, "y":8.06, "f":2, "h":"tomato_chopped",
|
||||
"s":"move", "pi":0 } ],
|
||||
"st":[ {"k":"chop","x":3,"y":2,"g":"onion","q":6,"p":0.42,"s":1} ],
|
||||
"o": [ {"id":17,"r":"soup","l":0.63,"pts":40} ],
|
||||
"s": 120, "tgt": 140, "tl": 83.65, "hz": [ {"t":"fire","x":7,"y":5,"l":0.8} ],
|
||||
"e": [ {"k":"served","x":9,"y":1,"i":17} ] }
|
||||
```
|
||||
Quantization: positions are floats rounded to 2 decimals on the wire, progress to 2
|
||||
decimals — enough for smooth interpolation, small enough to keep frames ≈ 300–900 B.
|
||||
|
||||
---
|
||||
|
||||
## 5. Simulation model (pure)
|
||||
|
||||
### Entity/state types (`sim/model.py`)
|
||||
|
||||
- `Vec2`, `Facing` (`up/right/down/left`), `TileKind` (`FLOOR, COUNTER, WALL, VOID`)
|
||||
- `StationKind` (`SPAWN_SHELF, CHOP_BOARD, STOVE, POT, PREP, SERVE, SINK, TRASH`)
|
||||
- `Station` — position, kind, contents (`list[Item]`), `progress`, `state`
|
||||
(`idle|working|ready|burning|dirty|blocked`), per-station `cooldown`
|
||||
- `Item` — `kind` (`tomato|onion|lettuce|dough|patty|rice|fish|…`), `form`
|
||||
(`raw|chopped|cooking|cooked|burnt|plated|dirty`), `quality` (1.0 → 0.0 as it burns),
|
||||
`ttl`
|
||||
- `Player` — id, pos, vel, facing, `carrying` (`Item|None`), `action` state, `state`
|
||||
(`idle|moving|using|stunned`), `stun`, `score_share`, `connected`
|
||||
- `Order` — id, recipe id, `deadline`, `time_left`, `value`
|
||||
- `Hazard` — `fire|spill|moving_obstacle`, pos, `life`, `path`/`period` for movers
|
||||
- `KitchenState` — level id, seed, tick, grid, stations, players, items, orders,
|
||||
hazards, score, target, time_left, `result` (`None|won|lost`), counters, rng state
|
||||
|
||||
### Movement
|
||||
|
||||
Continuous positions in tile units, velocity from the input vector, radius 0.35 tile,
|
||||
axis-separated AABB resolution against blocked tiles, other players (soft push, mass 1),
|
||||
and carried items. Max speed 3.5 tiles/s, accel 30 tiles/s², no diagonal speed boost.
|
||||
Void tiles are lethal to movement (blocked, not falling — v1 keeps it simple); a
|
||||
`moving_obstacle` occupies tiles it passes over and stuns on contact (0.6 s).
|
||||
|
||||
### Interaction model
|
||||
|
||||
One action button. `interact(state, player)` resolves against, in priority order:
|
||||
1. **carrying → drop/transfer onto target station** (throwing is out of scope v1),
|
||||
2. **station in reach (1 tile in front, then radius 0.6):** pick item, start work,
|
||||
place item, plate, serve, wash, trash (long-press),
|
||||
3. **nothing** → short "empty" event (client plays a whiff).
|
||||
|
||||
Two-hand rules: one carried item per player. Chopping requires holding `act` for the
|
||||
chop duration; taking a chopped item off the board requires the board to be `ready`.
|
||||
Contested targets resolve by earliest server-received input, tie-broken by player
|
||||
seat index; the loser gets `denied` (client shows a nudge) — never a silent no-op.
|
||||
|
||||
### Station pipelines
|
||||
|
||||
| Station | Input | Progress | Output |
|
||||
|---|---|---|---|
|
||||
| Spawn shelf | — | instant, infinite while orders need it | `raw` item |
|
||||
| Chop board | `raw` | hold `act` ~0.9 s | `chopped` (rot `raw` in 20 s) |
|
||||
| Stove + pot | `chopped` (soup) / `raw` (rice) | 4–8 s | `cooked` → `burnt` after 8 s idle |
|
||||
| Pan/griddle | `raw` patty/fish | 5 s | `cooked` → `burnt`; can start `fire` |
|
||||
| Prep/assemble | `cooked`/`chopped` + plate | per recipe | `plated` dish |
|
||||
| Serve window | `plated` | instant match vs orders | score, quality bonus, streak |
|
||||
| Sink | `dirty` plate/pot | 1.2 s | clean (dirty stock caps available plates/pots) |
|
||||
| Trash | any item | instant | gone (small score nudge −2) |
|
||||
|
||||
### Orders, scoring, results
|
||||
|
||||
- Orders spawn from the level's `pool` with `concurrent` and `spawn_interval` bounds.
|
||||
- Serving a correct dish: `base × quality × streak_mult × time_bonus`. Wrong dish:
|
||||
rejection event, no score, order stays.
|
||||
- Missed order (deadline 0): `−order_penalty`, streak reset, client shake.
|
||||
- `target_score` reached → `result=won` immediately (with `stars` from remaining time);
|
||||
`time_left == 0` → `won` if `score ≥ target` else `lost`.
|
||||
- Per-player contribution is tracked for the result screen (display only, never
|
||||
competitive gatekeeping).
|
||||
|
||||
---
|
||||
|
||||
## 6. Lobby, rooms, and state machines
|
||||
|
||||
### Room FSM (`server/room.py`)
|
||||
|
||||
```
|
||||
LOBBY ──(host starts, ≥1 ready)──▶ COUNTDOWN(3s) ──▶ PLAYING
|
||||
▲ │
|
||||
│ result=won|lost|aborted
|
||||
│ next-level / retry ▼
|
||||
└──────────────(≤ 20 s auto, any host action)── ROUND_END
|
||||
│ ROUND_END ──(idle > lobby_idle_timeout)──▶ CLOSED (GC, seats freed)
|
||||
└ LOBBY ──(all seats empty > grace)────────▶ CLOSED
|
||||
```
|
||||
|
||||
- `LOBBY`: joinable/leavable, ready flags, level pick (host; limited to unlocked),
|
||||
chat-free emotes only.
|
||||
- `COUNTDOWN`: 3…2…1 broadcast; late joins wait in `SPECTATING` until `LOBBY`.
|
||||
- `PLAYING`: inputs accepted from seated players; joiners become spectators; dropouts
|
||||
keep their seat (`connected=false`, sim keeps them idle 3 s then parks them).
|
||||
- `ROUND_END`: results, unlocked-next-level state, retry/next/host-picked level.
|
||||
- `CLOSED`: supervisor drops the room; tokens become invalid.
|
||||
|
||||
### Supervisor rules
|
||||
|
||||
- Codes: Crockford base32, 5 chars, retried on collision; never reused within the
|
||||
process; the empty-lobby room is created on click, not on page load.
|
||||
- Caps: `max_players` (4) per room, `max_rooms` (200) process-wide → `503 room_full`.
|
||||
- GC task every 5 s: close idle lobbies, evict orphaned rooms, purge stale seats.
|
||||
- Every room owns exactly one asyncio tick task; task cancellation is the only teardown.
|
||||
- Crash isolation: a room's tick exception is caught, logged, the room is marked
|
||||
`aborted`, players are told, and the rest of the server is unaffected.
|
||||
|
||||
### Session records (`server/sessions.py`, in memory, bounded)
|
||||
|
||||
`session_token → {display_name, unlocked_level, best[ level_id ] = {score, stars,
|
||||
completed_at}, rounds_played}`. LRU-capped at 10 000 entries; wiped on process
|
||||
restart (acceptable per A6).
|
||||
|
||||
---
|
||||
|
||||
## 7. Wire protocol (`docs/PROTOCOL.md`)
|
||||
|
||||
Protocol version `p: 1`. Server closes the socket with code `4400` on major mismatch.
|
||||
|
||||
| Direction | `type` | Payload | Accepted when |
|
||||
|---|---|---|---|
|
||||
| C→S | `hello` | `{token?, name?, p}` | connecting |
|
||||
| C→S | `ready` | `{}` | `LOBBY` |
|
||||
| C→S | `set_level` | `{level}` | `LOBBY`, host only, level unlocked |
|
||||
| C→S | `start` | `{}` | `LOBBY`, host only |
|
||||
| C→S | `input` | `{seq, move:{x,y}, act, face}` | `PLAYING` |
|
||||
| C→S | `emote` | `{id}` | `LOBBY`, `PLAYING` |
|
||||
| C→S | `to_lobby` / `next_level` | `{}` | `ROUND_END` |
|
||||
| C→S | `ping` | `{t}` | always |
|
||||
| S→C | `welcome` | `{you, room, state, level, seats}` | after `hello` |
|
||||
| S→C | `lobby` | `{seats[], level, host, can_start}` | lobby-state changes |
|
||||
| S→C | `countdown` | `{n}` | `COUNTDOWN` |
|
||||
| S→C | `snap` | snapshot (§4) | `PLAYING` |
|
||||
| S→C | `round` | `{result, score, target, stars, unlocked, players[]}` | `ROUND_END` |
|
||||
| S→C | `ev` | `[{k, …}]` (FX/log) | any |
|
||||
| S→C | `err` | `{code, msg, seq?}` | on rejection |
|
||||
| S→C | `pong` | `{t, now}` | after `ping` |
|
||||
|
||||
Inbound guards: max `max_message_bytes`; JSON-only; unknown `type` → `err unknown_type`
|
||||
(disconnect after 5); per-state whitelist; per-connection token bucket
|
||||
`max_inputs_per_s`; name sanitization (strip control chars, trim, 16 chars); one socket
|
||||
per token (second connection evicts the first).
|
||||
|
||||
---
|
||||
|
||||
## 8. Level content and the difficulty curve
|
||||
|
||||
`levels/recipes.yaml` defines ingredients and recipes; `levels/NN_*.yaml` defines each
|
||||
level. Every list the game reads comes from these files — the code never enumerates
|
||||
levels or recipes (validated by a test that adds a fixture level and expects it to
|
||||
appear in `/api/levels`).
|
||||
|
||||
```yaml
|
||||
id: 07_riverboat
|
||||
name: Riverboat Galley
|
||||
order: 7
|
||||
time_limit: 100
|
||||
target_score: 150
|
||||
grid: # ASCII, one char per tile; '#'=wall '.'=floor …
|
||||
- "##############"
|
||||
- "#S..C..P....X#"
|
||||
- "#..==..==....#"
|
||||
- "#P1.2......ZZ#"
|
||||
tiles: { "S": {kind: spawn_shelf, item: tomato}, "C": {kind: chop_board}, … }
|
||||
spawns: [{x: 1, y: 9}, {x: 12, y: 9}]
|
||||
orders:
|
||||
pool: [soup, salad, fried_rice]
|
||||
concurrent: 3
|
||||
spawn_interval: [7, 11]
|
||||
deadline: 42
|
||||
hazards:
|
||||
- {type: fire, station: stove_2, chance_per_s: 0.02, extinguish: sink}
|
||||
- {type: spill, x: 6, y: 5, respawn_s: 25}
|
||||
- {type: moving_obstacle, path: [[4,3],[10,3]], period_s: 9}
|
||||
station_limits: {plates: 3, pots: 2}
|
||||
escalation_notes: "splits the kitchen; fire pressure without an extinguish shortcut"
|
||||
```
|
||||
|
||||
### Escalation axes (each campaign level must move ≥ 2 of them vs. its predecessor)
|
||||
|
||||
1. **Recipe complexity** — 1 → 4 ingredients, multi-station chains.
|
||||
2. **Order pressure** — concurrent 1 → 4, `spawn_interval` 16 s → 6 s, `deadline` 60 → 30 s.
|
||||
3. **Time budget** — `time_limit` 150 → 75 s against rising `target_score`.
|
||||
4. **Station scarcity** — 1 shared stove for 4 players, limited plates/pots (dirty loop).
|
||||
5. **Layout hostility** — open room → two halves → narrow bridges → void gaps.
|
||||
6. **Hazards** — none → spills → fires → moving obstacles → order-spam "rush" waves.
|
||||
7. **Distance** — ingredient ↔ cook ↔ serve travel 3 → 14 tiles.
|
||||
|
||||
Campaign: L1 tutorial (solo-friendly), L2 two orders, L3 chop+cook chain, L4 dirty-dish
|
||||
loop, L5 split kitchen, L6 fire, L7 boat + movers, L8 bridge/void, L9 rush waves,
|
||||
L10 gauntlet (all axes). Then `endless`: generated by sampling a difficulty scalar
|
||||
`d = 10 + rounds` against the same axes over the authored layouts.
|
||||
|
||||
**Validation gate** (`undercooked-levels validate`, also a test over every shipped
|
||||
level): ids unique; grid rectangular, spawns/stations on floor; every spawn, station,
|
||||
and serve window mutually reachable (BFS on walkable tiles, movers excluded); every
|
||||
recipe's ingredient reachable and every required station kind present; `time_limit > 0`;
|
||||
`target_score ≤ theoretical_max_score × 0.6` (greedy plausibility: orders possible in
|
||||
the time window × mean order value); hazards reference existing stations; escalation
|
||||
axes actually increase between consecutive `order` values.
|
||||
|
||||
---
|
||||
|
||||
## 9. Validation / verification workflow (how we know it is right)
|
||||
|
||||
Layered, cheapest first — every phase runs the layers it touched, and the pipeline gate
|
||||
runs all of them:
|
||||
|
||||
1. **Static**: `ruff check` (lint incl. `ASYNC`, `S`, `PERF`), `pyright`.
|
||||
2. **Pure-sim unit tests**: movement/collision, station state machines, recipe/serve
|
||||
resolution, scoring, hazards, validation rules — table-driven, no I/O.
|
||||
3. **Determinism/replay**: seeded input script → N ticks → state hash compared to a
|
||||
golden file; a second run after a refactor must match bit-for-bit.
|
||||
4. **Room/protocol integration**: in-process FastAPI + real WebSocket clients —
|
||||
create/join/ready/start/snap/round/rejoin, illegal-message rejection, rate limiting,
|
||||
4-player full round, room GC.
|
||||
5. **Content gate**: `undercooked-levels validate` over every YAML (fixture + shipped).
|
||||
6. **E2E per user story** (Playwright): real browser, real sockets; multiplayer stories
|
||||
drive **two browser contexts** against the same room and assert *both* observe the
|
||||
same outcome (score, result, seats).
|
||||
7. **Netcode tests**: server-side latency injection (`UNDERCOOKED_SIM_LATENCY_MS=150`),
|
||||
socket-kill mid-round + rejoin, prediction error bound assertions.
|
||||
8. **Scale/perf budget** (marked `slow`): 200 rooms × 4 clients ticking 60 s — mean tick
|
||||
≤ 2 ms/room, RSS growth < 20 %, zero missed ticks > 50 ms.
|
||||
9. **Doc & a11y pass**: README/AGENTS reviewed project-wide; axe-core-free checks
|
||||
(contrast, focus order, `aria-live` result announcements) in the a11y story's suite.
|
||||
|
||||
A phase is *Complete* only when layers 1–5 relevant to its change pass and the
|
||||
**entire** suite is green with >90% coverage on new/modified code.
|
||||
|
||||
---
|
||||
|
||||
## 10. Performance and reliability budgets
|
||||
|
||||
| Budget | Target | How measured |
|
||||
|---|---|---|
|
||||
| Server tick cost | mean ≤ 2 ms, p99 ≤ 8 ms (4 players) | `room.tick` timing histogram, stress test |
|
||||
| Snapshot size | ≤ 1.5 KB JSON | encoded-size assert in integration test |
|
||||
| Downlink rate | ≤ 60 KB/s per player | measured in integration test |
|
||||
| Client frame time | ≤ 16 ms at 4 players on a mid-tier laptop | Playwright `performance.now()` sampling |
|
||||
| Input → visible echo | ≤ 120 ms (incl. 100 ms interpolation) | netcode test |
|
||||
| Room memory | ≤ 1 MB per active room | RSS delta in stress test |
|
||||
| Room capacity | ≥ 200 concurrent rooms | stress test |
|
||||
| Rejoin to playable | ≤ 3 s after page load | e2e test |
|
||||
|
||||
Degradation policy: if a tick exceeds budget, halve that room's effective tick rate for
|
||||
5 s and emit `ev{kind:"tick_slow"}`; never drop players for lagging.
|
||||
|
||||
---
|
||||
|
||||
## 11. Security & privacy posture
|
||||
|
||||
- No PII collected; no cookies; no localStorage beyond the session token and last room
|
||||
code; no third-party requests at runtime (test-enforced).
|
||||
- WebSocket: origin check (`ALLOWED_ORIGINS`, default same-host), message-size and rate
|
||||
limits, per-state whitelists, one socket per token, idle close.
|
||||
- Room codes are unguessable-ish but are *capability secrets*: never logged with
|
||||
contents, and every server-side check assumes the code holder is a legitimate player
|
||||
and validates their *actions*, not their identity.
|
||||
- Static serving: no directory listing, path traversal guarded by `StaticFiles`,
|
||||
`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, CSP
|
||||
`default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self'`.
|
||||
- No `eval`, no dynamic JS assembly from server strings, no user HTML rendering
|
||||
(names are rendered via `textContent`).
|
||||
|
||||
---
|
||||
|
||||
## 12. Roadmap (phases; details in `.agents/phases/todo/`)
|
||||
|
||||
| # | Phase | Delivers (player-visible if shipped) |
|
||||
|---|---|---|
|
||||
| 01 | `runnable_service_scaffold` | `uv run undercooked` serves the shell page + `/healthz`; checks green |
|
||||
| 02 | `level_content_and_validation` | level/recipe schema, loader, validator, 2 levels, `undercooked-levels validate` |
|
||||
| 03 | `simulation_movement_and_interaction` | deterministic movement, reach, carry; replay hashes pinned |
|
||||
| 04 | `kitchen_pipeline_and_scoring` | full cook chain, orders, scoring, win/lose; headless round sim |
|
||||
| 05 | `anonymous_lobbies_and_room_lifecycle` | supervisor, codes, ready-up, round FSM, session progression |
|
||||
| 06 | `realtime_websocket_protocol` | `/ws/{code}`, inputs in, snapshots out, `docs/PROTOCOL.md` |
|
||||
| 07 | `canvas_client_and_hud` | playable-in-browser single player vs. clock, HUD, art |
|
||||
| 08 | `client_prediction_and_touch` | smooth remote play, prediction/reconciliation, touch controls |
|
||||
| 09 | `co_op_two_player_e2e` | two real browsers cooking together, contested-resource arbitration |
|
||||
| 10 | `hazards_and_escalation_mechanics` | fire, spills, movers, rush waves + client FX |
|
||||
| 11 | `campaign_levels_and_progression` | all 10 levels authored+validated, campaign flow, curve check |
|
||||
| 12 | `reconnect_resilience_and_scale` | mid-round rejoin, seat grace, room GC, 200-room stress test |
|
||||
| 13 | `endless_mode_and_scoreboard` | endless generator, session scoreboard, post-campaign loop |
|
||||
| 14 | `accessibility_polish_and_hardening` | contrast/reduced-motion/screen-reader, perf+security pass, docs |
|
||||
|
||||
Every phase leaves the app launchable and the suite green; phases 01→14 are strictly
|
||||
sequential (each depends on the previous phase's capability).
|
||||
|
||||
---
|
||||
|
||||
## 13. Risks and mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|---|---|---|
|
||||
| Python tick too slow at scale | laggy rooms | perf budget test in phase 12 from day one; sim purity keeps the hot loop cheap; halve-tick degradation |
|
||||
| Client desync/rubber-banding feels bad | unplayable | server-authoritative + interpolation delay + reconciliation tests (phase 08) |
|
||||
| Contested pickups feel unfair | co-op friction | deterministic arbitration + explicit `denied` feedback (phase 09) |
|
||||
| Difficulty curve guesswork | too easy/hard | escalation-axis metrics + headless greedy solver bound (phase 11) |
|
||||
| Level content explodes in complexity | unmaintainable | strict YAML schema + validator + content test that never hard-codes lists |
|
||||
| Anonymous rooms abused (spam) | noisy lobbies | no chat in v1, emote whitelist, rate limits |
|
||||
| Restart wipes runs | lost progress | accepted by design (A6); result screen encourages saving nothing; endless/scoreboard explicitly in-memory |
|
||||
| Trademark confusion with "Overcooked" | legal | original art, names, and content; homage note in README |
|
||||
|
||||
### Documentation index (canonical)
|
||||
|
||||
These are the only docs the project promises. A phase creates a file the first time it needs
|
||||
one; no phase references a doc that does not exist by the end of the last phase.
|
||||
|
||||
| Path | Contents | Created by |
|
||||
|---|---|---|
|
||||
| `README.md` | what it is, quickstart, how to play, controls, deploy pointer, demo-vs-production | phase 01, refined throughout |
|
||||
| `AGENTS.md` | agent rules: commands, architecture, conventions, guardrails | phase 01, verified every phase |
|
||||
| `BUDGETS.md` | measured performance/latency budgets vs §10 targets, dated | phase 08 |
|
||||
| `docs/DESIGN.md` | architecture, module map, the decisions behind A1–A12 in prose | phase 01 (stub) → phase 09 |
|
||||
| `docs/SIMULATION.md` | tick contract, movement/collision, pipeline rules, arbitration, hazard rules | phase 02/03 |
|
||||
| `docs/LEVELS.md` | level + recipe YAML format, escalation axes, how to author and validate a level | phase 02, phase 11 |
|
||||
| `docs/PROTOCOL.md` | wire messages, enums, error codes, close codes, budgets, versioning policy | phase 06 |
|
||||
| `docs/CLIENT.md` | client module map, render loop, snapshot buffer, input, assets | phase 07 |
|
||||
| `docs/DEPLOYMENT.md` | container build/run, compose, TLS/reverse proxy, ops runbook | phase 01 (stub) → phase 14 |
|
||||
| `docs/TESTING.md` | test layout, how to run everything, artifact policy | phase 01 |
|
||||
| `docs/ACCESSIBILITY.md` | what WCAG 2.1 AA guarantees, how each is tested, text-mode contract, known limits | phase 14 |
|
||||
| `docs/SECURITY.md` | threat model + finding table with severity, status, and the evidence test per row | phase 14 |
|
||||
| `tests/e2e/README.md` | multiplayer harness usage, debugging failing browser tests | phase 07 |
|
||||
|
||||
`tests/unit/test_docs_index.py` (phase 01) keeps this table and the `docs/` tree identical, so
|
||||
neither can drift silently.
|
||||
|
||||
---
|
||||
|
||||
## 14. Execution
|
||||
|
||||
- Run the pipeline with the `phased-execution` skill (`/next-phase`, `/run-phase`, `/phase-status`);
|
||||
task files in `.agents/phases/todo/NN_name/` are the unit of work, and each phase moves to
|
||||
`.agents/phases/complete/` only when its `00_phase.md` Completion Criteria are all checked.
|
||||
- New work is never implemented directly: it is captured as a phase via the `phase-authoring`
|
||||
skill first, so this roadmap stays the record of what the project claims to do.
|
||||
- Amending a LOCKED decision (§3) requires editing §3 itself in the same phase, with the reason
|
||||
and the phase number recorded inline — no silent reversals.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Phase 01 — Runnable service scaffold
|
||||
|
||||
**Story:** `US-01_create_anonymous_lobby` (partial — landing surface only)
|
||||
**Context:** `.agents/PLAN.md` §4 (app/static/HTTP boundary), A1, A2, A12, A17, A18, A20
|
||||
|
||||
## Objective
|
||||
Turn the empty repository into a launchable FastAPI service that serves the client
|
||||
shell, exposes `/healthz`, enforces the no-external-asset rule, and has its quality
|
||||
toolchain wired end to end. Nothing about gameplay exists yet; the app must still boot,
|
||||
serve, and pass every check.
|
||||
|
||||
## Dependencies
|
||||
- none (first phase; `pyproject.toml`, `Containerfile`, `compose.yaml`, `README.md`,
|
||||
`AGENTS.md`, `.agents/PLAN.md` already exist in the repo)
|
||||
|
||||
## Tasks
|
||||
1. `01_lock_toolchain_and_packages.md` — deps resolve, `uv sync` clean, test tree + markers, first passing test
|
||||
2. `02_config_and_debugpy.md` — typed env config, `.env.example`, debugpy bootstrap
|
||||
3. `03_fastapi_app_and_static_shell.md` — app factory, CLI entry point, static shell page, security headers
|
||||
4. `04_no_cdn_guard_and_docs.md` — no-external-asset test, header tests, README/AGENTS verified
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: config parsing/defaults/overrides; static asset scan finds zero external URLs;
|
||||
security headers present; `/healthz` returns 200 with a JSON body.
|
||||
- Integration: app boots through `create_app()` and serves `/` and `/static/*`.
|
||||
- Coverage: **>90%** on new/modified code (`config.py` and `server/app.py` fully covered).
|
||||
- The phase is "Complete" only when the **entire** project test suite passes 100%
|
||||
(every test in the project, not just this phase's).
|
||||
- Gate commands, all green: `uv run ruff check .`, `uv run pyright`, `uv run pytest`.
|
||||
|
||||
## Documentation & Consistency
|
||||
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
|
||||
will stand after this phase — fixing every inconsistency found, **including
|
||||
pre-existing ones not caused by this phase** (e.g. commands or variable names that
|
||||
drifted from `config.py`).
|
||||
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are
|
||||
user-visible (setup, commands, configuration, features, conventions).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv sync --extra dev` succeeds and `uv.lock` is committed
|
||||
- [ ] `uv run undercooked --host 127.0.0.1 --port 8000 &` then
|
||||
`curl -fsS http://127.0.0.1:8000/healthz` → `{"status":"ok"}`; `curl -fsS http://127.0.0.1:8000/` → the shell HTML
|
||||
- [ ] `DEBUGPY=1 uv run undercooked` opens port 5678 and still serves `/healthz`
|
||||
- [ ] `uv run pytest -q` → 100% passing, coverage ≥ 90%
|
||||
- [ ] `uv run ruff check .` and `uv run pyright` → zero errors
|
||||
- [ ] no file under `app/static/` references an external URL (test-enforced)
|
||||
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
|
||||
- [ ] no behavior change in completed phases (n/a — first phase)
|
||||
@@ -0,0 +1,50 @@
|
||||
# Task 01 — Lock toolchain and packages
|
||||
|
||||
**Phase:** `01_runnable_service_scaffold` · **Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
Make `uv` resolve the locked dependency set and establish the test tree, markers, and
|
||||
first green test run, so every later phase starts from a working harness.
|
||||
|
||||
## Work
|
||||
1. `pyproject.toml` — verify it matches A1/A13/A20: runtime deps (`fastapi`,
|
||||
`uvicorn[standard]`, `pydantic`, `python-dotenv`, `pyyaml`, `websockets`), dev extras
|
||||
(`debugpy`, `ruff`, `pyright`, `pytest`, `pytest-asyncio`, `pytest-cov`, `httpx`,
|
||||
`playwright`). Add nothing else; if something is missing, add it and note it in the
|
||||
report. Confirm `[project.scripts]` entries point at modules created in tasks 02–03
|
||||
(`undercooked.__main__:main`, `undercooked.tools.levels:main`) — the second one is
|
||||
created in phase 02, so temporarily drop it **only if** it breaks `uv sync`; otherwise
|
||||
leave it and let the console script fail lazily.
|
||||
2. Run `uv sync --extra dev`; commit `uv.lock` (it is tracked — verify `.gitignore` does
|
||||
not exclude it).
|
||||
3. Create the package skeleton: `src/undercooked/__init__.py` (`__version__ = "0.1.0"`,
|
||||
empty `__all__`), `src/undercooked/server/__init__.py`, `src/undercooked/sim/__init__.py`,
|
||||
`src/undercooked/tools/__init__.py`.
|
||||
4. Create the test tree with `tests/__init__.py`? **No** — keep tests a non-package tree;
|
||||
add `tests/conftest.py` with: a `settings` fixture factory (`make_settings(**overrides)`),
|
||||
an `app`/`client` fixture pair (`fastapi.testclient.TestClient`), and a
|
||||
`random_seed` fixture. Add empty `__init__.py`-free dirs `tests/unit/`,
|
||||
`tests/integration/`, `tests/sim/`, `tests/e2e/` each with a `README.md` one-liner
|
||||
describing what belongs there.
|
||||
5. `tests/unit/test_harness.py` — asserts `undercooked.__version__`, that the four test
|
||||
markers are registered (`--strict-markers` passes), and that
|
||||
`pytest -m e2e --collect-only` collects zero items without error (no e2e tests yet).
|
||||
6. `tests/conftest.py` — add an autouse fixture that sets `UNDERCOOKED_LEVELS_DIR` to a
|
||||
test fixture dir (`tests/fixtures/levels/`, created empty with a `.gitkeep`) so no
|
||||
test can accidentally read the real content before phase 02 exists.
|
||||
7. Install the Playwright browser once and record the command in `README.md`
|
||||
(`uv run playwright install chromium`); do not add a conftest hook that downloads
|
||||
browsers during unit runs.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_harness.py` passes; `uv run pytest -q` green.
|
||||
- Coverage: **>90%** on this task's new/modified code (trivial modules may hit 100%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv sync --extra dev` exits 0; `uv run python -c "import fastapi, yaml, websockets"` exits 0
|
||||
- [ ] `uv run pytest -q` → all collected tests pass, coverage gate met
|
||||
- [ ] `uv run ruff check .` and `uv run pyright` → zero errors
|
||||
- [ ] `git status --porcelain uv.lock` shows the lock file tracked (staged by the owner later)
|
||||
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,49 @@
|
||||
# Task 02 — Typed config and debugpy bootstrap
|
||||
|
||||
**Phase:** `01_runnable_service_scaffold` · **Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
Give the project one authoritative, env-driven `Settings` object (A17) plus a
|
||||
`DEBUGPY`-gated debugger hook (A18), so no later phase ever reads `os.environ` inline.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/config.py`:
|
||||
- `@dataclass(frozen=True, slots=True) class Settings` with fields matching the
|
||||
README table: `host`, `port`, `tick_hz`, `snapshot_hz`, `max_players`, `max_rooms`,
|
||||
`levels_dir: Path`, `lobby_idle_timeout_s`, `guest_idle_timeout_s`,
|
||||
`max_message_bytes`, `max_inputs_per_s`, `sim_latency_ms`, `debugpy: bool`,
|
||||
`debugpy_port`, `allowed_origins: tuple[str, ...]`.
|
||||
- `load_settings(env: Mapping[str, str] | None = None) -> Settings` reading
|
||||
`UNDERCOOKED_*` / `DEBUGPY*` keys, calling `dotenv.load_dotenv()` once when
|
||||
`env is None`, with the README's defaults, `Path` expansion for `levels_dir`,
|
||||
comma-splitting for `ALLOWED_ORIGINS`, and validation that raises
|
||||
`ConfigError` (defined here) on: non-integer values, `tick_hz` outside 5–60,
|
||||
`snapshot_hz` > `tick_hz`, `max_players` outside 1–4, `max_rooms` < 1, negative
|
||||
timeouts, `max_message_bytes` < 256.
|
||||
- `get_settings()` cached accessor (`functools.lru_cache` on a private `_load`) plus
|
||||
`reset_settings_cache()` for tests. No module-level import-time env reads that
|
||||
break test isolation.
|
||||
2. `.env.example` — every variable, defaulted, commented (one line each). No secrets, no
|
||||
real hostnames.
|
||||
3. `src/undercooked/debug.py` — `maybe_start_debugpy(settings) -> bool`: returns `False`
|
||||
and does nothing when `debugpy` is false; otherwise imports debugpy inside the
|
||||
function, `listen(("0.0.0.0", settings.debugpy_port))`, `wait_for_client=False`, logs
|
||||
one line, and never raises (a debug failure must not stop the server).
|
||||
4. `tests/unit/test_config.py` — defaults, per-var overrides, `Path` handling,
|
||||
comma-separated origins, every `ConfigError` branch, cache/reset behavior,
|
||||
`.env` file loading via `monkeypatch` + `tmp_path`.
|
||||
5. `tests/unit/test_debug.py` — `maybe_start_debugpy` is a no-op when disabled; when
|
||||
enabled it calls `listen` exactly once (patch `debugpy` via `sys.modules` so the real
|
||||
debugger never binds a port in CI) and swallows import/listen errors.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: all `ConfigError` branches, default/override matrix, debugpy on/off/error paths.
|
||||
- Coverage: **>90%** on `config.py` and `debug.py` (aim 100%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_config.py tests/unit/test_debug.py -q` passes
|
||||
- [ ] `UNDERCOOKED_TICK_HZ=99 uv run python -c "from undercooked.config import load_settings; load_settings()"` fails with a clear `ConfigError` message
|
||||
- [ ] `uv run python -c "from undercooked.config import load_settings; print(load_settings().tick_hz)"` prints `20` with no `.env` present
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,68 @@
|
||||
# Task 03 — FastAPI app factory, CLI entry point, static shell
|
||||
|
||||
**Phase:** `01_runnable_service_scaffold` · **Story:** `US-01_create_anonymous_lobby` (landing surface)
|
||||
|
||||
## Objective
|
||||
Create the process that later phases attach rooms and sockets to: `create_app()`,
|
||||
`python -m undercooked`, security headers, and the client shell page served from
|
||||
`app/static/`.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/app.py`:
|
||||
- `create_app(settings: Settings | None = None) -> FastAPI` — builds the app, stores
|
||||
`app.state.settings`, installs middleware (see 3), registers `GET /healthz`
|
||||
returning `{"status":"ok","version":__version__,"rooms":0}` (`rooms` is a placeholder
|
||||
literal until phase 05 wires the supervisor), mounts
|
||||
`app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")`, and adds
|
||||
`GET /` returning `FileResponse(STATIC_DIR / "index.html")`.
|
||||
- `STATIC_DIR` resolved from the package location (`Path(__file__).resolve().parents[3] / "app" / "static"`)
|
||||
with an env override `UNDERCOOKED_STATIC_DIR` for containers; add it to `Settings`
|
||||
(extend `.env.example` and the README table in this task).
|
||||
- Startup hook: log the resolved settings (never log `.env` contents), and
|
||||
`maybe_start_debugpy(settings)`.
|
||||
2. `src/undercooked/__main__.py` — `main()` with argparse: `--host`, `--port`, `--reload`,
|
||||
`--levels-dir`, precedence CLI > env > default; calls
|
||||
`uvicorn.run("undercooked.server.app:app", ...)` (reload-capable string form) and
|
||||
module-attribute `app = create_app()` guarded so import-time work stays cheap.
|
||||
Exit code 0 on clean shutdown, 2 on `ConfigError` (usage/config error) with a one-line
|
||||
message on stderr.
|
||||
3. `src/undercooked/server/headers.py` — middleware adding:
|
||||
`Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' ws: wss:; font-src 'self'; base-uri 'none'; frame-ancestors 'none'`,
|
||||
`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`,
|
||||
`X-Frame-Options: DENY`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`.
|
||||
No `Report-To`/remote endpoints.
|
||||
4. Client shell (vanilla ES modules, zero external references):
|
||||
- `app/static/index.html` — `<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">`,
|
||||
a `#screen-lobby` section with heading, *Create lobby* button, *Join* form
|
||||
(`inputmode="text"`, `autocapitalize="characters"`, `maxlength="5"`, label,
|
||||
visible focus styles), a `#status` `aria-live="polite"` region, and
|
||||
`<script type="module" src="/static/js/app.js">`. Buttons wired to
|
||||
`console.log`-free placeholders that render "not implemented until phase 05" into
|
||||
`#status`.
|
||||
- `app/static/css/base.css` — CSS custom properties for the palette, system font
|
||||
stack, `prefers-reduced-motion` block, 44 px minimum hit targets, ≥ 4.5:1 text
|
||||
contrast, 360 px → 4K fluid layout, `#kcanvas` sized by JS later.
|
||||
- `app/static/js/app.js` — imports nothing external; small `mount()` that reads the
|
||||
form, and a `getToken()` helper using `sessionStorage`
|
||||
(`secrets`-style random via `crypto.getRandomValues`, base64url) storing
|
||||
`uc.token`; keeps `uc.lastCode` in `sessionStorage`.
|
||||
5. `tests/integration/test_app.py` — `create_app()` boots; `/healthz` 200 + JSON shape;
|
||||
`/` serves HTML containing `/static/js/app.js`; `/static/css/base.css` 200 with
|
||||
`text/css`; unknown path 404; `/static/../pyproject.toml` cannot escape (404/400);
|
||||
every response carries the five security headers; `create_app(Settings(...))` honors
|
||||
an overridden `max_players`.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: routes, static mount, traversal attempt, header presence on 3 route types.
|
||||
- Unit: `__main__._resolve()` precedence logic (CLI > env > default) extracted as a pure
|
||||
function so it is testable without spawning uvicorn.
|
||||
- Coverage: **>90%** on `app.py`, `headers.py`, `__main__.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run undercooked --port 8137 &` then `curl -fsSD- http://127.0.0.1:8137/healthz` → 200 with the security headers visible
|
||||
- [ ] `curl -fsS http://127.0.0.1:8137/ | grep -c '/static/js/app.js'` → ≥ 1
|
||||
- [ ] `curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8137/static/../pyproject.toml` → 404 or 400
|
||||
- [ ] `uv run pytest -q` → all green, coverage ≥ 90%
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,61 @@
|
||||
# Task 04 — No-CDN guard, gate wiring, docs verification
|
||||
|
||||
**Phase:** `01_runnable_service_scaffold` · **Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
Make the no-external-asset rule (A12) mechanically enforced, adapt the pipeline gate to
|
||||
this project's real checks, and prove the README/AGENTS instructions are true as written.
|
||||
|
||||
## Work
|
||||
1. `tests/unit/test_no_external_assets.py`:
|
||||
- walk `app/static/**` (html, css, js, svg) and fail on `http://`, `https://`,
|
||||
`//cdn.`, `@import url(`, `<link rel="stylesheet" href="//`, `srcset`, `fetch(` with
|
||||
an absolute URL, `import "` with an absolute URL, `WebSocket("ws`, and
|
||||
`new EventSource`.
|
||||
- parse `index.html` for `<script src>`/`<link href>`/`<img src>` and assert each is
|
||||
a same-origin `/static/...` path.
|
||||
- fail if any `.html`/`.js` file contains `googleapis`, `gstatic`, `jsdelivr`,
|
||||
`unpkg`, `cdnjs`, or `tailwindcss`.
|
||||
- resolve the static root the same way `app.py` does (import `STATIC_DIR`), so the
|
||||
test cannot pass by scanning the wrong directory.
|
||||
2. `tests/unit/test_docs_commands.py` — extract fenced `bash` blocks from `README.md`,
|
||||
and assert every command line starts with a known-safe prefix
|
||||
(`uv `, `podman `, `bash .agents/validate.sh`, `curl `, `cd `, `#`), i.e. the docs do
|
||||
not drift into unrunnable pseudo-commands; assert every `UNDERCOOKED_*` variable named
|
||||
in README also appears in `config.py` or `.env.example` (and vice versa).
|
||||
3. `.agents/validate.sh` — if the phased-execution harness has not installed it yet, copy
|
||||
its template; then adapt it to this project, in this order, failing fast with clear
|
||||
output: `uv run ruff check .`, `uv run pyright`, `uv run undercooked-levels validate`
|
||||
(skip with a notice only if `src/undercooked/tools/levels.py` does not exist yet —
|
||||
phase 02 makes it real), `uv run pytest -q` (full suite, coverage gate on), plus the
|
||||
template's repo-hygiene checks (tracked files matching `.gitignore`, log-drop/key
|
||||
patterns). Keep `set -euo pipefail` and make it runnable from the repo root or the
|
||||
`.agents/` directory.
|
||||
4. Run `bash .agents/validate.sh` and fix everything it reports.
|
||||
5. Create the doc stubs the PLAN §13 documentation index promises, each with a one-paragraph
|
||||
purpose and a "filled in by phase NN" marker so no later phase references a missing file:
|
||||
`docs/DESIGN.md`, `docs/TESTING.md`, `docs/DEPLOYMENT.md`. `docs/TESTING.md` documents the
|
||||
`tests/{unit,sim,server,integration,e2e}` layout, the marker set (`slow`, `e2e`), and the
|
||||
artifact policy (`test-results/` is never committed).
|
||||
6. Add a `tests/unit/test_docs_index.py` guard: every `docs/*.md` listed in PLAN §13's
|
||||
documentation index exists, and no `docs/` file exists that the index does not list — so
|
||||
the doc set stays exactly the promised one.
|
||||
7. Docs pass for this phase: confirm README's Quickstart block, config table (add
|
||||
`UNDERCOOKED_STATIC_DIR`, `UNDERCOOKED_ALLOWED_ORIGINS`), and Layout section match
|
||||
the code as it now stands; confirm AGENTS.md §5's test path
|
||||
`tests/unit/test_no_external_assets.py` matches the file created here.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the guard itself (temp fixture dirs with a planted external URL must fail —
|
||||
`tmp_path` + monkeypatched root; assert the failure message names the offending file).
|
||||
- Coverage: **>90%** on new test-support code; content tests are self-verifying.
|
||||
- Full suite green; `bash .agents/validate.sh` exits 0.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `bash .agents/validate.sh` exits 0 from both the repo root and `.agents/`
|
||||
- [ ] planting `https://cdn.example.com/x.js` in a temp static file makes `uv run pytest tests/unit/test_no_external_assets.py -q` fail (then removed)
|
||||
- [ ] `uv run pytest -q` → 100% passing, coverage ≥ 90%
|
||||
- [ ] every README command block runs as written on a clean `uv sync --extra dev` checkout
|
||||
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
|
||||
- [ ] full test suite **100% passing** (every test in the project, not just this phase's)
|
||||
- [ ] no behavior change in completed phases
|
||||
@@ -0,0 +1,50 @@
|
||||
# Phase 02 — Level content, schema, and validation
|
||||
|
||||
**Story:** `US-07_campaign_progression` (content foundation)
|
||||
**Context:** `.agents/PLAN.md` §5 (entity model), §8 (content + escalation), A5, A8
|
||||
|
||||
## Objective
|
||||
Define the pure simulation's data model and the data-driven content pipeline: YAML →
|
||||
validated Pydantic level/recipe definitions → in-memory `LevelDef`, plus a CLI that
|
||||
proves every shipped level loads, is reachable, and is solvable. No networking, no
|
||||
movement yet — but the app can already list levels over HTTP.
|
||||
|
||||
## Dependencies
|
||||
- `01_runnable_service_scaffold` (complete) — `create_app()`, `Settings.levels_dir`,
|
||||
validate gate, test tree
|
||||
|
||||
## Tasks
|
||||
1. `01_sim_core_data_model.md` — enums, `Vec2`, `Item`, `Station`, `Player`, `Order`, `Hazard`, `KitchenState`, `Grid`
|
||||
2. `02_recipe_and_level_schema.md` — Pydantic models for `recipes.yaml` + level YAML, ASCII grid parser
|
||||
3. `03_level_validation_rules.md` — reachability, solvability, target plausibility, structured `LevelError` report
|
||||
4. `04_seed_recipes_and_two_levels.md` — `levels/recipes.yaml`, `levels/01_…yaml`, `levels/02_…yaml`, `/api/levels`
|
||||
5. `05_levels_cli_and_manifest_test.md` — `undercooked-levels validate|list|show`, content regression tests
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: grid geometry and walkability; ASCII parser (ragged grids, unknown legend chars,
|
||||
spawn off-floor); validator rules (each rule needs at least one failing fixture and one
|
||||
passing fixture); loader ordering (campaign order by `order` field, ties broken by id);
|
||||
duplicate id detection; recipe referencing an unknown ingredient fails.
|
||||
- Integration: `GET /api/levels` returns both seeded levels with id/name/order/target/
|
||||
time_limit/difficulty pips and nothing else (no leak of internal grid text).
|
||||
- Coverage: **>90%** on new/modified code (`sim/level.py`, `sim/validation.py`, `sim/grid.py`
|
||||
at ≥ 95% — they carry the content contract).
|
||||
- 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**.
|
||||
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are
|
||||
user-visible: this phase adds `docs/LEVELS.md` (schema reference) and must keep the
|
||||
README layout/commands table aligned with it.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run undercooked-levels validate` exits 0 and prints one line per level
|
||||
- [ ] `uv run undercooked-levels list` prints both levels in campaign order
|
||||
- [ ] `curl -fsS http://127.0.0.1:8000/api/levels | python -m json.tool` lists both levels with numeric target/time
|
||||
- [ ] `uv run undercooked-levels validate --dir tests/fixtures/levels/bad/` reports the expected named rule failures (non-zero exit)
|
||||
- [ ] adding a fixture YAML file to `tests/fixtures/levels/` makes it appear in the loader output with no Python change (test-enforced)
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,73 @@
|
||||
# Task 01 — Core simulation data model
|
||||
|
||||
**Phase:** `02_level_content_and_validation` · **Story:** `US-04_move_and_cook`, `US-07_campaign_progression`
|
||||
|
||||
## Objective
|
||||
Write the vocabulary every later phase shares: pure dataclasses/enums for the kitchen,
|
||||
and the grid type with its geometry and walkability rules.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/model.py` (pure: no imports outside stdlib + this package; add a
|
||||
module docstring stating the A5 purity contract):
|
||||
- `Facing` (`str, Enum`: `UP/RIGHT/DOWN/LEFT`) with `delta()` and `OPPOSITE`.
|
||||
- `Vec2` frozen dataclass `x, y` with `+`, `-`, `scale`, `length`, `normalized`,
|
||||
`quantized(digits=2)` (used by the wire encoder later), `KEY = (int(round(x*64)), int(round(y*64)))`
|
||||
for hashing.
|
||||
- `TileKind` (`FLOOR, COUNTER, WALL, VOID`), `WALKABLE: frozenset[TileKind] = {FLOOR}`.
|
||||
- `StationKind` (`SPAWN_SHELF, CHOP_BOARD, STOVE, PREP, SERVE, SINK, TRASH`),
|
||||
`StationState` (`IDLE, WORKING, READY, BUSY, DIRTY, BLOCKED`).
|
||||
- `ItemKind` (`TOMATO, ONION, LETTUCE, DOUGH, PATTY, RICE, FISH, PLATE`) and
|
||||
`ItemForm` (`RAW, CHOPPED, COOKING, COOKED, BURNT, PLATED, DIRTY`).
|
||||
- `Item` dataclass: `kind`, `form`, `quality: float = 1.0`, `ttl: float = 0.0`,
|
||||
`dish_id: int | None = None`; `is_trash()`, `with_form()` helpers (returns new
|
||||
instance — keep value semantics).
|
||||
- `Station` dataclass: `id: str`, `kind`, `x`, `y`, `contents: tuple[Item, ...]`,
|
||||
`state`, `progress: float`, `duration: float`, `cooldown: float`,
|
||||
`worker_id: str | None`, `dirty: bool`.
|
||||
- `PlayerState` (`IDLE, MOVING, USING, STUNNED, DISCONNECTED`), `Player` dataclass:
|
||||
`id`, `pos: Vec2`, `vel: Vec2`, `facing`, `carrying: Item | None`, `state`,
|
||||
`stun: float`, `score: int`, `dishes_served: int`, `connected: bool`, `seat: int`.
|
||||
- `Order` dataclass: `id: int`, `recipe_id: str`, `time_left: float`, `deadline: float`,
|
||||
`value: int`.
|
||||
- `HazardKind` (`FIRE, SPILL, MOVING_OBSTACLE, RUSH`), `Hazard` dataclass: `id`, `kind`,
|
||||
`pos`, `life`, `path: tuple[Vec2, ...]`, `period_s`, `station_id`, `chance_per_s`.
|
||||
- `RoundResult` (`WON, LOST, ABORTED`), `KitchenState` dataclass: `level_id`, `seed`,
|
||||
`tick`, `grid`, `stations: tuple[Station, ...]`, `players: tuple[Player, ...]`,
|
||||
`orders: tuple[Order, ...]`, `hazards`, `score`, `target_score`, `time_left`,
|
||||
`streak`, `result: RoundResult | None`, `counters: Mapping[str, int]`,
|
||||
`rng_state: int`. Provide `state_hash() -> str` (sha256 over a canonical
|
||||
repr of the fields that matter — used by replay tests) and `with_*` helpers so
|
||||
mutation stays centralized.
|
||||
- `Event` dataclass: `kind: str`, `x`, `y`, `payload: Mapping`, plus `EventKind`
|
||||
string constants (`CHOPPED`, `SERVED`, `BURNT`, `ORDER_MISSED`, `FIRE_STARTED`,
|
||||
`DENIED`, `SCORE`, `STUNNED`, `WASHED`, `TRASHED`, `TICK_SLOW`).
|
||||
2. `src/undercooked/sim/grid.py`:
|
||||
- `Grid` frozen dataclass `width`, `height`, `tiles: tuple[TileKind, ...]`
|
||||
(row-major), `station_at: Mapping[int, str]`.
|
||||
- `in_bounds`, `tile_at(x, y)`, `is_walkable(x, y)`, `walkable_cells()`,
|
||||
`blocking_mask()`, `neighbours(cell, four_way=True)`, `bfs_reachable(start,
|
||||
extra_blocked=frozenset()) -> frozenset[int]`, `manhattan(a, b)`.
|
||||
- `Grid.from_rows(rows: Sequence[str], legend: Mapping[str, TileKind]) -> Grid` —
|
||||
raises `GridError` on ragged rows, empty grid, unknown legend char, or size 0/out of
|
||||
range (`MAX_GRID = 64`).
|
||||
3. `src/undercooked/sim/purity.py` — `assert_pure_imports()` helper that imports every
|
||||
`sim` module and asserts none pulled in `fastapi`, `uvicorn`, `asyncio`, `socket`, or
|
||||
`time` (allow `time.perf_counter` nowhere in sim; check module-level, keep it a
|
||||
20-line guard used by a test).
|
||||
4. `tests/sim/test_model.py`, `tests/sim/test_grid.py`, `tests/sim/test_purity.py` —
|
||||
geometry, walkability, BFS around walls, ragged-grid errors, `state_hash` stability
|
||||
across field insertion order for mappings, helpers return new instances.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: every helper listed above; `Vec2` arithmetic incl. zero-length normalization;
|
||||
`state_hash` changes when a scored field changes and ignores cosmetic ones.
|
||||
- Coverage: **>90%** on `model.py`/`grid.py`/`purity.py` (target 95%+).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run python -c "from undercooked.sim.grid import Grid"` succeeds
|
||||
- [ ] `uv run pytest tests/sim -q` → all green
|
||||
- [ ] `uv run pytest tests/sim/test_purity.py -q` proves no forbidden import in `sim/`
|
||||
- [ ] `uv run ruff check src/undercooked/sim && uv run pyright` → zero errors
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,69 @@
|
||||
# Task 02 — Recipe and level schema (YAML → typed)
|
||||
|
||||
**Phase:** `02_level_content_and_validation` · **Story:** `US-07_campaign_progression`
|
||||
|
||||
## Objective
|
||||
Make content files the only source of levels and recipes: strict Pydantic models, an
|
||||
ASCII-grid parser wired to a tile legend, and a loader that returns campaign-ordered
|
||||
`LevelDef`s.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/recipe.py` — Pydantic models:
|
||||
- `Ingredient(kind: ItemKind, form: ItemForm, count: int = 1)`
|
||||
- `Step(kind: Literal["pick","chop","cook","plate","serve","wash"], station:
|
||||
StationKind, seconds: float = 0.0)`
|
||||
- `Recipe(id: str, name: str, base_score: int, ingredients: list[Ingredient],
|
||||
steps: list[Step], requires_plating: bool = True, tags: list[str] = [])`
|
||||
- `RecipeBook(recipes: dict[str, Recipe])` with `get(id)`, `ids()`,
|
||||
`required_station_kinds(recipe_id) -> frozenset[StationKind]`,
|
||||
`required_item_kinds(recipe_id) -> frozenset[ItemKind]`,
|
||||
`min_seconds_to_complete(recipe_id) -> float` (sum of step seconds + handling
|
||||
constant `HANDLING_S = 0.6`) — the solver bound used by validation.
|
||||
- `load_recipe_book(path: Path) -> RecipeBook` (YAML, `extra="forbid"`,
|
||||
`validate_assignment=True`).
|
||||
2. `src/undercooked/sim/level.py` — Pydantic models mirroring PLAN §8:
|
||||
- `TileSpec(kind: TileKind, station: StationKind | None = None, item: ItemKind | None = None,
|
||||
id_prefix: str | None = None)`
|
||||
- `OrdersSpec(pool: list[str], concurrent: int, spawn_interval: tuple[float, float],
|
||||
deadline: float, penalty: int = 10)`
|
||||
- `HazardSpec(type: HazardKind, x: int | None, y: int | None, station: str | None,
|
||||
chance_per_s: float | None, respawn_s: float | None, path: list[tuple[int,int]] | None,
|
||||
period_s: float | None, intensity: float = 1.0)` — model_validator: `station` XOR
|
||||
`x/y`; `MOVING_OBSTACLE` requires `path` ≥ 2 points + `period_s`; `FIRE` requires
|
||||
`station` or coordinates; `RUSH` requires `intensity`.
|
||||
- `StationLimits(plates: int = 99, pots: int = 99)`
|
||||
- `LevelDef(id: str, name: str, order: int, time_limit: float, target_score: int,
|
||||
grid: list[str], tiles: dict[str, TileSpec], spawns: list[tuple[int,int]],
|
||||
orders: OrdersSpec, hazards: list[HazardSpec] = [], station_limits: StationLimits = …,
|
||||
difficulty: int = 1, blurb: str = "", escalation_notes: str = "")`
|
||||
with `model_validator`s: rectangular grid, spawns inside bounds, ≥ 1 spawn,
|
||||
`difficulty` 1–10, exactly one `SERVE` legend char per level, every legend char used.
|
||||
- `build_grid(level: LevelDef) -> Grid` (legend → `TileKind`, station ids assigned
|
||||
deterministically as `f"{kind.value}_{n}"` in reading order) and
|
||||
- `Level` dataclass pairing `LevelDef` with its `Grid` + station list + spawn list.
|
||||
3. `src/undercooked/sim/content.py` — `ContentBundle(recipe_book, levels: tuple[Level, …],
|
||||
by_id: Mapping[str, Level])`, `load_content(levels_dir: Path, recipes_path: Path)
|
||||
-> ContentBundle`; sorts by `(order, id)`; raises `ContentError` listing **all**
|
||||
file-level errors at once (not the first only); tolerates subdirectories and
|
||||
non-matching `*.yml`/`*.yaml`; ignores files starting with `_` (fixtures/overrides).
|
||||
4. `tests/unit/test_recipe_schema.py`, `tests/unit/test_level_schema.py`,
|
||||
`tests/unit/test_content_loader.py` — happy paths plus one failing YAML per rule
|
||||
(ragged grid, unused legend char, two serve legends, unknown recipe in pool, hazard
|
||||
with neither station nor coords, `min` > `max` spawn interval, non-rectangular grid,
|
||||
out-of-bounds spawn, `difficulty=11`, duplicate level ids, unknown extra key).
|
||||
5. `docs/LEVELS.md` — schema reference: every field, type, default, meaning, plus the
|
||||
legend conventions and a complete annotated example. README Layout + Contributing get
|
||||
a pointer to it.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: every validator branch; loader aggregation ordering; `min_seconds_to_complete`
|
||||
and `required_station_kinds` correctness for a 2-step and a 4-step recipe.
|
||||
- Coverage: **>90%** on `recipe.py`, `level.py`, `content.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_level_schema.py tests/unit/test_content_loader.py tests/unit/test_recipe_schema.py -q` → green
|
||||
- [ ] `uv run python -c "from undercooked.sim.content import load_content; print(len(load_content('levels','levels/recipes.yaml').levels))"` works once task 04 adds content (skip-note allowed before that; must work at phase end)
|
||||
- [ ] `docs/LEVELS.md` documents every field present in the models (a test compares the model field sets to the doc headings)
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,62 @@
|
||||
# Task 03 — Level validation rules
|
||||
|
||||
**Phase:** `02_level_content_and_validation` · **Story:** `US-07_campaign_progression`
|
||||
|
||||
## Objective
|
||||
Prove a level is playable before anyone plays it: reachability, recipe solvability,
|
||||
target plausibility, and hazard references, reported as a structured, named-rule error
|
||||
list.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/validation.py`:
|
||||
- `RuleId` string constants: `GRID_SHAPE`, `SPAWN_ON_FLOOR`, `SPAWN_COUNT`,
|
||||
`STATION_REACHABLE`, `SERVE_REACHABLE`, `SPAWNS_MUTUALLY_REACHABLE`,
|
||||
`RECIPE_STATION_MISSING`, `RECIPE_INGREDIENT_UNAVAILABLE`, `ORDER_POOL_UNKNOWN_RECIPE`,
|
||||
`TARGET_IMPLAUSIBLE`, `TIME_LIMIT_INVALID`, `HAZARD_REF_UNKNOWN`,
|
||||
`HAZARD_ON_UNREACHABLE_TILE`, `STATION_LIMIT_ZERO`, `DUPLICATE_ID`,
|
||||
`DIFFICULTY_ORDER`, `TRAVEL_TOO_SHORT`.
|
||||
- `LevelViolation(rule: RuleId, level_id: str, message: str, detail: Mapping = {})`
|
||||
and `ValidationReport(violations, warnings)` with `.ok`, `.errors_for(level_id)`,
|
||||
`.render() -> str` (grouped, stable ordering, no ANSI).
|
||||
- `validate_level(level: Level, book: RecipeBook, prev: Level | None = None) -> ValidationReport`
|
||||
implementing PLAN §8:
|
||||
- BFS from each spawn over walkable tiles → one reachable set; every station cell,
|
||||
serve window, and spawn must be in it (movers ignored here, they are dynamic).
|
||||
- For each recipe in the order pool: every required station kind exists and is
|
||||
reachable; every `RAW` ingredient kind is produced by a reachable spawn shelf
|
||||
(or a station that yields it).
|
||||
- `theoretical_max = floor(time_limit / mean_complete)` where
|
||||
`mean_complete = mean(book.min_seconds_to_complete(r) for r in pool) + travel_bound`
|
||||
and reject when `target_score > 0.6 * theoretical_max * mean_order_value`
|
||||
(so a target is never mathematically unreachable). Emit a warning when
|
||||
`target_score > 0.45 * …` (tight-but-fair).
|
||||
- Hazards reference existing station ids / in-bounds reachable tiles.
|
||||
- `time_limit ≥ 30`, `station_limits` positive, ids unique across the bundle
|
||||
(bundle-level check in `validate_bundle`).
|
||||
- `DIFFICULTY_ORDER` when `prev` is given: at least two escalation axes must move
|
||||
vs. the predecessor (axes computed by `escalation_axes(level)` → dict of
|
||||
`recipe_complexity`, `order_pressure`, `time_budget`, `station_scarcity`,
|
||||
`layout_hostility`, `hazard_density`, `travel_distance`).
|
||||
- `TRAVEL_TOO_SHORT`: mean spawn→serve path length ≥ 3 tiles for `difficulty ≥ 3`.
|
||||
- `validate_bundle(bundle: ContentBundle) -> ValidationReport` — per level plus
|
||||
duplicate ids and campaign monotonicity for `difficulty`.
|
||||
- Warnings never fail; every error must name the rule id and a human message with
|
||||
coordinates.
|
||||
2. `tests/sim/test_validation.py` — one test per rule id with a minimal fixture level
|
||||
built by a `make_level(**overrides)` factory (unreachable station behind a wall,
|
||||
missing sink, ingredient with no shelf, `target_score` 10× too high, hazard pointing at
|
||||
a nonexistent station id, level 2 with fewer axes moved than level 1, …), and a test
|
||||
asserting a clean level reports zero errors/warnings.
|
||||
3. `tests/sim/test_validation_render.py` — `render()` is deterministic (two runs equal),
|
||||
mentions rule ids, and stays ≤ 40 lines for a 10-violation level.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: every rule id has a failing and a passing case; report ordering/rendering stable.
|
||||
- Coverage: **>90%** on `validation.py` (target 95%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_validation.py -q` → green, one test per rule id (assert the count matches `len(RuleId.ALL)`)
|
||||
- [ ] planting an unreachable station in a temp level produces exactly the `STATION_REACHABLE` violation (test asserts this)
|
||||
- [ ] `uv run pytest -q` → full 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
|
||||
@@ -0,0 +1,60 @@
|
||||
# Task 04 — Seed recipes and the first two levels, expose `/api/levels`
|
||||
|
||||
**Phase:** `02_level_content_and_validation` · **Story:** `US-07_campaign_progression`
|
||||
|
||||
## Objective
|
||||
Ship real content: an ingredient/recipe book and the first two campaign levels, loaded at
|
||||
startup, validated, and readable by the client over HTTP.
|
||||
|
||||
## Work
|
||||
1. `levels/recipes.yaml` — ingredients (`tomato, onion, lettuce, dough, patty, rice,
|
||||
fish, plate`) and recipes:
|
||||
- `soup` (tomato + onion → chop → pot/stove → plate) base 40
|
||||
- `salad` (lettuce + tomato → chop → plate) base 30
|
||||
- `fried_rice` (rice + onion → stove → plate) base 50
|
||||
- `burger` (patty + dough + lettuce → pan/stove + chop → plate) base 70 (defined now,
|
||||
used by later levels)
|
||||
Keep `steps` consistent with the station pipelines in PLAN §5.
|
||||
2. `levels/01_first_kitchen.yaml` — order 1, difficulty 1, 12×9 open kitchen, single
|
||||
spawn shelf per ingredient used by `soup`, one chop board, one stove, one prep, one
|
||||
serve, one sink, `time_limit: 150`, `target_score: 45`, `orders.pool: [soup]`,
|
||||
`concurrent: 1`, `spawn_interval: [16, 20]`, `deadline: 60`, no hazards, 2 spawns,
|
||||
`blurb` written for a first-timer.
|
||||
3. `levels/02_double_shift.yaml` — order 2, difficulty 2, counters that create a mild
|
||||
pinch, `pool: [soup, salad]`, `concurrent: 2`, `spawn_interval: [12, 16]`,
|
||||
`deadline: 52`, `time_limit: 130`, `target_score: 80`, 2 spawns. Must move ≥ 2
|
||||
escalation axes versus level 1 (order pressure + time budget + recipe complexity).
|
||||
4. Startup wiring: `create_app()` loads the content bundle once
|
||||
(`app.state.content`), running `validate_bundle`; on errors, the app **refuses to
|
||||
start** with the rendered report on stderr (content bugs must not surface as a
|
||||
half-working lobby). `Settings.levels_dir` drives the directory; `levels/recipes.yaml`
|
||||
resolved beside it (overridable with `UNDERCOOKED_RECIPES_PATH`, added to `Settings`,
|
||||
`.env.example`, and the README table).
|
||||
5. `GET /api/levels` in `src/undercooked/server/app.py` (new
|
||||
`src/undercooked/server/routes_content.py` router) returning
|
||||
`[{id, name, order, difficulty, blurb, target_score, time_limit, recipes: [names]}]`
|
||||
in campaign order — no grids, no internal station ids.
|
||||
`GET /api/levels/{id}` returns the same plus `hazards: [{type}]` only (never layout).
|
||||
`GET /api/config` returns client-safe values (`max_players`, `tick_hz`, `snapshot_hz`,
|
||||
`protocol_version`) and nothing else.
|
||||
6. `tests/integration/test_content_api.py` — lists exactly the shipped levels in order,
|
||||
shape-validated by an assertable response model, no `grid` key anywhere in the body;
|
||||
`/api/levels/01_first_kitchen` 200, unknown id 404; `/api/config` exposes no setting
|
||||
name containing `secret`/`password`/`origins`.
|
||||
7. `tests/unit/test_shipped_content.py` — for **every** file in `levels/`: validates
|
||||
clean, has a `blurb` (≥ 20 chars), `order` unique and contiguous from 1, difficulty in
|
||||
range, and the recipes in its pool exist. This is the content regression test.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: content API shape/ordering/leaks.
|
||||
- Unit: shipped content validates; startup refuses on a broken content dir
|
||||
(`tmp_path` copy of a bad level + assert `ConfigError`/`ContentError` and stderr report).
|
||||
- Coverage: **>90%** on `routes_content.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run undercooked --port 8139 &` → `curl -fsS http://127.0.0.1:8139/api/levels` lists both levels, no grid data
|
||||
- [ ] `uv run pytest tests/unit/test_shipped_content.py tests/integration/test_content_api.py -q` → green
|
||||
- [ ] removing `serve` from a copied level makes startup fail with `SERVE_*`/`STATION_REACHABLE` named in stderr
|
||||
- [ ] 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
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# Task 05 — `undercooked-levels` CLI and manifest tests
|
||||
|
||||
**Phase:** `02_level_content_and_validation` · **Story:** `US-07_campaign_progression`
|
||||
|
||||
## Objective
|
||||
Give authors a fast, scriptable feedback loop over content and lock in the "no
|
||||
hard-coded content lists" rule with a test that adds a level file and expects it to show
|
||||
up.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/tools/levels.py` — argparse CLI (stdlib only; A10/A18 style, exit
|
||||
codes 0/1/2):
|
||||
- `validate [--dir PATH] [--recipes PATH] [--json]` → runs `validate_bundle`, prints
|
||||
`report.render()` (or JSON), exit 1 on any error, 2 on a load failure.
|
||||
- `list [--dir PATH]` → one line per level: `order id difficulty target time recipes`.
|
||||
- `show ID [--grid]` → resolved level summary; `--grid` prints the ASCII map plus a
|
||||
reachability overlay (`.` reachable, `?` unreachable) to debug a failing level.
|
||||
- `new ID [--order N]` → writes a starter YAML from a built-in template into the
|
||||
levels dir, then runs validation on it (author onboarding).
|
||||
- `axes [--dir PATH]` → escalation-axis table for all levels (feeds the curve work in
|
||||
phase 11).
|
||||
Module is importable: `main(argv: Sequence[str] | None = None) -> int`.
|
||||
2. Make the console script real: confirm `undercooked-levels` in
|
||||
`[project.scripts]` points at `undercooked.tools.levels:main`; verify
|
||||
`uv run undercooked-levels validate` works from the repo root **and** from another cwd
|
||||
(path resolution via `Settings.levels_dir`, never `cwd`).
|
||||
3. Wire the CLI into `.agents/validate.sh` unconditionally (remove the phase-01 skip
|
||||
notice).
|
||||
4. `tests/unit/test_levels_cli.py` — each subcommand's exit code and stdout, including:
|
||||
`validate` green on shipped content; `validate --dir <bad fixture>` exit 1 naming the
|
||||
rule; `show 01_first_kitchen --grid` overlay marks an unreachable station; `axes`
|
||||
table ordering; `--json` output parses.
|
||||
5. `tests/unit/test_content_not_hardcoded.py` — the rule lock:
|
||||
- copy the shipped levels to a `tmp_path`, add `90_zerocool.yaml` (valid, order 99),
|
||||
and assert `load_content`, `/api/levels` (via `TestClient` with
|
||||
`UNDERCOOKED_LEVELS_DIR` pointed there), and `undercooked-levels list` **all**
|
||||
include it, with no Python edited.
|
||||
- assert no Python source under `src/undercooked/` contains a literal level id
|
||||
(`grep`-style scan for `"01_first_kitchen"` etc.) — content lives in YAML only.
|
||||
6. `docs/LEVELS.md` — append the "Authoring workflow" section: `new` → edit → `validate`
|
||||
→ `axes`, with the overlay debugging trick and the rule-id table.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: all subcommands, exit codes, cwd-independence, hardcoding scan.
|
||||
- Coverage: **>90%** on `tools/levels.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run undercooked-levels validate` exits 0 from `/tmp` (not just the repo root)
|
||||
- [ ] `uv run undercooked-levels list` prints both shipped levels in campaign order
|
||||
- [ ] `uv run undercooked-levels show 01_first_kitchen --grid` prints a reachability overlay
|
||||
- [ ] `uv run undercooked-levels validate --json | python -m json.tool` parses, `errors: []`
|
||||
- [ ] `uv run pytest tests/unit/test_content_not_hardcoded.py -q` → green
|
||||
- [ ] `bash .agents/validate.sh` runs the content gate and exits 0
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,52 @@
|
||||
# Phase 03 — Simulation: fixed-step kernel, movement, interaction
|
||||
|
||||
**Story:** `US-04_move_and_cook` (server-side half)
|
||||
**Context:** `.agents/PLAN.md` §5 (movement, interaction model), A4, A5, A21
|
||||
|
||||
## Objective
|
||||
Stand up the deterministic simulation kernel: a pure `step(state, dt)` driven at 20 Hz
|
||||
with seeded randomness, tile+body collision, reach-based targeting, and pick/carry/drop
|
||||
semantics — all reproducible from a seed plus an input script, proven by golden hashes.
|
||||
|
||||
## Dependencies
|
||||
- `02_level_content_and_validation` (complete) — `Level`, `Grid`, `RecipeBook`,
|
||||
`KitchenState`, `Vec2`, `Event`
|
||||
|
||||
## Tasks
|
||||
1. `01_fixed_step_kernel.md` — `build_state`, `step(state, dt)`, subsystem order, seeded pure RNG
|
||||
2. `02_movement_and_collision.md` — velocity integration, tile AABB + player soft-push resolution, facing, stun
|
||||
3. `03_interaction_targeting.md` — front-tile + forgiveness-radius target resolution, verb selection, `DENIED` reasons
|
||||
4. `04_pickup_carry_drop.md` — one-item carry rules, handoff via counters, trash, deterministic contested resolution
|
||||
5. `05_replay_harness_and_goldens.md` — input-script format, golden hash fixtures, perf smoke
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: integration/clamping math, collision against every tile kind and another player,
|
||||
facing rules, target priority ordering, every `DeniedReason`, RNG reproducibility and
|
||||
independence of two states with the same seed.
|
||||
- Determinism (`tests/sim/test_determinism.py`): same seed + same script → identical
|
||||
`state_hash()`; **different** seed → different hash; running 2×50 ticks equals running
|
||||
1×100 ticks at the same total time within the documented tolerance, and exactly equals
|
||||
it when using the same substep count (documented, asserted).
|
||||
- Replay (`tests/sim/test_replay.py`): scripted 600-tick walk + pickup + drop matches a
|
||||
committed golden hash; the golden regenerates only when the sim contract intentionally
|
||||
changes, and the test prints the diff hint when it breaks.
|
||||
- Perf smoke (`slow`): 4 players, 2000 ticks ≤ 400 ms total (≥ 20 Hz headroom).
|
||||
- Coverage: **>90%** on new/modified code; `sim/movement.py` and `sim/interact.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**.
|
||||
- Tasks update `docs/SIMULATION.md` (new) as they land: constants table, step order,
|
||||
determinism contract, denied-reason list.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run python -c "from undercooked.sim.step import step"` succeeds
|
||||
- [ ] `uv run pytest tests/sim -q` → green, including determinism + golden replay
|
||||
- [ ] `uv run pytest -m slow tests/sim/test_replay.py -q` shows ticks/s ≥ 10 000 for a 4-player kitchen
|
||||
- [ ] changing a sim constant without regenerating the golden fails the suite (demonstrated by the test design, not by hand)
|
||||
- [ ] `docs/SIMULATION.md` documents the step order, constants, and the determinism contract
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,66 @@
|
||||
# Task 01 — Fixed-step kernel and seeded RNG
|
||||
|
||||
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
|
||||
|
||||
## Objective
|
||||
Create the loop that everything else plugs into: `build_state(level, players, seed)` and
|
||||
`step(state, dt) -> (state, events)`, with a deterministic RNG and a fixed subsystem
|
||||
order.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/rng.py` — a pure, seedable PRNG (splitmix64 or xoshiro128+; no
|
||||
stdlib `random`, which is not portable across versions):
|
||||
`Rng(state: int)` with `next_u64()`, `next_float() -> float` in [0,1),
|
||||
`range_int(lo, hi)`, `choice(seq)`, `fork(salt: int) -> Rng`; returns a new `Rng`
|
||||
(immutable state threading). Golden vector test with fixed expected values.
|
||||
2. `src/undercooked/sim/state_build.py`:
|
||||
- `build_state(level: Level, seats: Sequence[SeatSpec], seed: int, book: RecipeBook) -> KitchenState`
|
||||
placing stations from `level`, spawning players at `level.spawns` (seat index →
|
||||
spawn, wrapping for > spawns), `time_left = level.time_limit`,
|
||||
`target_score`, `orders=()`, `hazards=()` (populated in phase 10), `rng_state = seed`.
|
||||
- `SeatSpec(player_id: str, seat: int, display_name: str, color: str)` with a fixed
|
||||
4-color palette constant.
|
||||
- Spawn assignment wraps when there are more seats than spawns (`seat % len(spawns)`)
|
||||
and offsets stacked players by 0.25 tile so nobody starts overlapping; raise
|
||||
`StateBuildError` only when the level defines zero spawns.
|
||||
3. `src/undercooked/sim/step.py`:
|
||||
- `STEP_DT` constant (0.05) and `step(state, dt, inputs: Sequence[PlayerInput] = ()) -> tuple[KitchenState, tuple[Event, ...]]`
|
||||
with a documented, fixed order:
|
||||
1. clamp `dt` to `[0, MAX_DT = 0.1]` (never spiral), accumulate fractional ticks via
|
||||
`state.accumulator` so the sim advances in exact `STEP_DT` substeps,
|
||||
2. apply inputs (movement intent + action requests) — earliest-received wins,
|
||||
tie-break by seat,
|
||||
3. movement + collision (`movement.step_positions`),
|
||||
4. station progress (`stations.step` — phase 04 makes it real; now an identity hook),
|
||||
5. orders (`orders.step` — phase 04 hook),
|
||||
6. hazards (`hazards.step` — phase 10 hook),
|
||||
7. round timer + end conditions (`round_state.step` — phase 04 hook; timer counts down now),
|
||||
8. `tick += 1`.
|
||||
Hooks are no-op functions in this phase, each in its own module with a docstring
|
||||
naming the phase that fills it in.
|
||||
- `PlayerInput(player_id, seq, move: Vec2, action: bool, face: Facing | None,
|
||||
received_at_tick: int)` frozen dataclass; unknown `player_id` → `err` event, never a
|
||||
crash.
|
||||
- Never mutates the input state: build a new `KitchenState` per substep (dataclasses
|
||||
with `replace`/explicit rebuild; a `_draft` mutable helper is allowed internally as
|
||||
long as the public function is pure).
|
||||
4. `docs/SIMULATION.md` — constants table (`STEP_DT`, `MAX_DT`, `PLAYER_RADIUS`,
|
||||
`MAX_SPEED`, `ACCEL`, `REACH`, `HANDLING_S`), the 8-step order, purity + determinism
|
||||
contract, and how to regenerate goldens.
|
||||
5. `tests/sim/test_step_kernel.py` — dt clamping, substep accumulation (17 ticks over
|
||||
0.85 s at 0.05), unknown player id, purity (input state object unchanged after step),
|
||||
two states from the same seed stay identical over 200 steps, `tick` monotonic.
|
||||
6. `tests/sim/test_rng.py` — golden vectors, `fork` determinism, distribution sanity
|
||||
(10 000 draws within tolerance), immutability.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: as listed above; RNG is golden-vector locked.
|
||||
- Coverage: **>90%** on `rng.py`, `step.py`, `state_build.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_rng.py tests/sim/test_step_kernel.py -q` → green
|
||||
- [ ] `uv run python -c "from undercooked.sim.step import step; from undercooked.sim.state_build import build_state"` succeeds
|
||||
- [ ] the purity guard test (`tests/sim/test_purity.py` from phase 02) still passes with the new modules
|
||||
- [ ] 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 02 — Movement, collision, facing, stun
|
||||
|
||||
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
|
||||
|
||||
## Objective
|
||||
Make cooks walk: analog velocity, axis-separated tile collision, soft player push,
|
||||
facing, and stun handling — deterministic and cheap.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/movement.py`:
|
||||
- Constants: `MAX_SPEED = 3.5` tiles/s, `ACCEL = 30.0`, `DECEL = 40.0`,
|
||||
`RADIUS = 0.35`, `DIAGONAL_NORM = True`.
|
||||
- `desired_velocity(player, intent: Vec2, dt, modifiers: MoveModifiers) -> Vec2` —
|
||||
accelerate toward the intent direction (normalized so diagonals are not faster),
|
||||
decelerate to zero when the intent is zero, apply `MoveModifiers(speed_scale,
|
||||
accel_scale, slide)` (used by spills in phase 10).
|
||||
- `resolve_moves(state, dt) -> tuple[tuple[Player, ...], tuple[Event, ...]]` —
|
||||
integrate X then Y separately, clamping against blocked tiles (`WALL`, `COUNTER`,
|
||||
`VOID`, out-of-bounds) using AABB-vs-circle-as-square resolution; then resolve
|
||||
player↔player overlap with a symmetric soft push (mass 1, positional correction
|
||||
only, no velocity injection), iterating twice for chains, falling back to reverting
|
||||
the later-received input on a deadlock.
|
||||
- `update_facing(player, intent, action) -> Facing` — facing follows the last nonzero
|
||||
intent direction; an action press with zero intent keeps facing.
|
||||
- `apply_stun(players, dt)` — decrement `stun`, zero velocity while stunned, emit
|
||||
`EventKind.STUNNED` once on entry.
|
||||
- Guarantees to assert in docstrings and tests: a player never ends a tick inside a
|
||||
blocked tile; no tunneling at 2× `MAX_SPEED`; two players never share an overlap
|
||||
after resolution; a stunned player cannot move.
|
||||
2. `src/undercooked/sim/collision.py` — pure helpers: `cell_blocked(grid, x, y, extra_blocked)`,
|
||||
`sweep_axis(pos, delta, radius, blocked) -> float` (max allowed delta), `body_cells(pos, radius)`,
|
||||
`station_body_cells(station)` (stations occupy their tile as `COUNTER`-like blockers
|
||||
unless `kind in {SERVE, SINK, TRASH}` where the cook may stand adjacent-adjacent).
|
||||
3. Update `step.py` to call `movement.step_movement(state, dt)` in slot 3 (replace the
|
||||
phase-03-task-01 hook) and feed `intent` from `PlayerInput.move`, clamping the intent
|
||||
vector length to ≤ 1 server-side (a client cannot send speed 50).
|
||||
4. `tests/sim/test_movement.py` — table-driven: walk into each tile kind, corner catch,
|
||||
diagonal speed parity, soft push with 2 and 4 players, stun blocks movement, no
|
||||
tunneling at `dt = MAX_DT` and doubled `MAX_SPEED`, intent longer than 1 is clamped,
|
||||
out-of-bounds containment, `MoveModifiers` effect.
|
||||
5. `tests/sim/test_movement_stress.py` — 4 players, 2 000 ticks of pseudo-random walk
|
||||
from a fixed seed: assert no player is ever inside a blocked tile and no two players
|
||||
overlap by more than 2×`RADIUS` at any tick.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: everything listed; stress test asserts invariants rather than exact positions.
|
||||
- Coverage: **>90%** on `movement.py`/`collision.py` (target 95%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_movement.py tests/sim/test_movement_stress.py -q` → green
|
||||
- [ ] a test proves a doubled `MAX_SPEED` still cannot pass through a 1-tile wall
|
||||
- [ ] the phase-03 golden replay hash from task 05 (once created) is unaffected by later cosmetic refactors — regenerate only on contract change
|
||||
- [ ] 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 03 — Interaction targeting and verb selection
|
||||
|
||||
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
|
||||
|
||||
## Objective
|
||||
Decide, purely and deterministically, *what the action button means* right now: which
|
||||
entity is targeted and which verb applies, with explicit denial reasons the client can
|
||||
show.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/interact.py`:
|
||||
- `DeniedReason` constants: `OUT_OF_REACH`, `HANDS_FULL`, `HANDS_EMPTY`,
|
||||
`STATION_BUSY`, `STATION_DIRTY`, `NOTHING_HERE`, `NOT_FACING`, `STUNNED`,
|
||||
`WRONG_ITEM`, `INVALID_STATE`, `CONTESTED_LOST`.
|
||||
- `Target` union: `StationTarget(station)`, `ItemTarget(item, on_station_id | None)`,
|
||||
`ServeTarget(station)`, `None`.
|
||||
- `candidate_cells(player) -> tuple[tuple[int,int], ...]` — the tile directly in
|
||||
front, then the two diagonal-neighbouring front tiles, then the tile under the
|
||||
player; ordered, deduped.
|
||||
- `select_target(state, player) -> Target | None` — walk `candidate_cells` in order,
|
||||
preferring (a) the item on a station the player is carrying-compatible with,
|
||||
(b) station, (c) none; a target is only valid within `REACH = 1.0` tile plus a
|
||||
`FORGIVE = 0.6` distance check from the player's position.
|
||||
- `Verb` constants: `PICK_ITEM`, `PICK_FROM_STATION`, `START_WORK`, `HOLD_WORK`,
|
||||
`PLACE_ON_STATION`, `PLATE_DISH`, `SERVE`, `WASH`, `TRASH`, `RELEASE`, `NONE`.
|
||||
- `select_verb(state, player, target) -> tuple[Verb, DeniedReason | None]` — the
|
||||
priority ladder from PLAN §5: carrying→place/drop; else pick, work, plate, serve,
|
||||
wash; long-press trash handled in task 04. Every rejection returns a reason, never a
|
||||
silent `None` with no explanation.
|
||||
- `resolve_action(state, player, held_for: float) -> tuple[KitchenState, tuple[Event, ...]]`
|
||||
— in this phase implements only `RELEASE`/`PICK_ITEM`/`NONE`/denials; the station
|
||||
verbs are stubbed with `TODO(phase 04)` docstrings that return
|
||||
`INVALID_STATE`-free no-ops so tests can pin the ladder shape now.
|
||||
2. Wire the action edge/hold semantics into the kernel: `step.py` slot 2 tracks
|
||||
`player.action_held_ticks` and `player.action_pressed_tick` (added to `Player`),
|
||||
emitting `EventKind.DENIED` events with the reason in the payload.
|
||||
3. `tests/sim/test_targeting.py` — candidate-cell ordering per facing, forgiveness radius
|
||||
boundary (0.99 reach ok, 1.61 rejected), priority when item + station share a cell,
|
||||
diagonal-front preference, target selection when a wall separates the cook from the
|
||||
station, `STUNNED` denial, out-of-reach press emits exactly one `DENIED` per press
|
||||
(not per tick).
|
||||
4. `tests/sim/test_verbs.py` — table-driven `(carrying, target, expected_verb,
|
||||
expected_denial)` covering every `Verb` and every `DeniedReason` at least once.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: full verb/denial matrix; targeting ordering deterministic for identical states.
|
||||
- Coverage: **>90%** on `interact.py` (target 95%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_targeting.py tests/sim/test_verbs.py -q` → green
|
||||
- [ ] every `DeniedReason` appears in at least one test (assert set coverage in the test file)
|
||||
- [ ] a press that does nothing emits exactly one `DENIED` event (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
|
||||
@@ -0,0 +1,57 @@
|
||||
# Task 04 — Pick up, carry, drop, hand off, trash
|
||||
|
||||
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
|
||||
|
||||
## Objective
|
||||
Implement the hands: one carried item per cook, deterministic arbitration when two cooks
|
||||
want the same thing, counter-based handoff between players, and an always-available trash.
|
||||
|
||||
## Work
|
||||
1. Extend `src/undercooked/sim/interact.py` (and add `src/undercooked/sim/carry.py` if
|
||||
the file grows past ~350 lines):
|
||||
- `PICK_ITEM` / `PICK_FROM_STATION`: moves an `Item` into `player.carrying`, removes it
|
||||
from the station/cell; emits `PICKED` (add `EventKind.PICKED`, `DROPPED`,
|
||||
`HANDED_OFF`, `TRASHED_ALREADY_EMPTY` as needed in `model.py`).
|
||||
- `PLACE_ON_STATION` / `RELEASE`: placing onto a station that accepts the item kind
|
||||
transfers it; releasing into open floor is **not** allowed in v1 (items live on
|
||||
stations and in hands) → `RELEASE` onto a `FLOOR` cell becomes a no-op with
|
||||
`NOTHING_HERE`.
|
||||
- Handoff: two players may both act on the same `PREP`/counter tile; placing an item
|
||||
there and another player picking it up is the sanctioned pass. Emit `HANDED_OFF`
|
||||
with both player ids when a placed item is picked by a different player within
|
||||
`HANDOFF_WINDOW_TICKS = 20`.
|
||||
- `TRASH`: long-press (≥ `TRASH_HOLD_TICKS = 8`) over a `TRASH` station destroys the
|
||||
carried item, `counters["trashed"] += 1`, score nudge `-2` (never below 0 overall —
|
||||
clamp at 0 in `scoring` from phase 04; store the raw delta in the event payload).
|
||||
- **Contested resolution** (PLAN §5): within one substep, when two players target the
|
||||
same item/station, the winner is the one with the smaller `received_at_tick`, then
|
||||
the smaller seat. Winner gets the verb; losers get
|
||||
`EventKind.DENIED{reason: CONTESTED_LOST}`. No item ever exists in two places.
|
||||
- `active_claims` bookkeeping on the state draft: `Mapping[target_key, player_id]` for
|
||||
the current substep only (never persisted into the snapshot).
|
||||
2. Update `docs/SIMULATION.md` — carry rules, handoff window, trash long-press, and the
|
||||
contested-resolution tie-break (this is the contract phase 09's E2E asserts).
|
||||
3. `tests/sim/test_carry.py`:
|
||||
- one item per hand: second pick while carrying → `HANDS_FULL`.
|
||||
- pick/place round trip preserves `Item` identity fields and clears the station cell.
|
||||
- handoff between two players emits `HANDED_OFF` with correct ids; a third party
|
||||
picking it up later does not.
|
||||
- trash long-press destroys at tick 8 and not at 7; short press does nothing.
|
||||
- contested: two players same target, `received_at_tick` tie broken by seat, exactly
|
||||
one winner, loser event reason `CONTESTED_LOST`, item count conserved.
|
||||
- 100-tick fuzz with random simultaneous presses from a fixed seed: item count
|
||||
invariant (sum of items in hands + on stations + trashed == initial) holds every tick.
|
||||
4. `tests/sim/test_replay_carry.py` — a scripted two-player sequence (one pass + one
|
||||
contest) reaches a committed golden hash.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: rules above plus the conservation invariant under fuzz.
|
||||
- Coverage: **>90%** on carry/interaction changes.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_carry.py tests/sim/test_replay_carry.py -q` → green
|
||||
- [ ] the item-conservation invariant test runs 100 ticks and cannot be satisfied by a hack (asserts per tick, not only at the end)
|
||||
- [ ] `docs/SIMULATION.md` documents the contested tie-break exactly as implemented
|
||||
- [ ] 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
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# Task 05 — Replay harness, golden hashes, perf smoke
|
||||
|
||||
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
|
||||
|
||||
## Objective
|
||||
Lock determinism into CI: a reusable input-script format, committed golden state hashes,
|
||||
a regeneration path for intentional contract changes, and a headless perf smoke test.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/replay.py` (library, stdlib only):
|
||||
- `InputScript` = `{seed, level_id, tick_hz, frames: [{tick, player_id, move, action, face}]}`
|
||||
with `load(path)`, `dump(path)`, `validate()`.
|
||||
- `run_script(level: Level, book: RecipeBook, script: InputScript, max_ticks: int | None = None)
|
||||
-> ReplayResult(state, hashes: tuple[tuple[int, str], ...], events: tuple[Event, ...],
|
||||
wall_seconds: float)` — pure driver: builds the state, feeds frames at their tick,
|
||||
records `(tick, state_hash())` every `record_every` ticks.
|
||||
- `compare(result, golden: dict[int, str]) -> list[HashMismatch]` reporting the first
|
||||
divergent tick plus a field-level diff (which player/station/order changed) so a
|
||||
failure is diagnosable in one read.
|
||||
- CLI: `uv run python -m undercooked.sim.replay record|check <path.json>` writing
|
||||
`tests/goldens/<name>.golden.json` (record is manual; CI only checks).
|
||||
2. Replay fixtures + goldens (committed):
|
||||
- `tests/fixtures/replays/walk_solo.json` — 600 ticks of scripted walking around the
|
||||
level-1 kitchen (no actions): exercises movement + collision only.
|
||||
- `tests/fixtures/replays/pick_drop_pass.json` — two players, pick/place/pass/trash.
|
||||
- `tests/goldens/walk_solo.golden.json`, `tests/goldens/pick_drop_pass.golden.json`.
|
||||
3. `tests/sim/test_replay.py` — both scripts match their goldens; a deliberately mutated
|
||||
script diverges and `compare()` names the first bad tick and the changed field; the
|
||||
hash record cadence is stable; replay of the same script twice in one process is
|
||||
identical (no hidden global state); goldens are never rewritten by tests.
|
||||
4. `tests/sim/test_perf_smoke.py` (mark `slow`) — 4 players, 2 000 ticks on level 1:
|
||||
assert mean step < 0.2 ms and total < 400 ms on the dev machine, and print the measured
|
||||
numbers in the report. Mark the test `slow` so the quick loop skips it but
|
||||
`.agents/validate.sh` runs it.
|
||||
5. `.agents/validate.sh` — add `uv run pytest -m slow tests/sim -q` as a final stage
|
||||
(after the full-suite run) so determinism and perf are gated, not advisory.
|
||||
6. `docs/SIMULATION.md` — "Replaying and re-blessing goldens" section (when a re-bless is
|
||||
legitimate: a deliberate rule change; never to silence a failure).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: replay determinism, mismatch diagnostics, goldens committed and used.
|
||||
- Perf: budget asserted, numbers reported.
|
||||
- Coverage: **>90%** on `replay.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_replay.py -q` → green against committed goldens
|
||||
- [ ] `uv run python -m undercooked.sim.replay check tests/fixtures/replays/walk_solo.json` exits 0
|
||||
- [ ] editing one coordinate in the fixture makes `check` exit 1 and print the first divergent tick + changed field
|
||||
- [ ] `uv run pytest -m slow tests/sim -q` meets the perf budget
|
||||
- [ ] `bash .agents/validate.sh` includes the slow stage and exits 0
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,57 @@
|
||||
# Phase 04 — Kitchen pipeline, orders, and scoring
|
||||
|
||||
**Story:** `US-04_move_and_cook`, `US-05_orders_score_and_result`
|
||||
**Context:** `.agents/PLAN.md` §5 (station pipelines, orders/scoring/results), A5, A21
|
||||
|
||||
## Objective
|
||||
Make the kitchen actually cook: station state machines with timers, recipe-driven order
|
||||
spawning and expiry, serve resolution with quality/streak bonuses, burning and the
|
||||
dirty-dish loop, and round win/lose with stars — all deterministic and simulable headless
|
||||
to a completed round.
|
||||
|
||||
## Dependencies
|
||||
- `03_simulation_movement_and_interaction` (complete) — step kernel, targeting/verbs,
|
||||
carry rules, replay harness and goldens
|
||||
|
||||
## Tasks
|
||||
1. `01_station_state_machines.md` — `sim/stations.py`: idle/working/ready/dirty transitions, hold-to-work, station acceptance matrix
|
||||
2. `02_order_spawning_and_expiry.md` — `sim/orders.py`: seeded spawns from the level pool, concurrency, deadlines, miss penalty
|
||||
3. `03_serve_resolution_and_scoring.md` — `sim/scoring.py`: dish match, quality, streak, time bonus, wrong-dish rejection, contribution tracking
|
||||
4. `04_spoil_burn_and_dirty_loop.md` — quality decay, burnt items, dirty plates/pots and the sink, waste counters
|
||||
5. `05_round_end_results_and_stars.md` — `sim/round_state.py`: target reached, timer expiry, stars, per-player summary
|
||||
6. `06_headless_round_simulations.md` — scripted full rounds to win and to loss; golden refresh; hook coverage
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: acceptance matrix per station kind (every `ItemKind` × every `StationKind`
|
||||
combination has an explicit accepted/rejected expectation); progress timing at exact
|
||||
tick boundaries (±1 tick); seeded order-spawn cadence within the level's
|
||||
`spawn_interval` bounds; deadline expiry to the tick; scoring math (quality × streak ×
|
||||
time bonus) against hand-computed values; star thresholds.
|
||||
- Integration (headless sim): a scripted single-player run completes one soup and scores;
|
||||
a scripted full round reaches `target_score` → `WON`; an idle round expires → `LOST`;
|
||||
an all-idle round never divides by zero, never spawns unbounded orders, and stays within
|
||||
the item-count invariant.
|
||||
- Determinism: every new mechanic is exercised by a replay fixture; the phase-03 goldens
|
||||
are re-blessed **once** at the end of task 06 with a note in the commit that the sim
|
||||
contract changed intentionally (document in `docs/SIMULATION.md`).
|
||||
- Coverage: **>90%** on new/modified code; `sim/stations.py`, `sim/scoring.py`,
|
||||
`sim/orders.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**.
|
||||
- Tasks update `docs/SIMULATION.md` (acceptance matrix, scoring formulas, star rule) and
|
||||
`docs/LEVELS.md` (new recipe fields) as they land.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run python -c "from undercooked.sim.stations import step_stations"` succeeds
|
||||
- [ ] a headless test drives level 1 from tick 0 to `result=WON` with a scripted player
|
||||
- [ ] a headless test drives an idle round to `result=LOST` with score 0
|
||||
- [ ] every `ItemKind` × `StationKind` pair is covered by the acceptance matrix test
|
||||
- [ ] scoring hand-check test proves `base × quality × streak × time_bonus` for 5 cases
|
||||
- [ ] `uv run pytest tests/sim -q` (incl. re-blessed goldens) → green
|
||||
- [ ] 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 (movement/carry semantics unchanged; goldens re-blessed only for pipeline additions)
|
||||
@@ -0,0 +1,54 @@
|
||||
# Task 01 — Station state machines and acceptance matrix
|
||||
|
||||
**Phase:** `04_kitchen_pipeline_and_scoring` · **Story:** `US-04_move_and_cook`
|
||||
|
||||
## Objective
|
||||
Give every station kind its legal state graph and timers, so the verbs from phase 03 have
|
||||
something real to act on.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/stations.py`:
|
||||
- `ACCEPTS: Mapping[StationKind, frozenset[ItemKind]]` and
|
||||
`TRANSITIONS: Mapping[tuple[StationKind, ItemForm], tuple[ItemForm, float]]`
|
||||
declared as data, not `if` ladders — the matrix is the spec:
|
||||
shelf→`RAW` out (infinite while `orders` need it, capped by
|
||||
`shelf_stock_per_level`), chop board `RAW→CHOPPED` 0.9 s, stove pot
|
||||
`CHOPPED→COOKED` 6 s (rice `RAW→COOKED` 8 s), pan `PATTY/FISH RAW→COOKED` 5 s,
|
||||
prep `COOKED|CHOPPED (+PLATE)→PLATED` 0.6 s, serve consumes `PLATED`,
|
||||
sink `DIRTY→None` 1.2 s, trash consumes anything.
|
||||
- `step_stations(state, dt) -> tuple[tuple[Station, ...], tuple[Event, ...]]` — advance
|
||||
`progress` on `WORKING` stations, flip to `READY` at `duration`, tick `cooldown`,
|
||||
release `worker_id` when the worker leaves the tile (no worker within reach for
|
||||
`RELEASE_TICKS = 4`), clear `BLOCKED` when its cause clears, emit `CHOPPED`,
|
||||
`COOKED`, `WASHED`, `READY` events.
|
||||
- `apply_station_verb(state, player, verb, target)` implementing `START_WORK`,
|
||||
`HOLD_WORK` (progress only advances while `action_held_ticks` grows and the player is
|
||||
in reach and `worker_id` is theirs), `PLACE_ON_STATION`, `PLATE_DISH`, `SERVE`
|
||||
(delegates scoring to phase 04 task 03's `scoring.resolve_serve`), `WASH`.
|
||||
- Station occupancy: `WORKING` + `worker_id` set ⇒ second player gets
|
||||
`STATION_BUSY`; `DIRTY` station requires a wash before use ⇒ `STATION_DIRTY`.
|
||||
- Dirty-dish stock: `state.counters["plates_clean"]` / `["pots_clean"]` decremented on
|
||||
use, incremented by the sink; `PLATE_DISH` with zero clean plates → `NOTHING_HERE`
|
||||
with payload `{"why":"no_plate"}`.
|
||||
2. Wire into `step.py` slot 4 (replace the hook) and make phase-03's `resolve_action`
|
||||
stubs call `apply_station_verb`.
|
||||
3. `tests/sim/test_stations.py` — one test per transition row; timing boundaries
|
||||
(progress at `duration-1 tick` is still `WORKING`, at `duration` is `READY`); hold
|
||||
semantics (release early ⇒ progress freezes, resume continues, never rolls back);
|
||||
occupancy lock/unlock; dirty gating; plate/pot stock counters; the full acceptance
|
||||
matrix test enumerating `StationKind × ItemKind` with an explicit expected
|
||||
accepted/rejected table (no gaps allowed — the test asserts the matrix is total).
|
||||
4. `tests/sim/test_station_events.py` — event kinds and payloads (station id, item, tick)
|
||||
for chop/cook/wash/ready.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: transitions, boundaries, occupancy, stock, totality of the matrix.
|
||||
- Coverage: **>90%** on `stations.py` (target 95%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_stations.py tests/sim/test_station_events.py -q` → green
|
||||
- [ ] the acceptance-matrix test fails if a new `ItemKind` or `StationKind` is added without an explicit expectation
|
||||
- [ ] hold-to-chop freezes on release and resumes without rollback (asserted at tick granularity)
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,49 @@
|
||||
# Task 02 — Order spawning and expiry
|
||||
|
||||
**Phase:** `04_kitchen_pipeline_and_scoring` · **Story:** `US-05_orders_score_and_result`
|
||||
|
||||
## Objective
|
||||
Fill the ticket rail: seeded, level-driven order spawning with concurrency caps,
|
||||
deadlines, and miss penalties — reproducible from a seed.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/orders.py`:
|
||||
- `step_orders(state, dt, book) -> tuple[tuple[Order, ...], tuple[Event, ...]]`:
|
||||
- maintains `state.next_order_at` (tick); when `tick >= next_order_at` and
|
||||
`len(orders) < level.orders.concurrent`, spawn an order: recipe chosen by
|
||||
`Rng(state.rng_state).choice(pool)` with state threading, `deadline` from the
|
||||
level, `value = recipe.base_score`, id monotonically increasing.
|
||||
- schedules the next spawn at `tick + rng.range_int(*spawn_interval)` in ticks;
|
||||
never schedules two spawns for the same tick; if the rail is full, retry after
|
||||
`min_retry = 1 s` without consuming the interval draw (documented and tested —
|
||||
otherwise a full rail silently warps the cadence).
|
||||
- decrements `time_left` on every order; at 0 → remove, `counters["missed"] += 1`,
|
||||
emit `ORDER_MISSED` with the recipe and id; streak reset is applied by
|
||||
`scoring.py` (single owner), not here.
|
||||
- first order spawns at `FIRST_ORDER_DELAY_TICKS = 1.5 s` so the countdown-to-cook
|
||||
beat feels right.
|
||||
- `order_summary(state) -> tuple[Mapping, ...]` — the compact dict the snapshot encoder
|
||||
uses later (`id`, `r`, `l` (fraction remaining), `pts`).
|
||||
- No unbounded growth: assert `len(orders) <= concurrent` every tick (test).
|
||||
2. Wire into `step.py` slot 5; the level's `orders` spec must reach the state — add the
|
||||
needed fields to `KitchenState` (`orders_spec` reference or copied scalars; prefer
|
||||
copying scalars so the state stays self-contained for replay).
|
||||
3. `tests/sim/test_orders.py` — first-order delay; concurrency cap under idle and busy
|
||||
rails; spawn interval bounds over 200 simulated seconds (all gaps within
|
||||
`[min, max]` ticks, and the full-rail retry rule); deadline expiry exactly at the tick
|
||||
it reaches 0; `ORDER_MISSED` payload; same seed ⇒ identical order sequence and ids;
|
||||
different seed ⇒ different sequence; rail never exceeds `concurrent`.
|
||||
4. `tests/sim/test_orders_fuzz.py` — 20 levels-worth of randomized specs (fixed seed):
|
||||
no negative times, no duplicate ids, no unbounded order count, no exceptions.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: cadence, caps, expiry, seeded reproducibility, fuzz safety.
|
||||
- Coverage: **>90%** on `orders.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_orders.py tests/sim/test_orders_fuzz.py -q` → green
|
||||
- [ ] a same-seed test proves the order sequence is identical across two runs
|
||||
- [ ] the full-rail retry rule has its own test (not just implied)
|
||||
- [ ] 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
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# Task 03 — Serve resolution and scoring
|
||||
|
||||
**Phase:** `04_kitchen_pipeline_and_scoring` · **Story:** `US-05_orders_score_and_result`
|
||||
|
||||
## Objective
|
||||
Turn a plated dish into points: match it against the live orders, apply quality, streak,
|
||||
and time bonuses, reject wrong dishes without destroying them, and track per-player
|
||||
contribution.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/scoring.py`:
|
||||
- `ServeOutcome` = `Matched(order_id, points, quality, streak, time_bonus) |
|
||||
Rejected(reason)` with `reason in {NO_ORDERS, WRONG_RECIPE, INCOMPLETE, BURNT,
|
||||
NO_TARGET}`.
|
||||
- `resolve_serve(state, player, dish: Item, book) -> tuple[KitchenState, tuple[Event, ...]]`:
|
||||
- the dish's `recipe_id` + ingredient composition is validated against the recipe
|
||||
(`INCOMPLETE` when an ingredient is missing or the wrong one is inside);
|
||||
- match against the **oldest** compatible live order (FIFO); no compatible order ⇒
|
||||
`WRONG_RECIPE`/`NO_ORDERS` — the dish is **not** consumed, and the client gets
|
||||
`DENIED` with the reason (US-04 criterion 7);
|
||||
- `points = round(base × quality × streak_mult × time_bonus)` where
|
||||
`quality = dish.quality` (1.0 → 0.0, burnt ⇒ 0.0 and rejected as `BURNT`),
|
||||
`streak_mult = 1 + 0.1 × min(streak, 5)` (streak counted *after* increment),
|
||||
`time_bonus = 1 + 0.5 × (order.time_left / order.deadline)`;
|
||||
- `state.score += points`, `state.streak += 1`, `player.score += points`,
|
||||
`player.dishes_served += 1`, `counters["served"] += 1`, order removed, dirty
|
||||
plate/pots returned to the sink pool (`counters["plates_dirty"] += …`),
|
||||
emit `SERVED` (`{order_id, points, quality, streak}`) and `SCORE`.
|
||||
- order miss handling lives in `orders.py` but the streak reset and penalty land
|
||||
here: `apply_miss(state, order)` → `score = max(0, score - penalty)`,
|
||||
`streak = 0`, emit `SCORE` with a negative delta.
|
||||
- `MAX_STREAK_MULT = 1.5` and `MIN_SCORE_FLOOR = 0` constants; score never negative.
|
||||
- Never reads the clock or anything outside `state`.
|
||||
2. Wire: `stations.apply_station_verb(SERVE)` calls `resolve_serve`; `orders` calls
|
||||
`apply_miss` on expiry (inject it as a callback or import one way — `orders.py` must
|
||||
not import `scoring` and vice versa; put the shared bits in `model.py` or pass the
|
||||
function in from `step.py`).
|
||||
3. `tests/sim/test_scoring.py` — 5 hand-computed cases in the test file with the expected
|
||||
integers written out (e.g. `base 40, quality 0.8, streak 3, time_left/deadline 0.5 →
|
||||
50`); rejection paths (no orders, wrong recipe, incomplete dish, burnt dish) leaving
|
||||
the dish and order intact; FIFO matching when two compatible orders are live; streak
|
||||
cap at 1.5×; score floor at 0; per-player contribution sums to `state.score`.
|
||||
4. `tests/sim/test_scoring_property.py` — property-style (fixed seed, 500 random serves):
|
||||
points always ≥ 0, `served` count equals removed orders, streak never exceeds cap,
|
||||
no order served twice.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: hand-computed math, rejections, FIFO, caps, floors, contribution sum.
|
||||
- Property: invariants over 500 randomized serves.
|
||||
- Coverage: **>90%** on `scoring.py` (target 95%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_scoring.py tests/sim/test_scoring_property.py -q` → green
|
||||
- [ ] the 5 hand-computed cases are written as literals in the test file (no formula re-derived in the test)
|
||||
- [ ] serving a wrong dish leaves both the dish and the order unchanged (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
|
||||
@@ -0,0 +1,52 @@
|
||||
# Task 04 — Spoilage, burning, and the dirty-dish loop
|
||||
|
||||
**Phase:** `04_kitchen_pipeline_and_scoring` · **Story:** `US-04_move_and_cook`, `US-05_orders_score_and_result`
|
||||
|
||||
## Objective
|
||||
Add the clocks that create pressure: quality decay, burnt food, rotting raw items, and
|
||||
the sink loop that keeps plates and pots finite — with no dead ends.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/freshness.py`:
|
||||
- `step_freshness(state, dt) -> tuple[KitchenState, tuple[Event, ...]]`:
|
||||
- `READY` stove/pan contents: after `BURN_AFTER_S = 8` become `BURNT`
|
||||
(`quality = 0`), station goes `DIRTY`, emit `BURNT`;
|
||||
- `CHOPPED` items on a board decay `quality` linearly over `ROT_S = 20` and become
|
||||
`BURNT` at 0;
|
||||
- `RAW` items on a board are pushed off (returned to nothing / despawned with
|
||||
`WASTED` event) when a new raw item must be placed and the old is `BURNT`
|
||||
— never silently overwrite a usable item;
|
||||
- carried items decay at half rate (hands are not a pantry, but not a fridge either);
|
||||
- `counters["wasted"] += 1` on any destruction.
|
||||
- Dirty loop: `PLATE_DISH` and pot use set `plates_dirty++` / `pots_dirty++`;
|
||||
`pots_clean + pots_dirty == level.station_limits.pots` invariant asserted in tests;
|
||||
sink wash converts dirty → clean and cleans the station (`DIRTY → IDLE`).
|
||||
- Deadlock guard: if `plates_clean == 0 and plates_dirty == total` the game is *not*
|
||||
over — the sink is always reachable (validated in phase 02), and the validator's
|
||||
`STATION_LIMIT_ZERO` rule prevents zero-plate levels. Add a runtime safety:
|
||||
`plates_clean` can never go below 0 and `PLATE_DISH` denies cleanly.
|
||||
2. Wire into `step.py` (runs right after `step_stations`, before orders).
|
||||
3. `tests/sim/test_freshness.py` — burn boundary tick-exact; quality linear decay values at
|
||||
25/50/75/100 % of `ROT_S`; half-rate carry decay; burnt item rejected by serve
|
||||
(`BURNT`, task 03's path); wasted-event accounting; dirty counters round-trip
|
||||
through the sink; the clean+dirty conservation invariant over a 1 000-tick scripted
|
||||
sequence.
|
||||
4. `tests/sim/test_no_deadlock.py` — a scripted 2 000-tick "worst case" (serve everything
|
||||
possible, never wash until forced) always remains able to plate again after one wash;
|
||||
no state where `plates_clean == 0` persists for more than `wash_time + 5 s` while a
|
||||
player is alive and can reach the sink.
|
||||
5. Update `docs/SIMULATION.md` — decay table, dirty-dish flow diagram, and the "no dead
|
||||
end" guarantee.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: decay math, burn boundaries, counters conservation, waste events.
|
||||
- Integration: scripted worst-case run proves no deadlock.
|
||||
- Coverage: **>90%** on `freshness.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_freshness.py tests/sim/test_no_deadlock.py -q` → green
|
||||
- [ ] clean+dirty dish conservation holds every tick of the scripted sequence (asserted per tick)
|
||||
- [ ] burnt food cannot be served and the station becomes `DIRTY` (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
|
||||
@@ -0,0 +1,47 @@
|
||||
# Task 05 — Round end, results, and stars
|
||||
|
||||
**Phase:** `04_kitchen_pipeline_and_scoring` · **Story:** `US-05_orders_score_and_result`
|
||||
|
||||
## Objective
|
||||
Define exactly when a round ends, whether it was won, how many stars it earned, and what
|
||||
the result screen gets to show — computed by the server, never by the client.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/round_state.py`:
|
||||
- `step_round(state, dt) -> tuple[KitchenState, tuple[Event, ...]]`:
|
||||
- `time_left -= dt` (clamped at 0);
|
||||
- `score >= target_score` ⇒ `result = WON` immediately, emit `ROUND_END`
|
||||
- `time_left == 0` ⇒ `WON` if `score >= target_score` else `LOST`, emit `ROUND_END`;
|
||||
- once `result` is set the sim freezes gameplay inputs (movement/stations/orders
|
||||
ignored) but keeps ticking for 1.0 s of "settle" so in-flight events land —
|
||||
documented, tested;
|
||||
- `abort(state, reason)` ⇒ `result = ABORTED` (used by room crash isolation,
|
||||
phase 12).
|
||||
- `stars(state) -> int` — `0–3`: 1 for winning, 2 with `time_left ≥ 25 %` of
|
||||
`time_limit`, 3 with `time_left ≥ 50 %`; 0 when lost.
|
||||
- `result_payload(state) -> Mapping` — the `round` message body: `result`, `score`,
|
||||
`target`, `stars`, `time_left`, `counters` (served/missed/wasted), `longest_streak`,
|
||||
`players[]` (`id`, `name`, `score`, `dishes_served`, `missed_contrib` estimate —
|
||||
documented as display-only), `level_id`, `seed`, `ticks`.
|
||||
- `longest_streak` tracked in `counters`/state during play (single owner: `scoring.py`
|
||||
updates, `round_state` reads).
|
||||
2. Wire into `step.py` slot 7 (replace the timer hook).
|
||||
3. `tests/sim/test_round_state.py` — early win at target, timeout win, timeout loss,
|
||||
star thresholds at exactly 25 %/50 % boundaries (both sides of the tick), result
|
||||
payload keys and types (assert exact key set), `ABORTED` path, input freeze after end,
|
||||
settle window emits no new `SERVED`, `time_left` never negative, monotonic `tick`.
|
||||
4. `tests/sim/test_result_payload.py` — payload is JSON-serializable with no `Vec2`/enum
|
||||
leakage (a recursive `assert_json_safe` helper), and stable across two identical runs.
|
||||
5. Update `docs/SIMULATION.md` with the win/lose/star rules table.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: thresholds at exact boundaries, payload contract, freeze semantics, abort.
|
||||
- Coverage: **>90%** on `round_state.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_round_state.py tests/sim/test_result_payload.py -q` → green
|
||||
- [ ] star boundary tests cover just-below and just-at 25 % and 50 % (4 assertions minimum)
|
||||
- [ ] `result_payload` passes the JSON-safety check with zero leaks
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,48 @@
|
||||
# Task 06 — Headless full-round simulations and golden refresh
|
||||
|
||||
**Phase:** `04_kitchen_pipeline_and_scoring` · **Story:** `US-04_move_and_cook`, `US-05_orders_score_and_result`
|
||||
|
||||
## Objective
|
||||
Prove the whole pipeline works without a browser: scripted full rounds that win, lose,
|
||||
and wash dishes — and re-bless the replay goldens now that the pipeline is real.
|
||||
|
||||
## Work
|
||||
1. `tests/sim/helpers.py` (test code only, never imported by `src/`): builders and a
|
||||
friendly script compiler — `make_level_1_state(seed=…, seats=…)`,
|
||||
`walk_to(state, target)`, `press_at(state, cell)`, `script(*steps) -> InputScript`
|
||||
that compiles a list of high-level steps into tick-indexed frames.
|
||||
2. `tests/sim/test_full_rounds.py`:
|
||||
- `test_win_level_1_solo` — a scripted cook: grab tomato + onion, chop both, pot, cook,
|
||||
plate, serve, repeat until `result == WON`; assert `score >= target_score`, `stars ≥ 1`,
|
||||
`counters["served"] ≥ 2`, and the run finishes with ≥ 1 s of time left (the level is
|
||||
winnable with slack, not on the last frame).
|
||||
- `test_loss_by_timer_idle` — no inputs, `time_left` reaches 0, `result == LOST`,
|
||||
`score == 0`, `counters["missed"] ≥ 1`.
|
||||
- `test_two_player_win` — two scripted cooks divide the work (one chops, one cooks/serves)
|
||||
and win faster than solo (assert `ticks_won < ticks_solo_won`), proving co-op is
|
||||
actually rewarded by the rules.
|
||||
- `test_wash_cycle_required` — with `station_limits.plates: 1`, the script must wash
|
||||
before the second plate; assert the denial then success, and that the round still ends.
|
||||
- `test_no_order_flooding_over_full_round` — order rail never exceeds `concurrent` for
|
||||
the entire round; ids strictly increasing.
|
||||
3. Re-bless goldens **once** (`uv run python -m undercooked.sim.replay record …`) and
|
||||
note in the task report that the sim contract changed by design; `docs/SIMULATION.md`
|
||||
gains a "Golden history" line: `phase 03 → phase 04 (pipeline added)`.
|
||||
4. `tests/sim/test_perf_full_pipeline.py` (mark `slow`) — 4 players, 3 000 ticks with the
|
||||
full pipeline: mean step < 0.5 ms, total < 1 500 ms; report the numbers.
|
||||
5. `tests/unit/test_sim_hooks_wired.py` — assert no `TODO(phase …)` marker remains for
|
||||
phases ≤ 04 in `src/undercooked/sim/` (grep-style, so a half-wired hook cannot hide).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration (headless): full win/loss/co-op/wash rounds; flooding bound; perf.
|
||||
- Coverage: **>90%** on any sim code touched by this task; helper code lives in tests.
|
||||
- The suite must be green including the re-blessed goldens.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_full_rounds.py -q` → green (win, loss, co-op, wash)
|
||||
- [ ] `uv run pytest -m slow tests/sim -q` meets the full-pipeline perf budget
|
||||
- [ ] `uv run pytest tests/unit/test_sim_hooks_wired.py -q` → no stale hooks for phases ≤ 04
|
||||
- [ ] goldens regenerated and the change noted in the phase report and `docs/SIMULATION.md`
|
||||
- [ ] 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 (movement/carry/determinism unchanged; only pipeline hooks went live)
|
||||
@@ -0,0 +1,52 @@
|
||||
# Phase 05 — Anonymous lobbies and room lifecycle
|
||||
|
||||
**Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`, `US-03_ready_up_and_start`
|
||||
**Context:** `.agents/PLAN.md` §6 (room FSM, supervisor, sessions), A6, A15, A16
|
||||
|
||||
## Objective
|
||||
Build the multiplayer shell without any sockets yet: a `RoomSupervisor` that mints and
|
||||
reclaims anonymous room codes, a `Room` whose finite state machine walks
|
||||
lobby → countdown → playing → round-end → closed, and an in-memory session store that
|
||||
remembers unlocks and best scores. Everything is exercised through direct Python calls
|
||||
plus thin HTTP endpoints, so the transport (phase 06) is a thin layer over a tested core.
|
||||
|
||||
## Dependencies
|
||||
- `04_kitchen_pipeline_and_scoring` (complete) — `build_state`, `step`,
|
||||
`result_payload`, round results
|
||||
|
||||
## Tasks
|
||||
1. `01_room_supervisor_and_codes.md` — code minting, capacity, registry, GC task
|
||||
2. `02_room_state_machine.md` — `Room` FSM with guarded transitions and tick ownership
|
||||
3. `03_lobby_state_and_presence.md` — seats, ready, host election/handoff, level pick, spectators
|
||||
4. `04_session_store_and_progression.md` — in-memory LRU session records, unlocks, best scores
|
||||
5. `05_lifecycle_integration_suite.md` — HTTP probes + full lifecycle tests, GC/idle behaviour, crash isolation
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: code alphabet and collision retry, capacity rejection, FSM transition legality
|
||||
(every illegal pair rejected), host handoff ordering, GC decisions, LRU eviction,
|
||||
unlock monotonicity.
|
||||
- Integration: `create → join(×3) → ready → start → tick to result → round_end → next
|
||||
level → lobby`; solo start; join-during-play becomes spectator; host leaves mid-round;
|
||||
all-seats-empty closes the room; `max_rooms` refusal; a room whose tick raises is aborted
|
||||
and isolated.
|
||||
- Coverage: **>90%** on new/modified code; `server/room.py`, `server/rooms.py`,
|
||||
`server/sessions.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**.
|
||||
- Tasks add `docs/ROOMS.md` (FSM diagram, supervisor rules, session semantics, what is
|
||||
lost on restart) and update `AGENTS.md` §6 "where things go" if a new module appears.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run python -c "from undercooked.server.rooms import RoomSupervisor"` succeeds
|
||||
- [ ] `curl -fsS -X POST http://127.0.0.1:8000/api/rooms` returns a 5-char Crockford code; `curl -fsS http://127.0.0.1:8000/api/rooms/{code}` shows the seat list
|
||||
- [ ] lifecycle integration test drives a 4-seat room to `WON` and back to `LOBBY` without sockets
|
||||
- [ ] an exception injected into one room's tick aborts that room only; other rooms keep ticking (test)
|
||||
- [ ] idle lobby GC closes a room within 2 GC cycles (test with a fake clock)
|
||||
- [ ] `docs/ROOMS.md` exists with the FSM diagram and restart-semantics note
|
||||
- [ ] 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
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Task 01 — Room supervisor, code minting, capacity, GC
|
||||
|
||||
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`
|
||||
|
||||
## Objective
|
||||
Own the registry of live rooms: mint unguessable-but-shoutable codes, enforce capacity,
|
||||
reclaim dead rooms, and do it all behind an injectable clock so tests are deterministic.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/clock.py` — `Clock` protocol (`now() -> float`) with
|
||||
`SystemClock` and `FakeClock(start=0.0)` (`advance(seconds)`); every time-dependent
|
||||
server object receives a `clock` — **no bare `time.monotonic()` calls** in `server/`
|
||||
(add a grep-style test in task 05).
|
||||
2. `src/undercooked/server/codes.py`:
|
||||
- `CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"` (5 chars → excludes I, L, O, U);
|
||||
`CODE_LEN = 5`.
|
||||
- `new_code(taken: Container[str], rng: random.Random | None = None) -> str` using
|
||||
`secrets.choice`; retries up to 100 times on collision, then raises `CodeSpaceFull`.
|
||||
- `normalize_code(raw: str) -> str | None` — uppercase, strip spaces/dashes, map
|
||||
confusables (`I`/`l`→`1`, `O`→`0`), reject wrong length or invalid chars → `None`
|
||||
(never a partial match, and the caller must not be able to distinguish "bad format"
|
||||
from "no such room" — see US-02 criterion 4).
|
||||
3. `src/undercooked/server/rooms.py`:
|
||||
- `RoomSupervisor(settings, clock, content)` holding `dict[str, Room]`,
|
||||
`dict[str, str]` token→code index, `stats()`.
|
||||
- `create_room(session: SessionRecord, display_name: str) -> Room` → raises
|
||||
`RoomCapacityError` when `len(rooms) >= max_rooms`; the creator takes seat 0 as host.
|
||||
- `join(code, token, display_name) -> Room` → `RoomNotFound`, `RoomFull`,
|
||||
`RoomClosed`; a known token re-joining its own room reclaims its seat even if the
|
||||
seat was marked disconnected (rejoin path, phase 12 refines it).
|
||||
- `leave(code, token)`, `get(code)`, `by_token(token)`, `all_rooms()`.
|
||||
- `async def gc_pass()` — close rooms whose last seat left more than
|
||||
`SEAT_GRACE_S` ago, close `LOBBY` rooms idle past `lobby_idle_timeout_s`, drop stale
|
||||
token index entries; `async def gc_loop()` every 5 s, cancellable.
|
||||
- `async def start()` / `async def shutdown()` — cancel all room tasks and await them.
|
||||
- Registers itself in `app.state.supervisor` from `create_app()` (feature-flagged on
|
||||
`Settings.levels_dir` content being loaded), and exposes
|
||||
`POST /api/rooms`, `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join` in
|
||||
`src/undercooked/server/routes_rooms.py` so the lifecycle is testable over HTTP
|
||||
before WebSockets exist. Responses carry `room_code`, `state`, `seats`, `you` (token,
|
||||
seat, host flag) — tokens are generated server-side with
|
||||
`secrets.token_urlsafe(24)` and returned once.
|
||||
4. `tests/unit/test_codes.py` — alphabet excludes I/L/O/U, length, 20 000 codes without
|
||||
duplicates, collision retry via a stubbed `taken` set, `CodeSpaceFull`, all confusable
|
||||
normalizations, rejection cases.
|
||||
5. `tests/integration/test_supervisor.py` — create/join/leave matrix, `RoomFull` at
|
||||
`max_players`, `RoomCapacityError` at `max_rooms` (with `max_rooms=3`), rejoin by token,
|
||||
unknown code indistinguishable from malformed code at the API boundary, `stats()`
|
||||
accuracy, shutdown cancels everything, GC with `FakeClock` closes exactly what it should.
|
||||
6. `tests/integration/test_rooms_api.py` — the three HTTP endpoints: status codes, body
|
||||
shapes, no token leakage for *other* players, `POST /api/rooms` at capacity → 503 with
|
||||
`{code:"room_full"}`.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit + integration as listed; no test sleeps — all time via `FakeClock`.
|
||||
- Coverage: **>90%** on `rooms.py`, `codes.py`, `clock.py`, `routes_rooms.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_codes.py tests/integration/test_supervisor.py tests/integration/test_rooms_api.py -q` → green
|
||||
- [ ] `curl -fsS -X POST http://127.0.0.1:8000/api/rooms` returns a 5-char code from the Crockford alphabet
|
||||
- [ ] no `time.monotonic()`/`time.time()` literal in `src/undercooked/server/` (grep-asserted test)
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,58 @@
|
||||
# Task 02 — Room state machine and tick ownership
|
||||
|
||||
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-03_ready_up_and_start`
|
||||
|
||||
## Objective
|
||||
Implement `Room` as an explicit, guarded FSM that owns exactly one 20 Hz tick task and
|
||||
turns a level plus seats into a running simulation and a result.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/room.py`:
|
||||
- `RoomState` enum: `LOBBY, COUNTDOWN, PLAYING, ROUND_END, CLOSED`.
|
||||
- `RoomTransition` table as data (`{(from, event): to}`); `Room._fire(event)` raises
|
||||
`IllegalTransition` for anything not in the table — no ad-hoc state writes.
|
||||
- Events: `host_start`, `countdown_done`, `round_over`, `to_lobby`, `retry`,
|
||||
`next_level`, `close`, `all_seats_empty`, `idle_expired`.
|
||||
- State data: `code`, `seats: list[Seat]` (`Seat(player_id, token, display_name,
|
||||
color, ready, host, connected, last_seen, seat_index)`), `level: Level`,
|
||||
`state`, `state_since`, `countdown_left`, `sim_state`, `last_result`,
|
||||
`round_no`, `events_since_snapshot` buffer, `snapshot_seq`.
|
||||
- `async def tick_loop()` — `while` loop targeting `1/tick_hz` using
|
||||
`clock.now()`-based scheduling (no `asyncio.sleep(1/hz)` drift: compute the
|
||||
next deadline), calling `self.tick_once(now)`.
|
||||
- `def tick_once(now: float | None = None)` — synchronous core so tests can drive it
|
||||
manually: `PLAYING` → `sim.step(sim_state, dt, queued_inputs)`, drain events into the
|
||||
buffer, on `result is not None` fire `round_over`; `COUNTDOWN` → decrement, fire
|
||||
`countdown_done`; `LOBBY`/`ROUND_END` → idle-expiry checks.
|
||||
- `queue_input(player_id, input)` — only legal in `PLAYING`, unknown player →
|
||||
`err` event; input queue is per-tick and cleared after each step.
|
||||
- Crash isolation: `tick_once` wrapped so any exception logs (code + round, never
|
||||
player tokens), calls `sim.round_state.abort(...)`, fires `close`, and never
|
||||
propagates out of the loop (one room must not kill the server).
|
||||
- `start()`/`close()` manage the task; `close()` is idempotent and frees seats.
|
||||
2. `docs/ROOMS.md` — the FSM diagram (ASCII), the transition table, and what each state
|
||||
accepts from clients (forward reference to phase 06's whitelist).
|
||||
3. `tests/integration/test_room_fsm.py` — every legal transition, every illegal pair
|
||||
raises, `host_start` requires ≥ 1 ready non-host or a solo host, countdown counts 3
|
||||
frames of 1 s, `round_over` stores `last_result` and payload, `retry` reuses the level
|
||||
with a new seed, `next_level` switches to an explicitly supplied level id (task 04 wires
|
||||
that value from the session store), `close` idempotent,
|
||||
`all_seats_empty` closes after grace, queued inputs dropped outside `PLAYING`.
|
||||
4. `tests/integration/test_room_tick.py` — `tick_once` drives the sim forward
|
||||
(player positions change under a scripted input), tick cadence has no drift over 100
|
||||
manual calls, event buffer drains exactly once, injected exception aborts the round and
|
||||
leaves the room `CLOSED` without raising out of `tick_loop`.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: full transition table, tick cadence with `FakeClock`, crash isolation.
|
||||
- Unit: seat helpers, input legality, seed freshness on retry.
|
||||
- Coverage: **>90%** on `room.py` (target 95%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_room_fsm.py tests/integration/test_room_tick.py -q` → green
|
||||
- [ ] the illegal-transition test covers the full `states × events` matrix except legal pairs
|
||||
- [ ] a `Room` driven by `tick_once` reaches `WON` on level 1 with a scripted input
|
||||
- [ ] injected sim exception closes only that room (asserted via a second live room)
|
||||
- [ ] 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 03 — Seats, ready-up, host election, spectators
|
||||
|
||||
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-02_join_by_code`, `US-03_ready_up_and_start`
|
||||
|
||||
## Objective
|
||||
Make the lobby mean something: named seats with colors, ready toggles, host authority and
|
||||
handoff, level selection restricted to unlocks, and spectators for people who arrive
|
||||
mid-round.
|
||||
|
||||
## Work
|
||||
1. Extend `src/undercooked/server/room.py` (or split into `server/seats.py` if it exceeds
|
||||
~400 lines):
|
||||
- `Seat` behavior: `toggle_ready()`, `mark_connected()/mark_disconnected(now)`,
|
||||
`is_host`, `display_name` sanitized at the boundary.
|
||||
- `sanitize_name(raw: str | None, seat_index: int) -> str` — strip control chars and
|
||||
`<>"'`, collapse whitespace, trim, cap at 16 chars, fall back to `Cook #{seat+1}`;
|
||||
duplicate names allowed; the result is rendered as text by the client (never HTML).
|
||||
- Color assignment: first free color from the 4-color palette in seat order; reconnect
|
||||
keeps the same color.
|
||||
- Host rules: seat 0 of the creator is host; `transfer_host()` picks the lowest
|
||||
occupied, connected seat when the host leaves **in `LOBBY` or `ROUND_END`**; in
|
||||
`PLAYING`/`COUNTDOWN` the round continues and the host slot is empty (no mid-round
|
||||
election) — documented, tested. If the host returns, they do not automatically regain
|
||||
the crown (explicit, avoids flip-flopping).
|
||||
- `set_level(token, level_id)` — host only, level must exist and be unlocked for the
|
||||
session; rejects with `NotHost`/`LevelLocked`/`UnknownLevel`; only in `LOBBY`.
|
||||
- `start(token)` — host only; legal when `len(occupants) == 1` (solo) or
|
||||
`any(seat.ready for seat in occupants if not seat.host)`; raises `NothingToStart`.
|
||||
- Spectators: `join()` during `COUNTDOWN`/`PLAYING`/`ROUND_END` adds a spectator entry
|
||||
(no seat, `role="spectator"`); `leave()` removes it; spectators receive state/snapshot
|
||||
but their inputs are ignored (`err` `not_seated`).
|
||||
- `lobby_payload()` — the `lobby` message body: `code`, `state`, `level`, `host_seat`,
|
||||
`can_start`, `seats[{index, name, color, ready, host, connected}]`,
|
||||
`spectators[{name, color}]`, `unlocked` (for the requesting session), `round_no`.
|
||||
- Emotes: `emote(token, id)` validated against an `EMOTES` frozenset constant, throttled
|
||||
to 2/s per connection, re-broadcast as an event (no chat in v1).
|
||||
2. `docs/ROOMS.md` — host rules, spectator semantics, name rules, emote list.
|
||||
3. `tests/integration/test_lobby.py` — ready toggles visible in `lobby_payload`,
|
||||
solo-start allowed, 2-player start requires a non-host ready, non-host `start`/
|
||||
`set_level` rejected, host handoff in lobby and *not* in play, spectator join during
|
||||
play + ignored inputs, spectator removal on leave, color stability across reconnect,
|
||||
emote whitelist + throttle, name sanitization table (control chars, 40 chars, emoji,
|
||||
`"`, `<script>`, whitespace-only → fallback name).
|
||||
4. `tests/unit/test_sanitize_name.py` — property-ish table incl. unicode, RTL marks, zero
|
||||
width chars, and a length-1000 input; output length ≤ 16 and never contains `<`, `>`, `"`.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: lobby behaviors above.
|
||||
- Unit: sanitizer hardening.
|
||||
- Coverage: **>90%** on the lobby code paths.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_lobby.py tests/unit/test_sanitize_name.py -q` → green
|
||||
- [ ] `<script>alert(1)</script>` as a display name comes back sanitized and ≤ 16 chars (asserted)
|
||||
- [ ] no mid-round host election occurs (asserted explicitly, not by omission)
|
||||
- [ ] a spectator's `input` is rejected with `not_seated` (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
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# Task 04 — In-memory session store and campaign progression
|
||||
|
||||
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-07_campaign_progression`
|
||||
|
||||
## Objective
|
||||
Remember, per browser session and purely in memory, which levels are unlocked and the
|
||||
best score per level — the smallest thing that makes a campaign possible (A6).
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/sessions.py`:
|
||||
- `SessionRecord` dataclass: `token`, `display_name`, `unlocked_level: int`
|
||||
(campaign *order*, starts at 1), `best: dict[level_id, BestRun]`, `rounds_played`,
|
||||
`dishes_served`, `created_at`, `last_seen`, `endless_best: int | None`.
|
||||
- `BestRun`: `score`, `stars`, `completed_at` (monotonic-relative, not wall-clock PII —
|
||||
store `time.time()` rounded to seconds is fine and documented; no timezone, no IP).
|
||||
- `SessionStore(max_entries=10_000)`:
|
||||
- `get_or_create(token) -> SessionRecord`, `record_result(token, level, result_payload)`
|
||||
→ updates `best` when the score beats the stored one, bumps `unlocked_level` to
|
||||
`level.order + 1` **only on `WON`** and never decreases it, increments counters;
|
||||
- `is_unlocked(token, level_id) -> bool`, `next_level_id(token, content) -> str | None`,
|
||||
- LRU eviction on insert (oldest `last_seen` first), `stats()` for `/api/rooms`
|
||||
diagnostics, `to_public(token)` returning only what the client may see.
|
||||
- Optional bounded persistence: **none** (A6). A comment at the top of the module
|
||||
states "in-memory only; a process restart clears all progression" and the README
|
||||
repeats it (US-07 criterion 8).
|
||||
- No personal data: the module must never store IP, user-agent, or raw headers
|
||||
(enforced by test).
|
||||
2. Wire it: `RoomSupervisor` owns a `SessionStore`; `POST /api/rooms` creates/uses a
|
||||
session from the `X-Undercooked-Token` header (or a `token` body field) and returns the
|
||||
token in the body **once**; `GET /api/session` returns `to_public(...)`; the `Room`
|
||||
asks `session_store.is_unlocked(...)` in `set_level` (task 03) and
|
||||
`next_level_id(...)` for `next_level` (task 02); `round_over` calls
|
||||
`record_result` for every seated player.
|
||||
3. `tests/integration/test_sessions.py` — unlock only on win, unlock monotonic (win L1 →
|
||||
L2 unlocked; lose L2 → still unlocked), best score replaces only when higher, stars
|
||||
recorded, counters accumulate across rooms, `to_public` leaks nothing beyond the public
|
||||
shape, unknown token auto-creates at `unlocked_level=1`, LRU eviction evicts the stalest
|
||||
and never the current session, 10 001 sessions stay bounded in size.
|
||||
4. `tests/unit/test_session_privacy.py` — assert the `SessionRecord` field set and a
|
||||
`repr()` scan for `ip`, `user_agent`, `authorization`, `cookie`; assert
|
||||
`SessionStore` has no attribute holding raw headers.
|
||||
5. `docs/ROOMS.md` — "Progression and what survives a restart".
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: unlock/best/LRU/privacy behaviors.
|
||||
- Unit: privacy surface.
|
||||
- Coverage: **>90%** on `sessions.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_sessions.py tests/unit/test_session_privacy.py -q` → green
|
||||
- [ ] winning level 1 then losing level 2 leaves level 2 unlocked (asserted)
|
||||
- [ ] a lower score never overwrites a stored best (asserted)
|
||||
- [ ] `GET /api/session` with a fresh token returns `unlocked: 1` and empty `best`
|
||||
- [ ] 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
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Task 05 — Lifecycle integration, GC behaviour, crash isolation
|
||||
|
||||
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`, `US-03_ready_up_and_start`
|
||||
|
||||
## Objective
|
||||
Prove the whole lobby/room lifecycle end to end over HTTP (still no WebSockets),
|
||||
including the unglamorous parts: idle expiry, seat grace, capacity refusal, and one room
|
||||
failing without taking the server down.
|
||||
|
||||
## Work
|
||||
1. `tests/integration/test_lifecycle_end_to_end.py` — using `TestClient` + direct
|
||||
`tick_once` driving (no sleeps):
|
||||
- full happy path: create → 3 joins → readies → host start → countdown completes →
|
||||
tick to `WON` → `ROUND_END` payload correct → `next_level` → back to `LOBBY` with the
|
||||
same seats and colors;
|
||||
- solo path: create → start (no other players) → win → unlock advanced;
|
||||
- full-room path: 5th join refused `room_full`, code retained;
|
||||
- bad-code path: `GET /api/rooms/ZZZZZ` and `GET /api/rooms/nope!` return the *same*
|
||||
status and body shape (US-02 criterion 4 — no oracle for room existence beyond
|
||||
"not here"), and the response is rate-limit friendly (no stack trace);
|
||||
- abandon path: all players leave a `LOBBY` → after `SEAT_GRACE_S` + one `gc_pass()`
|
||||
the room is `CLOSED` and its code no longer resolvable;
|
||||
- idle lobby expiry with `FakeClock` advancing past `lobby_idle_timeout_s`;
|
||||
- capacity: with `max_rooms=2`, the third `POST /api/rooms` → 503 `room_full`, and the
|
||||
existing rooms are untouched.
|
||||
2. `tests/integration/test_room_isolation.py`:
|
||||
- two live rooms; inject an exception into room A's sim via a test-only
|
||||
`UNDERCOOKED_TEST_HOOK` seam (a `Callable` on `Room` named `_test_step_override`,
|
||||
set only in tests — never read from the network);
|
||||
- assert: room A becomes `CLOSED`/aborted with an error event delivered, room B keeps
|
||||
ticking correctly, the supervisor's `gc_pass()` reaps room A, and the server keeps
|
||||
serving `/healthz`.
|
||||
3. `tests/integration/test_no_wall_clock.py` — grep test: no `asyncio.sleep(` with a
|
||||
non-zero literal and no `time.time()`/`time.monotonic()` inside
|
||||
`src/undercooked/server/room*.py`, `rooms.py`, `sessions.py` (all timing through
|
||||
`Clock`), so the lifecycle stays deterministically testable.
|
||||
4. `docs/ROOMS.md` — add the "Lifecycle walkthrough" section mirroring the happy-path
|
||||
test step by step, and the "Failure and expiry" section (grace windows, GC period,
|
||||
crash policy).
|
||||
5. Docs sweep: confirm README mentions the token header/`/api/session`, `SEAT_GRACE_S`,
|
||||
`GUEST_IDLE_TIMEOUT_S` naming is consistent between README table, `config.py`, and
|
||||
docs (rename in this task if they drifted, updating all three).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: the full lifecycle, expiry, capacity, and isolation suites above.
|
||||
- Coverage: **>90%** on new/modified code; the phase must leave `server/room.py`,
|
||||
`server/rooms.py`, `server/sessions.py` each ≥ 95%.
|
||||
- Full suite green; `bash .agents/validate.sh` exits 0.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_lifecycle_end_to_end.py -q` → green (all five paths)
|
||||
- [ ] `uv run pytest tests/integration/test_room_isolation.py -q` → one room's failure does not affect the other
|
||||
- [ ] `uv run pytest tests/integration/test_no_wall_clock.py -q` → no direct clock calls in the lifecycle modules
|
||||
- [ ] `curl` walk-through of create → join → ready → (HTTP has no start yet: document the gap in `docs/ROOMS.md` as closed by phase 06) works as written
|
||||
- [ ] 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,59 @@
|
||||
# Phase 07 — Canvas client, art, and HUD
|
||||
|
||||
**Story:** `US-01_create_anonymous_lobby`, `US-04_move_and_cook`, `US-05_orders_score_and_result`
|
||||
**Context:** `.agents/PLAN.md` §4 (client, snapshot contract), A9, A10, A11, A12
|
||||
|
||||
## Objective
|
||||
Build the browser half: the repo's own SVG tileset, a DPR-aware Canvas 2D renderer that
|
||||
draws interpolated snapshots, the screen state machine (landing → lobby → countdown →
|
||||
play → result), and the HUD (orders, score, timer). At the end of this phase a person can
|
||||
create a lobby, join from a second tab, and actually play level 1 — with desktop keyboard
|
||||
input only.
|
||||
|
||||
## Dependencies
|
||||
- `06_realtime_websocket_protocol` (complete) — `/ws/{code}`, `welcome`/`lobby`/`snap`/
|
||||
`countdown`/`round`/`ev`, `docs/PROTOCOL.md`
|
||||
- `02_level_content_and_validation` (complete) — `/api/levels` for the level list
|
||||
|
||||
## Tasks
|
||||
1. `01_svg_tileset_and_atlas.md` — generated in-repo art: floors, counters, stations, items in every form, cooks, order tickets
|
||||
2. `02_screen_state_machine_and_lobby_ui.md` — landing, create/join wiring, lobby seats/ready/level picker, result screen
|
||||
3. `03_snapshot_buffer_and_interpolation.md` — snapshot buffer, delta decode, entity diffing, 100 ms interpolation, the single render loop and composition root
|
||||
4. `04_canvas_renderer_and_hud.md` — camera fit, layer draw order, sprites, HUD (orders/score/timer/streak), event FX hooks
|
||||
5. `05_keyboard_input_and_first_playable_e2e.md` — desktop input, first end-to-end browser story, renderer smoke assertions
|
||||
|
||||
## Testing & Quality
|
||||
- E2E (Playwright, one suite per story touched): create lobby and see the code; two tabs
|
||||
in one lobby see each other's seat; countdown → kitchen renders (canvas pixel-variance
|
||||
check, not a blank frame); a scripted keyboard cook completes one soup and the HUD score
|
||||
bumps; result screen shows stars and the retry action.
|
||||
- Renderer unit-level checks run in the browser: the interpolation math and the snapshot
|
||||
buffer are pure ES modules exercised by a Playwright page that calls them directly
|
||||
(`tests/e2e/test_us06_interp_math.py`) — no bundler, no jsdom. Suites added here:
|
||||
`test_atlas_loads.py`, `test_us01_create_lobby.py`, `test_us02_join_by_code.py`,
|
||||
`test_us03_ready_and_start.py`, `test_us06_interp_math.py`, `test_us06_smoothness.py`,
|
||||
`test_us04_render_smoke.py`, `test_us04_move_and_cook.py`.
|
||||
- Asset guards: every `src`/`href` is same-origin (extends the phase-01 no-CDN test to the
|
||||
real asset set); no SVG file exceeds 40 KB; the atlas loads with zero console errors.
|
||||
- Coverage: **>90%** on new/modified server code (any route/encoder tweak); client
|
||||
correctness is demonstrated by the e2e assertions and pure-module tests above.
|
||||
- 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**.
|
||||
- Tasks create `docs/CLIENT.md`: module map, render loop, snapshot buffer, asset
|
||||
conventions (how to add a sprite), and "no bundler" rules. README gains the
|
||||
"how to play" section it has been promising.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] two browser tabs: create in A, join in B, both ready, start, play level 1, and serve one dish end to end with keyboard only
|
||||
- [ ] `uv run pytest tests/e2e -q` → green (all suites added in this phase)
|
||||
- [ ] the canvas is provably not blank: a pixel-variance assertion passes during play and changes after a move
|
||||
- [ ] HUD score/timer/orders update within 200 ms of the server event (e2e-asserted)
|
||||
- [ ] zero console errors and zero non-same-origin network requests during a full round (e2e-asserted)
|
||||
- [ ] `docs/CLIENT.md` exists and matches the module layout on disk
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,62 @@
|
||||
# Task 01 — In-repo SVG tileset, sprites, and atlas loader
|
||||
|
||||
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-04_move_and_cook`
|
||||
|
||||
## Objective
|
||||
Create the game's whole visual language as small, in-repo SVG files plus a loader, with no
|
||||
external art, fonts, or CDNs.
|
||||
|
||||
## Work
|
||||
1. `assets_src/` (source art) and `app/static/assets/` (served output) — hand-authored SVG
|
||||
(simple, readable geometry — this is a vector-cartoon kitchen, not pixel art):
|
||||
- `tiles.svg` — floor, counter, wall, void (with 4 subtle variants of floor for texture),
|
||||
- `stations.svg` — spawn shelf (per ingredient tint), chop board, stove (off/working/
|
||||
ready/burning), pot, pan, prep counter, serve window, sink, trash,
|
||||
- `items.svg` — every `ItemKind` × `ItemForm` combination that can appear
|
||||
(raw/chopped/cooked/burnt/plated/dirty), each 32×32 viewBox,
|
||||
- `cooks.svg` — 4 cook bodies (palette-swappable via CSS `fill` on a `use`-able symbol),
|
||||
4 facings × 2 walk frames, plus a `carrying` overlay layer and a `stun` spin frame,
|
||||
- `fx.svg` — chop spark, steam, flame, splash, deny cross, score pop, sparkly serve,
|
||||
- `ui.svg` — order ticket, star, crown (host), ready check, disconnect ghost, streak flame.
|
||||
Keep each file ≤ 40 KB (guard test), one `<symbol id>` per drawable, ids namespaced
|
||||
(`tile-floor`, `item-tomato-chopped`, `cook-2-front-1`).
|
||||
2. `tools/gen_art_index.py` — a small stdlib script that parses the SVGs, writes
|
||||
`app/static/assets/index.json` (`{id: {file, w, h}}`), and fails if a symbol id is
|
||||
duplicated or if an expected id from `EXPECTED_IDS` (in the script, derived from the
|
||||
sim enums by importing `undercooked.sim.model`) is missing. Run it as part of the task;
|
||||
commit the generated `index.json`.
|
||||
3. `app/static/js/render/atlas.js` — fetches `index.json`, loads each SVG into an `Image`,
|
||||
exposes `atlas.ready()`, `atlas.draw(ctx, id, x, y, w, h, opts)` (with `tint`,
|
||||
`alpha`, `flipX`); caches by id; surfaces a `Error` on unknown id (never a silent
|
||||
nothing-drawn).
|
||||
4. `app/static/assets/palette.css` — the cook colors, colorblind-safe alternate palette
|
||||
(`[data-palette="cb"]`), and the semantic colors (danger/hot/ready/dirty) used by both
|
||||
CSS and canvas (a JS module re-exports the same hex values; a test asserts the two
|
||||
files agree).
|
||||
5. `tests/unit/test_assets.py` — every served asset exists, ≤ 40 KB, parses as XML with a
|
||||
`<symbol>` per id in `index.json`, no external `xlink:href`/`http` reference; the
|
||||
sim-enum coverage check (every `ItemKind`×reachable form has a symbol); palette hex
|
||||
agreement between `palette.css` and `js/render/palette.js`.
|
||||
6. `tests/e2e/test_atlas_loads.py` — loads `/`, requests the atlas, asserts
|
||||
`atlas.ready()` resolves, zero console errors, and `atlas.draw` on a probe canvas
|
||||
produces non-blank pixels.
|
||||
7. Fix the phase-01 guard for XML namespaces: `tests/unit/test_no_external_assets.py`
|
||||
currently rejects any `http` occurrence, but every SVG legitimately contains
|
||||
`xmlns="http://www.w3.org/2000/svg"`. Teach the guard an explicit allowlist
|
||||
(`http://www.w3.org/2000/svg`, `…/xlink`, `…/xml`, `…/1999/xhtml` — namespace
|
||||
declarations only, never `href`/`src`/`url()` targets) and add a test that a planted
|
||||
`xlink:href="https://evil.example/x.svg"` still fails while the namespace declaration
|
||||
passes.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit + e2e as listed; art completeness is enforced against the sim enums, not by eye.
|
||||
- Coverage: **>90%** on `tools/gen_art_index.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run python tools/gen_art_index.py` writes `index.json` and exits 0
|
||||
- [ ] removing a required symbol makes the generator (and `tests/unit/test_assets.py`) fail with the missing id named
|
||||
- [ ] every asset is ≤ 40 KB and same-origin
|
||||
- [ ] `uv run pytest tests/e2e/test_atlas_loads.py -q` → green
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,60 @@
|
||||
# Task 02 — Screen state machine, lobby UI, and result screen
|
||||
|
||||
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`, `US-03_ready_up_and_start`, `US-05_orders_score_and_result`
|
||||
|
||||
## Objective
|
||||
Replace the phase-01 shell placeholders with the real DOM screens and a single client-side
|
||||
state machine that owns them, driven by server messages.
|
||||
|
||||
## Work
|
||||
1. `app/static/js/app.js` — `createApp()` with an explicit screen enum
|
||||
(`LANDING, CONNECTING, LOBBY, COUNTDOWN, PLAYING, ROUND_END, ERROR`) and
|
||||
`show(screen, data)`; unknown transitions log once and go to `ERROR`; every screen is a
|
||||
module exporting `{mount(root, data), unmount()}` — no global DOM spaghetti.
|
||||
2. Screens (real HTML, keyboard operable, `aria`-labelled, no innerHTML with server data):
|
||||
- `screens/landing.js` — *Create lobby*, join form with code input
|
||||
(`inputmode="text"`, `autocapitalize="characters"`, `spellcheck="false"`,
|
||||
`maxlength="5"`), inline validation, last-code prefill from `sessionStorage`, and the
|
||||
offline/"server full" messages from `err` codes.
|
||||
- `screens/lobby.js` — room code with copy button (clipboard API + fallback), seat list
|
||||
(`index, name, color chip, ready, crown, disconnected`), *Ready* toggle, host-only
|
||||
*Level* picker fed by `/api/levels` (difficulty pips, target, time, blurb), *Start*
|
||||
button enabled from `lobby.can_start`, disabled-with-reason otherwise, session panel
|
||||
(`/api/session`: unlocks + best scores), spectator notice.
|
||||
- `screens/countdown.js` — big numerals 3/2/1, reduced-motion-safe.
|
||||
- `screens/play.js` — canvas host element (`#kcanvas`), HUD root, *Leave* control,
|
||||
reconnecting overlay (populated in phase 08), `emote` button row (whitelist from
|
||||
`docs/PROTOCOL.md`).
|
||||
- `screens/round_end.js` — result, stars (1–3, animated unless reduced motion),
|
||||
score/target, best, per-player list, counters (served/missed/wasted, longest streak),
|
||||
`Next level` (only when unlocked) / `Retry` / `Back to lobby`, `aria-live="polite"`
|
||||
announcement of the outcome.
|
||||
- `screens/error.js` — friendly copy per `ErrorCode` (`room_full`, `room_gone`,
|
||||
`protocol_mismatch`, `rate_limited`, `evicted`, …) with the right retry affordance and
|
||||
the code shown for support.
|
||||
3. `app/static/js/net/connection.js` (thin here, hardened in phase 08) — `connect(code,
|
||||
token)`, message dispatch to a single `onMessage` reducer, `send(model)`, and a
|
||||
`protocol_mismatch` path that stops reconnecting and shows `error`.
|
||||
4. `app/static/css/screens.css` — layout for the screens, 360 px → 4K, 44 px targets,
|
||||
focus rings, `[data-palette]` aware, `prefers-reduced-motion` respected.
|
||||
5. `tests/e2e/test_us01_create_lobby.py`, `test_us02_join_by_code.py`,
|
||||
`test_us03_ready_and_start.py` — the story suites: create/join/ready/start across two
|
||||
contexts, full-room and bad-code copy, non-host controls disabled, host crown moves when
|
||||
the host leaves, countdown then kitchen on both, result screen content after a forced
|
||||
short round (test-only level with `time_limit: 4`), and `aria-live` announcement of the
|
||||
result.
|
||||
6. `docs/CLIENT.md` — screen state machine diagram and the message→screen mapping table.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: the four story suites above; assert visible text, not pixels, for DOM screens.
|
||||
- Coverage: **>90%** on any server code touched; client behavior covered by e2e.
|
||||
- Full suite green including phases 01–06 suites (no regressions).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us01_create_lobby.py tests/e2e/test_us02_join_by_code.py tests/e2e/test_us03_ready_and_start.py -q` → green
|
||||
- [ ] two contexts: ready state visible on both, non-host start control disabled, countdown identical on both
|
||||
- [ ] the result screen shows stars, score/target, best, and per-player rows after a forced short round
|
||||
- [ ] every interactive element is reachable and operable with the keyboard alone (asserted in the e2e suite)
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,67 @@
|
||||
# Task 03 — Snapshot buffer and interpolation
|
||||
|
||||
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-06_coop_shared_kitchen`, `US-05_orders_score_and_result`
|
||||
|
||||
## Objective
|
||||
Turn the incoming `snap` stream into smooth motion: a jitter-tolerant buffer, entity
|
||||
diffing, and render-time interpolation at a fixed delay.
|
||||
|
||||
## Work
|
||||
1. `app/static/js/net/snapshots.js` (pure module, no DOM):
|
||||
- `createSnapshotBuffer({interpDelayMs = 100, maxAgeMs = 1000})` with
|
||||
`push(frame)`, `sample(now)` → `{a, b, alpha}` (the two bracketing frames plus the
|
||||
lerp factor), `latest()`, `needsFullResync()`, `stats()`.
|
||||
- Rules: frames keyed by `t` (server tick), inserted in order, older-than-current
|
||||
dropped, `t` gaps recorded (`gaps`), a gap longer than `MAX_GAP_TICKS = 6` triggers
|
||||
`needsFullResync()` (the client asks for a full snapshot; phase 06's cadence guarantees
|
||||
one arrives), and if no frame arrives for `maxAgeMs` the buffer freezes rather than
|
||||
extrapolating forever (never animate a ghost cook into a wall).
|
||||
- `lerpState(a, b, alpha)` → a render-ready interpolated state: positions lerped along
|
||||
the shorter path, facing snapped at the alpha ≥ 0.5 boundary, `progress` lerped,
|
||||
entities added/removed between frames handled (appear at `b`, vanish at `b`), station
|
||||
and order lists diffed by id.
|
||||
- `applyDelta(frame, prev)` — mirrors the server's delta encoding (task 04 of phase 06)
|
||||
into a full frame client-side; correctness is asserted against full frames.
|
||||
2. `app/static/js/net/decode.js` — the client-side counterpart of `server/encoding.py`:
|
||||
delta application, enums mapped through `app/static/js/shared/enums.js`, and
|
||||
`tests/unit/test_client_enum_parity.py` asserting every Python value of `TileKind`,
|
||||
`StationKind`, `ItemKind`, `ItemForm`, `Facing`, `EventKind`, `ErrorCode` appears in the
|
||||
matching JS module and vice versa (same drift-guard pattern as the movement constants).
|
||||
3. `app/static/js/render/loop.js` + `app/static/js/main.js` — one `requestAnimationFrame`
|
||||
loop (`sample → render → hud.update`, self-measured `frameMs` p50/p95 on `window.__uc`,
|
||||
paused when `document.hidden` or the screen is not `PLAYING`, `?freeze=1` hook for
|
||||
pixel-diff tests) and the composition root that parses URL params (`room`, `seed`,
|
||||
`controls`, `freeze`, `motion`), builds the buffer/hud/connection, and routes messages
|
||||
(snapshots → buffer, everything else → the screen machine).
|
||||
4. `app/static/js/net/events.js` — event queue with a per-kind `ttlMs`, dedup window, and
|
||||
`drift(now)` pruning, so a burst of chop events cannot leak listeners or replay after a
|
||||
resync (`tick` reset detection).
|
||||
5. `tests/e2e/test_us06_interp_math.py` — Playwright page that imports the pure module and
|
||||
asserts, via `page.evaluate`: two frames 1 tick apart lerp to the midpoint at
|
||||
`alpha 0.5`; out-of-order frames are dropped and counted; the gap rule triggers
|
||||
`needsFullResync()`; a 30-frame synthetic stream yields monotonic sampled positions with
|
||||
max step ≤ `MAX_SPEED × (1/snapshot_hz) × 1.35`; frozen buffer holds position;
|
||||
`applyDelta` output equals the full frame the server would have sent (fixture pair).
|
||||
6. `tests/e2e/test_us06_smoothness.py` — against the live server, two clients: the observed
|
||||
remote cook's per-frame pixel delta stays within the bound above, no visual stutter
|
||||
(frame-to-frame position difference never exceeds the bound) with `sim_latency_ms: 60`
|
||||
injected, and both converge within 1 s of the round ending.
|
||||
7. `docs/CLIENT.md` — buffer rules, `interpDelayMs` rationale, resync contract, the render
|
||||
loop's single-owner rule, and the "never extrapolate forever" policy.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E-driven unit tests for pure math (deterministic fixtures) + live smoothness bounds.
|
||||
- Coverage: **>90%** on any server code touched; the pure client module's paths are each
|
||||
exercised by at least one `page.evaluate` assertion.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us06_interp_math.py -q` → green
|
||||
- [ ] `uv run pytest tests/e2e/test_us06_smoothness.py -q` → green with 60 ms injected latency
|
||||
- [ ] delta-applied frames equal the full-frame fixture (asserted field by field)
|
||||
- [ ] a synthetic gap > 6 ticks triggers a resync request (asserted, and debounced to one per 500 ms)
|
||||
- [ ] `uv run pytest tests/unit/test_client_enum_parity.py -q` → green (removing a JS enum value breaks it: verify once, then revert)
|
||||
- [ ] the render loop pauses when the tab is hidden and resumes on show (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
|
||||
@@ -0,0 +1,60 @@
|
||||
# Task 04 — Canvas renderer, camera, and HUD
|
||||
|
||||
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-04_move_and_cook`, `US-05_orders_score_and_result`
|
||||
|
||||
## Objective
|
||||
Draw the kitchen and everything on it, and put the game's readouts on screen — the phase
|
||||
that makes the game legible.
|
||||
|
||||
## Work
|
||||
1. `app/static/js/render/camera.js` — fit-to-viewport with a min/max zoom in tiles-per-pixel,
|
||||
integer-ish scaling to avoid shimmer, letterboxing, DPR-aware (`devicePixelRatio`,
|
||||
capped at 2), resize/visibility handling, `worldToScreen`/`screenToWorld`.
|
||||
2. `app/static/js/render/canvas.js`:
|
||||
- explicit layer order: floor → floor decals (spills) → station bases → carried-under
|
||||
items → players (y-sorted) → carried overlays → station progress rings → hazards →
|
||||
particles → selection/target highlight,
|
||||
- `drawFrame(interpolated, now)`; sprite selection maps `TileKind`/`StationKind`+state/
|
||||
`ItemKind`+form/`Facing`+anim-frame to atlas ids; unknown id throws (surfaced to the
|
||||
console once, then suppressed to avoid spam),
|
||||
- animation clocks derived from `now`, pausable when `document.hidden`
|
||||
(`visibilitychange`), fixed-step sprite animation so 120 Hz displays do not animate
|
||||
faster,
|
||||
- `renderStats()` — `drawCalls`, `frameMs` moving average, exposed on `window.__uc` for
|
||||
the perf assertions;
|
||||
- target highlight: the tile the server would target (client-side approximation of
|
||||
`interact.candidate_cells` from the local facing/position) drawn faintly, so reaching
|
||||
for a station is predictable.
|
||||
3. `app/static/js/render/fx.js` — event→effect mapping (`CHOPPED` spark, `COOKED`/`READY`
|
||||
steam, `SERVED` pop + score float, `BURNT` smoke, `DENIED` cross + nudge, `STUNNED`
|
||||
spin, `ORDER_MISSED` flash, `SCORE` float), a small particle pool (no allocation per
|
||||
event), and `[data-motion="reduced"]` fallbacks (static markers, no shake).
|
||||
4. `app/static/js/ui/hud.js` — order rail (ticket per order: recipe icon chain, time bar,
|
||||
points; the oldest highlighted), score + target bar, countdown timer (turns amber < 20 s,
|
||||
red < 10 s, with a non-color cue), streak indicator, dirty-dish counter, connection
|
||||
indicator, spectator banner, all DOM (not canvas) so it stays crisp and readable by
|
||||
assistive tech; updates coalesced to one write per animation frame.
|
||||
5. `tests/e2e/test_us04_render_smoke.py`:
|
||||
- canvas not blank during play (variance over sampled pixels) and changes after a move,
|
||||
- `renderStats().drawCalls` within a sane band (no per-frame explosion: < 4 000),
|
||||
- frame time ≤ 16 ms p95 over a 10 s scripted round on the CI profile,
|
||||
- HUD elements exist and update (score text changes after a serve; timer decreases),
|
||||
- `document.hidden` simulation pauses the loop (frame count stops advancing),
|
||||
- reduced-motion mode: no shake transform, hazard markers still visible.
|
||||
6. `docs/CLIENT.md` — layer order table, atlas id conventions, how to add a new drawable
|
||||
(SVG symbol → `index.json` → mapping entry), HUD DOM structure.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: pixel-variance and perf budgets, HUD update assertions, reduced-motion check.
|
||||
- Coverage: **>90%** on server-side changes; renderer covered by the above plus task 03's
|
||||
math tests.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us04_render_smoke.py -q` → green
|
||||
- [ ] frame time ≤ 16 ms p95 and `drawCalls` within band are both asserted, not printed
|
||||
- [ ] every station state and item form is reachable by the mapping (a test walks the mapping keys against the sim enums)
|
||||
- [ ] HUD changes are visible within 200 ms of the server event (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
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Task 05 — Keyboard input and the first playable end-to-end story
|
||||
|
||||
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-04_move_and_cook`
|
||||
|
||||
## Objective
|
||||
Close the loop: desktop keyboard control wired to the protocol, and a browser test that
|
||||
plays a real soup from shelf to serve window — the first time the whole stack is proven by
|
||||
a single test.
|
||||
|
||||
## Work
|
||||
1. `app/static/js/input/keyboard.js`:
|
||||
- WASD + arrow keys → an 8-way intent vector; opposite keys cancel; the vector is sent
|
||||
only when it changes, plus a keep-alive empty input at ≥ 5 Hz,
|
||||
- `Space`/`Enter` = action (press + hold tracked with `action_held_ticks` semantics from
|
||||
the protocol), `Escape` = leave/confirm-cancel, `1`–`4` = emotes,
|
||||
- `seq` counter per connection, monotonic, reset only on a new connection,
|
||||
- ignores key events when focus is in an input field, when `document.hidden`, or when a
|
||||
modifier is held (browser shortcuts survive: no `preventDefault` on
|
||||
Ctrl/Cmd/Meta combos),
|
||||
- `blur`/`visibilitychange`/pagehide → send a zero-intent input and release the action
|
||||
(no cook running away when you alt-tab),
|
||||
- key map exposed via `getKeyMap()` and rendered in the help panel (also used by
|
||||
`README.md`'s controls section — keep them identical; a test asserts the two agree).
|
||||
2. `app/static/ui/help.js` + a `?`-key help overlay listing the controls (no external
|
||||
docs needed mid-game).
|
||||
3. `tests/e2e/test_us04_move_and_cook.py` — the flagship suite, against the live server on
|
||||
level 1 with a fixed seed (test-only `?seed=` query param honored when
|
||||
`UNDERCOOKED_TEST_MODE=1`, documented):
|
||||
- `test_moves_and_faces` — key presses produce the expected position deltas,
|
||||
- `test_stops_at_walls` — walking into a wall does not pass through and does not jitter,
|
||||
- `test_serves_one_soup` — the full chain (grab tomato, grab onion, chop both, pot,
|
||||
cook, plate, serve) with the HUD score bump and the order ticket disappearing,
|
||||
- `test_denied_out_of_reach` — an early press shows the deny feedback and the item stays,
|
||||
- `test_two_players_same_target` — two contexts press simultaneously; exactly one gets
|
||||
the item, the other sees `denied`, and the server's item count is conserved,
|
||||
- `test_alt_tab_parks_the_cook` — hidden tab sends zero intent.
|
||||
4. `tests/e2e/README.md` — how to run the browser suites (start the server, install
|
||||
chromium), what `?seed=` and the test seam do, and why the flagship suite is the
|
||||
release gate.
|
||||
5. README "How to play" section: controls, objectives, tips (chop before pot, wash or run
|
||||
out of plates), and a pointer to the in-game help overlay.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: the flagship suite above (this is the suite every later phase must keep green).
|
||||
- Coverage: **>90%** on any server-side change (e.g. the test-only seed seam); keyboard
|
||||
logic verified through the browser.
|
||||
- Full suite green, including all prior phases' suites.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us04_move_and_cook.py -q` → green, including the full soup chain
|
||||
- [ ] a human can create a lobby, start, cook, and serve with the keyboard (record what you saw in the phase report)
|
||||
- [ ] two contexts on the same target produce exactly one winner and one `denied` (asserted)
|
||||
- [ ] alt-tab sends zero intent (asserted via the server's observed position freeze)
|
||||
- [ ] README control list matches `getKeyMap()` exactly (test-enforced)
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,53 @@
|
||||
# Phase 08 — Netcode hardening, prediction, and touch input
|
||||
|
||||
**Story:** `US-06_coop_shared_kitchen`, `US-09_disconnect_and_rejoin`, `US-11_play_on_phone`
|
||||
**Context:** `.agents/PLAN.md` §4 (client interpolation), §10 (latency budgets), A4, A9
|
||||
|
||||
## Objective
|
||||
Make remote play feel local and stay unbroken: a resilient connection lifecycle with
|
||||
backoff and resume, client-side prediction with gentle reconciliation against the
|
||||
authoritative snapshots, on-screen touch controls, and measurable budgets for echo delay,
|
||||
drift, and reconnect time under injected latency.
|
||||
|
||||
## Dependencies
|
||||
- `07_canvas_client_and_hud` (complete) — renderer fed by the snapshot buffer, keyboard
|
||||
input, `net/connection.js`, `window.__uc` telemetry
|
||||
- `06_realtime_websocket_protocol` (complete) — `UNDERCOOKED_SIM_LATENCY_MS`, the test-only
|
||||
socket-kill seam, snapshot resync rules
|
||||
|
||||
## Tasks
|
||||
1. `01_connection_lifecycle_and_backoff.md` — reconnect with jittered backoff, resume semantics, protocol-mismatch stop, input flush on drop
|
||||
2. `02_input_channel_and_seq_discipline.md` — input pacing, change-only sends, seq ownership, resync handling, blur/hidden safety in the net layer
|
||||
3. `03_client_prediction_and_reconciliation.md` — mirrored integrator, ring buffer, gentle correction, divergence telemetry
|
||||
4. `04_touch_controls_and_responsive_layout.md` — thumb stick, action button, safe areas, viewport/scroll lockdown
|
||||
5. `05_netcode_budget_and_latency_suite.md` — the §10 budgets as tests: echo ≤ 120 ms, drift bound, rejoin ≤ 3 s, 4-client latency matrix
|
||||
|
||||
## Testing & Quality
|
||||
- E2E with latency injection (`UNDERCOOKED_SIM_LATENCY_MS` = 0 / 60 / 150 / 250) and the
|
||||
server's socket-kill seam; assertions read `window.__uc` telemetry and server stats, not
|
||||
screenshots.
|
||||
- Client/server constant parity test: `MAX_SPEED`, `ACCEL`, `RADIUS`, `REACH`, `STEP_DT`
|
||||
must be identical between `src/undercooked/sim/movement.py` and
|
||||
`app/static/js/shared/constants.js` (fails if either drifts).
|
||||
- Budgets asserted, not printed: input→echo ≤ 120 ms; visible error ≤ 0.25 tile at 150 ms;
|
||||
corrections ≤ 4 per 10 s steady movement; rejoin-to-playable ≤ 3 s; 0 lost frames per
|
||||
connection over a clean 30 s round.
|
||||
- Coverage: **>90%** on new/modified server code; client paths covered by the e2e matrix.
|
||||
- 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**.
|
||||
- Tasks update `docs/CLIENT.md` (connection lifecycle, prediction rules, touch controls)
|
||||
and `README.md` (mobile play, controls, what "connection lost" means).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us06_prediction.py -q` → green at 0 and 150 ms injected latency
|
||||
- [ ] killing the socket mid-round (server seam) shows the reconnecting overlay and returns to playable ≤ 3 s
|
||||
- [ ] touch controls move the cook and complete an action on a 390×844 touch context, with no page scroll or zoom
|
||||
- [ ] the constant-parity test fails if a movement constant is changed on one side only (demonstrated once, then reverted)
|
||||
- [ ] `uv run pytest tests/e2e/test_us11_touch_mobile.py -q` → green
|
||||
- [ ] 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 (level 1 soup chain from phase 07 still passes)
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Task 01 — Connection lifecycle, backoff, and resume
|
||||
|
||||
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-09_disconnect_and_rejoin`
|
||||
|
||||
## Objective
|
||||
Give the client a connection that survives reality: dropped Wi-Fi, laptop sleep, server
|
||||
restart, and protocol mismatch each get a distinct, correct behavior.
|
||||
|
||||
## Work
|
||||
1. `app/static/js/net/connection.js` (harden the phase-07 stub):
|
||||
- explicit lifecycle enum (`IDLE, CONNECTING, OPEN, RECONNECTING, FAILED, CLOSED`);
|
||||
- jittered exponential backoff (0.5 s base, ×2, 8 s cap, ±25 % jitter), attempt counter
|
||||
exposed, `online`/`offline` events used to pause and resume immediately,
|
||||
- `hello` carries the stored token; a `welcome.rejoined` response clears the overlay, a
|
||||
`room_gone` error routes to `LANDING` with the code preserved in the join field,
|
||||
- stop-reconnecting conditions (each shows a distinct message): protocol mismatch
|
||||
(`4400`), abuse close (`4404`), room gone (`4401`), repeated `room_full` (`4402`) —
|
||||
reconnect only for network-level failures,
|
||||
- on drop: flush the pending input queue, zero the intent, tell the renderer to freeze
|
||||
(`snapshots.freeze()`), show the `RECONNECTING` overlay with attempt count and a
|
||||
"back to lobby" escape hatch,
|
||||
- on resume: the client must not wait up to `FULL_SNAPSHOT_EVERY_TICKS` for the next
|
||||
full frame. Add `snap` to the `welcome` payload (server includes the current full
|
||||
snapshot when the room is `PLAYING`): a documented wire change, so bump
|
||||
`PROTOCOL_VERSION` to `2`, update `docs/PROTOCOL.md`, `MIN_SUPPORTED_VERSION` policy
|
||||
(server accepts only 2), and let `tests/unit/test_protocol_doc_sync.py` prove the doc
|
||||
and models move together. Record the tick gap in telemetry.
|
||||
- never leak timers: `close()` clears backoff timers and listeners (test for listener
|
||||
count stability across 5 reconnect cycles).
|
||||
2. `app/static/ui/overlay.js` — `RECONNECTING`, `FROZEN`, `SPECTATING`, `RATE_LIMITED`
|
||||
overlays: `aria-live="polite"`, non-blocking, keyboard-dismissable where sensible.
|
||||
3. `tests/e2e/test_us09_reconnect.py`:
|
||||
- server seam kill mid-round → overlay within 1 s, playable ≤ 3 s, same seat and color,
|
||||
score continuous (no reset),
|
||||
- kill during `LOBBY` → rejoins the lobby with the ready state intact,
|
||||
- server restart (fixture restarts uvicorn on the same port) → distinct message, no
|
||||
infinite spinner, join field keeps the code,
|
||||
- offline event (`context.set_offline(True)`) → backoff visibly slows and stops the
|
||||
moment `online` fires,
|
||||
- protocol mismatch (client sends a bogus `p`) → stops reconnecting, shows `error` copy
|
||||
with a reload affordance,
|
||||
- 5 reconnect cycles leave the listener/timer count flat (no leak).
|
||||
4. `docs/CLIENT.md` — lifecycle diagram, backoff parameters, and the stop-reconnect matrix.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: every lifecycle branch, with the seam and offline emulation.
|
||||
- Coverage: **>90%** on any server-side change; leak check via listener counts.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us09_reconnect.py -q` → green
|
||||
- [ ] rejoin-to-playable ≤ 3 s measured in the test (assert, not logged)
|
||||
- [ ] protocol mismatch does not loop reconnects (asserted by attempt count ≤ 1)
|
||||
- [ ] listener/timer count after 5 reconnect cycles equals the count after 1 (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
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Task 02 — Input channel and sequence discipline (client side)
|
||||
|
||||
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-04_move_and_cook`, `US-06_coop_shared_kitchen`
|
||||
|
||||
## Objective
|
||||
Own the outbound input channel: one place that paces sends, owns `seq`, survives tab
|
||||
changes, and behaves sanely when the server rejects or drops a sequence.
|
||||
|
||||
## Work
|
||||
1. `app/static/js/net/input_channel.js`:
|
||||
- single writer of `input` messages (keyboard and touch both call
|
||||
`setIntent(vec)`, `setAction(bool)` — neither sends directly),
|
||||
- send-on-change plus a keep-alive at ≥ 5 Hz; send cadence capped at
|
||||
`min(settings.max_inputs_per_s × 0.5, 30)/s` (the client stays well under the server's
|
||||
rate limit so legitimate play never trips it),
|
||||
- `seq` ownership: monotonic per connection, persisted only in memory, zeroed on new
|
||||
connection, `err{seq}` handled by adopting the server's `seq` hint (from the `resync`
|
||||
event payload) rather than resuming a broken count,
|
||||
- drop-oldest pending slot when the socket backpressures (one pending frame is enough;
|
||||
an old intent is worse than a missing one),
|
||||
- `freeze(reason)` on: `document.hidden`, `blur`, overlay open, `ROUND_END`, spectating
|
||||
role — zero intent, action released, sends paused (keep-alive only),
|
||||
- telemetry: `sent`, `dropped`, `staleErrs` on `window.__uc`.
|
||||
2. `app/static/js/input/*` refactor: keyboard (phase 07) and touch (task 04) become pure
|
||||
intent producers; assert no other module calls `connection.send({type:"input"})`
|
||||
(grep test).
|
||||
3. `tests/e2e/test_us06_input_channel.py`:
|
||||
- input send rate stays within the cap while holding a direction for 10 s, and
|
||||
change-only (no send storm while idle),
|
||||
- tab hidden → exactly one zero-intent send, then silence; unhide resumes,
|
||||
- overlay open (reconnecting) → no inputs reach the server (server stats unchanged),
|
||||
- forced seq error (test hook rewinds `seq`) → adopted cleanly, no error loop,
|
||||
- spectator role → no inputs sent at all.
|
||||
4. `docs/CLIENT.md` — input channel rules, seq ownership, freeze reasons table.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: rate caps, freeze semantics, seq recovery; server-side stats used as ground truth.
|
||||
- Coverage: **>90%** on server-side changes (test hooks), client verified behaviorally.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us06_input_channel.py -q` → green
|
||||
- [ ] a grep test proves only `input_channel.js` sends `input` messages
|
||||
- [ ] idle client sends ≤ 6 messages/s (asserted from server `stats()`)
|
||||
- [ ] seq error recovery does not loop (≤ 1 error in 5 s, 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
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Task 03 — Client prediction and reconciliation
|
||||
|
||||
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-06_coop_shared_kitchen`
|
||||
|
||||
## Objective
|
||||
Make the local cook feel instantly responsive without ever becoming authoritative:
|
||||
dead-reckon from local input, reconcile against server snapshots gently, and stay bounded
|
||||
under latency.
|
||||
|
||||
Prediction is *presentation only*: the server state remains the truth the rules run on, and
|
||||
no client-side value is ever sent back as authoritative.
|
||||
|
||||
## Work
|
||||
1. `app/static/js/net/predict.js`:
|
||||
- mirrors the server's movement integrator (accel/decel/max speed/radius) — keep the
|
||||
mirrored constants in `app/static/js/shared/constants.js` with a comment naming
|
||||
`src/undercooked/sim/movement.py` as the source of truth.
|
||||
- `pushLocalInput(input)` → applies to the local ghost immediately and stores
|
||||
`{seq, applied}` in a ring buffer of `PREDICT_WINDOW = 60` entries.
|
||||
- `reconcile(snapshot)`: re-simulate the buffered inputs newer than `snapshot.t` from the
|
||||
snapshot's position for the local player; if the resulting position differs from the
|
||||
ghost by < `SNAP_ACCEPT = 0.15` tile, snap silently; if larger, ease toward the
|
||||
corrected position over ≤ 150 ms (never teleport: US-06 criterion 3) and keep easing
|
||||
bounded so a bad frame cannot fling the cook.
|
||||
- Anti-rubber-band rules: at most one correction in flight; corrections during a
|
||||
contested `denied` are applied immediately (the server won on purpose); when
|
||||
`stun`/`denied` is active, prediction pauses.
|
||||
- Divergence telemetry: `window.performance` counters exposed on `window.__uc` for tests
|
||||
(`maxCorrPx`, `corrCount`).
|
||||
2. `app/static/js/net/echo.js` — input echo suppression: the local cook's rendered
|
||||
position always prefers the predicted ghost, never the raw snapshot, to avoid double
|
||||
motion.
|
||||
3. Tests (Playwright, deterministic; drive the client against the real server with
|
||||
`UNDERCOOKED_SIM_LATENCY_MS` set by a fixture):
|
||||
- `tests/e2e/test_us06_prediction.py`:
|
||||
- input → visible echo ≤ 120 ms (measure key-down timestamp → pixel change in the
|
||||
cook region),
|
||||
- at 150 ms injected latency the visible error vs. the authoritative snapshot stays
|
||||
≤ 0.25 tile while moving (read `window.__uc` + a test-only debug hook that exposes
|
||||
the latest snapshot),
|
||||
- corrections ≤ 4 per 10 s of steady movement (no rubber-banding),
|
||||
- a forced divergence (test hook sets ghost position 3 tiles off) eases to the server
|
||||
position in ≤ 150 ms without teleport frames ≥ 0.5 tile,
|
||||
- prediction pauses while stunned (assert the ghost matches the snapshot).
|
||||
4. `docs/CLIENT.md` — prediction/reconciliation section with the constants table and the
|
||||
"presentation only" rule.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E with injected latency; assertions read telemetry rather than screenshots.
|
||||
- Constant parity (browser-free): `tests/unit/test_client_constants_parity.py` parses the
|
||||
numeric constants out of `src/undercooked/sim/movement.py` (`ast`, no import tricks) and
|
||||
out of `app/static/js/shared/constants.js` (regex on `export const NAME = value`) and
|
||||
fails on any mismatch for `MAX_SPEED`, `ACCEL`, `DECEL`, `RADIUS`, `REACH`, `STEP_DT`.
|
||||
The mirrored integrator itself stays in a pure ES module and is exercised through the
|
||||
browser in the suite above.
|
||||
- Coverage: **>90%** on new server-side code; client logic covered by the e2e assertions.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us06_prediction.py -q` → green at 0 and 150 ms injected latency
|
||||
- [ ] a test fails if `MAX_SPEED`/`ACCEL`/`RADIUS` differ between `movement.py` and `constants.js` (proven by temporarily changing one value)
|
||||
- [ ] `window.__uc.maxCorrPx` stays within the bound during a 30 s scripted movement
|
||||
- [ ] 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
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# Task 04 — Touch controls and responsive layout
|
||||
|
||||
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-11_play_on_phone`
|
||||
|
||||
## Objective
|
||||
Make the game playable with two thumbs on a phone: a drag stick, a hold-to-work action
|
||||
button, and a layout that survives every viewport without scroll or zoom.
|
||||
|
||||
## Work
|
||||
1. `app/static/js/input/touch.js` (Pointer Events only):
|
||||
- left half of the play surface is a floating thumb zone: touch-down anchors the stick,
|
||||
drag sets the intent, quantized to 8 directions with a 12 px dead zone and a 55 px rim,
|
||||
release → zero intent; stick rendered as a light vector overlay (no DOM layout thrash),
|
||||
- right side: one large *act* button (≥ 72 px), press → action `true`, release →
|
||||
`false`, long-press (≥ 400 ms) also satisfies the trash hold,
|
||||
- multi-touch: stick and action simultaneously (2 pointers tracked by `pointerId`);
|
||||
a third pointer (a second player's finger on one device) is ignored, not glued,
|
||||
- no `touchstart`/`click` double-fire, no 300 ms delay reliance, `setPointerCapture` for
|
||||
the stick, `pointercancel` treated as release,
|
||||
- controls auto-hide on non-touch devices and can be forced on via
|
||||
`?controls=touch` (test hook + accessibility preference).
|
||||
2. Layout lockdown in `app/static/css/screens.css` + `play.js`:
|
||||
- `touch-action: none` **scoped to the play surface and stick zone only**;
|
||||
`overscroll-behavior: none`; `height: 100dvh` with a `100svh` fallback;
|
||||
`viewport-fit=cover` + `env(safe-area-inset-*)` padding; `user-select: none` during
|
||||
play. Do **not** add `maximum-scale`/`user-scalable=no` to the viewport meta — that
|
||||
blocks accessibility zoom everywhere. Pinch-zoom stays available on DOM screens, and on
|
||||
the play surface `touch-action: none` is what stops in-game zoom/pan/scroll.
|
||||
- HUD reflow: order rail to the top edge, score/timer compact, controls never overlap the
|
||||
serve window or order rail (asserted geometrically in the test),
|
||||
- orientation: both portrait and landscape playable; a hint suggests landscape below
|
||||
500 px height without blocking play.
|
||||
3. `tests/e2e/test_us11_touch_mobile.py` (`devices["iPhone 13"]`-style context plus a
|
||||
360×640 custom context):
|
||||
- create + join by typing the code (US-02 on mobile) with the right input attributes,
|
||||
- pointer drag moves the cook in the expected direction and stops on release,
|
||||
- act button completes a chop (progress ring visible, `CHOPPED` event),
|
||||
- simultaneous stick+act works (one pointer each),
|
||||
- no page scroll/zoom: `document.scrollingElement.scrollTop` stays 0 and
|
||||
`visualViewport.scale` stays 1 during vigorous drags,
|
||||
- control hit targets ≥ 44 px (computed `getBoundingClientRect`),
|
||||
- controls absent on a desktop context, forced with `?controls=touch`.
|
||||
4. `docs/CLIENT.md` — touch mapping, dead zone/rim numbers, safe-area strategy, and the
|
||||
"pinch zoom stays on DOM screens" rationale.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E on two mobile viewports + a desktop negative case.
|
||||
- Coverage: **>90%** on any server-side change.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us11_touch_mobile.py -q` → green on iPhone-13 and 360×640 contexts
|
||||
- [ ] stick + act simultaneously completes a chop (asserted via the `CHOPPED` event)
|
||||
- [ ] no scroll or zoom during play (asserted), pinch-zoom still works on the lobby screen
|
||||
- [ ] every touch control ≥ 44 px (asserted from bounding boxes)
|
||||
- [ ] 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
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Task 05 — Netcode budget and latency matrix
|
||||
|
||||
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-06_coop_shared_kitchen`, `US-09_disconnect_and_rejoin`
|
||||
|
||||
## Objective
|
||||
Turn PLAN §10's promises into an executable latency matrix so "it feels fine on my
|
||||
machine" can never again be an argument.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/conftest.py` — a `latency` fixture that parameterizes the server's
|
||||
`UNDERCOOKED_SIM_LATENCY_MS` over `{0, 60, 150, 250}` (server restarted per value, or a
|
||||
per-room override via the test seam if cheaper) and exposes
|
||||
`measure(page, fn) -> Metrics` reading `window.__uc` plus CDP `performance` data.
|
||||
2. `tests/e2e/test_us06_latency_matrix.py` — for each latency value, a 30 s scripted round
|
||||
with two clients:
|
||||
- input → visible echo ≤ 120 ms **local** (this is local prediction, so it must stay
|
||||
flat across all latency values — the point of the phase),
|
||||
- remote-cook visible error vs. authoritative ≤ 0.25 tile while moving,
|
||||
- corrections ≤ 4 per 10 s of steady movement, `maxCorrPx` within the ease bound,
|
||||
- zero dropped frames attributable to the net layer (`stats().netStallFrames == 0` at
|
||||
≤ 150 ms; ≤ 2 at 250 ms),
|
||||
- score/result identical on both clients within 1 s of `round`.
|
||||
3. `tests/e2e/test_us09_latency_matrix.py` — for each latency value: kill mid-round via the
|
||||
server seam, assert rejoin-to-playable ≤ 3 s (≤ 4 s at 250 ms), no duplicate seat, no
|
||||
score discontinuity, and the ghost label appears for the other client within 1 s.
|
||||
4. `tests/integration/test_server_latency_flag.py` — the `UNDERCOOKED_SIM_LATENCY_MS`
|
||||
injects the configured delay within ±20 % on localhost (server-side check so the matrix
|
||||
is trustworthy) and `0` adds nothing measurable.
|
||||
5. `BUDGETS.md` (repo root) — the numbers table with the **measured** values per latency
|
||||
setting from this run, dated, and the rule "a phase that regresses a budget must update
|
||||
PLAN.md with the user's sign-off, not relax the test". `README.md` links it.
|
||||
6. `.agents/validate.sh` — run the netcode matrix as a `slow` stage (so it is gated, not
|
||||
optional) and print the measured table on failure.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E matrix across 4 latency values, asserting the PLAN §10 budgets.
|
||||
- Coverage: **>90%** on any server-side change (latency flag, test seams).
|
||||
- Full suite green; the whole pipeline gate passes with the matrix included.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us06_latency_matrix.py -q` → green at 0/60/150/250 ms
|
||||
- [ ] `uv run pytest tests/e2e/test_us09_latency_matrix.py -q` → green (rejoin ≤ 3 s at ≤ 150 ms)
|
||||
- [ ] `BUDGETS.md` records this run's measured numbers with a date
|
||||
- [ ] `bash .agents/validate.sh` includes the matrix stage and exits 0
|
||||
- [ ] 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 (the phase-07 soup-chain suite still passes)
|
||||
@@ -0,0 +1,47 @@
|
||||
# Phase 09 — Cooperative play with 2–4 humans
|
||||
|
||||
**Story:** `US-06_coop_shared_kitchen` (the contract), `US-04_move_and_cook`
|
||||
**Context:** `.agents/PLAN.md` §5 (contested resolution), `docs/SIMULATION.md` carry rules
|
||||
|
||||
## Objective
|
||||
Prove and polish the part that makes this a co-op game rather than four single players
|
||||
sharing pixels: deterministic arbitration of contested items and stations, station
|
||||
occupancy that reads on screen, an item pass between two cooks, and a multi-browser test
|
||||
harness that other suites reuse.
|
||||
|
||||
## Dependencies
|
||||
- `08_client_prediction_and_touch` (complete) — prediction, input channel, latency tooling
|
||||
- `07_canvas_client_and_hud` (complete) — renderer, HUD, deny feedback
|
||||
- `04_kitchen_pipeline_and_scoring` (complete) — contested-resolution rules to test
|
||||
|
||||
## Tasks
|
||||
1. `01_contested_arbitration_and_station_locks.md` — tighten server arbitration, `denied` payloads, lock release timing, fairness stats
|
||||
2. `02_multi_context_playwright_harness.md` — reusable two/three/four-context room fixture with shared code+seed and deterministic input recording
|
||||
3. `03_cooperative_workflow_e2e.md` — division of labour, item pass, shared station queue, both clients converge
|
||||
4. `04_four_player_fairness_and_starvation.md` — 4-player starvation guard, soft-push fairness, fairness telemetry
|
||||
|
||||
## Testing & Quality
|
||||
- Unit (sim): contested arbitration tie-breaks across all pairings; station lock acquire/
|
||||
release timing; `denied` payload correctness per reason; fairness counters.
|
||||
- E2E: the flagship two-context suites (below) plus regression on phase 07/08 suites.
|
||||
- Fairness metric: over a 3-minute scripted 4-player round, no player receives `denied`
|
||||
more than 25 % more often than the mean (guards against systematic seat-order bias).
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
- 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**.
|
||||
- Tasks update `docs/SIMULATION.md` (arbitration contract), `docs/CLIENT.md` (deny
|
||||
presentation), and `README.md` (co-op tips: passing, queueing, who chops).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] two browser contexts complete one order together (one chops, one cooks and serves) and both show the same score and result
|
||||
- [ ] a contested pickup yields exactly one winner and one visible `denied` (asserted at both server and DOM level)
|
||||
- [ ] an item pass between two cooks via the prep counter works and is attributed as `HANDED_OFF`
|
||||
- [ ] the 4-player starvation guard passes (fairness counters within band)
|
||||
- [ ] the multi-context harness is used by at least two suites and documented in `tests/e2e/README.md`
|
||||
- [ ] 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
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Task 01 — Contested arbitration and station locks
|
||||
|
||||
**Phase:** `09_co_op_two_player_e2e` · **Story:** `US-06_coop_shared_kitchen`
|
||||
|
||||
## Objective
|
||||
Make sharing one kitchen feel fair: exact, explainable arbitration when two cooks want the
|
||||
same tomato or the same stove, with payloads the client can render meaningfully.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/arbitration.py` (extract from `interact.py` if it grew there):
|
||||
- `resolve_contest(state, requests) -> tuple[Winner, list[Loss]]` implementing the
|
||||
documented rule: smallest `received_at_tick`, then smallest `seat`; stable across
|
||||
runs; no randomness.
|
||||
- Station occupancy: `WORKING` + `worker_id` is the lock; lock released when the worker
|
||||
leaves reach for `RELEASE_TICKS`, when the station reaches `READY`, or when the worker
|
||||
is disconnected/parked; a queued second player gets `STATION_BUSY` with
|
||||
`{"busy_by": "<name>", "eta_s": <remaining>}` so the client can say "Marco is chopping".
|
||||
- `denied` payloads are mandatory and typed per reason (extend `EventKind.DENIED`
|
||||
payload rules): `OUT_OF_REACH` → target cell, `STATION_BUSY` → busy_by + eta,
|
||||
`HANDS_FULL` → carried item, `CONTESTED_LOST` → winner id, `NO_PLATE` → why.
|
||||
- Fairness counters on the state: `contest_requests`, `contest_losses[player]`,
|
||||
`lock_waits[player]` (ticks spent blocked by a busy station) — surfaced in
|
||||
`result_payload` under `fairness` (display/debug only).
|
||||
- A `denied` must never cost the player time they already invested (chop progress stays;
|
||||
verify explicitly).
|
||||
2. `docs/SIMULATION.md` — the arbitration rule as a numbered contract with two worked
|
||||
examples (this is what the E2E asserts).
|
||||
3. `tests/sim/test_arbitration.py` — all pairings (1v2, 2v3, 4-way), tick tie-break, seat
|
||||
tie-break, lock release on `READY`/leave/disconnect/park, eta correctness, per-reason
|
||||
payload shape, invested progress preserved, counters accumulate correctly, determinism
|
||||
of a 1 000-tick contest-heavy fuzz.
|
||||
4. `tests/integration/test_ws_denied_payloads.py` — two sockets contest a real item; the
|
||||
loser receives the documented payload; the winner does not.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit + integration as listed; contest-heavy fuzz keeps determinism honest.
|
||||
- Coverage: **>90%** on `arbitration.py` (target 95%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_arbitration.py tests/integration/test_ws_denied_payloads.py -q` → green
|
||||
- [ ] a 4-way contest resolves to exactly one winner with three `CONTESTED_LOST` events (asserted)
|
||||
- [ ] `STATION_BUSY` payload includes `busy_by` and `eta_s` (asserted)
|
||||
- [ ] chop progress is not lost on a denied second press (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
|
||||
@@ -0,0 +1,50 @@
|
||||
# Task 02 — Reusable multi-context Playwright harness
|
||||
|
||||
**Phase:** `09_co_op_two_player_e2e` · **Story:** `US-06_coop_shared_kitchen`
|
||||
|
||||
## Objective
|
||||
Build the multiplayer test rig every later suite needs: N browser contexts in one room,
|
||||
deterministic seeds, scripted keyboard drivers, and server-side observation — so co-op
|
||||
tests read like choreography instead of plumbing.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/conftest.py` (extend, do not duplicate):
|
||||
- `game_server` fixture — one uvicorn instance per module with overridable settings
|
||||
(`levels_dir`, `tick_hz`, `sim_latency_ms`, `UNDERCOOKED_TEST_MODE=1`), port from an
|
||||
ephemeral bind, and clean shutdown;
|
||||
- `room` fixture — creates a room over HTTP and returns `{code, seed}`;
|
||||
- `player` factory — `player(name, viewport=None, touch=False, latency_ms=0)` →
|
||||
a `PlayerCtx` wrapping a browser context + page that: opens `/`, joins `code`
|
||||
(typing the code with `page.fill` + submit), waits for `welcome`, exposes
|
||||
`hold(key, seconds)`, `tap(key)`, `walk(dx_cell, dy_cell)`, `act(seconds)`,
|
||||
`snapshot()` (server-side sim state via the test seam), `score()`, `dom_text(sel)`,
|
||||
`telemetry()`, `kill_socket()`, `close()`;
|
||||
- `assert_eventually(predicate, timeout=5)` — polling helper with a readable failure
|
||||
message (never bare `sleep`);
|
||||
- `record_frames(player, seconds)` — samples `window.__uc` for motion assertions.
|
||||
2. `tests/e2e/test_harness_itself.py` — meta-suite proving the rig works: 4 players join
|
||||
one room, all four report the same seat list, `hold("ArrowRight", 1)` moves each within
|
||||
the expected bounds, `kill_socket` produces exactly one ghost, fixture teardown leaves
|
||||
zero live rooms (`/api/health` → `rooms == 0`).
|
||||
3. `tests/e2e/README.md` — rewrite around the harness: the canonical co-op test shape, the
|
||||
"assert on the server, present in the DOM" rule, why seeds are pinned, how to debug a
|
||||
failing multiplayer test (`--headed`, `--slowmo`, `PLAYWRIGHT_TRACING=1` → traces
|
||||
artifacts are gitignored), and the artifact paths.
|
||||
4. Add tracing/screenshot-on-failure config to `tests/e2e/conftest.py` (context
|
||||
`tracing="retain-on-failure"`, artifacts under `test-results/`, already gitignored) and
|
||||
document that these artifacts must never be committed.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E meta-suite; teardown hygiene (zero leaked rooms/connections after the suite).
|
||||
- Coverage: harness code is test code; **>90%** requirement applies to any server change.
|
||||
- Full suite green, including phases 07–08 suites refactored onto the harness (refactor only
|
||||
their fixtures, never their assertions).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_harness_itself.py -q` → green with 4 players
|
||||
- [ ] at least two pre-existing e2e suites now use the `player` factory (diff shows fixture-only changes)
|
||||
- [ ] after `uv run pytest tests/e2e -q`, `curl /api/health` reports `rooms: 0` and the server logs show no leaked tasks
|
||||
- [ ] a deliberately failing test produces a trace + screenshot under `test-results/` (and that path is gitignored)
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,46 @@
|
||||
# Task 03 — Cooperative workflow end-to-end
|
||||
|
||||
**Phase:** `09_co_op_two_player_e2e` · **Story:** `US-06_coop_shared_kitchen`
|
||||
|
||||
## Objective
|
||||
Test the behaviors that only exist with two humans: division of labour, handing work over,
|
||||
queueing at one stove, and both screens agreeing at the end.
|
||||
|
||||
## Work
|
||||
1. Scripted choreographies on the harness (two `player`s, level 1, fixed seed) in
|
||||
`tests/e2e/test_us06_coop_workflow.py`:
|
||||
- `test_division_of_labour_soup` — A chops tomato+onion and drops them on the prep
|
||||
counter; B picks them up, runs the pot and plate, serves; both clients show the same
|
||||
`SERVED` event, order count, and score;
|
||||
- `test_item_pass_via_counter` — the dropped item is pickable by the other player and
|
||||
attributed `HANDED_OFF` server-side;
|
||||
- `test_handoff_while_holding` — passing by dropping onto the other player's tile is
|
||||
rejected with `HANDS_FULL`/`CONTESTED_LOST` as documented (no teleporting items);
|
||||
- `test_two_cooks_one_stove_queue` — A starts the pot, B gets `STATION_BUSY` with the
|
||||
right eta, and B's cook visibly waits then takes the finished pot;
|
||||
- `test_shared_dirty_duty` — both players wash; the sink counter and the shared dirty
|
||||
pool converge on both clients;
|
||||
- `test_soft_push_does_not_trap` — two cooks pushing in a corridor never produce an
|
||||
infinite jitter loop (position variance bounded over 5 s) and neither is displaced into
|
||||
a wall;
|
||||
- `test_both_see_the_same_result` — after `round`, both result payloads are equal
|
||||
(JSON-compared) including per-player rows.
|
||||
2. `docs/SIMULATION.md` — a short "Co-op contract" subsection restating what these tests
|
||||
assert, in player terms ("if you both grab it, one of you gets it and the other is told
|
||||
so").
|
||||
3. `tests/unit/test_soft_push_stability.py` — 500 randomized two-body pushes: no
|
||||
wall penetration, no NaN, position delta bounded, symmetric outcome when the seats swap.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E choreographies (the phase's headline) + a soft-push stability fuzz.
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us06_coop_workflow.py -q` → green (all scenarios)
|
||||
- [ ] the hand-off chain is attributed `HANDED_OFF` and visible on both clients (asserted)
|
||||
- [ ] queued cook receives `STATION_BUSY` with a plausible eta and eventually serves (asserted)
|
||||
- [ ] soft-push fuzz finds no wall penetration or NaN over 500 cases
|
||||
- [ ] full test suite **100% passing**, coverage >90%
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,41 @@
|
||||
# Task 04 — Four-player fairness and starvation guard
|
||||
|
||||
**Phase:** `09_co_op_two_player_e2e` · **Story:** `US-06_coop_shared_kitchen`
|
||||
|
||||
## Objective
|
||||
Scale the co-op guarantees to a full 4-player room and prove the simulation does not
|
||||
systematically favour one seat.
|
||||
|
||||
## Work
|
||||
1. Headless fairness harness `tests/sim/test_fairness_4p.py`:
|
||||
- a 3-minute (3 600-tick) scripted 4-player round on level 2 (tight layout) driven by a
|
||||
deterministic bot policy (`tests/sim/bots.py`: nearest-target greedy with a per-seat
|
||||
bias), measuring: `contest_losses` per seat, `lock_waits` per seat, served-count per
|
||||
seat, spawn-collision refusals per seat;
|
||||
- assert no seat's `contest_losses` exceed the mean by > 25 %, no seat's `lock_waits`
|
||||
exceed the mean by > 40 %, and each seat serves ≥ 60 % of the top seat's count;
|
||||
- permutation test: the same run with seats rotated 4× must produce a
|
||||
rotation-equivalent distribution (assert max/min ratio of the metric ≤ 1.5) — this is
|
||||
what catches seat-order bias rather than layout bias.
|
||||
2. Fix whatever the harness finds, in the sim (typical culprits: iteration order in
|
||||
`interact.resolve`, spawn spacing on tight layouts, target-order ties broken by seat
|
||||
alone). Document each fix as a numbered rule in `docs/SIMULATION.md`.
|
||||
3. `tests/integration/test_ws_four_players.py` — 4 real sockets in one room: all four
|
||||
receive identical `SERVED` events for the same order, the same final payload, and the
|
||||
4-player layout renders (canvas not blank, HUD shows 4 names).
|
||||
4. `docs/DESIGN.md` — a "fairness" note: what we measure, the thresholds, and why they are
|
||||
measured on a rotated seat permutation.
|
||||
|
||||
## Testing & Quality
|
||||
- Sim + integration; the permutation test is the interesting part and must be able to fail.
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
- Full suite green (phases 01–09).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_fairness_4p.py -q` → green, including the 4× rotation permutation
|
||||
- [ ] `uv run pytest tests/integration/test_ws_four_players.py -q` → green with identical payloads on 4 sockets
|
||||
- [ ] at least one real fairness fix landed, or the report states that none was needed with the measured margins
|
||||
- [ ] fairness thresholds and measured values recorded in `docs/DESIGN.md`
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,56 @@
|
||||
# Phase 10 — Hazards and pressure mechanics
|
||||
|
||||
**Story:** `US-08_hazards_under_pressure`
|
||||
**Context:** `.agents/PLAN.md` §5 (hazards), §6 (`hazards` level block), `docs/SIMULATION.md`
|
||||
|
||||
## Objective
|
||||
Add the mechanics that create the panic: fire that spreads and is extinguishable, spills
|
||||
that make cooks slip, moving obstacles that stun, and rush waves that shorten the clock —
|
||||
each with a warning window, a counter-play, and an explicit reduced-motion presentation.
|
||||
Every mechanic is authored through the level YAML `hazards` block, never hard-coded per
|
||||
level.
|
||||
|
||||
## Dependencies
|
||||
- `09_co_op_two_player_e2e` (complete) — arbitration, station locks, multi-context harness
|
||||
- `04_kitchen_pipeline_and_scoring` (complete) — order/score/spoil systems hazards plug into
|
||||
- `07_canvas_client_and_hud` (complete) — event→FX pipeline, reduced-motion mode
|
||||
|
||||
## Tasks
|
||||
1. `01_fire.md` — ignition, spread rules, extinguishing (sink/trash), damage to items and orders
|
||||
2. `02_spills_and_slipping.md` — spill creation, slip stun, recovery, cleaning loop
|
||||
3. `03_moving_obstacles.md` — path-driven obstacles, telegraph, stun, item knock-off
|
||||
4. `04_rush_waves_and_pressure_modifiers.md` — timed waves, shortened order timers, spawn-rate ramps, level-declared modifier table
|
||||
5. `05_hazard_presentation_and_warnings.md` — telegraphs, FX, audio-free cues, reduced-motion alternates, onboarding tooltip for the first hazard
|
||||
6. `06_hazard_determinism_and_validator_hardening.md` — determinism under hazard fuzz, validator rules for the new hazard fields, `docs/SIMULATION.md` rules
|
||||
|
||||
## Testing & Quality
|
||||
- Unit (sim): every hazard rule with a fake clock; spread/stun/wave boundaries; interaction
|
||||
matrix (fire × carried item, spill × soft push, obstacle × parked disconnected cook).
|
||||
- Sim determinism: 3 000-tick hazard-heavy replay reproduces hash exactly; a hazard seeded
|
||||
run is bit-identical across two `Room` instances.
|
||||
- E2E (`test_us08_hazards_under_pressure.py`): fire visible + extinguishable by a player,
|
||||
slip visibly stuns and is recoverable, obstacle telegraph appears ≥ its warning window
|
||||
before contact, rush wave visibly shortens the timer, and reduced-motion mode still shows
|
||||
every hazard as a static marker.
|
||||
- Safety floor: hazards never kill, never permanently block the serve window, and never make
|
||||
a level unwinnable (asserted by the phase-11 solver reuse here on the hazard levels).
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
- 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**.
|
||||
- Tasks update `docs/SIMULATION.md` (hazard rules), `docs/LEVELS.md` (every hazard field and
|
||||
its defaults), `docs/CLIENT.md` (warning/FX and reduced-motion mapping), `README.md`
|
||||
(tips: extinguish, mop, watch the telegraph).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim -q -k "fire or spill or obstacle or rush"` → green
|
||||
- [ ] `uv run pytest tests/e2e/test_us08_hazards_under_pressure.py -q` → green (all mechanics, both motion modes)
|
||||
- [ ] 3 000-tick hazard-heavy replay is hash-identical to the golden and across two room instances
|
||||
- [ ] every hazard field in `levels/*.yaml` is documented in `docs/LEVELS.md` and rejected by the validator when out of range (schema test)
|
||||
- [ ] no level becomes unwinnable with its hazards enabled (solver bound re-run)
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,53 @@
|
||||
# Task 01 — Fire
|
||||
|
||||
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
|
||||
|
||||
## Objective
|
||||
Fire as a fair, readable threat: it starts from burnt food, spreads to adjacent
|
||||
combustible tiles on a schedule, damages what is on it, and is put out with water.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/hazards/fire.py`:
|
||||
- `IgnitionSource`: a `BURNT` item on a `stove`/`chop`/`prep` tile ignites that tile with
|
||||
probability 1 (deterministic, no RNG — reproducibility beats realism here);
|
||||
- `HazardState {kind=FIRE, cell, stage, ticks_in_stage, fuel}`; stages
|
||||
`SPARKING` (warning, `spark_ticks` from level YAML, default 30) → `BURNING` →
|
||||
`OUT`/`EXTINGUISHED`;
|
||||
- spread: while `BURNING`, every `spread_ticks` (level field, default 40) ignite one
|
||||
adjacent `COMBUSTIBLE` tile (counters, not walls/void) in a deterministic order
|
||||
(N, E, S, W) — cap the simultaneous fire count per level (`max_fires`, default 4) so a
|
||||
kitchen can't cascade into an unwinnable state;
|
||||
- effects: items on a `BURNING` tile are destroyed (`WASTED` counter, `ITEM_BURNED`
|
||||
event); a cook entering takes a `STUNNED(short)` and drops the carried item onto the
|
||||
nearest safe tile (never deleted); orders are never destroyed by fire directly — the
|
||||
player loses the ingredients, not the order;
|
||||
- extinguish: interacting with a `sink` (or `trash`) while `action_held` and adjacent to
|
||||
the fire cell puts it out (`FIRE_OUT` event), costs one `dirty` dish if the cook is
|
||||
holding nothing (the bucket is dirty — keeps the sink loop meaningful); water has a
|
||||
cooldown (`water_cooldown_ticks`, default 10) so it cannot be spam-toggled;
|
||||
- disconnected/parked cooks never spread or extinguish fire.
|
||||
2. Level fields (`level.py` + validator + `docs/LEVELS.md`): `hazards.fire.{enabled,
|
||||
spark_ticks, spread_ticks, max_fires, water_cooldown_ticks}` with bounds, and the
|
||||
validator rule: if `fire.enabled`, the level must have ≥ 1 `sink` reachable from every
|
||||
fire-capable tile (BFS) — no fire without a way to fight it.
|
||||
3. Events: `FIRE_IGNITED`, `FIRE_SPREAD`, `FIRE_OUT`, `ITEM_BURNED` (+ client FX mapping in
|
||||
`render/fx.js`, with static markers under reduced motion).
|
||||
4. `tests/sim/test_fire.py` — ignition from burnt food, stage timing at each boundary,
|
||||
spread order and cap, item destruction + counter, cook stun + item relocation (never
|
||||
lost), extinguish path + cooldown, no spread to walls/void/serve window, parked
|
||||
disconnected cook is inert, 3 000-tick determinism with fire enabled.
|
||||
5. `tests/unit/test_level_validator_fire.py` — the "fire requires a reachable sink" rule
|
||||
(positive + two negatives), out-of-range field rejection, defaults filled.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit (sim) + validator tests; boundaries at `spark_ticks-1/spark_ticks/+1`.
|
||||
- Coverage: **>90%** on `sim/hazards/fire.py` (target 95%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_fire.py tests/unit/test_level_validator_fire.py -q` → green
|
||||
- [ ] fire never exceeds `max_fires` and never spreads to non-combustible tiles (asserted)
|
||||
- [ ] a carried item displaced by fire always lands on a reachable tile or is counted in `lost_items` (asserted, never silently dropped)
|
||||
- [ ] `uv run undercooked-levels validate` passes shipped levels with fire enabled
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,50 @@
|
||||
# Task 02 — Spills and slipping
|
||||
|
||||
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
|
||||
|
||||
## Objective
|
||||
Wet floors: dropped/burst contents make a spill, stepping in it slips you, and mopping is a
|
||||
real chore that competes with cooking.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/hazards/spill.py`:
|
||||
- creation: `ITEM_BURNED`, a dropped `pot`/`pan` with contents, an ingredient destroyed by
|
||||
an obstacle, and level-declared `hazards.spills.seed_cells` (used by level 5) each create
|
||||
a `HazardState {kind=SPILL, cell, life_ticks}`; multiple spills do not stack on one cell
|
||||
(life refreshes instead);
|
||||
- slip: a cook entering a spill cell rolls a slip (`slip_chance` expressed deterministically
|
||||
as `every_n_th_entry`, default 1 — a spill always slips you on the level that has them, so
|
||||
the mechanic is legible; per-level tunable) → `STUNNED(slip_stun_ticks, default 20)` plus a
|
||||
short scripted slide in the entry direction until a blocking tile (slide never enters
|
||||
`VOID`, never crosses a wall, capped at `max_slide_cells`, default 2);
|
||||
- slide carries the carried item with the cook (it does not fall off mid-slide) unless the
|
||||
slide ends adjacent to a `BURNING` tile;
|
||||
- cleanup: interacting with the spill while `action_held` cleans it (`SPILL_CLEANED`) and adds
|
||||
one `dirty` dish to the sink pool (mop/bucket realism), or the cook can walk the dirty-dish
|
||||
route — both are viable; cleanup has a `clean_cooldown_ticks` (default 6);
|
||||
- spills decay after `life_ticks` (default 300) so an ignored kitchen does not become
|
||||
permanently unplayable; decay emits `SPILL_GONE`.
|
||||
2. Level fields: `hazards.spills.{enabled, every_n_th_entry, slip_stun_ticks,
|
||||
max_slide_cells, life_ticks, clean_cooldown_ticks, seed_cells[]}`; validator: `seed_cells`
|
||||
must be `FLOOR` and reachable from ≥ 2 spawn points (a spill you can never clean is a bug).
|
||||
3. Events `SPILL_CREATED`, `SLIPPED`, `SPILL_CLEANED`, `SPILL_GONE` + FX (decal, slide dust,
|
||||
static marker under reduced motion).
|
||||
4. `tests/sim/test_spills.py` — creation sources, no stacking, slip + slide boundaries
|
||||
(`max_slide_cells`, wall stop, void stop), carried item stays through the slide, cleanup +
|
||||
cooldown + dirty-dish accounting, decay, deterministic entry counting, 3 000-tick replay
|
||||
with spills seeded.
|
||||
5. `tests/unit/test_level_validator_spills.py` — `seed_cells` must be floor and reachable;
|
||||
bounds rejection.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit (sim) + validator; slide math tested with a wall and void fixture map.
|
||||
- Coverage: **>90%**.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_spills.py tests/unit/test_level_validator_spills.py -q` → green
|
||||
- [ ] a slide never enters a wall or void and never exceeds `max_slide_cells` (asserted on a fixture map)
|
||||
- [ ] cleanup always adds exactly one dirty dish and respects the cooldown (asserted)
|
||||
- [ ] an ignored spill decays and the round stays winnable (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
|
||||
@@ -0,0 +1,55 @@
|
||||
# Task 03 — Moving obstacles
|
||||
|
||||
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
|
||||
|
||||
## Objective
|
||||
Things that move through the kitchen on rails: a telegraph, a stun on contact, and a clear
|
||||
way to avoid them.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/hazards/obstacle.py`:
|
||||
- authored as `hazards.obstacles[]: {id, path: [[x,y],...], speed_cells_s, mode:
|
||||
pingpong|loop, period_s?, offset_s?, stun_ticks}`; position is a pure function of the
|
||||
tick (`pos_at(tick)`) — no integration drift, trivially replayable and testable;
|
||||
- telegraph: `TELEGRAPH_TICKS` (default 12) before entering a cell, that cell is flagged
|
||||
in the snapshot (`hazard_cells` with intensity) so the client can draw the warning tile
|
||||
before the obstacle is there;
|
||||
- contact: a cook in the obstacle's cell (or the telegraph cell at contact time) takes
|
||||
`STUNNED(stun_ticks)` and drops the carried item one tile back along their movement
|
||||
vector (onto a reachable tile, else `lost_items` + `ITEM_LOST`); the obstacle never
|
||||
moves a cook through a wall;
|
||||
- items on the path are knocked, not destroyed (destroy only if the destination is
|
||||
`BURNING`, reusing the fire rule);
|
||||
- orders and stations are unaffected; an obstacle never occupies the serve-window tile or
|
||||
a spawn tile while a cook is spawning;
|
||||
- two obstacles on intersecting paths pass through each other (documented, avoids
|
||||
deadlock); obstacle vs. parked disconnected cook = stun on the ghost, no effect on state
|
||||
beyond the counter.
|
||||
2. Snapshot/protocol: `hazard_cells` array (`{x, y, kind, intensity}`) added to the state
|
||||
encoding — a wire change, so bump `PROTOCOL_VERSION`, update `docs/PROTOCOL.md`,
|
||||
`tests/unit/test_protocol_doc_sync.py`, and the client renderer's hazard layer.
|
||||
3. Level fields + validator: `path` cells in bounds and traversable (floor, not wall/void),
|
||||
`speed_cells_s` within `0.5..4`, at least one cell on the path reachable by no cook at
|
||||
spawn, obstacles cannot fully separate the serve window from the spawns at any tick
|
||||
(BFS over the union of blocked cells across one full period) — this is the rule that keeps
|
||||
levels fair and is the interesting part of the validator.
|
||||
4. `tests/sim/test_obstacles.py` — `pos_at` periodicity/ping-pong correctness, telegraph
|
||||
timing, stun + knockback placement, no wall traversal, path never disconnects the kitchen
|
||||
(property check over one period), two obstacles passing, determinism across two rooms.
|
||||
5. `tests/unit/test_level_validator_obstacles.py` — bad path, bad speed, unreachable-path,
|
||||
kitchen-severing path (each rejected with the specific message).
|
||||
6. `tests/e2e/test_us08_obstacle_telegraph.py` — the warning tile is visible ≥ telegraph
|
||||
window before contact, contact stuns visibly, reduced motion still shows the marker.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit (sim) + validator + one e2e; the path/BFS checks are property-tested over a full period.
|
||||
- Coverage: **>90%** on `obstacle.py` and the new validator rules.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_obstacles.py tests/unit/test_level_validator_obstacles.py -q` → green
|
||||
- [ ] no obstacle path ever disconnects the serve window from the spawns (asserted over a full period)
|
||||
- [ ] the telegraph precedes contact by exactly `TELEGRAPH_TICKS` (asserted) and the e2e sees it
|
||||
- [ ] `PROTOCOL_VERSION` bump + doc sync test green
|
||||
- [ ] 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
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Task 04 — Rush waves and pressure modifiers
|
||||
|
||||
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
|
||||
|
||||
## Objective
|
||||
Escalate inside a single round: timed waves that shorten clocks and raise the spawn rate,
|
||||
declared in level YAML so every level composes pressure from the same vocabulary.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/hazards/rush.py`:
|
||||
- `hazards.rush.waves[]: {at_s, duration_s?, order_time_mult, spawn_mult?, max_orders_delta?,
|
||||
label?}`; effects applied as multipliers over the level's base order budget, computed in
|
||||
`orders.py` via a `PressureModel` the round step consults (no ad-hoc timer edits);
|
||||
- deterministic by tick: `pressure_at(tick)` is a pure function; overlapping waves compose
|
||||
multiplicatively with a documented clamp (`order_time_mult ≥ 0.35`, `spawn_mult ≤ 3.0`) so
|
||||
a level cannot author an impossible squeeze;
|
||||
- `RUSH_START` / `RUSH_END` events with the `label` for the HUD banner; the HUD shows the
|
||||
active wave name and the modified timer scale;
|
||||
- `hazards.modifiers` (existing level field) wired to the same hook: `slow_cook`,
|
||||
`fast_spoil`, `no_plates_until_wash`, `dark_kitchen` (vision-limited client overlay,
|
||||
server unchanged), each a named, tested rule with a `docs/SIMULATION.md` entry — a
|
||||
modifier that is declared but unimplemented fails the validator.
|
||||
2. Validator + `docs/LEVELS.md`: wave bounds, monotonic `at_s`, clamp documentation, and a
|
||||
rule that a level's total `order_time_mult` schedule cannot drop below the solver's minimum
|
||||
feasible window (reuses the phase-11 solver hook if present; otherwise the static bound
|
||||
`time_limit × min(order_time_mult) ≥ shortest_recipe_seconds × 2`).
|
||||
3. `tests/sim/test_rush_waves.py` — `pressure_at` step boundaries at `at_s`, `at_s+durations`,
|
||||
clamp behaviour, overlap composition, event emission once per wave, no wave mutating
|
||||
already-spawned orders (documented: pressure affects new orders only), determinism.
|
||||
4. `tests/sim/test_modifiers.py` — one test per modifier, plus "declared-but-unimplemented
|
||||
fails".
|
||||
5. `tests/e2e/test_us08_rush_wave.py` — HUD banner appears at the wave, the order time bar
|
||||
visibly shorter for a post-wave order, and the round remains winnable.
|
||||
6. `docs/SIMULATION.md` — pressure model as a formula, and the "pressure never retroactively
|
||||
shortens a live order" rule.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit (sim) + validator + e2e; boundary tests at every `at_s`.
|
||||
- Coverage: **>90%**.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_rush_waves.py tests/sim/test_modifiers.py -q` → green
|
||||
- [ ] pressure affects only orders spawned after the wave (asserted)
|
||||
- [ ] clamps hold for a deliberately over-aggressive test level (asserted, not clamped away silently)
|
||||
- [ ] declaring an unknown/unimplemented modifier fails `uv run undercooked-levels validate`
|
||||
- [ ] `uv run pytest tests/e2e/test_us08_rush_wave.py -q` → green
|
||||
- [ ] 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
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Task 05 — Hazard presentation, warnings, and reduced motion
|
||||
|
||||
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`, `US-12_play_accessibly`
|
||||
|
||||
## Objective
|
||||
Make every hazard readable at a glance on any screen and without motion — a player must be
|
||||
able to survive hazards they cannot animate.
|
||||
|
||||
## Work
|
||||
1. `app/static/js/render/hazards.js` — draw from `hazard_cells`: intensity ramp, telegraph
|
||||
(dashed outline + 3-pip countdown), fire stages, spill decal, obstacle body; obstacles
|
||||
drawn on the player layer so they are never hidden behind a counter.
|
||||
2. Non-color, non-motion redundancy (required, not optional):
|
||||
- each hazard kind has a distinct glyph (flame / droplet / arrow) drawn at the cell,
|
||||
- a textual status strip in the HUD (`FIRE ×2`, `WET FLOOR`, `CONVEYOR`, `RUSH: ORDER RUSH`)
|
||||
with `aria-live="polite"` on appearance only (not every tick),
|
||||
- `data-motion="reduced"`: no pulsing, no screen shake, no particle storm — static markers,
|
||||
full-opacity glyphs, telegraph shown as a filled tile instead of an animation,
|
||||
- colorblind palette keeps hazards distinguishable (guard: hazard glyph set must be drawn
|
||||
even when colors are forced to grayscale in the test).
|
||||
3. First-hazard onboarding: the first time a level's hazard appears, a one-time caption
|
||||
("Fire! Use the sink to put it out") for 3 s, dismissible, suppressed for repeats and
|
||||
stored per session in `sessionStorage` — no tutorial screens.
|
||||
4. `assets_src/fx.svg` extensions (flame stages, droplet, arrow, telegraph pips) regenerated
|
||||
into `index.json` by `tools/gen_art_index.py` (extend `EXPECTED_IDS`).
|
||||
5. `tests/e2e/test_us08_hazard_presentation.py`:
|
||||
- telegraph pixel region changes before contact (variance sample, not screenshot equality),
|
||||
- HUD status strip lists the active hazard kinds within 200 ms,
|
||||
- reduced-motion context: no CSS `transform: translate` shake on the canvas wrapper, glyphs
|
||||
still present (probe: draw the reduced-motion frame and assert non-background pixels at
|
||||
the hazard cell),
|
||||
- caption appears once and not again after a retry,
|
||||
- grayscale filter context: hazard cells still differ from non-hazard cells by luminance.
|
||||
6. `docs/CLIENT.md` + `docs/SIMULATION.md` cross-reference: the redundancy contract (glyph +
|
||||
text + color, never color alone).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E with four contexts (normal, reduced motion, forced grayscale, colorblind palette).
|
||||
- Coverage: **>90%** on any server-side change (none expected beyond event payloads).
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us08_hazard_presentation.py -q` → green across all four contexts
|
||||
- [ ] every hazard kind has a glyph, a HUD text label, and a distinct color (a test walks the hazard enum against the mapping)
|
||||
- [ ] reduced motion removes shake/pulses but keeps every hazard visible (asserted by pixel probe)
|
||||
- [ ] the first-hazard caption shows once per session and is dismissible
|
||||
- [ ] 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
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Task 06 — Hazard determinism, fuzz, and validator hardening
|
||||
|
||||
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
|
||||
|
||||
## Objective
|
||||
Prove the hazard suite cannot desync, cannot wedge a round, and cannot be authored into an
|
||||
unwinnable level.
|
||||
|
||||
## Work
|
||||
1. `tests/sim/test_hazard_determinism.py`:
|
||||
- property-based (hypothesis-free, seeded random generators are enough) fuzz: 200 generated
|
||||
hazard configurations (random fire/spill/obstacle/rush combos on the shipped maps) ×
|
||||
600 ticks with 4 scripted bots; assert two runs of the same config+seed produce identical
|
||||
state hashes and identical event logs;
|
||||
- no-crash sweep: NaN/inf checks on every numeric field, `stun` never negative, hazard
|
||||
counts never exceed caps, no exception escapes `step()`;
|
||||
- invariants every run: serve window reachable, ≥ 1 spawn tile free, item count + wasted +
|
||||
lost conserved, dirty-dish pool ≤ plates total.
|
||||
2. `tests/sim/test_hazard_fairness.py` — for each hazard level, the solver bound re-runs with
|
||||
hazards enabled: the level is still finishable within `time_limit` and the star thresholds
|
||||
stay achievable (assert the same invariant phase 11 establishes; if the solver hook does not
|
||||
exist yet, use a scripted greedy bot that must reach `target_orders`).
|
||||
3. Validator extension (`level.py`) + tests: cross-field rules that only make sense once all
|
||||
hazards exist — fire enabled but no sink (phase 10 task 01) plus: spill seeds plus obstacles
|
||||
may not block the only path to the sink; `max_fires` ≤ combustible tiles; a level with ≥ 2
|
||||
hazard kinds must have `time_limit ≥ 100` (pressure needs room); `rush` waves must not start
|
||||
before `t=20 s`.
|
||||
4. `docs/SIMULATION.md` — "Hazard invariants" section listing exactly the properties the fuzz
|
||||
asserts (this is the contract future phases must not break).
|
||||
5. Replay golden: commit `tests/sim/goldens/hazards_3000s.json` (config, seed, hash chain
|
||||
sample, final hash) produced by a documented `uv run python tools/record_hazard_golden.py`.
|
||||
|
||||
## Testing & Quality
|
||||
- Fuzz + invariant + golden; the suite must run in < 90 s (mark `slow` if it exceeds that, and
|
||||
still run it in `validate.sh`).
|
||||
- Coverage: **>90%** on new/modified code; hazard modules ≥ 95%.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_hazard_determinism.py tests/sim/test_hazard_fairness.py -q` → green
|
||||
- [ ] `uv run python tools/record_hazard_golden.py` regenerates the golden byte-identically (run twice, compare hashes)
|
||||
- [ ] corrupting one generated hazard config in a scratch copy makes a validator rule fire (demonstrated, then reverted)
|
||||
- [ ] every invariant in `docs/SIMULATION.md` names the test that enforces it
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,50 @@
|
||||
# Phase 11 — The ten-level campaign and progression
|
||||
|
||||
**Story:** `US-07_campaign_progression`
|
||||
**Context:** `.agents/PLAN.md` §6 (levels), §12 (escalation axes), `docs/LEVELS.md`
|
||||
|
||||
## Objective
|
||||
Ship the campaign: author levels 3–10 on the escalation axes, make progression (unlock,
|
||||
best score, next-level flow) real across a session, and prove the curve climbs — every
|
||||
level measurably harder than the last by the metrics, not by opinion.
|
||||
|
||||
## Dependencies
|
||||
- `10_hazards_and_escalation_mechanics` (complete) — hazards, pressure model, solver/fairness hooks
|
||||
- `05_anonymous_lobbies_and_room_lifecycle` (complete) — session progression store
|
||||
- `07_canvas_client_and_hud` (complete) — level picker, result screen `Next level`
|
||||
|
||||
## Tasks
|
||||
1. `01_author_levels_03_to_06.md` — two-room split, first conveyor, tight galley, spill kitchen
|
||||
2. `02_author_levels_07_to_10.md` — firehouse, rush gauntlet, dark kitchen, the finale
|
||||
3. `03_escalation_curve_gate.md` — per-level metrics computed and asserted monotonic non-decreasing
|
||||
4. `04_campaign_flow_and_level_picker.md` — unlock gating, next/retry flow, best scores, host picker UX
|
||||
5. `05_campaign_playthrough_suite.md` — scripted 10-level playthrough proving unlock order and persistence
|
||||
|
||||
## Testing & Quality
|
||||
- Content: `uv run undercooked-levels validate` clean; manifest count test never hard-codes the
|
||||
number of levels; every level's `id`, `order_book`, `hazards`, and `stars` documented in
|
||||
`docs/LEVELS.md` with a doc-coverage test.
|
||||
- Curve: `tests/sim/test_escalation_curve.py` — for consecutive levels, the composite difficulty
|
||||
score (defined in the task) must be non-decreasing, with at least 7 of 9 strict increases
|
||||
across the campaign; solver completion time must rise or the star target must rise.
|
||||
- E2E: full campaign playthrough (fast-forced via the test clock), unlock gating enforced
|
||||
server-side (a client cannot start a locked level), best score round-trip.
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
- 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**.
|
||||
- Tasks complete `docs/LEVELS.md` (authoring guide + per-level sheet), `README.md` campaign
|
||||
list, `docs/DESIGN.md` difficulty model.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] 10 shipped levels validate clean, and `tests/` still contains no hard-coded level list
|
||||
- [ ] `uv run pytest tests/sim/test_escalation_curve.py -q` → green (non-decreasing composite, ≥ 7 strict increases)
|
||||
- [ ] `uv run pytest tests/e2e/test_us07_campaign_progression.py -q` → green (locked level refused server-side)
|
||||
- [ ] a scripted session completes levels 1→10 in order with best scores persisted and displayed
|
||||
- [ ] `docs/LEVELS.md` documents every level and every field (doc-coverage test green)
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,47 @@
|
||||
# Task 01 — Author levels 3–6
|
||||
|
||||
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
|
||||
|
||||
## Objective
|
||||
Build the middle of the curve: four levels that each introduce exactly one new pressure and
|
||||
teach it before testing it.
|
||||
|
||||
## Work
|
||||
1. Author `levels/03_two_rooms.yaml` … `levels/06_spill_kitchen.yaml` to the schema, each with
|
||||
`id`, `name`, `blurb`, `difficulty`, `time_limit`, `target_orders`, `order_book`,
|
||||
`stations`, `hazards`, `stars`, `spawn_points`, `grid`:
|
||||
- **03 Two Rooms** — kitchen split by a wall with two doorways; one side holds the chop +
|
||||
pot, the other the stove + serve window; teaches route cost. Hazards: none. Difficulty 3.
|
||||
- **04 Conveyor** — first `mode: conveyor` counter row (deterministic 1-tile-per-
|
||||
`conveyor_period` shift, wraps at the end into a `drop_slot` that returns items to the
|
||||
spawn shelf): teaches timing your grab. Difficulty 4.
|
||||
- **05 Galley** — 14 × 6 corridor map, 2 spawn points, tight reach overlaps, no hazards but
|
||||
`max_orders: 5` and a 2-recipe book; teaches contested space (the phase-09 arbitration
|
||||
shows up here naturally). Difficulty 5.
|
||||
- **06 Spill Kitchen** — `hazards.spills.enabled` with 3 `seed_cells`, `every_n_th_entry: 1`,
|
||||
one sink shared by both halves; mop-or-lose tension. Difficulty 6.
|
||||
2. Each level gets a fixture test in `tests/sim/test_level_playability.py` (parametrized by the
|
||||
manifest, never by a hard-coded list): a greedy scripted bot reaches `target_orders` within
|
||||
`time_limit` for 1–4 player counts, the star thresholds are strictly ordered
|
||||
(`1 < 2 < 3` and 3-star requires beating the target by the documented margin), and the
|
||||
reachable-set assertions from phase 02 hold.
|
||||
3. `docs/LEVELS.md` — one sheet per level: intent, the one new pressure, layout ASCII map, the
|
||||
metric snapshot from task 03 (filled by task 03, placeholder until then), and authoring notes.
|
||||
4. Balance pass: create `tools/curve.py` now (stdlib + the sim package only) — it loads the
|
||||
manifest, runs the greedy bot per player count, and prints the per-level metric table
|
||||
defined in task 03; task 03 adds the monotonicity gate on top. Adjust `time_limit`,
|
||||
`target_orders`, and `order_book` weights until the composite score is non-decreasing for
|
||||
1→6, and record the numbers in the phase report.
|
||||
|
||||
## Testing & Quality
|
||||
- Sim playability fixtures per level + validator; no hard-coded level lists anywhere.
|
||||
- Coverage: **>90%** on any changed sim/validator code.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run undercooked-levels validate` → clean with 6 levels
|
||||
- [ ] `uv run pytest tests/sim/test_level_playability.py -q` → green for 1, 2, and 4 players on every shipped level
|
||||
- [ ] each new level introduces exactly one new pressure (stated in `docs/LEVELS.md` and checkable from the YAML's diff vs the previous level)
|
||||
- [ ] composite difficulty is non-decreasing from level 1 through 6 (numbers in the phase report)
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,42 @@
|
||||
# Task 02 — Author levels 7–10
|
||||
|
||||
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
|
||||
|
||||
## Objective
|
||||
The back half: layered hazards and pressure, ending on a level that uses everything the
|
||||
campaign taught.
|
||||
|
||||
## Work
|
||||
1. Author `levels/07_firehouse.yaml` … `levels/10_finale.yaml`:
|
||||
- **07 Firehouse** — high-burn recipes (`burn_ticks` tightened), 2 stoves, sink central,
|
||||
`hazards.fire.enabled` with `max_fires: 3`; teaches extinguish-vs-cook triage. Difficulty 7.
|
||||
- **08 Rush Gauntlet** — `hazards.rush.waves` (3 waves at 40 s / 95 s / 140 s,
|
||||
`order_time_mult` 0.8 / 0.65 / 0.5, labels `LUNCH`, `RUSH`, `LAST CALL`), no physical
|
||||
hazards; pure time pressure. Difficulty 7.
|
||||
- **09 Obstacle Course** — 2 conveyor rows + 1 `pingpong` obstacle crossing the only bridge;
|
||||
`hazards.spills` disabled to keep one variable legible. Difficulty 8.
|
||||
- **10 Finale** — two rooms + conveyor + fire + a 2-wave rush + `dark_kitchen` modifier for
|
||||
the final 30 s; 3 recipes; `time_limit: 200`; the everything-at-once test. Difficulty 10.
|
||||
2. Same playability fixtures as task 01 (the parametrized file picks them up automatically) —
|
||||
if the greedy bot cannot finish a level, that is a level bug (fix the level), unless the bot
|
||||
genuinely lacks the mechanics (then say so in the report and add the missing bot behaviour to
|
||||
`tests/sim/bots.py`, which is allowed).
|
||||
3. `dark_kitchen` modifier: server effect is already defined in phase 10 task 04 — verify the
|
||||
client overlay (`render/veil.js`) is driven **only** by the modifier flag in state (no
|
||||
position hiding on the server; this stays a presentation effect, documented).
|
||||
4. `docs/LEVELS.md` sheets for 7–10, plus an "everything the campaign teaches" table mapping
|
||||
each mechanic to the level that introduces it and the level that tests it under pressure.
|
||||
|
||||
## Testing & Quality
|
||||
- Sim playability + validator + curve numbers.
|
||||
- Coverage: **>90%**.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run undercooked-levels validate` → clean with 10 levels
|
||||
- [ ] `uv run pytest tests/sim/test_level_playability.py -q` → green on all 10 levels, 1/2/4 players
|
||||
- [ ] level 10 is completable by the bot with all four hazard systems active (asserted)
|
||||
- [ ] `docs/LEVELS.md` has the mechanic-introduction table complete for all 10 levels
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,45 @@
|
||||
# Task 03 — Escalation curve gate
|
||||
|
||||
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
|
||||
|
||||
## Objective
|
||||
Turn "harder" into a number, and make CI fail if the campaign stops climbing.
|
||||
|
||||
## Work
|
||||
1. Define the composite difficulty score in `tools/curve.py` as an explicit, documented formula
|
||||
over measurable axes (no vibes):
|
||||
- `throughput_demand = target_orders / time_limit` (normalized),
|
||||
- `route_cost` = mean bot path length per served order (cells),
|
||||
- `contention` = `contest_losses + lock_waits` per 100 ticks from the bot run,
|
||||
- `pipeline_depth` = mean recipe steps across the `order_book`,
|
||||
- `hazard_load` = weighted count of active hazard systems × their intensity fields,
|
||||
- `dirty_pressure` = sink round-trips per served order,
|
||||
- `score = Σ w_i · axis_i` with the weights declared once in `tools/curve.py` (and mirrored in
|
||||
`docs/DESIGN.md`), so a weight change is a deliberate, reviewable edit.
|
||||
2. `tests/sim/test_escalation_curve.py`:
|
||||
- loads the manifest, runs the bot at 2 players for every level, computes the axes,
|
||||
asserts the composite is non-decreasing across `difficulty` order, with ≥ 7 of 9 strict
|
||||
increases (equal scores allowed only for adjacent levels explicitly annotated
|
||||
`difficulty_tie: <reason>` in the YAML — the annotation is validated too),
|
||||
- asserts `stars` thresholds are strictly increasing per level and that 3 stars requires
|
||||
beating `target_orders` by ≥ the documented margin,
|
||||
- prints a table on failure (so a balance regression names the offending axis).
|
||||
3. `tools/curve.py --report` writes the current table to `docs/LEVELS.md`'s metrics section
|
||||
between `<!-- curve:begin -->` / `<!-- curve:end -->` markers; `tests/unit/test_docs_index.py`
|
||||
extended to assert the markers exist and the table is not stale (regenerating produces
|
||||
identical bytes).
|
||||
4. Fix any level the gate flags by editing YAML, not the weights (weights move only with a
|
||||
documented rationale in `docs/DESIGN.md`).
|
||||
|
||||
## Testing & Quality
|
||||
- The gate itself is the deliverable; runtime < 120 s (mark `slow`, run in `validate.sh`).
|
||||
- Coverage: **>90%** on `tools/curve.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest -m slow tests/sim/test_escalation_curve.py -q` → green
|
||||
- [ ] temporarily editing one level's `time_limit` to flatten the curve makes the test fail and names the axis (demonstrated, then reverted)
|
||||
- [ ] `uv run python tools/curve.py --report` is idempotent (byte-identical on a second run)
|
||||
- [ ] weights and axes documented in `docs/DESIGN.md` with the rationale for each
|
||||
- [ ] 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
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Task 04 — Campaign flow, unlock gating, and the level picker
|
||||
|
||||
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
|
||||
|
||||
## Objective
|
||||
Make progression real and tamper-proof: the server decides what is unlocked, the picker shows
|
||||
it, and finishing a level offers the right next step.
|
||||
|
||||
## Work
|
||||
1. Server (`rooms/session.py`, `server/rooms.py`):
|
||||
- `unlocked_levels(session_id)` derived from `best_scores` (a level unlocks when the previous
|
||||
one is completed at ≥ 1 star) with `levels/campaign.yaml` order as the source of truth;
|
||||
- `set_level` **and** `start` reject a level the starting player's session has not unlocked
|
||||
→ `err(level_locked)` with the payload naming the highest unlocked id; spectating/host
|
||||
changes never bypass it, and a room whose members have different progress uses the **host's**
|
||||
progress (documented, and shown in the picker as "host progress");
|
||||
- `/api/session` gains `unlocked: [...]`, `next_level`, `campaign_complete: bool`;
|
||||
- `result` payload gains `unlocked_next` (`id | null`) and `campaign_complete`.
|
||||
2. Client:
|
||||
- level picker: locked levels visible-but-dimmed with the unlock condition as a tooltip,
|
||||
best score + stars per level, `difficulty` pips, and the `blurb`; keyboard navigable,
|
||||
`aria-disabled` rather than hidden;
|
||||
- result screen: `Next level` only when `unlocked_next`, focus moves to the primary action,
|
||||
`Retry` keeps the room and level, `Back to lobby` returns to `LOBBY`, and a
|
||||
`campaign_complete` celebration panel (non-blocking, dismissible, announced);
|
||||
- "host progress" badge plus a note when your own progress differs.
|
||||
3. `tests/server/test_unlock_gating.py` — unlock derivation, `set_level`/`start` rejection with
|
||||
the exact error code, host-progress rule, `campaign_complete`, a spoofed `set_level` for a
|
||||
locked id over a real socket refused.
|
||||
4. `tests/e2e/test_us07_picker.py` — locked level not startable (control disabled + server
|
||||
refusal if forced), best score/stars render, `Next level` appears only when unlocked,
|
||||
keyboard-only navigation reaches every control, screen-reader text present for locked state.
|
||||
|
||||
## Testing & Quality
|
||||
- Server + e2e; the spoofed-locked-level test is the security-relevant one.
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/server/test_unlock_gating.py tests/e2e/test_us07_picker.py -q` → green
|
||||
- [ ] forcing a locked `set_level` over the wire is refused with `level_locked` (asserted)
|
||||
- [ ] `Next level` / `Retry` / `Back to lobby` behave correctly and are keyboard reachable
|
||||
- [ ] campaign completion state is visible and announced once
|
||||
- [ ] 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 05 — Full campaign playthrough suite
|
||||
|
||||
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
|
||||
|
||||
## Objective
|
||||
One suite that plays the whole campaign start to finish and proves the progression story holds
|
||||
end to end, in a browser, with real sockets.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_us07_campaign_progression.py` (marked `slow`):
|
||||
- a single browser context + session token plays levels 1→10 via the bot driver
|
||||
(`tests/e2e/bot_driver.py`: reuses the sim bot, replaying its input intents through the
|
||||
real input channel rather than poking the server), finishing each level, then clicking
|
||||
`Next level`;
|
||||
- asserts after every level: result screen shows stars and best score, the next level became
|
||||
unlocked (picker state + `/api/session`), `unlocked_next` matches, and the session record
|
||||
advanced;
|
||||
- asserts a retry after a "lost" round (forced by a test-only `?fail=1` flag that makes the
|
||||
bot idle) does not lose progress and does not re-unlock anything;
|
||||
- asserts mid-campaign disconnect (socket kill between levels) preserves progress on rejoin;
|
||||
- asserts a second browser context with a fresh token sees level 1 only, and that the two
|
||||
sessions do not leak into each other;
|
||||
- finishes the campaign and asserts the completion panel + `campaign_complete` in
|
||||
`/api/session`.
|
||||
2. Runtime discipline: the suite uses the test clock (`UNDERCOOKED_TIME_SCALE=8`, honored only
|
||||
when `UNDERCOOKED_TEST_MODE=1`) so 10 levels run in < 4 minutes; if `TIME_SCALE` does not
|
||||
exist yet, implement it as a room-level tick-source change (the sim's `STEP_DT` never
|
||||
changes — only how fast ticks are pulled), documented in `docs/SIMULATION.md`, with unit
|
||||
tests that a scaled round produces identical state to an unscaled one.
|
||||
3. `tests/server/test_time_scale.py` — scaling changes wall-clock pacing only: same input
|
||||
script, same tick count, same final hash, half the wall time (± tolerance).
|
||||
4. `tests/e2e/README.md` — how to run just the campaign suite, and how to shorten it
|
||||
(`-k "level_0"` for the first three).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E `slow` campaign suite + time-scale unit/server tests; the whole thing must be deterministic
|
||||
(fixed seeds, no wall-clock assertions tighter than 3× tolerance).
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest -m slow tests/e2e/test_us07_campaign_progression.py -q` → green in < 6 minutes
|
||||
- [ ] `uv run pytest tests/server/test_time_scale.py -q` → green with identical final state hash scaled vs unscaled
|
||||
- [ ] progress survives a between-levels socket kill (asserted)
|
||||
- [ ] a fresh session sees only level 1 (asserted), and sessions are isolated
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,48 @@
|
||||
# Phase 12 — Resilience, garbage collection, and scale
|
||||
|
||||
**Story:** `US-09_disconnect_and_rejoin`, `US-01_create_anonymous_lobby`
|
||||
**Context:** `.agents/PLAN.md` §10 (performance budgets), §8 (lifecycle), A6
|
||||
|
||||
## Objective
|
||||
Make the service boringly reliable: rooms that always empty, memory that always comes back,
|
||||
rejoins that work under churn, and measured capacity for 200 concurrent rooms with hard
|
||||
limits that fail safe instead of degrading everyone.
|
||||
|
||||
## Dependencies
|
||||
- `11_campaign_levels_and_progression` (complete) — realistic room load (10 levels, hazards) to
|
||||
scale against
|
||||
- `06_realtime_websocket_protocol` (complete) — heartbeats, seat grace, socket-kill seam
|
||||
|
||||
## Tasks
|
||||
1. `01_room_garbage_collection.md` — every room-releasing path, idle GC, leak test that memory returns to baseline
|
||||
2. `02_rejoin_chaos_matrix.md` — disconnect/rejoin/restart churn under load with invariant checks
|
||||
3. `03_tick_scheduler_under_load.md` — one scheduler for all rooms, adaptive coalescing, per-room fairness
|
||||
4. `04_capacity_and_degradation.md` — 200-room capacity test, admission control, graceful "full" behavior
|
||||
5. `05_ops_observability_and_runbook.md` — `/api/health` deep metrics, structured logs, `docs/DEPLOYMENT.md` runbook
|
||||
|
||||
## Testing & Quality
|
||||
- Integration + perf (`slow`): leak test (200 create/play/close cycles → RSS within 5 % of
|
||||
baseline), churn matrix, 200-room capacity with p95 tick budget respected, admission control
|
||||
returning the documented `503`/`room_full` instead of queuing.
|
||||
- Invariants under every stress: no orphaned tasks (`asyncio.all_tasks` bounded), no room whose
|
||||
`last_activity` exceeds the GC window, no session record for a closed room beyond the TTL, zero
|
||||
unhandled exceptions in the server log.
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
- 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**.
|
||||
- Tasks finish `docs/DEPLOYMENT.md` (capacity numbers, limits, runbook: "rooms not emptying",
|
||||
"tick lag", "memory climb"), and README's "what this demo is / is not" section.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest -m slow tests/integration/test_capacity.py -q` → 200 rooms, p95 tick cost ≤ 2 ms, memory within budget
|
||||
- [ ] `uv run pytest tests/integration/test_room_gc.py -q` → green: 200 create/close cycles return RSS to within 5 % of baseline
|
||||
- [ ] `uv run pytest -m slow tests/integration/test_rejoin_chaos.py -q` → green, zero invariant violations
|
||||
- [ ] admission control returns the documented response at the cap (asserted), never a silent queue
|
||||
- [ ] `docs/DEPLOYMENT.md` runbook covers the three named failure modes with the actual commands
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,47 @@
|
||||
# Task 01 — Room garbage collection and leak proof
|
||||
|
||||
**Phase:** `12_reconnect_resilience_and_scale` · **Story:** `US-09_disconnect_and_rejoin`
|
||||
|
||||
## Objective
|
||||
Every code path that could keep a room alive must be enumerated, tested, and provably
|
||||
non-leaking.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/gc.py`:
|
||||
- single owner of reclamation: `RoomReaper` with rules — empty room past
|
||||
`empty_grace_s`, all guests disconnected past `abandon_s`, round finished past
|
||||
`result_hold_s` with no activity, lobby idle past `lobby_idle_s`, session records past
|
||||
`session_ttl_s` with no connected seat;
|
||||
- every release path funnels through `release(room_code, reason)` with a `reason` enum
|
||||
(`host_leave`, `all_empty`, `idle`, `finished`, `evicted`, `shutdown`) — no ad-hoc
|
||||
`del`/dict pops anywhere else (enforced by a grep test);
|
||||
- cancellation is total: room task, tick task, per-connection send tasks, heartbeat timers;
|
||||
`release` awaits them with a timeout and logs (counted in `/api/health`) any that had to be
|
||||
abandoned;
|
||||
- `sweep_once(now)` returns the swept codes + reasons; runs on a timer **and** is callable
|
||||
from tests with a `FakeClock`.
|
||||
2. `tests/integration/test_room_gc.py`:
|
||||
- one test per release reason, each asserting: room gone from the registry, no tasks left,
|
||||
connections closed with the right code, session record TTL'd, and `sweep_once` is idempotent
|
||||
(a second sweep sweeps nothing);
|
||||
- the leak cycle: 200 create → start → play 5 s → close cycles; assert RSS within 5 % of
|
||||
baseline after forced `gc.collect()` (measured with `tracemalloc` top-3 report printed on
|
||||
failure), and `asyncio.all_tasks()` count back to the baseline;
|
||||
- a room with one connected spectator is never swept while the spectator watches.
|
||||
3. `tests/unit/test_release_paths_exclusive.py` — static guard: no `rooms._rooms.pop` outside
|
||||
`gc.py` (AST scan of `src/undercooked/server/`), so future code cannot leak by side door.
|
||||
4. `/api/health` gains `rooms`, `seats`, `spectators`, `tasks`, `rss_mb`, `sweeps_total` broken
|
||||
out by reason.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration + unit (AST guard); tracemalloc-backed leak assertions.
|
||||
- Coverage: **>90%** on `gc.py` (target 95%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_room_gc.py tests/unit/test_release_paths_exclusive.py -q` → green
|
||||
- [ ] 200 create/close cycles leave RSS within 5 % of baseline (asserted, with a tracemalloc report on failure)
|
||||
- [ ] planting a `rooms._rooms.pop` outside `gc.py` fails the AST guard (demonstrated, then reverted)
|
||||
- [ ] every sweep reason is covered by exactly one named test
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,44 @@
|
||||
# Task 02 — Rejoin chaos matrix
|
||||
|
||||
**Phase:** `12_reconnect_resilience_and_scale` · **Story:** `US-09_disconnect_and_rejoin`
|
||||
|
||||
## Objective
|
||||
Throw connection churn at real rooms and prove nothing corrupts: no double seats, no lost
|
||||
items, no score jumps, no stuck countdowns.
|
||||
|
||||
## Work
|
||||
1. `tests/integration/test_rejoin_chaos.py` (`slow`):
|
||||
- matrix over `{players: 2, 4} × {level: 1, 7, 10} × {churn: abrupt_close, offline_event,
|
||||
server_side_kill, token_reuse_storm, join_during_countdown, rejoin_after_grace}`;
|
||||
- each scenario: 30 random churn events during a live round (seeded), then assert the
|
||||
invariants below;
|
||||
- invariants after every scenario: exactly one seat per token, no seat with two connections,
|
||||
item conservation (`counted + wasted + lost == produced`), score monotonic non-decreasing,
|
||||
`round` payload produced exactly once per round, no room stuck in `COUNTDOWN` past
|
||||
`countdown_s + 2`, ghost states converge (`GHOST_ON` ⇒ eventually `GHOST_OFF` or seat
|
||||
released), zero unhandled exceptions in the captured server log;
|
||||
- a `token_reuse_storm` (same token connecting 10× concurrently) must end with exactly one
|
||||
live connection for that seat and the others closed with `4404` or `1000` (documented).
|
||||
2. Fix the failures it finds in `server/room.py` / `server/ws.py`; each fix gets a focused
|
||||
regression test in `tests/integration/test_ws_disconnect.py` (extend) with a name that states
|
||||
the race.
|
||||
3. `docs/SIMULATION.md` / `docs/PROTOCOL.md` — document the concurrency rules that make this
|
||||
work (single-writer room task, inputs applied only inside the tick, no awaits inside the sim
|
||||
step) so future code does not break them.
|
||||
4. `/api/health` gains `rejoins_total`, `ghost_seats`, `seat_conflicts_total` (the last must stay
|
||||
0 — asserted in the chaos suite).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration stress (`slow`), seeded and reproducible (failure prints the seed and a
|
||||
reproduction command).
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest -m slow tests/integration/test_rejoin_chaos.py -q` → green for all 12 scenarios
|
||||
- [ ] `seat_conflicts_total == 0` across the matrix (asserted)
|
||||
- [ ] item conservation and score monotonicity hold in every scenario (asserted)
|
||||
- [ ] every fix has a named regression test; the report lists the races found
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,45 @@
|
||||
# Task 03 — One scheduler for all rooms
|
||||
|
||||
**Phase:** `12_reconnect_resilience_and_scale` · **Story:** `US-01_create_anonymous_lobby`
|
||||
|
||||
## Objective
|
||||
Replace per-room tick loops with a single fair scheduler so 200 rooms cost ~200× the sim and
|
||||
not 200× the event-loop overhead.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/scheduler.py`:
|
||||
- one `RoomScheduler` task owning all room tick timers; rooms register/unregister;
|
||||
- fixed 20 Hz budget loop: each cycle computes the due rooms, runs their `tick_once` in
|
||||
registration order (deterministic, no starvation), and yields between rooms every
|
||||
`YIELD_EVERY_ROOMS = 16` to keep the event loop responsive;
|
||||
- adaptive degradation ladder (documented + metrics): if the cycle overruns
|
||||
`budget_ms`, degrade in order — drop snapshot rate for distant spectators → halve tick rate
|
||||
for rooms with all-seats-idle → refuse new rooms (task 04's admission control) — never
|
||||
drop a room's inputs, and always emit a `DEGRADED` metric with the ladder step;
|
||||
- per-room fairness counter (`starved_cycles`) exposed in `/api/health`; must stay 0 in the
|
||||
capacity test.
|
||||
2. Remove per-room tick tasks from `server/room.py` (keep the room's single-writer queue
|
||||
semantics: inputs still applied only inside `tick_once`).
|
||||
3. `tests/integration/test_scheduler.py`:
|
||||
- 50 rooms tick at the correct rate (measured ticks/second per room within 2 %),
|
||||
- a deliberately slow room (test-only injected sleep) does not starve others
|
||||
(`starved_cycles == 0` for the other 49),
|
||||
- degradation ladder engages and reports the right step, then recovers when the load drops,
|
||||
- scheduler shutdown cancels cleanly (no pending tasks), and a room unregistering mid-cycle
|
||||
is safe (no "task was destroyed", no tick on a released room).
|
||||
4. `docs/DEPLOYMENT.md` — the ladder, what each step means for players, and the metric to watch.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration with a controllable clock and injected slow rooms.
|
||||
- Coverage: **>90%** on `scheduler.py` (target 95%).
|
||||
- Full suite green (the phase-06 two-client round suite must pass unchanged — it is the real
|
||||
behavior guard).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_scheduler.py -q` → green
|
||||
- [ ] 50 rooms hold 20 Hz within 2 % (asserted)
|
||||
- [ ] one slow room does not starve the others and the ladder reports the correct step (asserted)
|
||||
- [ ] `uv run pytest tests/integration/test_ws_round_two_clients.py -q` → still green
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,50 @@
|
||||
# Task 04 — Capacity, admission control, and fail-safe limits
|
||||
|
||||
**Phase:** `12_reconnect_resilience_and_scale` · **Story:** `US-01_create_anonymous_lobby`
|
||||
|
||||
## Objective
|
||||
Know the ceiling, enforce it politely, and prove the service degrades for nobody rather than
|
||||
for everybody.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/admission.py`:
|
||||
- limits from config: `max_rooms`, `max_connections_total`, `max_connections_per_ip`,
|
||||
`max_rooms_per_ip`, `max_starting_per_second`;
|
||||
- `POST /api/rooms` returns `503` + `Retry-After` + JSON `{code: "server_full"}` when at
|
||||
capacity (documented in `docs/PROTOCOL.md`), `/ws` closes with `4403` (new close code) for
|
||||
the same condition, and the client shows "the kitchen is full, try again in a moment"
|
||||
(retry affordance, code preserved) instead of spinning;
|
||||
- never queue: refuse immediately (a queue would create the timeout UX we are avoiding);
|
||||
- `/api/health` reports `capacity: {rooms, connections, limit, degraded}`;
|
||||
`ErrorCode` gains `server_full` and close code `4403` is added, so bump
|
||||
`PROTOCOL_VERSION`, update `docs/PROTOCOL.md`, and let
|
||||
`tests/unit/test_client_enum_parity.py` force the JS side to follow (AGENTS §5).
|
||||
2. `tests/integration/test_capacity.py` (`slow`):
|
||||
- 200 rooms × 4 seats created and started (bots idle at the counter), measured over 60 s:
|
||||
p95 `tick_once` cost ≤ 2 ms, mean ≤ 1 ms, RSS ≤ `max_memory_mb`, snapshot downlink per
|
||||
connection ≤ 60 KB/s, `starved_cycles == 0`, zero errors in the log;
|
||||
- 201st room → `503`/`server_full` and the 200 existing rooms keep their budgets (measured
|
||||
before/after, no regression > 5 %);
|
||||
- per-IP limits enforced with a rotated-source simulation (fixture patches the ASGI
|
||||
`client` scope), and `max_rooms_per_ip` prevents one browser from hoarding codes;
|
||||
- memory after the test returns to baseline (uses task 01's reaper).
|
||||
3. Client: `screens/error.js` handles `server_full` and close `4403` with a countdown retry and
|
||||
the join code preserved; `docs/CLIENT.md` documents the codes.
|
||||
4. `docs/DEPLOYMENT.md` — measured capacity table (rooms, seats, RSS, CPU), the limits and how
|
||||
to raise them, and the "one process, no horizontal scaling in v1" statement with the reason
|
||||
(in-memory rooms, A6) and what v2 would need (consistent hashing / sticky routing — explicitly
|
||||
out of scope).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration perf (`slow`) with hard numeric gates; the suite is the release capacity proof.
|
||||
- Coverage: **>90%** on `admission.py`.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest -m slow tests/integration/test_capacity.py -q` → green with all budgets met
|
||||
- [ ] the 201st create request returns `503 server_full` and existing rooms are unaffected (asserted)
|
||||
- [ ] `/ws` at capacity closes `4403` and the client shows the retry copy (e2e-asserted)
|
||||
- [ ] `docs/DEPLOYMENT.md` records the measured numbers with the date and machine profile
|
||||
- [ ] 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
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Task 05 — Observability and the operations runbook
|
||||
|
||||
**Phase:** `12_reconnect_resilience_and_scale` · **Story:** `US-09_disconnect_and_rejoin`
|
||||
|
||||
## Objective
|
||||
Make the running service diagnosable from the outside, with logs that never leak anything
|
||||
personal, and write the runbook someone else would follow at 2 a.m.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/observability.py`:
|
||||
- structured JSON logs (stdlib `logging` + a JSON formatter): `ts`, `level`, `event`,
|
||||
`room` (short code), `seat`, `tick`, `dur_ms`, `code` — no names, no tokens, no IPs in
|
||||
info logs (IP only at `debug`, and `debug` is off by default);
|
||||
- counters/histograms in-process (`metrics.py`): rooms live, seats, rejoins, ghosts, sweeps
|
||||
by reason, denies by reason, tick cost histogram, snapshot bytes, ws closes by code,
|
||||
admission rejections, degradation steps;
|
||||
- `GET /api/health` (public, counts only, no room codes) and `GET /api/metrics`
|
||||
(text/plain, Prometheus-style exposition) gated by `metrics_enabled` **and**
|
||||
`metrics_bind_private` (default: only served when the request comes from a private/
|
||||
loopback client — the reverse proxy is the intended consumer), documented in
|
||||
`docs/PROTOCOL.md`.
|
||||
2. Log-leak guard `tests/unit/test_no_pii_in_logs.py`: run a scripted round with capability-tagged
|
||||
fake secrets (`SECRET_TOKEN_marker`, `SECRET_NAME_marker`) as tokens/names, capture logs, and
|
||||
fail if any `SECRET_*_marker` appears in INFO/WARNING output; also assert room codes *are*
|
||||
present (they are the support handle) so the guard is not trivially satisfied.
|
||||
3. `tests/server/test_metrics.py` — exposition format parses, the documented metric names exist,
|
||||
private-network gate allows loopback and refuses a public client IP, `metrics_enabled=false`
|
||||
yields `404`.
|
||||
4. `docs/DEPLOYMENT.md` — full runbook: build, run, TLS via the deployment repo's certbot/nginx
|
||||
pattern (WebSocket `proxy_http_version 1.1` + `Upgrade` headers spelled out), systemd unit,
|
||||
log rotation, the three failure modes ("rooms not emptying", "tick lag", "memory climb") with
|
||||
the exact `/api/health` + `/api/metrics` fields to read, and the restart-is-wipes-everything
|
||||
caveat (A6) stated plainly.
|
||||
5. `README.md` — "Running this for real" section pointing at `docs/DEPLOYMENT.md`, plus an honest
|
||||
"what this is / is not" paragraph (single process, in-memory, demo-grade persistence,
|
||||
production-grade transport).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit (log leak, formatter) + server (metrics, gating).
|
||||
- Coverage: **>90%** on `observability.py`/`metrics.py`.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_no_pii_in_logs.py tests/server/test_metrics.py -q` → green
|
||||
- [ ] planting a token in an INFO log makes the guard fail (demonstrated, then reverted)
|
||||
- [ ] `/api/metrics` is 404 for a public client IP and 200 for loopback (asserted)
|
||||
- [ ] `docs/DEPLOYMENT.md` nginx block actually proxies a WebSocket (verified once against a local nginx config or stated as unverified in the 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
|
||||
@@ -0,0 +1,50 @@
|
||||
# Phase 13 — Endless mode and session scoreboard
|
||||
|
||||
**Story:** `US-10_endless_mode_and_scoreboard`
|
||||
**Context:** `.agents/PLAN.md` §6 (endless), A6 (in-memory only — no persistence anywhere)
|
||||
|
||||
## Objective
|
||||
Add the mode that never ends and the board that makes it worth retrying: procedurally
|
||||
escalating endless rounds from the shipped level vocabulary, and an in-memory session
|
||||
scoreboard — explicitly volatile, honestly labelled as such in the UI.
|
||||
|
||||
## Dependencies
|
||||
- `11_campaign_levels_and_progression` (complete) — 10 authored levels, curve tooling, bots
|
||||
- `12_reconnect_resilience_and_scale` (complete) — GC and capacity so endless rooms cannot grow
|
||||
without bound
|
||||
|
||||
## Tasks
|
||||
1. `01_endless_generator.md` — deterministic level remixer: seeded mutations of authored maps within the validator's rules
|
||||
2. `02_endless_round_loop.md` — escalating waves with no round end, failure/streak rules, per-wave events
|
||||
3. `03_session_scoreboard_store.md` — in-memory top scores keyed by session/room with TTL and hard caps
|
||||
4. `04_scoreboard_ui_and_honesty.md` — board UI, "stored in memory only" labelling, personal-best comparison
|
||||
5. `05_endless_e2e_and_bounds.md` — e2e story suite plus the hard-bounds tests (memory, time, entries)
|
||||
|
||||
## Testing & Quality
|
||||
- Unit (sim): generator determinism, generated maps pass every phase-02/10 validator rule
|
||||
(property test over 500 seeds), escalation monotonicity, no unwinnable configuration.
|
||||
- Server: scoreboard caps, TTL, ordering rules, tie-breaks, and a restart-clears-it test.
|
||||
- E2E (`test_us10_endless_mode_and_scoreboard.py`): start endless, survive waves, die/restart,
|
||||
see yourself on the board, and see the volatility notice.
|
||||
- Hard bounds: generated level count, scoreboard entries, and per-room endless memory growth are
|
||||
capped and asserted (a 30-minute accelerated endless round must not grow RSS beyond a stated
|
||||
per-room budget).
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
- 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**.
|
||||
- Tasks update `docs/LEVELS.md` (generator vocabulary), `docs/SIMULATION.md` (endless rules),
|
||||
`docs/PROTOCOL.md` (new messages/codes), `README.md` (endless mode + the persistence caveat).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_endless_generator.py -q` → 500 seeds validate, deterministic, monotonic escalation
|
||||
- [ ] `uv run pytest tests/e2e/test_us10_endless_mode_and_scoreboard.py -q` → green
|
||||
- [ ] an accelerated 30-minute endless round stays within the stated per-room memory budget (asserted)
|
||||
- [ ] the UI states plainly that scores are in-memory and vanish on restart (e2e-asserted text)
|
||||
- [ ] restarting the server clears the board (asserted) and nothing is written to disk
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,49 @@
|
||||
# Task 01 — Endless level generator
|
||||
|
||||
**Phase:** `13_endless_mode_and_scoreboard` · **Story:** `US-10_endless_mode_and_scoreboard`
|
||||
|
||||
## Objective
|
||||
Produce infinite kitchen layouts from the authored vocabulary — deterministic per seed, always
|
||||
valid, never unwinnable.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/sim/endless/gen.py`:
|
||||
- input: a base level id + `seed` + `wave_index`; output: a `Level` object (not YAML — built
|
||||
in memory, validated by the same `level.validate()` code path as authored levels);
|
||||
- mutations (each parameterized, each individually testable): `rotate_map`, `mirror_x`,
|
||||
`relocate_station(kind, count_delta)`, `resize(width, height)`, `spawn_order_book(weights,
|
||||
depth)`, `hazard_intensity(scale)`, `conveyor_repath`, `obstacle_repath`;
|
||||
- invariants the generator must guarantee *before* returning (belt-and-braces with the
|
||||
validator): reachable serve window from every spawn, ≥ 1 sink and ≥ 1 chop station, no
|
||||
isolated region, obstacles never sever the map (phase-10 rule), `spawn_points` on free
|
||||
floor, station counts within `[min,max]` per kind;
|
||||
- determinism: pure function of `(base_level, seed, wave_index)` — same inputs, byte-identical
|
||||
level (test hashes the serialized level).
|
||||
2. `src/undercooked/sim/endless/escalate.py` — the wave curve: `wave_params(wave_index)` returns
|
||||
`{order_time_mult, spawn_interval, max_orders, hazard_scale, recipes_allowed}` with an
|
||||
explicit asymptote (it approaches but never exceeds the clamp from phase 10), so difficulty
|
||||
cannot run away into impossibility.
|
||||
3. `tests/sim/test_endless_generator.py`:
|
||||
- 500 seeds × 3 base levels × waves 0/5/15: every generated level passes `level.validate()`;
|
||||
- determinism (hash equality across two generator instances and two processes — use a
|
||||
subprocess with the same seed);
|
||||
- monotonic escalation (`wave_params` composite non-decreasing, clamped at the asymptote);
|
||||
- solvability: greedy bot reaches `target_orders` for waves 0, 3, 8 and *attempts* waves 12+
|
||||
without crashing or hanging (assert the bot makes progress ≥ N serves, not a win);
|
||||
- mutation coverage: every mutation function exercised by at least one seed (assert the
|
||||
coverage set, so a mutation that never fires cannot hide).
|
||||
4. `docs/LEVELS.md` — the generator vocabulary table, invariants, and "why generated levels go
|
||||
through the same validator as authored ones".
|
||||
|
||||
## Testing & Quality
|
||||
- Property-style seeded tests + subprocess determinism check; runtime < 120 s (`slow` if longer).
|
||||
- Coverage: **>90%** on `endless/` (target 95%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_endless_generator.py -q` → green (500 seeds, 3 bases)
|
||||
- [ ] same seed in a separate process yields the identical level hash (asserted)
|
||||
- [ ] every mutation function is proven to fire at least once (asserted coverage set)
|
||||
- [ ] generated levels cannot exceed the difficulty clamp (asserted at wave 50)
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,47 @@
|
||||
# Task 02 — Endless round loop
|
||||
|
||||
**Phase:** `13_endless_mode_and_scoreboard` · **Story:** `US-10_endless_mode_and_scoreboard`
|
||||
|
||||
## Objective
|
||||
Run the mode that has no round end: waves regenerate, mistakes accumulate, and the room stays
|
||||
bounded and honest.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/endless.py` + `sim/endless/loop.py`:
|
||||
- room mode `ENDLESS`: no `time_limit`; wave N runs until `orders_served >= wave_target(N)` or
|
||||
`misses >= wave_miss_limit(N)`; on wave clear the generator produces wave N+1's level and
|
||||
the room transitions in place (players keep positions when the tile is still floor,
|
||||
otherwise relocate to the nearest free spawn — carried items are preserved or counted in
|
||||
`lost_items`, never deleted);
|
||||
- failure rule: `strikes` accumulate across waves (`max_strikes`, level-config); at max, the
|
||||
run ends with `reason: "strikes"`; a clean wave clear refunds one strike (documented);
|
||||
- scoring: cumulative score + per-wave bonus, streak rules unchanged, `endless_result`
|
||||
payload `{waves, score, best_wave, longest_streak, duration_s, seed, base_level}`;
|
||||
- bounding: only the current and previous wave's level objects are retained; order history is
|
||||
a ring buffer of 64 entries; events older than the client TTL are dropped server-side too
|
||||
(`event_history_limit`);
|
||||
- protocol: `start{mode: "endless", base_level, seed?}` (new optional fields ⇒ bump
|
||||
`PROTOCOL_VERSION`, update `docs/PROTOCOL.md`), `ev WAVE_START/WAVE_CLEAR/STRIKE/RUN_END`.
|
||||
2. `tests/sim/test_endless_loop.py` — wave transitions (position carry-over, item preservation),
|
||||
strike accounting and refund, run end at max strikes, ring-buffer caps, retention (only two
|
||||
levels alive), determinism for a seeded 20-wave run, and the invariant "no wave is generated
|
||||
while a tick is mid-flight".
|
||||
3. `tests/server/test_endless_room.py` — `start` mode routing, spectator behaviour, host-only
|
||||
restart, room GC while endless (an idle endless room is swept like any other — an endless
|
||||
mode must never defeat the reaper).
|
||||
4. `docs/SIMULATION.md` — endless rules (waves, strikes, refunds, caps).
|
||||
|
||||
## Testing & Quality
|
||||
- Sim + server tests; the GC interaction is a required test, not an assumption.
|
||||
- Coverage: **>90%** on `endless/`.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/sim/test_endless_loop.py tests/server/test_endless_room.py -q` → green
|
||||
- [ ] a 20-wave seeded run is deterministic and retains ≤ 2 level objects (asserted)
|
||||
- [ ] strikes refund on a clean clear and end the run at max (asserted)
|
||||
- [ ] an idle endless room is garbage collected (asserted)
|
||||
- [ ] `PROTOCOL_VERSION` bump + doc-sync test green
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,44 @@
|
||||
# Task 03 — In-memory scoreboard store
|
||||
|
||||
**Phase:** `13_endless_mode_and_scoreboard` · **Story:** `US-10_endless_mode_and_scoreboard`
|
||||
|
||||
## Objective
|
||||
A scoreboard that is fast, bounded, honestly volatile, and impossible to poison.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/scores.py`:
|
||||
- `ScoreStore` (no disk, no env, no external service): `add(entry)`, `top(scope, limit)`,
|
||||
`around(scope, session_id)`, `prune(now)`;
|
||||
- scopes: `global` (whole process) and `room` (per code); entry
|
||||
`{id, scope, session_id, name, score, waves, streak, mode, base_level, seed, ts}`;
|
||||
- caps: `max_entries_global` (default 500), `max_entries_room` (default 50),
|
||||
`score_ttl_s` (default 3 600); insertion beyond the cap evicts the lowest score (ties by
|
||||
oldest `ts`), and an entry is only accepted if it came from a room that actually reported a
|
||||
finished run for that `session_id` (the room is the authority — the client never posts
|
||||
scores over HTTP; assert no unauthenticated write path exists);
|
||||
- dedupe: one best entry per `(session_id, mode, base_level)`; a better score replaces it and
|
||||
returns `improved: true`;
|
||||
- name handling: the display name is sanitized at write time with the lobby's sanitizer and
|
||||
length-capped; `session_id` is the token hash, never the raw token.
|
||||
2. `GET /api/scores?scope=&room=&limit=` (public read, `limit ≤ 50`, no session ids in the
|
||||
response — only a `you: true` marker computed from the caller's token) documented in
|
||||
`docs/PROTOCOL.md`.
|
||||
3. `tests/server/test_scores.py` — ordering, tie-breaks, caps + eviction, TTL pruning, dedupe
|
||||
replacement, `around()` windowing, authority (a forged score with an unknown session/room is
|
||||
refused), privacy (response contains no session ids/tokens, sanitized names), and
|
||||
`prune()` idempotence.
|
||||
4. `tests/unit/test_scores_no_disk.py` — AST/`monkeypatch` guard: importing and exercising
|
||||
`scores.py` performs zero filesystem writes (patch `open`/`pathlib.Path.write_text` to raise).
|
||||
|
||||
## Testing & Quality
|
||||
- Server + a hard "no disk writes" guard (A6 is a promise, so it is tested).
|
||||
- Coverage: **>90%** on `scores.py` (target 95%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/server/test_scores.py tests/unit/test_scores_no_disk.py -q` → green
|
||||
- [ ] a forged score submission for an unknown room/session is refused (asserted)
|
||||
- [ ] the API response contains no raw tokens or session ids (asserted)
|
||||
- [ ] caps and TTL pruning verified with a fake clock
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,43 @@
|
||||
# Task 04 — Scoreboard UI and volatility honesty
|
||||
|
||||
**Phase:** `13_endless_mode_and_scoreboard` · **Story:** `US-10_endless_mode_and_scoreboard`, `US-12_play_accessibly`
|
||||
|
||||
## Objective
|
||||
Show the board — and never let a player believe their score is saved somewhere it isn't.
|
||||
|
||||
## Work
|
||||
1. `app/static/js/ui/scoreboard.js` + `screens/scoreboard.js`:
|
||||
- two tabs (`This room`, `All time` — the latter's tooltip says "this server process only"), sorted
|
||||
list with rank, name, score, waves, streak, mode badge, `you` highlight, personal-best row
|
||||
pinned on top when you are not in the list;
|
||||
- live refresh during a room (≤ every 5 s, only while the tab is visible, paused when hidden),
|
||||
`aria-live="polite"` on rank changes only;
|
||||
- honesty, required and prominent: a persistent caption "Scores live in this server's memory.
|
||||
They are gone when it restarts." on the board and a shorter form (`In memory only`) beside
|
||||
the tab control; an empty state that says the same thing;
|
||||
- endless HUD additions: current wave, strikes (as pips with text), best wave this session.
|
||||
2. Endless flow UI: *Endless* entry on the lobby level picker (base level = currently selected),
|
||||
run-end panel with waves/strikes/duration/seed (seed shown so a run is reproducible — links to
|
||||
`?seed=`), and `Play again` preserving base level.
|
||||
3. `tests/e2e/test_us10_scoreboard_ui.py`:
|
||||
- finish an endless run → your row appears with your name and `you` marker;
|
||||
- the volatility caption is present on the board, in the empty state, and on the run-end panel;
|
||||
- board refreshes without a page reload when another context sets a better score;
|
||||
- tab hidden stops polling (asserted from request counts);
|
||||
- keyboard navigation + `aria` roles on the tabs and list; 44 px targets on mobile context.
|
||||
4. `docs/CLIENT.md` — scoreboard module, refresh policy, honesty-copy rules (exact strings, so a
|
||||
later phase cannot quietly drop them).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E with two contexts + request-count assertions; exact-string tests for the honesty copy.
|
||||
- Coverage: **>90%** on any server change.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us10_scoreboard_ui.py -q` → green
|
||||
- [ ] the volatility notice is asserted by exact text in three places (board, empty state, run end)
|
||||
- [ ] polling stops when the tab is hidden (asserted from request counts)
|
||||
- [ ] the run-end panel shows the seed and `Play again` reproduces the same base level
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,41 @@
|
||||
# Task 05 — Endless bounds and the story suite
|
||||
|
||||
**Phase:** `13_endless_mode_and_scoreboard` · **Story:** `US-10_endless_mode_and_scoreboard`
|
||||
|
||||
## Objective
|
||||
Prove endless mode cannot grow without bound, and ship its user-story e2e suite.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_us10_endless_mode_and_scoreboard.py` (the story suite):
|
||||
- start endless from the lobby on base level 3 with a fixed seed; two contexts;
|
||||
- survive waves 1–3 (bot-driven), observe `WAVE_START`/`WAVE_CLEAR` banners on both clients,
|
||||
wave counter and strike pips in the HUD;
|
||||
- take strikes to the max (idle the bots) ⇒ run-end panel with waves/score/streak/duration/
|
||||
seed, `Play again` reproduces the same base level and a new seed;
|
||||
- both clients see the identical `endless_result` payload;
|
||||
- the board shows the run for both contexts, with the honesty caption;
|
||||
- rejoin mid-endless (socket kill) restores wave, strikes, and score within 3 s.
|
||||
2. `tests/integration/test_endless_bounds.py` (`slow`):
|
||||
- accelerated 30-minute-equivalent run (test clock, 200 waves) — assert per-room RSS growth
|
||||
≤ the documented budget (e.g. 4 MB), level objects retained ≤ 2, order ring buffer ≤ 64,
|
||||
event log bounded, scoreboard entries ≤ cap, and the room is collected after everyone leaves;
|
||||
- no unbounded structure: a `tracemalloc` snapshot diff between wave 10 and wave 200 printed on
|
||||
failure, with the top-3 growth sites;
|
||||
- determinism at scale: the same seed yields the same hash samples at waves 10/50/200.
|
||||
3. `docs/DEPLOYMENT.md` — the endless-mode budget numbers and the metric to watch
|
||||
(`rooms_endless`, `rss_mb` trend); `BUDGETS.md` gains the endless rows with measured values.
|
||||
4. README: endless mode section (what it is, the volatility caveat, how to reproduce a run).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E story suite + bounded-stress integration (`slow`).
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
- Full suite green — including every previous phase's suite.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us10_endless_mode_and_scoreboard.py -q` → green
|
||||
- [ ] `uv run pytest -m slow tests/integration/test_endless_bounds.py -q` → green within the stated budgets
|
||||
- [ ] 200-wave run retains ≤ 2 levels and ≤ 64 order entries (asserted)
|
||||
- [ ] `BUDGETS.md` has dated endless rows
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,52 @@
|
||||
# Phase 14 — Accessibility, input parity, and release hardening
|
||||
|
||||
**Story:** `US-11_play_on_phone`, `US-12_play_accessibly`
|
||||
**Context:** `.agents/PLAN.md` §4 (UI/UX rules), §9 (security/anonymity), §10 (budgets)
|
||||
|
||||
## Objective
|
||||
The last mile: every user story demonstrably satisfied, WCAG 2.1 AA verified mechanically,
|
||||
every input path equivalent, the security guardrails re-checked against reality, and the docs
|
||||
telling the truth about what ships.
|
||||
|
||||
## Dependencies
|
||||
- `13_endless_mode_and_scoreboard` (complete) — the full feature set is in place
|
||||
- all prior phases complete
|
||||
|
||||
## Tasks
|
||||
1. `01_accessibility_gate.md` — axe-powered e2e gate on every screen, contrast/colorblind/motion audits, keyboard-only and focus-order passes
|
||||
2. `02_canvas_alternative_and_redundancy.md` — text/table mode for the kitchen state, live regions, screen-reader narrative of the game state
|
||||
3. `03_input_parity_and_localization_hooks.md` — keyboard/touch/gamepad-free parity, pointer fallback, all strings in one catalog with no hard-coded English in logic
|
||||
4. `04_security_and_abuse_review.md` — re-run the audit against the finished app: headers, WS auth, rate limits, DoS surfaces, anonymity claims vs reality
|
||||
5. `05_release_hardening_and_docs.md` — production profile, CSP tightening, perf re-measure, README/docs truth pass, demo-vs-production statement
|
||||
|
||||
## Testing & Quality
|
||||
- E2E accessibility gate (axe-core vendored locally — **no CDN**, loaded from
|
||||
`app/static/vendor/axe/` with its version pinned and recorded) failing on any
|
||||
critical/serious violation on every screen, including lobby, countdown, play, result, scoreboard,
|
||||
and each error screen.
|
||||
- Redundancy: every game state readable without the canvas (text mode), every hazard without
|
||||
color/motion (already asserted in phase 10 — re-asserted here across the full campaign).
|
||||
- Security review re-runs phase 06's abuse suite plus new surfaces (scoreboard, endless start,
|
||||
metrics endpoint, reconnect storms) with an OWASP-shaped checklist written into the report.
|
||||
- Budgets re-measured and compared to `BUDGETS.md`; any regression > 10 % must be fixed, not
|
||||
documented away.
|
||||
- Coverage: **>90%** project-wide (the full-suite gate is re-run with `--cov-fail-under` set to
|
||||
the project's real number in `pyproject.toml`).
|
||||
- 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**. This is the release-truth pass: every claim in README is
|
||||
verified against the running app before it stays in the file.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_accessibility_gate.py -q` → green, zero critical/serious axe violations on all screens
|
||||
- [ ] every screen is completable with keyboard alone and with touch alone (asserted parity matrix)
|
||||
- [ ] the canvas has a text alternative that conveys orders, score, hazards, and positions (screen-reader test)
|
||||
- [ ] phase-06 abuse suite plus the new-surface tests pass with no new findings open
|
||||
- [ ] `BUDGETS.md` re-measured with dated values, no regression > 10 %
|
||||
- [ ] every README claim verified against a running instance (the report lists how each was checked)
|
||||
- [ ] full test suite **100% passing**, coverage >90% project-wide
|
||||
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
|
||||
- [ ] no behavior change in completed phases
|
||||
@@ -0,0 +1,47 @@
|
||||
# Task 01 — Accessibility gate on every screen
|
||||
|
||||
**Phase:** `14_accessibility_polish_and_hardening` · **Story:** `US-12_play_accessibly`
|
||||
|
||||
## Objective
|
||||
Make WCAG 2.1 AA a build gate rather than an intention, on every screen the app has.
|
||||
|
||||
## Work
|
||||
1. Vendor axe-core for **tests only**: download the pinned release once into
|
||||
`tests/e2e/vendor/axe.min.js` (version + SHA-256 recorded in `tests/e2e/vendor/VERSIONS.json`),
|
||||
injected with `page.add_script_tag(path=...)`. It is never served from `app/static/` and never
|
||||
shipped — the app's bundle stays dependency-free and the no-CDN rule stays intact.
|
||||
2. `tests/e2e/test_accessibility_gate.py`:
|
||||
- a `check_a11y(page)` helper run on every screen state: landing, lobby (1/2/4 seats, host and
|
||||
guest views), countdown, play, round-end (win/lose), scoreboard (populated/empty), and each
|
||||
error screen (`room_full`, `room_gone`, `level_locked`, `server_full`, `rate_limited`,
|
||||
`protocol_mismatch`, `evicted`);
|
||||
- fails on any `critical` or `serious` violation; the report prints the rule id, node selector,
|
||||
and help URL — with an explicit, reviewed `allowed` list (empty by default; every entry must
|
||||
carry a comment explaining why it is a false positive);
|
||||
- contrast audits run at 1.0 and 1.25 zoom and in the colorblind palette;
|
||||
- `prefers-reduced-motion` and `forced-colors: active` emulations re-run the scan.
|
||||
3. Fix every violation found (this is the bulk of the task): focus order on the lobby and result
|
||||
screens, labelled controls, `aria-live` regions, target sizes, disabled-vs-`aria-disabled`
|
||||
(locked levels must be reachable and explained, not removed), heading order on the landing page,
|
||||
`alt`/`aria-label` on the atlas-derived icons, and skip-link to the game controls.
|
||||
4. `tests/e2e/test_keyboard_only_matrix.py` — every flow completed with `keyboard` only
|
||||
(no mouse): create lobby, join, ready, start, cook+serve one dish, reach the scoreboard, leave;
|
||||
plus the same flows on the touch context with pointer input only (parity matrix: each cell
|
||||
asserts the flow completed).
|
||||
5. `docs/ACCESSIBILITY.md` — what is guaranteed, how it is tested, known limitations stated plainly
|
||||
(e.g. the canvas is a visual medium; the text mode from task 02 is the alternative), and add it
|
||||
to PLAN §13's documentation index.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E gate across ~12 screen states × 3 rendering contexts; the keyboard/touch parity matrix.
|
||||
- Coverage: **>90%** on any server change.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_accessibility_gate.py -q` → green with an empty `allowed` list (or a justified one)
|
||||
- [ ] `uv run pytest tests/e2e/test_keyboard_only_matrix.py -q` → green (every cell completed)
|
||||
- [ ] axe version + hash recorded and the file is not present under `app/static/` (asserted)
|
||||
- [ ] `docs/ACCESSIBILITY.md` exists, is listed in PLAN §13, and states the limitations honestly
|
||||
- [ ] 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 02 — Text mode: the kitchen without pixels
|
||||
|
||||
**Phase:** `14_accessibility_polish_and_hardening` · **Story:** `US-12_play_accessibly`
|
||||
|
||||
## Objective
|
||||
Give the game a non-visual channel: the same state, readable and listenable, at the same
|
||||
latency budget as the canvas.
|
||||
|
||||
## Work
|
||||
1. `app/static/js/ui/text_mode.js` (toggled by `?text=1`, the in-game `T` key, and
|
||||
`prefers-reduced-motion` + a screen-reader hint):
|
||||
- a live text/table rendering of the state: `orders` (each with its recipe chain, remaining
|
||||
time as seconds **and** a bar of block characters, points), `you` (position, facing, carried
|
||||
item, station in reach and its state), `others` (same, condensed), `hazards` (kind + cell +
|
||||
stage), `score`/`target`/`timer`/`streak`/`strikes`, `dirty` count, connection status;
|
||||
- DOM `role="table"` with proper headers, `aria-live="polite"` on the order list and score,
|
||||
`aria-live="assertive"` only for run-ending events; announcements are de-duplicated and
|
||||
rate-limited (≤ 1 per 900 ms per region) so a busy kitchen never floods a screen reader;
|
||||
- the text mode is a *consumer of the same snapshot buffer* — it must not open a second socket
|
||||
or recompute rules; a test asserts both modes derive from one buffer instance;
|
||||
- canvas hidden (not removed) in text mode so switching back is instant; both modes can be on
|
||||
at once for side-by-side debugging.
|
||||
2. Announcement vocabulary in the string catalog (task 03) with deterministic phrasing, e.g.
|
||||
`Order 2: soup — need tomato (chopped), onion (chopped) — 14 seconds — 30 points`.
|
||||
3. `tests/e2e/test_us12_text_mode.py`:
|
||||
- text mode reflects the live state within 200 ms (a scripted cook makes the strings change);
|
||||
- order/score/hazard strings match the authoritative snapshot exactly (compare against the
|
||||
server's test-seam state, not another client-side guess);
|
||||
- announcement rate limit holds under an event storm (count DOM live-region updates);
|
||||
- the same flows (create/join/ready/start/serve/result/scoreboard) are fully usable in text
|
||||
mode with keyboard only;
|
||||
- single-buffer assertion (two modes, one socket).
|
||||
4. `docs/ACCESSIBILITY.md` — text-mode contract, phrasing examples, and the rate-limit rule.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E comparing rendered text against the server's authoritative state (the strongest available
|
||||
check).
|
||||
- Coverage: **>90%** on any server change (none expected beyond test seams).
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us12_text_mode.py -q` → green
|
||||
- [ ] text-mode strings match the server snapshot for orders, score, hazards, and carried items (asserted)
|
||||
- [ ] live-region updates stay ≤ the rate limit during an event storm (asserted)
|
||||
- [ ] one socket serves both the canvas and text mode (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
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Task 03 — Input parity, pointer fallback, and a string catalog
|
||||
|
||||
**Phase:** `14_accessibility_polish_and_hardening` · **Story:** `US-12_play_accessibly`, `US-11_play_on_phone`
|
||||
|
||||
## Objective
|
||||
No input path is a second-class citizen, and no user-facing string is buried in logic.
|
||||
|
||||
## Work
|
||||
1. `app/static/js/input/index.js` — one input abstraction: every producer (keyboard, touch stick,
|
||||
pointer drag, `?text=1` command bar) emits the same `{intent, action}` into the phase-08 input
|
||||
channel; `getCapabilities()` reports what is available; parity matrix enforced by tests:
|
||||
keyboard ↔ touch ↔ pointer produce identical server-observed movement for the same scripted
|
||||
commands.
|
||||
2. Pointer fallback for unusual devices: mouse-drag on the play surface steers the cook (same dead
|
||||
zone/rim as touch), click = action, with a one-time hint; works on desktop without a keyboard
|
||||
and on a touch device with a stylus; `pointercancel`/`blur` release exactly like touch.
|
||||
3. Accessibility input options (persisted in `sessionStorage`, exposed in a settings panel):
|
||||
remappable keys (with conflict detection and a reset), sticky/hold toggle for the action,
|
||||
input repeat rate, dead-zone size, and a "slow motion" practice flag that is **client-side
|
||||
interpolation only** (it never changes the server tick rate — asserted, because it must not be a
|
||||
cheat in a shared room).
|
||||
4. `app/static/js/i18n.js` + `app/static/strings/en.json` — every user-facing string (HUD, screens,
|
||||
error copy, text-mode phrasing, announcements) in one catalog with named placeholders; en is the
|
||||
only locale shipped, but: no English literals in logic modules (guard test scans
|
||||
`app/static/js/**` for user-visible sentence-shaped string literals outside the catalog —
|
||||
`console.*` and internal identifiers excluded), and `/api/meta` exposes
|
||||
`{languages: ["en"], default: "en"}` so the client never guesses.
|
||||
5. `tests/e2e/test_us12_input_parity.py` — the parity matrix (3 input methods × move/action),
|
||||
remapping applies immediately and persists for the session, conflict detection refuses a
|
||||
duplicate binding, slow-motion is client-only (server tick rate and final state unchanged for
|
||||
the same input script), pointer fallback works with the keyboard absent.
|
||||
6. `tests/unit/test_string_catalog.py` — catalog parses, every key referenced by JS exists
|
||||
(static scan), no unused keys, en.json has no unreplaced placeholders, and `docs/CLIENT.md`
|
||||
documents the "add a string here, not in code" rule.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E parity matrix + catalog completeness (static scan + runtime reference check).
|
||||
- Coverage: **>90%** on any server change.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_us12_input_parity.py tests/unit/test_string_catalog.py -q` → green
|
||||
- [ ] keyboard, touch, and pointer produce the same server-observed movement (asserted parity)
|
||||
- [ ] slow motion does not change the server's tick count or final state for a fixed input script (asserted)
|
||||
- [ ] the catalog scan fails when a user-visible string is hard-coded in a logic module (demonstrated, then reverted)
|
||||
- [ ] 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 04 — Security and abuse review of the finished app
|
||||
|
||||
**Phase:** `14_accessibility_polish_and_hardening` · **Story:** `US-12_play_accessibly` (trust), all stories
|
||||
|
||||
## Objective
|
||||
Re-examine the whole shipped surface against the OWASP-shaped questions that matter for a
|
||||
WebSocket game, and close what is open.
|
||||
|
||||
## Work
|
||||
1. `docs/SECURITY.md` — the review itself, with a finding table (`id`, `area`, `severity`,
|
||||
`status`, `evidence test`), and the explicit threat model: anonymous public lobbies, hostile
|
||||
clients, hostile players (griefing, spam, DoS), no accounts, no persisted personal data.
|
||||
2. Verify each item with a test that fails if the property regresses (extend
|
||||
`tests/integration/test_ws_abuse.py`, add `tests/integration/test_http_abuse.py`):
|
||||
- **Transport/headers**: CSP `default-src 'self'` + `ws:`/`wss:` same-origin, `X-Content-Type-Options`,
|
||||
`Referrer-Policy: no-referrer`, `Permissions-Policy`, HSTS when behind TLS, no `Server` version
|
||||
leak, no directory traversal in the static mount (encoded `..` attempts), no symlinked asset
|
||||
served, `Cache-Control` correctness (`no-store` on `/api/*` and `/ws`, fingerprinted static
|
||||
cacheable);
|
||||
- **Auth/authz**: room code is the only capability and is 5-char Crockford with ≥ 60 bits of
|
||||
entropy claim verified by counting the code space; token guessing rate-limited; host-only
|
||||
actions refused for guests (each whitelisted action tested as a guest); spectator escalation
|
||||
refused; `set_level`/`start`/`restart`/`endless` host-gated; scoreboard writes require a real
|
||||
finished run (phase 13's authority rule re-asserted);
|
||||
- **Injection/XSS**: hostile display names (`<script>`, `"`, RTL override, zero-width, emoji,
|
||||
200 chars, control chars, ANSI) sanitized and never rendered via `innerHTML` (guard test scans
|
||||
the client for `innerHTML`/`outerHTML`/`document.write` with non-literal arguments); names in
|
||||
logs and metrics are sanitized; no HTML-in-JSON reflected unsanitized into DOM text nodes;
|
||||
- **DoS**: message size/rate caps (re-verify), code-space enumeration throttled with a growing
|
||||
penalty, room creation per IP capped, `/api/levels` and `/api/scores` cheap and cacheable, no
|
||||
unbounded loops over player input inside `step()`, generated endless levels bounded regardless
|
||||
of requested `wave_index` (request wave 10⁹ and assert a clamped, cheap result);
|
||||
- **Anonymity claims**: no cookies set unless strictly necessary and then `SameSite=Strict`,
|
||||
`Secure`, no tracking pixels, no fonts/images from anywhere but origin (re-run the phase-01
|
||||
guard across the final asset tree including the vendor dir rule), no IP persisted in any
|
||||
in-memory structure beyond the sliding rate-limit window (documented, TTL-verified).
|
||||
3. Fix everything rated `medium` or higher; `low` items either fixed or recorded in
|
||||
`docs/SECURITY.md` with the reason and a non-goal statement. No finding is left unlabeled.
|
||||
4. `tests/unit/test_security_docs_sync.py` — every finding row in `docs/SECURITY.md` names a test
|
||||
file that exists; every `open` finding is `low` or the build fails.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration + unit as above; the review is only as real as its evidence tests.
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
- Full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_ws_abuse.py tests/integration/test_http_abuse.py tests/unit/test_security_docs_sync.py -q` → green
|
||||
- [ ] `docs/SECURITY.md` has a finding per reviewed area with severity, status, and evidence test
|
||||
- [ ] no `medium`+ finding remains open
|
||||
- [ ] hostile-name fixtures round-trip sanitized through DOM, logs, metrics, and the scoreboard (asserted)
|
||||
- [ ] requesting endless wave 10⁹ is clamped and cheap (asserted)
|
||||
- [ ] 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
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Task 05 — Release hardening, re-measurement, and the docs truth pass
|
||||
|
||||
**Phase:** `14_accessibility_polish_and_hardening` · **Story:** all
|
||||
|
||||
## Objective
|
||||
Ship it: production profile, budgets re-measured against the real app, and every document
|
||||
verified claim by claim against a running instance.
|
||||
|
||||
## Work
|
||||
1. Production profile:
|
||||
- `config.py` gains `profile: dev | prod` (default from `UNDERCOOKED_PROFILE`); `prod` ⇒
|
||||
debugpy off, `metrics_enabled` on, `test_mode` refused at startup (boot fails loudly if
|
||||
`UNDERCOOKED_TEST_MODE=1` with `profile=prod`), `log_level=info`, static caching on, verbose
|
||||
tracebacks off, `docs/*` routes not required (they stay as static markdown in the repo only);
|
||||
- `Containerfile` builds the prod profile, runs as a non-root user, healthcheck hits
|
||||
`/api/health`, `compose.yaml` sets limits (`mem_limit`, `cpus`) consistent with the measured
|
||||
capacity from phase 12, restart policy, and no host-path mounts for app code;
|
||||
- `.env.example` regenerated so every documented variable exists in `config.py` and vice versa
|
||||
(guard test already exists — make it pass with the new keys).
|
||||
2. Re-measure every budget in `BUDGETS.md` (tick cost, snapshot size, downlink rate, echo, drift,
|
||||
rejoin, capacity, endless growth) on the full feature set, append a dated table, and fix any
|
||||
regression > 10 % instead of editing the target.
|
||||
3. `tools/release_check.py` — one command that runs, in order, and prints a single verdict table:
|
||||
`uv sync --extra dev`, `ruff`, `pyright`, `undercooked-levels validate`, `pytest` (full, with the
|
||||
coverage gate), the `slow` perf suites, and a fresh-container smoke (`podman build` +
|
||||
`podman run` + `curl /api/health` + one Playwright round). `bash .agents/validate.sh` calls it.
|
||||
4. Docs truth pass — for each document, verify the claim, then fix or delete it:
|
||||
- `README.md`: quickstart commands run verbatim on a clean clone; the controls list matches
|
||||
`getKeyMap()`; the campaign list matches the manifest; the "what this is / is not" paragraph
|
||||
states in-memory persistence, single process, no accounts, and the trademark/homage note;
|
||||
- `AGENTS.md`: every command, path, and test-location claim matches the tree; guardrails each
|
||||
name the test that enforces them;
|
||||
- `docs/`: `PROTOCOL.md` message tables match `protocol.py` field-for-field (existing sync test
|
||||
extended to cover every message added since phase 06), `SIMULATION.md` rules each cite their
|
||||
test, `LEVELS.md` covers all 10 levels + generator, `CLIENT.md` module map matches
|
||||
`app/static/js/**` (new: a test walks the tree and asserts each documented module appears),
|
||||
`DEPLOYMENT.md` commands work, `ACCESSIBILITY.md`/`SECURITY.md` limitations are current;
|
||||
- `.agents/PLAN.md`: the phase list matches `.agents/phases/`, the documentation index matches
|
||||
`docs/`, and any LOCKED decision that was amended has its amendment recorded with the phase
|
||||
that changed it.
|
||||
5. `tests/unit/test_docs_module_map.py` — the client module-map and docs-tree guards that make the
|
||||
truth pass permanent rather than a one-time read-through.
|
||||
|
||||
## Testing & Quality
|
||||
- The whole suite plus `slow` perf suites plus a container smoke; docs guards are real tests.
|
||||
- Coverage: **>90%** project-wide; `pyproject.toml` coverage floor raised to the measured value.
|
||||
- Full suite green, from a clean checkout (`uv sync --extra dev` then `bash .agents/validate.sh`).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `bash .agents/validate.sh` exits 0 on a fresh clone/container build, including the smoke round
|
||||
- [ ] `uv run python tools/release_check.py` prints the verdict table with every stage green
|
||||
- [ ] `BUDGETS.md` has the re-measured dated table, no target silently relaxed
|
||||
- [ ] `profile=prod` + `UNDERCOOKED_TEST_MODE=1` refuses to boot (asserted)
|
||||
- [ ] every doc claim verified; the report lists how each was checked and what was fixed
|
||||
- [ ] 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 (all prior story suites green in the same run)
|
||||
@@ -0,0 +1,25 @@
|
||||
# US-01 — Create an anonymous lobby
|
||||
|
||||
**As a** player who just opened the site,
|
||||
**I want to** press one button and get a room code,
|
||||
**so that** I can start a game with friends without creating an account.
|
||||
|
||||
## Acceptance criteria
|
||||
1. The landing screen shows exactly one primary action: *Create lobby*.
|
||||
2. Creating a lobby issues a 5-character code from the Crockford base32 alphabet
|
||||
(no `I`, `L`, `O`, `U`), uppercase, shown large and with a copy button.
|
||||
3. No account, email, password, cookie consent, or third-party request is involved.
|
||||
4. The browser stores only a random session token (`sessionStorage`) and the room code.
|
||||
5. The creator is marked `host` and holds a seat; the room is in `LOBBY`.
|
||||
6. Reload of the same tab with the same token returns the player to the same seat.
|
||||
7. If the server is at `max_rooms`, the UI shows a clear "lobby full, try again" error
|
||||
instead of a broken screen.
|
||||
8. An idle, empty lobby is garbage-collected.
|
||||
|
||||
## E2E suite
|
||||
`tests/e2e/test_us01_create_lobby.py` — load `/`, create lobby, assert code shape,
|
||||
reload → same seat, assert zero non-origin network requests.
|
||||
|
||||
## Phases
|
||||
`01_runnable_service_scaffold`, `05_anonymous_lobbies_and_room_lifecycle`,
|
||||
`07_canvas_client_and_hud`
|
||||
@@ -0,0 +1,29 @@
|
||||
# US-02 — Join a lobby with a code
|
||||
|
||||
**As a** friend who was told a 5-character code,
|
||||
**I want to** type it on my own device and be in the kitchen,
|
||||
**so that** we can play together from anywhere.
|
||||
|
||||
## Acceptance criteria
|
||||
1. The landing screen has a *Join* field accepting the code, case-insensitive,
|
||||
tolerating whitespace and `l`/`1`-style misreads for excluded characters.
|
||||
2. Joining with a valid code puts the player in a seat with a generated display name
|
||||
(`Cook #2`) if none was typed, and broadcasts a `lobby` update to everyone.
|
||||
3. Joining a full room shows "this kitchen is full (4/4)" and the room code is kept
|
||||
in the field for a retry.
|
||||
4. Joining an unknown code shows "no kitchen with that code" (no leak of which
|
||||
part failed) and never reveals whether a room exists behind a closed door.
|
||||
5. Joining during `PLAYING` puts the player in `SPECTATING` (visible "watching"
|
||||
banner) until the next `LOBBY`.
|
||||
6. A display name is sanitized (control chars stripped, 16-char cap) and is not
|
||||
unique-enforced; two players may share a name.
|
||||
7. The second socket from the same token evicts the first cleanly (no ghost).
|
||||
8. Join → seated takes < 1 s on localhost.
|
||||
|
||||
## E2E suite
|
||||
`tests/e2e/test_us02_join_by_code.py` — two browser contexts: create in A, join in B,
|
||||
assert both see 2 seats; full-room and bad-code paths.
|
||||
|
||||
## Phases
|
||||
`05_anonymous_lobbies_and_room_lifecycle`, `06_realtime_websocket_protocol`,
|
||||
`07_canvas_client_and_hud`
|
||||
@@ -0,0 +1,28 @@
|
||||
# US-03 — Ready up and start from the lobby
|
||||
|
||||
**As a** host,
|
||||
**I want to** pick the level and start once everyone is ready,
|
||||
**so that** the group begins together on the same kitchen.
|
||||
|
||||
## Acceptance criteria
|
||||
1. The lobby lists every seat: name, ready state, host crown, connection state.
|
||||
2. Any player can toggle *Ready*; the host can pick any **unlocked** level from a
|
||||
list showing name, target score, time limit, and difficulty pips.
|
||||
3. *Start* is enabled for the host when ≥ 1 non-host player is ready (or the host is
|
||||
alone — solo play is allowed).
|
||||
4. Starting begins a 3-2-1 `countdown` shown identically on all clients, then the
|
||||
first `snap` arrives.
|
||||
5. Non-host players cannot start or change level; the attempt shows a disabled control
|
||||
(no console errors, no silent failure).
|
||||
6. If the host leaves in `LOBBY`, host status transfers to the lowest occupied seat;
|
||||
in `PLAYING` the round continues.
|
||||
7. Someone leaving during `COUNTDOWN` cancels the countdown back to `LOBBY`.
|
||||
8. Starting creates a round record in the in-memory session store.
|
||||
|
||||
## E2E suite
|
||||
`tests/e2e/test_us03_ready_and_start.py` — two contexts: ready toggles visible to
|
||||
both, non-host start rejected, countdown then both screens show the kitchen.
|
||||
|
||||
## Phases
|
||||
`05_anonymous_lobbies_and_room_lifecycle`, `06_realtime_websocket_protocol`,
|
||||
`07_canvas_client_and_hud`
|
||||
@@ -0,0 +1,30 @@
|
||||
# US-04 — Move and cook in the shared kitchen
|
||||
|
||||
**As a** player,
|
||||
**I want to** walk my cook, grab ingredients, chop, cook, plate, serve, and wash,
|
||||
**so that** I can complete orders with my hands.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Keyboard (WASD/arrows) moves the cook smoothly in 8 directions without diagonal
|
||||
speed boost; the cook stops at walls, counters, and other players.
|
||||
2. One action key (`Space`/`Enter`) interacts with the tile(s) in front, choosing the
|
||||
sensible verb: pick up, start/hold work, place, plate, serve, wash, trash.
|
||||
3. Holding the action key performs progressive work (chop ≈ 0.9 s, cook auto-runs,
|
||||
wash ≈ 1.2 s) with a visible progress ring.
|
||||
4. One carried item per player; the carried item is drawn above the cook and its
|
||||
form (`raw`/`chopped`/`cooking`/`cooked`/`burnt`/`plated`) is visually distinct.
|
||||
5. Reach is 1 tile in front (plus a 0.6-tile forgiveness radius); out-of-reach presses
|
||||
give an explicit "move closer" nudge rather than doing nothing.
|
||||
6. A contested target resolves deterministically: one winner, and the loser gets
|
||||
visible `denied` feedback — never two players holding one item.
|
||||
7. Serving a wrong dish is refused with feedback and the order survives.
|
||||
8. The full chain is possible without dead ends: dirty plates/pots return to the sink
|
||||
and come back usable; trash always accepts an item.
|
||||
|
||||
## E2E suite
|
||||
`tests/e2e/test_us04_move_and_cook.py` — scripted keyboard session completes one soup
|
||||
end to end; asserts HUD score bump, station state changes, denied path on a blocked tile.
|
||||
|
||||
## Phases
|
||||
`03_simulation_movement_and_interaction`, `04_kitchen_pipeline_and_scoring`,
|
||||
`07_canvas_client_and_hud`, `08_client_prediction_and_touch`, `09_co_op_two_player_e2e`
|
||||
@@ -0,0 +1,29 @@
|
||||
# US-05 — Orders, score, timer, and the round result
|
||||
|
||||
**As a** player,
|
||||
**I want to** see what orders are pending, how I am scoring, and how much time is left,
|
||||
**so that** I know whether we won or lost and why.
|
||||
|
||||
## Acceptance criteria
|
||||
1. The HUD shows the order queue with recipe composition, a per-order time bar, and the
|
||||
point value; the currently-satisfiable order is visually distinguished.
|
||||
2. Score, target, and countdown timer are always visible and update within 200 ms of the
|
||||
server event.
|
||||
3. Serving a correct dish awards `base × quality × streak × time_bonus`, plays a pop
|
||||
effect, and removes the order; streak is shown from 2 upward.
|
||||
4. A missed order deducts the penalty, resets the streak, and flashes the HUD.
|
||||
5. Reaching `target_score` ends the round immediately as `won`; the timer hitting 0 ends
|
||||
it as `won` only if `score ≥ target`, else `lost`.
|
||||
6. The result screen shows result, stars (1–3), score/target, best score for the level,
|
||||
per-player contribution, and *Retry* / *Next level* / *Back to lobby* actions.
|
||||
7. The result screen is announced to assistive tech (`aria-live`) and is reachable with
|
||||
the keyboard alone.
|
||||
8. The same round driven by the same seed and input script produces the same score
|
||||
(covered by the replay test, not the browser).
|
||||
|
||||
## E2E suite
|
||||
`tests/e2e/test_us05_orders_score_result.py` — serve to win, assert result screen +
|
||||
stars; force a miss, assert penalty and streak reset.
|
||||
|
||||
## Phases
|
||||
`04_kitchen_pipeline_and_scoring`, `07_canvas_client_and_hud`
|
||||
@@ -0,0 +1,32 @@
|
||||
# US-06 — Cook cooperatively with 2–4 players
|
||||
|
||||
**As a** member of a group,
|
||||
**I want to** see the other cooks move in real time and share one kitchen,
|
||||
**so that** we can divide tasks instead of racing for the same knife.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Every client sees every other cook at their server-authoritative position with
|
||||
smooth motion (interpolated), a name label, and a distinct color.
|
||||
2. Remote motion at 150 ms RTT looks continuous: no teleporting, ≤ 0.25 tile visible
|
||||
error while moving, and no rubber-banding of the local cook.
|
||||
3. The local cook responds to input before the server confirms it (prediction) and is
|
||||
corrected gently (≤ 150 ms ease) when the server disagrees.
|
||||
4. Players physically block each other with a soft push (no standing on tiles already
|
||||
occupied). Carrying players is out of scope; passing an item hand-to-hand happens by
|
||||
both cooks using the same counter/prep tile.
|
||||
5. A contested pickup/station gives exactly one winner; the loser sees `denied` and can
|
||||
immediately target the next-best option.
|
||||
6. Occupancy: a station being actively worked shows who is working it; a second player
|
||||
cannot start a parallel job on it.
|
||||
7. All clients converge on the same score and the same result screen within 1 s of the
|
||||
round end.
|
||||
8. 4 players in one room keeps the mean server tick ≤ 2 ms.
|
||||
|
||||
## E2E suite
|
||||
`tests/e2e/test_us06_coop_shared_kitchen.py` — two contexts cooperatively serve one
|
||||
order and both observe the same score/result; contested-pickup arbitration test;
|
||||
4-connection integration test server-side.
|
||||
|
||||
## Phases
|
||||
`06_realtime_websocket_protocol`, `08_client_prediction_and_touch`,
|
||||
`09_co_op_two_player_e2e`
|
||||
@@ -0,0 +1,30 @@
|
||||
# US-07 — Progress through a campaign that gets harder
|
||||
|
||||
**As a** player finishing level after level,
|
||||
**I want to** unlock the next kitchen and feel it get harder,
|
||||
**so that** a session has a trajectory instead of one repeated map.
|
||||
|
||||
## Acceptance criteria
|
||||
1. The level list shows all shipped levels in campaign order with the difficulty pips,
|
||||
target, time limit, and per-level best score/stars.
|
||||
2. Completing a level unlocks the next one **for that session**; the next level is
|
||||
pre-selected in `ROUND_END` and startable by one action.
|
||||
3. Failing a level keeps the same next choice available (retry) and does not unlock
|
||||
forward; unlocking never goes backwards.
|
||||
4. Level content comes from `levels/*.yaml`; adding a new valid YAML file makes the level
|
||||
appear in `/api/levels` with no Python change (regression test).
|
||||
5. Every campaign level passes content validation: reachability, recipe solvability,
|
||||
target plausibility, hazard references.
|
||||
6. Each consecutive level moves at least two escalation axes (§8 of PLAN.md) versus its
|
||||
predecessor; the curve check fails the build otherwise.
|
||||
7. All 10 campaign levels are playable end to end with the intended station set.
|
||||
8. Progression state is per session token and survives a page reload; it is documented
|
||||
as in-memory and lost on server restart.
|
||||
|
||||
## E2E suite
|
||||
`tests/e2e/test_us07_campaign_progression.py` — win level 1, assert level 2 unlocked,
|
||||
selected, and startable; fail a level, assert locked. Plus
|
||||
`tests/unit/test_level_curve.py` for the escalation-axis and validation gates.
|
||||
|
||||
## Phases
|
||||
`02_level_content_and_validation`, `11_campaign_levels_and_progression`
|
||||
@@ -0,0 +1,29 @@
|
||||
# US-08 — Handle hazards and pressure without losing control
|
||||
|
||||
**As a** player in a late level,
|
||||
**I want to** deal with fires, spills, moving obstacles, and order surges,
|
||||
**so that** the escalation is a fair test of teamwork rather than random unfairness.
|
||||
|
||||
## Acceptance criteria
|
||||
1. **Fire** starts on a station per level probability, spreads to an adjacent station
|
||||
after its burn timer unless dealt with, blocks its tile, and ruins items on it; it is
|
||||
extinguished by a sink-filled douse (carry water) with visible steam.
|
||||
2. **Spill** makes its tile slippery (accel/brake reduced, cook slides ~0.5 tile) and is
|
||||
cleaned by a wipe action; a cook leaving the kitchen with a spill under a station
|
||||
does not propagate it.
|
||||
3. **Moving obstacles** (cart, boat mast) follow their authored path and stun on contact
|
||||
for 0.6 s with a visible telegraph 0.5 s before entering a tile.
|
||||
4. **Rush waves** spawn a burst of orders with a HUD "RUSH" banner and a wave counter.
|
||||
5. Every hazard is data-driven (level YAML): type, location/station reference, rate, and
|
||||
mitigation — no hard-coded hazard placement in Python.
|
||||
6. Hazard effects are deterministic under a fixed seed (replay test covers all four).
|
||||
7. Client FX exist for each hazard and respect `prefers-reduced-motion` (reduced: static
|
||||
markers instead of flicker/shake).
|
||||
8. Hazard penalties never soft-lock a round: the round always ends by timer or target.
|
||||
|
||||
## E2E suite
|
||||
`tests/e2e/test_us08_hazards.py` — level fixture with fire + spill: extinguish, clean,
|
||||
avoid a mover; asserts hazard states in the snapshot and reduced-motion rendering.
|
||||
|
||||
## Phases
|
||||
`10_hazards_and_escalation_mechanics`, `11_campaign_levels_and_progression`
|
||||
@@ -0,0 +1,29 @@
|
||||
# US-09 — Drop out and come back mid-round
|
||||
|
||||
**As a** player on flaky Wi-Fi (or a locked laptop),
|
||||
**I want to** reconnect and land back in the same kitchen mid-round,
|
||||
**so that** one dropped socket does not ruin the group's game.
|
||||
|
||||
## Acceptance criteria
|
||||
1. Losing the socket keeps the player's seat for `GUEST_IDLE_TIMEOUT_S` (default 45 s);
|
||||
the cook stays in the kitchen and, after 3 s idle, parks and drops what they carried.
|
||||
2. The other players see a "Cookie disconnected" label, not a vanished cook.
|
||||
3. Reconnecting with the same token + code mid-round restores the seat and resumes
|
||||
snapshots; time-to-playable ≤ 3 s after page load.
|
||||
4. The client shows a "reconnecting…" overlay with exponential backoff (0.5 s → 8 s cap,
|
||||
jittered) and never spams the server.
|
||||
5. Reconnecting after the grace window releases the seat and the player re-enters as a
|
||||
spectator (or joins the lobby if one exists).
|
||||
6. The server never leaves a ghost connection: a second socket for a token evicts the
|
||||
first, and closed rooms reject stale tokens with a clean error.
|
||||
7. A room whose last seat is gone is closed and its resources freed within one GC cycle.
|
||||
8. A mid-round server-side room exception aborts only that room and tells its players.
|
||||
|
||||
## E2E suite
|
||||
`tests/e2e/test_us09_disconnect_rejoin.py` — kill the socket (context.close of page B /
|
||||
server-side `kill_connection` test hook), assert ghost label, reload and rejoin mid-round,
|
||||
assert playable ≤ 3 s and identical score. Integration: seat grace, GC, stale-token paths.
|
||||
|
||||
## Phases
|
||||
`06_realtime_websocket_protocol`, `08_client_prediction_and_touch`,
|
||||
`12_reconnect_resilience_and_scale`
|
||||
@@ -0,0 +1,30 @@
|
||||
# US-10 — Keep playing after the campaign: endless mode
|
||||
|
||||
**As a** group that finished (or bombed) the campaign,
|
||||
**I want an** endless mode whose difficulty keeps rising and a session scoreboard,
|
||||
**so that** the night keeps going without re-running level 1.
|
||||
|
||||
## Acceptance criteria
|
||||
1. The lobby offers *Endless* as a selectable mode; it starts immediately from a layout
|
||||
drawn from the authored campaign layouts.
|
||||
2. Difficulty scales with completed rounds: order pressure, recipe complexity, time
|
||||
budget, station scarcity, and hazard density all increase monotonically per round
|
||||
(asserted by a property test on the generator).
|
||||
3. Each endless round is announced ("Round 4 — pressure 1.6×") and the failure of a
|
||||
round ends the endless run (score = cumulative).
|
||||
4. The session scoreboard shows, per session token: levels with best score + stars +
|
||||
date, best endless run, total rounds, and total dishes served.
|
||||
5. The scoreboard is visible in the lobby and on the result screen; it is explicitly
|
||||
labelled as this-server-only (in memory, cleared on restart).
|
||||
6. The endless generator only emits validated levels — it runs the same validator as
|
||||
authored content before a round starts, and falls back to the previous layout if a
|
||||
generated candidate fails.
|
||||
7. Scoreboard data never contains personal data beyond the display name the player typed.
|
||||
|
||||
## E2E suite
|
||||
`tests/e2e/test_us10_endless_scoreboard.py` — start endless, finish a round, assert
|
||||
round counter and rising pressure in the snapshot, assert scoreboard rows in the lobby.
|
||||
Plus `tests/unit/test_endless_generator.py` property tests.
|
||||
|
||||
## Phases
|
||||
`13_endless_mode_and_scoreboard`
|
||||
@@ -0,0 +1,30 @@
|
||||
# US-11 — Play on a phone
|
||||
|
||||
**As a** player with only a phone,
|
||||
**I want to** join with a code and control a cook with touch,
|
||||
**so that** the game works on the couch without a keyboard.
|
||||
|
||||
## Acceptance criteria
|
||||
1. The layout works from 360 px wide to 4K: the kitchen scales to fit, the HUD stays
|
||||
legible, nothing overlaps, and there is no page scroll during play.
|
||||
2. Touch controls appear on touch devices: a left thumb-zone drag stick (8-way
|
||||
quantized) and a large right *act* button (hold for progressive work).
|
||||
3. Controls are usable one-handed per thumb, ≥ 44 px hit targets, and do not cover the
|
||||
serve window or the order queue.
|
||||
4. `touch-action: none` on the play surface: no pinch-zoom, no scroll, no double-tap
|
||||
zoom; the on-screen keyboard never appears during play.
|
||||
5. Orientation: portrait plays with a rotated hint (both orientations are playable).
|
||||
6. Touch input uses Pointer Events; the same code path serves mouse (desktop click-to-act
|
||||
is not required — keyboard is the desktop input).
|
||||
7. Lobby joining on a phone (type code, tap create/join) completes in < 20 s and shows
|
||||
the correct focus/type UI (`inputmode="text"`, `autocapitalize="characters"`).
|
||||
8. Framerate on a mid-range mobile browser stays ≥ 30 fps with 4 cooks.
|
||||
|
||||
## E2E suite
|
||||
`tests/e2e/test_us11_touch_mobile.py` — mobile viewport + `has_touch` context: create,
|
||||
join by typed code, move via pointer drag, complete an action, assert no scroll and
|
||||
control hit-target sizes.
|
||||
|
||||
## Phases
|
||||
`07_canvas_client_and_hud`, `08_client_prediction_and_touch`,
|
||||
`14_accessibility_polish_and_hardening`
|
||||
@@ -0,0 +1,29 @@
|
||||
# US-12 — Play accessibly
|
||||
|
||||
**As a** player with low vision, motor limitations, or motion sensitivity,
|
||||
**I want** readable contrast, keyboard-only lobby use, and motion controls,
|
||||
**so that** I can play and follow the game state.
|
||||
|
||||
## Acceptance criteria
|
||||
1. All non-canvas UI (lobby, level list, result, HUD text) is real HTML with a logical
|
||||
focus order, visible focus rings, and ≥ 4.5:1 text contrast (3:1 for large text).
|
||||
2. The entire lobby → level-select → start → result loop is operable with the keyboard
|
||||
only (no pointer required), with sensible default buttons.
|
||||
3. Round start, score changes worth ≥ 10 points, order missed, and the round result are
|
||||
announced through a polite `aria-live` region; per-frame data is never announced.
|
||||
4. `prefers-reduced-motion: reduce` removes screen shake, flicker, and parallax, keeping
|
||||
static hazard markers; the setting is also togglable in-game and persisted locally.
|
||||
5. No information is conveyed by color alone: cooks, orders, and hazards have shape,
|
||||
label, or icon differences; a colorblind-safe palette option is provided.
|
||||
6. Canvas has a text alternative: `role="img"` + an `aria-label` summarizing state, and
|
||||
a DOM "state panel" (toggleable) listing score, timer, orders, and hazards.
|
||||
7. Text scales to 200% without loss of function; hit targets stay ≥ 44 px.
|
||||
8. Sound is optional, off by default, and never required to know the game state.
|
||||
|
||||
## E2E suite
|
||||
`tests/e2e/test_us12_accessibility.py` — keyboard-only loop, focus order, contrast audit
|
||||
of computed styles, `aria-live` announcements for result, reduced-motion class applied.
|
||||
|
||||
## Phases
|
||||
`07_canvas_client_and_hud`, `11_campaign_levels_and_progression`,
|
||||
`14_accessibility_polish_and_hardening`
|
||||
Reference in New Issue
Block a user