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:
@@ -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
|
||||
Reference in New Issue
Block a user