From f84c6ebaef0c2d1b237cf2f5c435e436b0d1834f Mon Sep 17 00:00:00 2001 From: ducoterra Date: Mon, 5 Oct 2026 17:05:34 -0400 Subject: [PATCH] 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 --- .agents/PLAN.md | 486 ++++++++++++++++++ .agents/phases/complete/.gitkeep | 0 .../01_runnable_service_scaffold/00_phase.md | 48 ++ .../01_lock_toolchain_and_packages.md | 50 ++ .../02_config_and_debugpy.md | 49 ++ .../03_fastapi_app_and_static_shell.md | 68 +++ .../04_no_cdn_guard_and_docs.md | 61 +++ .../00_phase.md | 50 ++ .../01_sim_core_data_model.md | 73 +++ .../02_recipe_and_level_schema.md | 69 +++ .../03_level_validation_rules.md | 62 +++ .../04_seed_recipes_and_two_levels.md | 60 +++ .../05_levels_cli_and_manifest_test.md | 56 ++ .../00_phase.md | 52 ++ .../01_fixed_step_kernel.md | 66 +++ .../02_movement_and_collision.md | 55 ++ .../03_interaction_targeting.md | 55 ++ .../04_pickup_carry_drop.md | 57 ++ .../05_replay_harness_and_goldens.md | 53 ++ .../00_phase.md | 57 ++ .../01_station_state_machines.md | 54 ++ .../02_order_spawning_and_expiry.md | 49 ++ .../03_serve_resolution_and_scoring.md | 58 +++ .../04_spoil_burn_and_dirty_loop.md | 52 ++ .../05_round_end_results_and_stars.md | 47 ++ .../06_headless_round_simulations.md | 48 ++ .../00_phase.md | 52 ++ .../01_room_supervisor_and_codes.md | 64 +++ .../02_room_state_machine.md | 58 +++ .../03_lobby_state_and_presence.md | 59 +++ .../04_session_store_and_progression.md | 56 ++ .../05_lifecycle_integration_suite.md | 57 ++ .../00_phase.md | 55 ++ .../01_protocol_models_and_wire_contract.md | 53 ++ .../02_ws_endpoint_and_handshake.md | 65 +++ .../03_input_ingestion_and_validation.md | 48 ++ .../04_snapshot_and_event_encoding.md | 59 +++ .../05_heartbeat_disconnect_and_seat_grace.md | 55 ++ .../06_websocket_integration_suite.md | 51 ++ .../todo/07_canvas_client_and_hud/00_phase.md | 59 +++ .../01_svg_tileset_and_atlas.md | 62 +++ .../02_screen_state_machine_and_lobby_ui.md | 60 +++ .../03_snapshot_buffer_and_interpolation.md | 67 +++ .../04_canvas_renderer_and_hud.md | 60 +++ ...5_keyboard_input_and_first_playable_e2e.md | 57 ++ .../00_phase.md | 53 ++ .../01_connection_lifecycle_and_backoff.md | 57 ++ .../02_input_channel_and_seq_discipline.md | 48 ++ ...03_client_prediction_and_reconciliation.md | 64 +++ ...04_touch_controls_and_responsive_layout.md | 58 +++ .../05_netcode_budget_and_latency_suite.md | 47 ++ .../todo/09_co_op_two_player_e2e/00_phase.md | 47 ++ ...contested_arbitration_and_station_locks.md | 46 ++ .../02_multi_context_playwright_harness.md | 50 ++ .../03_cooperative_workflow_e2e.md | 46 ++ .../04_four_player_fairness_and_starvation.md | 41 ++ .../00_phase.md | 56 ++ .../01_fire.md | 53 ++ .../02_spills_and_slipping.md | 50 ++ .../03_moving_obstacles.md | 55 ++ .../04_rush_waves_and_pressure_modifiers.md | 49 ++ .../05_hazard_presentation_and_warnings.md | 49 ++ ...ard_determinism_and_validator_hardening.md | 46 ++ .../00_phase.md | 50 ++ .../01_author_levels_03_to_06.md | 47 ++ .../02_author_levels_07_to_10.md | 42 ++ .../03_escalation_curve_gate.md | 45 ++ .../04_campaign_flow_and_level_picker.md | 46 ++ .../05_campaign_playthrough_suite.md | 48 ++ .../00_phase.md | 48 ++ .../01_room_garbage_collection.md | 47 ++ .../02_rejoin_chaos_matrix.md | 44 ++ .../03_tick_scheduler_under_load.md | 45 ++ .../04_capacity_and_degradation.md | 50 ++ .../05_ops_observability_and_runbook.md | 50 ++ .../00_phase.md | 50 ++ .../01_endless_generator.md | 49 ++ .../02_endless_round_loop.md | 47 ++ .../03_session_scoreboard_store.md | 44 ++ .../04_scoreboard_ui_and_honesty.md | 43 ++ .../05_endless_e2e_and_bounds.md | 41 ++ .../00_phase.md | 52 ++ .../01_accessibility_gate.md | 47 ++ .../02_canvas_alternative_and_redundancy.md | 48 ++ .../03_input_parity_and_localization_hooks.md | 48 ++ .../04_security_and_abuse_review.md | 55 ++ .../05_release_hardening_and_docs.md | 57 ++ .../US-01_create_anonymous_lobby.md | 25 + .agents/user_stories/US-02_join_by_code.md | 29 ++ .../user_stories/US-03_ready_up_and_start.md | 28 + .agents/user_stories/US-04_move_and_cook.md | 30 ++ .../US-05_orders_score_and_result.md | 29 ++ .../user_stories/US-06_coop_shared_kitchen.md | 32 ++ .../US-07_campaign_progression.md | 30 ++ .../US-08_hazards_under_pressure.md | 29 ++ .../US-09_disconnect_and_rejoin.md | 29 ++ .../US-10_endless_mode_and_scoreboard.md | 30 ++ .agents/user_stories/US-11_play_on_phone.md | 30 ++ .agents/user_stories/US-12_play_accessibly.md | 29 ++ .gitignore | 69 +++ AGENTS.md | 105 ++++ Containerfile | 34 ++ README.md | 90 ++++ compose.yaml | 25 + pyproject.toml | 89 ++++ 105 files changed, 5752 insertions(+) create mode 100644 .agents/PLAN.md create mode 100644 .agents/phases/complete/.gitkeep create mode 100644 .agents/phases/todo/01_runnable_service_scaffold/00_phase.md create mode 100644 .agents/phases/todo/01_runnable_service_scaffold/01_lock_toolchain_and_packages.md create mode 100644 .agents/phases/todo/01_runnable_service_scaffold/02_config_and_debugpy.md create mode 100644 .agents/phases/todo/01_runnable_service_scaffold/03_fastapi_app_and_static_shell.md create mode 100644 .agents/phases/todo/01_runnable_service_scaffold/04_no_cdn_guard_and_docs.md create mode 100644 .agents/phases/todo/02_level_content_and_validation/00_phase.md create mode 100644 .agents/phases/todo/02_level_content_and_validation/01_sim_core_data_model.md create mode 100644 .agents/phases/todo/02_level_content_and_validation/02_recipe_and_level_schema.md create mode 100644 .agents/phases/todo/02_level_content_and_validation/03_level_validation_rules.md create mode 100644 .agents/phases/todo/02_level_content_and_validation/04_seed_recipes_and_two_levels.md create mode 100644 .agents/phases/todo/02_level_content_and_validation/05_levels_cli_and_manifest_test.md create mode 100644 .agents/phases/todo/03_simulation_movement_and_interaction/00_phase.md create mode 100644 .agents/phases/todo/03_simulation_movement_and_interaction/01_fixed_step_kernel.md create mode 100644 .agents/phases/todo/03_simulation_movement_and_interaction/02_movement_and_collision.md create mode 100644 .agents/phases/todo/03_simulation_movement_and_interaction/03_interaction_targeting.md create mode 100644 .agents/phases/todo/03_simulation_movement_and_interaction/04_pickup_carry_drop.md create mode 100644 .agents/phases/todo/03_simulation_movement_and_interaction/05_replay_harness_and_goldens.md create mode 100644 .agents/phases/todo/04_kitchen_pipeline_and_scoring/00_phase.md create mode 100644 .agents/phases/todo/04_kitchen_pipeline_and_scoring/01_station_state_machines.md create mode 100644 .agents/phases/todo/04_kitchen_pipeline_and_scoring/02_order_spawning_and_expiry.md create mode 100644 .agents/phases/todo/04_kitchen_pipeline_and_scoring/03_serve_resolution_and_scoring.md create mode 100644 .agents/phases/todo/04_kitchen_pipeline_and_scoring/04_spoil_burn_and_dirty_loop.md create mode 100644 .agents/phases/todo/04_kitchen_pipeline_and_scoring/05_round_end_results_and_stars.md create mode 100644 .agents/phases/todo/04_kitchen_pipeline_and_scoring/06_headless_round_simulations.md create mode 100644 .agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/00_phase.md create mode 100644 .agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/01_room_supervisor_and_codes.md create mode 100644 .agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/02_room_state_machine.md create mode 100644 .agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/03_lobby_state_and_presence.md create mode 100644 .agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/04_session_store_and_progression.md create mode 100644 .agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/05_lifecycle_integration_suite.md create mode 100644 .agents/phases/todo/06_realtime_websocket_protocol/00_phase.md create mode 100644 .agents/phases/todo/06_realtime_websocket_protocol/01_protocol_models_and_wire_contract.md create mode 100644 .agents/phases/todo/06_realtime_websocket_protocol/02_ws_endpoint_and_handshake.md create mode 100644 .agents/phases/todo/06_realtime_websocket_protocol/03_input_ingestion_and_validation.md create mode 100644 .agents/phases/todo/06_realtime_websocket_protocol/04_snapshot_and_event_encoding.md create mode 100644 .agents/phases/todo/06_realtime_websocket_protocol/05_heartbeat_disconnect_and_seat_grace.md create mode 100644 .agents/phases/todo/06_realtime_websocket_protocol/06_websocket_integration_suite.md create mode 100644 .agents/phases/todo/07_canvas_client_and_hud/00_phase.md create mode 100644 .agents/phases/todo/07_canvas_client_and_hud/01_svg_tileset_and_atlas.md create mode 100644 .agents/phases/todo/07_canvas_client_and_hud/02_screen_state_machine_and_lobby_ui.md create mode 100644 .agents/phases/todo/07_canvas_client_and_hud/03_snapshot_buffer_and_interpolation.md create mode 100644 .agents/phases/todo/07_canvas_client_and_hud/04_canvas_renderer_and_hud.md create mode 100644 .agents/phases/todo/07_canvas_client_and_hud/05_keyboard_input_and_first_playable_e2e.md create mode 100644 .agents/phases/todo/08_client_prediction_and_touch/00_phase.md create mode 100644 .agents/phases/todo/08_client_prediction_and_touch/01_connection_lifecycle_and_backoff.md create mode 100644 .agents/phases/todo/08_client_prediction_and_touch/02_input_channel_and_seq_discipline.md create mode 100644 .agents/phases/todo/08_client_prediction_and_touch/03_client_prediction_and_reconciliation.md create mode 100644 .agents/phases/todo/08_client_prediction_and_touch/04_touch_controls_and_responsive_layout.md create mode 100644 .agents/phases/todo/08_client_prediction_and_touch/05_netcode_budget_and_latency_suite.md create mode 100644 .agents/phases/todo/09_co_op_two_player_e2e/00_phase.md create mode 100644 .agents/phases/todo/09_co_op_two_player_e2e/01_contested_arbitration_and_station_locks.md create mode 100644 .agents/phases/todo/09_co_op_two_player_e2e/02_multi_context_playwright_harness.md create mode 100644 .agents/phases/todo/09_co_op_two_player_e2e/03_cooperative_workflow_e2e.md create mode 100644 .agents/phases/todo/09_co_op_two_player_e2e/04_four_player_fairness_and_starvation.md create mode 100644 .agents/phases/todo/10_hazards_and_escalation_mechanics/00_phase.md create mode 100644 .agents/phases/todo/10_hazards_and_escalation_mechanics/01_fire.md create mode 100644 .agents/phases/todo/10_hazards_and_escalation_mechanics/02_spills_and_slipping.md create mode 100644 .agents/phases/todo/10_hazards_and_escalation_mechanics/03_moving_obstacles.md create mode 100644 .agents/phases/todo/10_hazards_and_escalation_mechanics/04_rush_waves_and_pressure_modifiers.md create mode 100644 .agents/phases/todo/10_hazards_and_escalation_mechanics/05_hazard_presentation_and_warnings.md create mode 100644 .agents/phases/todo/10_hazards_and_escalation_mechanics/06_hazard_determinism_and_validator_hardening.md create mode 100644 .agents/phases/todo/11_campaign_levels_and_progression/00_phase.md create mode 100644 .agents/phases/todo/11_campaign_levels_and_progression/01_author_levels_03_to_06.md create mode 100644 .agents/phases/todo/11_campaign_levels_and_progression/02_author_levels_07_to_10.md create mode 100644 .agents/phases/todo/11_campaign_levels_and_progression/03_escalation_curve_gate.md create mode 100644 .agents/phases/todo/11_campaign_levels_and_progression/04_campaign_flow_and_level_picker.md create mode 100644 .agents/phases/todo/11_campaign_levels_and_progression/05_campaign_playthrough_suite.md create mode 100644 .agents/phases/todo/12_reconnect_resilience_and_scale/00_phase.md create mode 100644 .agents/phases/todo/12_reconnect_resilience_and_scale/01_room_garbage_collection.md create mode 100644 .agents/phases/todo/12_reconnect_resilience_and_scale/02_rejoin_chaos_matrix.md create mode 100644 .agents/phases/todo/12_reconnect_resilience_and_scale/03_tick_scheduler_under_load.md create mode 100644 .agents/phases/todo/12_reconnect_resilience_and_scale/04_capacity_and_degradation.md create mode 100644 .agents/phases/todo/12_reconnect_resilience_and_scale/05_ops_observability_and_runbook.md create mode 100644 .agents/phases/todo/13_endless_mode_and_scoreboard/00_phase.md create mode 100644 .agents/phases/todo/13_endless_mode_and_scoreboard/01_endless_generator.md create mode 100644 .agents/phases/todo/13_endless_mode_and_scoreboard/02_endless_round_loop.md create mode 100644 .agents/phases/todo/13_endless_mode_and_scoreboard/03_session_scoreboard_store.md create mode 100644 .agents/phases/todo/13_endless_mode_and_scoreboard/04_scoreboard_ui_and_honesty.md create mode 100644 .agents/phases/todo/13_endless_mode_and_scoreboard/05_endless_e2e_and_bounds.md create mode 100644 .agents/phases/todo/14_accessibility_polish_and_hardening/00_phase.md create mode 100644 .agents/phases/todo/14_accessibility_polish_and_hardening/01_accessibility_gate.md create mode 100644 .agents/phases/todo/14_accessibility_polish_and_hardening/02_canvas_alternative_and_redundancy.md create mode 100644 .agents/phases/todo/14_accessibility_polish_and_hardening/03_input_parity_and_localization_hooks.md create mode 100644 .agents/phases/todo/14_accessibility_polish_and_hardening/04_security_and_abuse_review.md create mode 100644 .agents/phases/todo/14_accessibility_polish_and_hardening/05_release_hardening_and_docs.md create mode 100644 .agents/user_stories/US-01_create_anonymous_lobby.md create mode 100644 .agents/user_stories/US-02_join_by_code.md create mode 100644 .agents/user_stories/US-03_ready_up_and_start.md create mode 100644 .agents/user_stories/US-04_move_and_cook.md create mode 100644 .agents/user_stories/US-05_orders_score_and_result.md create mode 100644 .agents/user_stories/US-06_coop_shared_kitchen.md create mode 100644 .agents/user_stories/US-07_campaign_progression.md create mode 100644 .agents/user_stories/US-08_hazards_under_pressure.md create mode 100644 .agents/user_stories/US-09_disconnect_and_rejoin.md create mode 100644 .agents/user_stories/US-10_endless_mode_and_scoreboard.md create mode 100644 .agents/user_stories/US-11_play_on_phone.md create mode 100644 .agents/user_stories/US-12_play_accessibly.md create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Containerfile create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 pyproject.toml diff --git a/.agents/PLAN.md b/.agents/PLAN.md new file mode 100644 index 0000000..f5d0f95 --- /dev/null +++ b/.agents/PLAN.md @@ -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. diff --git a/.agents/phases/complete/.gitkeep b/.agents/phases/complete/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.agents/phases/todo/01_runnable_service_scaffold/00_phase.md b/.agents/phases/todo/01_runnable_service_scaffold/00_phase.md new file mode 100644 index 0000000..8008ce4 --- /dev/null +++ b/.agents/phases/todo/01_runnable_service_scaffold/00_phase.md @@ -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) diff --git a/.agents/phases/todo/01_runnable_service_scaffold/01_lock_toolchain_and_packages.md b/.agents/phases/todo/01_runnable_service_scaffold/01_lock_toolchain_and_packages.md new file mode 100644 index 0000000..2e5531a --- /dev/null +++ b/.agents/phases/todo/01_runnable_service_scaffold/01_lock_toolchain_and_packages.md @@ -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 diff --git a/.agents/phases/todo/01_runnable_service_scaffold/02_config_and_debugpy.md b/.agents/phases/todo/01_runnable_service_scaffold/02_config_and_debugpy.md new file mode 100644 index 0000000..69dacec --- /dev/null +++ b/.agents/phases/todo/01_runnable_service_scaffold/02_config_and_debugpy.md @@ -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 diff --git a/.agents/phases/todo/01_runnable_service_scaffold/03_fastapi_app_and_static_shell.md b/.agents/phases/todo/01_runnable_service_scaffold/03_fastapi_app_and_static_shell.md new file mode 100644 index 0000000..5028e14 --- /dev/null +++ b/.agents/phases/todo/01_runnable_service_scaffold/03_fastapi_app_and_static_shell.md @@ -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` — ``, + 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 + `` 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 diff --git a/.agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/04_session_store_and_progression.md b/.agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/04_session_store_and_progression.md new file mode 100644 index 0000000..144cc68 --- /dev/null +++ b/.agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/04_session_store_and_progression.md @@ -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 diff --git a/.agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/05_lifecycle_integration_suite.md b/.agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/05_lifecycle_integration_suite.md new file mode 100644 index 0000000..108a0fb --- /dev/null +++ b/.agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/05_lifecycle_integration_suite.md @@ -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 diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/00_phase.md b/.agents/phases/todo/06_realtime_websocket_protocol/00_phase.md new file mode 100644 index 0000000..0bf21b8 --- /dev/null +++ b/.agents/phases/todo/06_realtime_websocket_protocol/00_phase.md @@ -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 diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/01_protocol_models_and_wire_contract.md b/.agents/phases/todo/06_realtime_websocket_protocol/01_protocol_models_and_wire_contract.md new file mode 100644 index 0000000..b5da0de --- /dev/null +++ b/.agents/phases/todo/06_realtime_websocket_protocol/01_protocol_models_and_wire_contract.md @@ -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 diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/02_ws_endpoint_and_handshake.md b/.agents/phases/todo/06_realtime_websocket_protocol/02_ws_endpoint_and_handshake.md new file mode 100644 index 0000000..fe6c972 --- /dev/null +++ b/.agents/phases/todo/06_realtime_websocket_protocol/02_ws_endpoint_and_handshake.md @@ -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 diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/03_input_ingestion_and_validation.md b/.agents/phases/todo/06_realtime_websocket_protocol/03_input_ingestion_and_validation.md new file mode 100644 index 0000000..3817512 --- /dev/null +++ b/.agents/phases/todo/06_realtime_websocket_protocol/03_input_ingestion_and_validation.md @@ -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 diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/04_snapshot_and_event_encoding.md b/.agents/phases/todo/06_realtime_websocket_protocol/04_snapshot_and_event_encoding.md new file mode 100644 index 0000000..978845d --- /dev/null +++ b/.agents/phases/todo/06_realtime_websocket_protocol/04_snapshot_and_event_encoding.md @@ -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 diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/05_heartbeat_disconnect_and_seat_grace.md b/.agents/phases/todo/06_realtime_websocket_protocol/05_heartbeat_disconnect_and_seat_grace.md new file mode 100644 index 0000000..54da9cc --- /dev/null +++ b/.agents/phases/todo/06_realtime_websocket_protocol/05_heartbeat_disconnect_and_seat_grace.md @@ -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 diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/06_websocket_integration_suite.md b/.agents/phases/todo/06_realtime_websocket_protocol/06_websocket_integration_suite.md new file mode 100644 index 0000000..bd6c934 --- /dev/null +++ b/.agents/phases/todo/06_realtime_websocket_protocol/06_websocket_integration_suite.md @@ -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 diff --git a/.agents/phases/todo/07_canvas_client_and_hud/00_phase.md b/.agents/phases/todo/07_canvas_client_and_hud/00_phase.md new file mode 100644 index 0000000..fe061e2 --- /dev/null +++ b/.agents/phases/todo/07_canvas_client_and_hud/00_phase.md @@ -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 diff --git a/.agents/phases/todo/07_canvas_client_and_hud/01_svg_tileset_and_atlas.md b/.agents/phases/todo/07_canvas_client_and_hud/01_svg_tileset_and_atlas.md new file mode 100644 index 0000000..2c0a55b --- /dev/null +++ b/.agents/phases/todo/07_canvas_client_and_hud/01_svg_tileset_and_atlas.md @@ -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 `` 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 + `` 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 diff --git a/.agents/phases/todo/07_canvas_client_and_hud/02_screen_state_machine_and_lobby_ui.md b/.agents/phases/todo/07_canvas_client_and_hud/02_screen_state_machine_and_lobby_ui.md new file mode 100644 index 0000000..36f3da4 --- /dev/null +++ b/.agents/phases/todo/07_canvas_client_and_hud/02_screen_state_machine_and_lobby_ui.md @@ -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 diff --git a/.agents/phases/todo/07_canvas_client_and_hud/03_snapshot_buffer_and_interpolation.md b/.agents/phases/todo/07_canvas_client_and_hud/03_snapshot_buffer_and_interpolation.md new file mode 100644 index 0000000..b83e9ed --- /dev/null +++ b/.agents/phases/todo/07_canvas_client_and_hud/03_snapshot_buffer_and_interpolation.md @@ -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 diff --git a/.agents/phases/todo/07_canvas_client_and_hud/04_canvas_renderer_and_hud.md b/.agents/phases/todo/07_canvas_client_and_hud/04_canvas_renderer_and_hud.md new file mode 100644 index 0000000..5742c28 --- /dev/null +++ b/.agents/phases/todo/07_canvas_client_and_hud/04_canvas_renderer_and_hud.md @@ -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 diff --git a/.agents/phases/todo/07_canvas_client_and_hud/05_keyboard_input_and_first_playable_e2e.md b/.agents/phases/todo/07_canvas_client_and_hud/05_keyboard_input_and_first_playable_e2e.md new file mode 100644 index 0000000..f7d2a29 --- /dev/null +++ b/.agents/phases/todo/07_canvas_client_and_hud/05_keyboard_input_and_first_playable_e2e.md @@ -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 diff --git a/.agents/phases/todo/08_client_prediction_and_touch/00_phase.md b/.agents/phases/todo/08_client_prediction_and_touch/00_phase.md new file mode 100644 index 0000000..9883006 --- /dev/null +++ b/.agents/phases/todo/08_client_prediction_and_touch/00_phase.md @@ -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) diff --git a/.agents/phases/todo/08_client_prediction_and_touch/01_connection_lifecycle_and_backoff.md b/.agents/phases/todo/08_client_prediction_and_touch/01_connection_lifecycle_and_backoff.md new file mode 100644 index 0000000..b22bceb --- /dev/null +++ b/.agents/phases/todo/08_client_prediction_and_touch/01_connection_lifecycle_and_backoff.md @@ -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 diff --git a/.agents/phases/todo/08_client_prediction_and_touch/02_input_channel_and_seq_discipline.md b/.agents/phases/todo/08_client_prediction_and_touch/02_input_channel_and_seq_discipline.md new file mode 100644 index 0000000..7424a81 --- /dev/null +++ b/.agents/phases/todo/08_client_prediction_and_touch/02_input_channel_and_seq_discipline.md @@ -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 diff --git a/.agents/phases/todo/08_client_prediction_and_touch/03_client_prediction_and_reconciliation.md b/.agents/phases/todo/08_client_prediction_and_touch/03_client_prediction_and_reconciliation.md new file mode 100644 index 0000000..b394c66 --- /dev/null +++ b/.agents/phases/todo/08_client_prediction_and_touch/03_client_prediction_and_reconciliation.md @@ -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 diff --git a/.agents/phases/todo/08_client_prediction_and_touch/04_touch_controls_and_responsive_layout.md b/.agents/phases/todo/08_client_prediction_and_touch/04_touch_controls_and_responsive_layout.md new file mode 100644 index 0000000..66c27f7 --- /dev/null +++ b/.agents/phases/todo/08_client_prediction_and_touch/04_touch_controls_and_responsive_layout.md @@ -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 diff --git a/.agents/phases/todo/08_client_prediction_and_touch/05_netcode_budget_and_latency_suite.md b/.agents/phases/todo/08_client_prediction_and_touch/05_netcode_budget_and_latency_suite.md new file mode 100644 index 0000000..40c149d --- /dev/null +++ b/.agents/phases/todo/08_client_prediction_and_touch/05_netcode_budget_and_latency_suite.md @@ -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) diff --git a/.agents/phases/todo/09_co_op_two_player_e2e/00_phase.md b/.agents/phases/todo/09_co_op_two_player_e2e/00_phase.md new file mode 100644 index 0000000..af1e998 --- /dev/null +++ b/.agents/phases/todo/09_co_op_two_player_e2e/00_phase.md @@ -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 diff --git a/.agents/phases/todo/09_co_op_two_player_e2e/01_contested_arbitration_and_station_locks.md b/.agents/phases/todo/09_co_op_two_player_e2e/01_contested_arbitration_and_station_locks.md new file mode 100644 index 0000000..76263de --- /dev/null +++ b/.agents/phases/todo/09_co_op_two_player_e2e/01_contested_arbitration_and_station_locks.md @@ -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": "", "eta_s": }` 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 diff --git a/.agents/phases/todo/09_co_op_two_player_e2e/02_multi_context_playwright_harness.md b/.agents/phases/todo/09_co_op_two_player_e2e/02_multi_context_playwright_harness.md new file mode 100644 index 0000000..5490f34 --- /dev/null +++ b/.agents/phases/todo/09_co_op_two_player_e2e/02_multi_context_playwright_harness.md @@ -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 diff --git a/.agents/phases/todo/09_co_op_two_player_e2e/03_cooperative_workflow_e2e.md b/.agents/phases/todo/09_co_op_two_player_e2e/03_cooperative_workflow_e2e.md new file mode 100644 index 0000000..62f883c --- /dev/null +++ b/.agents/phases/todo/09_co_op_two_player_e2e/03_cooperative_workflow_e2e.md @@ -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 diff --git a/.agents/phases/todo/09_co_op_two_player_e2e/04_four_player_fairness_and_starvation.md b/.agents/phases/todo/09_co_op_two_player_e2e/04_four_player_fairness_and_starvation.md new file mode 100644 index 0000000..77cf6d7 --- /dev/null +++ b/.agents/phases/todo/09_co_op_two_player_e2e/04_four_player_fairness_and_starvation.md @@ -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 diff --git a/.agents/phases/todo/10_hazards_and_escalation_mechanics/00_phase.md b/.agents/phases/todo/10_hazards_and_escalation_mechanics/00_phase.md new file mode 100644 index 0000000..24d0325 --- /dev/null +++ b/.agents/phases/todo/10_hazards_and_escalation_mechanics/00_phase.md @@ -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 diff --git a/.agents/phases/todo/10_hazards_and_escalation_mechanics/01_fire.md b/.agents/phases/todo/10_hazards_and_escalation_mechanics/01_fire.md new file mode 100644 index 0000000..c909c10 --- /dev/null +++ b/.agents/phases/todo/10_hazards_and_escalation_mechanics/01_fire.md @@ -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 diff --git a/.agents/phases/todo/10_hazards_and_escalation_mechanics/02_spills_and_slipping.md b/.agents/phases/todo/10_hazards_and_escalation_mechanics/02_spills_and_slipping.md new file mode 100644 index 0000000..4cb05cc --- /dev/null +++ b/.agents/phases/todo/10_hazards_and_escalation_mechanics/02_spills_and_slipping.md @@ -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 diff --git a/.agents/phases/todo/10_hazards_and_escalation_mechanics/03_moving_obstacles.md b/.agents/phases/todo/10_hazards_and_escalation_mechanics/03_moving_obstacles.md new file mode 100644 index 0000000..63792df --- /dev/null +++ b/.agents/phases/todo/10_hazards_and_escalation_mechanics/03_moving_obstacles.md @@ -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 diff --git a/.agents/phases/todo/10_hazards_and_escalation_mechanics/04_rush_waves_and_pressure_modifiers.md b/.agents/phases/todo/10_hazards_and_escalation_mechanics/04_rush_waves_and_pressure_modifiers.md new file mode 100644 index 0000000..323f54b --- /dev/null +++ b/.agents/phases/todo/10_hazards_and_escalation_mechanics/04_rush_waves_and_pressure_modifiers.md @@ -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 diff --git a/.agents/phases/todo/10_hazards_and_escalation_mechanics/05_hazard_presentation_and_warnings.md b/.agents/phases/todo/10_hazards_and_escalation_mechanics/05_hazard_presentation_and_warnings.md new file mode 100644 index 0000000..b49dbf2 --- /dev/null +++ b/.agents/phases/todo/10_hazards_and_escalation_mechanics/05_hazard_presentation_and_warnings.md @@ -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 diff --git a/.agents/phases/todo/10_hazards_and_escalation_mechanics/06_hazard_determinism_and_validator_hardening.md b/.agents/phases/todo/10_hazards_and_escalation_mechanics/06_hazard_determinism_and_validator_hardening.md new file mode 100644 index 0000000..e05efee --- /dev/null +++ b/.agents/phases/todo/10_hazards_and_escalation_mechanics/06_hazard_determinism_and_validator_hardening.md @@ -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 diff --git a/.agents/phases/todo/11_campaign_levels_and_progression/00_phase.md b/.agents/phases/todo/11_campaign_levels_and_progression/00_phase.md new file mode 100644 index 0000000..b09fb40 --- /dev/null +++ b/.agents/phases/todo/11_campaign_levels_and_progression/00_phase.md @@ -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 diff --git a/.agents/phases/todo/11_campaign_levels_and_progression/01_author_levels_03_to_06.md b/.agents/phases/todo/11_campaign_levels_and_progression/01_author_levels_03_to_06.md new file mode 100644 index 0000000..9e02c12 --- /dev/null +++ b/.agents/phases/todo/11_campaign_levels_and_progression/01_author_levels_03_to_06.md @@ -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 diff --git a/.agents/phases/todo/11_campaign_levels_and_progression/02_author_levels_07_to_10.md b/.agents/phases/todo/11_campaign_levels_and_progression/02_author_levels_07_to_10.md new file mode 100644 index 0000000..2c6008a --- /dev/null +++ b/.agents/phases/todo/11_campaign_levels_and_progression/02_author_levels_07_to_10.md @@ -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 diff --git a/.agents/phases/todo/11_campaign_levels_and_progression/03_escalation_curve_gate.md b/.agents/phases/todo/11_campaign_levels_and_progression/03_escalation_curve_gate.md new file mode 100644 index 0000000..abbedd9 --- /dev/null +++ b/.agents/phases/todo/11_campaign_levels_and_progression/03_escalation_curve_gate.md @@ -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: ` 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 `` / `` 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 diff --git a/.agents/phases/todo/11_campaign_levels_and_progression/04_campaign_flow_and_level_picker.md b/.agents/phases/todo/11_campaign_levels_and_progression/04_campaign_flow_and_level_picker.md new file mode 100644 index 0000000..375ff0d --- /dev/null +++ b/.agents/phases/todo/11_campaign_levels_and_progression/04_campaign_flow_and_level_picker.md @@ -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 diff --git a/.agents/phases/todo/11_campaign_levels_and_progression/05_campaign_playthrough_suite.md b/.agents/phases/todo/11_campaign_levels_and_progression/05_campaign_playthrough_suite.md new file mode 100644 index 0000000..ed46c1a --- /dev/null +++ b/.agents/phases/todo/11_campaign_levels_and_progression/05_campaign_playthrough_suite.md @@ -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 diff --git a/.agents/phases/todo/12_reconnect_resilience_and_scale/00_phase.md b/.agents/phases/todo/12_reconnect_resilience_and_scale/00_phase.md new file mode 100644 index 0000000..fd530e4 --- /dev/null +++ b/.agents/phases/todo/12_reconnect_resilience_and_scale/00_phase.md @@ -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 diff --git a/.agents/phases/todo/12_reconnect_resilience_and_scale/01_room_garbage_collection.md b/.agents/phases/todo/12_reconnect_resilience_and_scale/01_room_garbage_collection.md new file mode 100644 index 0000000..6ac17e0 --- /dev/null +++ b/.agents/phases/todo/12_reconnect_resilience_and_scale/01_room_garbage_collection.md @@ -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 diff --git a/.agents/phases/todo/12_reconnect_resilience_and_scale/02_rejoin_chaos_matrix.md b/.agents/phases/todo/12_reconnect_resilience_and_scale/02_rejoin_chaos_matrix.md new file mode 100644 index 0000000..fcf0ffe --- /dev/null +++ b/.agents/phases/todo/12_reconnect_resilience_and_scale/02_rejoin_chaos_matrix.md @@ -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 diff --git a/.agents/phases/todo/12_reconnect_resilience_and_scale/03_tick_scheduler_under_load.md b/.agents/phases/todo/12_reconnect_resilience_and_scale/03_tick_scheduler_under_load.md new file mode 100644 index 0000000..d3f3de6 --- /dev/null +++ b/.agents/phases/todo/12_reconnect_resilience_and_scale/03_tick_scheduler_under_load.md @@ -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 diff --git a/.agents/phases/todo/12_reconnect_resilience_and_scale/04_capacity_and_degradation.md b/.agents/phases/todo/12_reconnect_resilience_and_scale/04_capacity_and_degradation.md new file mode 100644 index 0000000..646fd6d --- /dev/null +++ b/.agents/phases/todo/12_reconnect_resilience_and_scale/04_capacity_and_degradation.md @@ -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 diff --git a/.agents/phases/todo/12_reconnect_resilience_and_scale/05_ops_observability_and_runbook.md b/.agents/phases/todo/12_reconnect_resilience_and_scale/05_ops_observability_and_runbook.md new file mode 100644 index 0000000..2ba9974 --- /dev/null +++ b/.agents/phases/todo/12_reconnect_resilience_and_scale/05_ops_observability_and_runbook.md @@ -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 diff --git a/.agents/phases/todo/13_endless_mode_and_scoreboard/00_phase.md b/.agents/phases/todo/13_endless_mode_and_scoreboard/00_phase.md new file mode 100644 index 0000000..95bfa93 --- /dev/null +++ b/.agents/phases/todo/13_endless_mode_and_scoreboard/00_phase.md @@ -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 diff --git a/.agents/phases/todo/13_endless_mode_and_scoreboard/01_endless_generator.md b/.agents/phases/todo/13_endless_mode_and_scoreboard/01_endless_generator.md new file mode 100644 index 0000000..086d578 --- /dev/null +++ b/.agents/phases/todo/13_endless_mode_and_scoreboard/01_endless_generator.md @@ -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 diff --git a/.agents/phases/todo/13_endless_mode_and_scoreboard/02_endless_round_loop.md b/.agents/phases/todo/13_endless_mode_and_scoreboard/02_endless_round_loop.md new file mode 100644 index 0000000..57eccc2 --- /dev/null +++ b/.agents/phases/todo/13_endless_mode_and_scoreboard/02_endless_round_loop.md @@ -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 diff --git a/.agents/phases/todo/13_endless_mode_and_scoreboard/03_session_scoreboard_store.md b/.agents/phases/todo/13_endless_mode_and_scoreboard/03_session_scoreboard_store.md new file mode 100644 index 0000000..753f9d0 --- /dev/null +++ b/.agents/phases/todo/13_endless_mode_and_scoreboard/03_session_scoreboard_store.md @@ -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 diff --git a/.agents/phases/todo/13_endless_mode_and_scoreboard/04_scoreboard_ui_and_honesty.md b/.agents/phases/todo/13_endless_mode_and_scoreboard/04_scoreboard_ui_and_honesty.md new file mode 100644 index 0000000..4a909ae --- /dev/null +++ b/.agents/phases/todo/13_endless_mode_and_scoreboard/04_scoreboard_ui_and_honesty.md @@ -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 diff --git a/.agents/phases/todo/13_endless_mode_and_scoreboard/05_endless_e2e_and_bounds.md b/.agents/phases/todo/13_endless_mode_and_scoreboard/05_endless_e2e_and_bounds.md new file mode 100644 index 0000000..e749014 --- /dev/null +++ b/.agents/phases/todo/13_endless_mode_and_scoreboard/05_endless_e2e_and_bounds.md @@ -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 diff --git a/.agents/phases/todo/14_accessibility_polish_and_hardening/00_phase.md b/.agents/phases/todo/14_accessibility_polish_and_hardening/00_phase.md new file mode 100644 index 0000000..0234e9d --- /dev/null +++ b/.agents/phases/todo/14_accessibility_polish_and_hardening/00_phase.md @@ -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 diff --git a/.agents/phases/todo/14_accessibility_polish_and_hardening/01_accessibility_gate.md b/.agents/phases/todo/14_accessibility_polish_and_hardening/01_accessibility_gate.md new file mode 100644 index 0000000..b4f4f5a --- /dev/null +++ b/.agents/phases/todo/14_accessibility_polish_and_hardening/01_accessibility_gate.md @@ -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 diff --git a/.agents/phases/todo/14_accessibility_polish_and_hardening/02_canvas_alternative_and_redundancy.md b/.agents/phases/todo/14_accessibility_polish_and_hardening/02_canvas_alternative_and_redundancy.md new file mode 100644 index 0000000..f0a58d2 --- /dev/null +++ b/.agents/phases/todo/14_accessibility_polish_and_hardening/02_canvas_alternative_and_redundancy.md @@ -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 diff --git a/.agents/phases/todo/14_accessibility_polish_and_hardening/03_input_parity_and_localization_hooks.md b/.agents/phases/todo/14_accessibility_polish_and_hardening/03_input_parity_and_localization_hooks.md new file mode 100644 index 0000000..70dd83f --- /dev/null +++ b/.agents/phases/todo/14_accessibility_polish_and_hardening/03_input_parity_and_localization_hooks.md @@ -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 diff --git a/.agents/phases/todo/14_accessibility_polish_and_hardening/04_security_and_abuse_review.md b/.agents/phases/todo/14_accessibility_polish_and_hardening/04_security_and_abuse_review.md new file mode 100644 index 0000000..fed0c2f --- /dev/null +++ b/.agents/phases/todo/14_accessibility_polish_and_hardening/04_security_and_abuse_review.md @@ -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 (`