phase: 01_runnable_service_scaffold

**Phase 01 final verification pass — all criteria green**

- Verified (nothing left to implement; all 4 task files already in `complete/`): toolchain lock, typed `Settings`+`.env.example`, debugpy bootstrap, app factory, CLI (`--host/--port/--reload/--levels-dir`, `python -m undercooked`), static shell + `/static`, security headers, no-CDN/doc guards, `validate.sh`.
- `uv sync --extra dev` → exit 0; `uv.lock` present (untracked → harness commits it with the phase).
- `uv run undercooked --host 127.0.0.1 --port 8000` → `curl /healthz` = 200 `{"status":"ok","version":"0.1.0","rooms":0}` (superset of the criterion's body — now documented in README); `curl /` = 200 shell HTML; static css/js 200; CSP/nosniff/no-referrer/DENY on 200, 404 and 405 too.
- `DEBUGPY=1`: port 5678 is held on this machine by the user's own podman dev-container debugpy adapter — **no live change made/killed**; verified via `DEBUGPY=1 DEBUGPY_PORT=5679` → listener opens, `/healthz` still 200; the occupied-port case degraded gracefully as designed (warning, server serves). Bad config → one line + exit 2 ✓.
- `bash .agents/validate.sh` (root **and** `.agents/`) → exit 0: `ruff check .` 0 errors, `pyright` 0 errors, levels-CLI notice-skip (matches README prose), **284 passed** full suite incl. 10 Playwright e2e, coverage **100.00%** (floor 90), repo hygiene clean. `uv run pytest -m e2e` → 10 passed.
- Docs fixed: README Status → phase 01 complete + next pending 02 (was pointing at `todo/01…`), documented real `/healthz` body, precedence now "CLI flags > env > .env > default", added the PLAN §13-promised **How to play** section (honest about shell-only today), Layout rows marked what exists vs phases 02/07. AGENTS.md: recorded the `data:`-URL exemption in the no-CDN rule (test allows it) and matched the session-token rule to the browser's WebCrypto/base64url implementation. Also corrected a stale "session-wide chromium" docstring in `tests/e2e/conftest.py` (fixtures are function-scoped).
- Deviations: no container build run (needs an image pull; not a phase criterion — `Containerfile`/`compose.yaml` reviewed and consistent with `docs/DEPLOYMENT.md`); `HEAD /` is 405 (routes are GET-only by design; health checks use GET). No git index/history commands run.
- Next pending phase: `02_level_content_and_validation`.
This commit is contained in:
2026-10-06 01:51:20 -04:00
parent f84c6ebaef
commit 6fd83446ec
43 changed files with 5478 additions and 30 deletions
+48
View File
@@ -0,0 +1,48 @@
# DEPLOYMENT.md — building, running and operating the service
**Status:** stub written by phase 01 · **Filled in by phase 14**, which measures the real
capacity and writes the reverse-proxy/TLS/ops runbook against a production profile.
## Purpose
This document covers everything between "a repository" and "players can reach it": the
multi-stage container build (A19), the single-service `compose.yaml`, the environment
variables an operator actually sets, how TLS and a reverse proxy terminate the WebSocket
connection, and the runbook for the failure modes that an in-memory server has.
`README.md` "Container" stays the copy-paste quickstart; this file is the reasoning and
the operational detail.
## What is deployable today
One artifact, one process, no database (A6/A7): the image builds the package with
`pip install --prefix=/install .` in a builder stage and copies the result into a slim
Python 3.12 runtime that runs as the non-root `player` user (uid 10001), exposes port
8000, and health-checks `GET /healthz` from inside the container. `compose.yaml` runs that
one service with `restart: unless-stopped`. Configuration is environment-only — see the
README table and `.env.example`; the container sets `UNDERCOOKED_HOST=0.0.0.0` and points
`UNDERCOOKED_STATIC_DIR` / `UNDERCOOKED_LEVELS_DIR` at the paths copied into the image,
because the source-checkout defaults do not hold under `site-packages`.
```bash
podman build -t undercooked -f Containerfile .
podman run --rm -p 8000:8000 undercooked
podman compose up -d
```
Rules that hold for every deployment of this project:
- **Never set `DEBUGPY=1` on a host reachable from outside your machine** — the debugger
listener binds `0.0.0.0` (A18, dev-only).
- **Never commit `.env`.** It is gitignored, and `.env.example` is the secret-free
template; the game has no secrets by design (anonymous, A6/A16).
- Everything is in memory: a restart ends every round and erases every scoreboard. That is
a documented product decision, not an incident.
## What this document will contain when it is filled in
Reverse-proxy configuration (WebSocket upgrade, `Connection: upgrade`, idle timeouts
longer than the room's heartbeat), TLS termination and HSTS within the project's
`default-src 'self'` CSP (A12), resource limits derived from the phase-12 capacity
measurement in `BUDGETS.md`, log/metric surfaces that never record a room code with player
contents (§11), a rolling-restart procedure that accepts the round it kills, and the
backup story: none, and why.
+43
View File
@@ -0,0 +1,43 @@
# DESIGN.md — architecture and the decisions behind it
**Status:** stub written by phase 01 · **Filled in by phase 09**, when the client, the
protocol and the first two-player end-to-end story make the prose verifiable.
## Purpose
This document explains the shape of the server and the client in prose: which module owns
what, why the boundaries are drawn where they are, and the reasoning behind the LOCKED
decisions A1–A12 of `.agents/PLAN.md` §3. `PLAN.md` states *what* was decided and stays
frozen; this file is where the *why* lives and where a later phase records the
consequences of those decisions once the code proves them out.
## The two-line version
One asyncio process holds every room. The browser is an untrusted thin client: it sends
inputs, receives snapshots, and interpolates. All truth lives in
`src/undercooked/sim/`, a pure function of `(state, dt)` with no I/O, no clock and no
unseeded randomness, which is what makes headless determinism tests (A5/A21) possible at
all. Everything around that kernel — `server/` (FastAPI app, WebSocket protocol, room
supervisor, in-memory session records) and `app/static/` (vanilla ES modules, Canvas 2D)
— exists only to move bytes between a player's hands and the simulation.
## Module map (as it stands after phase 01)
| Path | Owns | Filled in by |
|---|---|---|
| `src/undercooked/config.py` | the only read of the environment → typed, validated `Settings` | phase 01 (done) |
| `src/undercooked/debug.py` | `DEBUGPY`-gated debugger bootstrap (A18) | phase 01 (done) |
| `src/undercooked/server/app.py` | app factory, `/healthz`, `/`, `/static`, startup logging | phase 01 (done) |
| `src/undercooked/server/headers.py` | CSP + the four other security headers on every response | phase 01 (done) |
| `src/undercooked/sim/` | game truth: model, `step()`, interactions, scoring, hazards | phase 03–04 |
| `src/undercooked/server/room.py`, `rooms.py` | one match, its FSM and tick task; the registry that owns all of them | phase 05–06 |
| `src/undercooked/server/protocol.py` | versioned wire messages, whitelists, inbound validation | phase 06 |
| `src/undercooked/server/sessions.py` | bounded in-memory progression records (A6: no database) | phase 05 |
| `app/static/js/` | screen state machine, net buffer/interpolation/prediction, renderer, HUD | phase 07–08 |
## What this document will contain when it is filled in
The request/response and tick data flow with real timings, the room lifecycle state
machine as built, the arbitration rule for contested resources, the client prediction and
reconciliation model, and one worked paragraph per anchor in A1–A12 covering the
alternatives that were rejected and the measured cost of the choice that was taken.
+80
View File
@@ -0,0 +1,80 @@
# TESTING.md — how this project is verified
**Status:** current as of phase 01 · **Filled in by phase 14**, which re-runs every layer
below against the finished game and records the release-time suite inventory.
## Purpose
This document is the single reference for *how we know the game is right*: the test tree,
what belongs in each directory, how to run each layer without waiting for the others, the
marker set, the coverage floor, and the artifact policy. The layered verification workflow
behind it is `.agents/PLAN.md` §9; the gate that enforces all of it before any phase
completes is `.agents/validate.sh`.
## Layout
`tests/` is deliberately **not** a package (no `__init__.py`); fixtures shared by every
layer live in `tests/conftest.py`, and layer-specific fixtures live in that layer's own
`conftest.py`.
| Directory | Contains | I/O allowed |
|---|---|---|
| `tests/unit/` | pure logic with no I/O: config parsing, header values, CLI precedence, protocol/validator helpers, and the doc/content guards that keep README, `.env.example`, `PLAN.md` and the code agreeing | none (in-process `TestClient` requests are fine) |
| `tests/sim/` | headless determinism and replay tests against the pure kernel (`tests/sim/test_replay_*.py`, golden state hashes) | none — the sim must stay callable without a server |
| `tests/integration/` | multi-module, in-process tests: app boot through `create_app()`, HTTP routes, static serving, room/protocol behaviour with real WebSocket clients (room + protocol integration is PLAN §9 layer 4, and lives here) | in-process app, loopback sockets |
| `tests/e2e/` | one Playwright suite per user story, named `test_usNN_*.py`; multiplayer stories drive **two browser contexts** against one room | real browser, real socket |
| `tests/fixtures/` | fixture data only. `tests/fixtures/levels/` is the content dir every test sees — an autouse session fixture points `UNDERCOOKED_LEVELS_DIR` at it so no test can silently read the shipped `levels/` | none |
There is **no `tests/server/`**: PLAN §9 counts server/route layers of coverage, not
directories. A route asserted as a pure function (header values, status codes, CLI
precedence) is a `tests/unit/` test; a route exercised against a process that actually
binds a socket is `tests/integration/`. Adding a fifth directory would only hide which of
those two a test is.
## Markers
Registered in `pyproject.toml` and enforced with `--strict-markers`:
| Marker | Meaning | Default run |
|---|---|---|
| `unit` | pure, no I/O, fast | yes |
| `integration` | multi-module, in-process server/room tests | yes |
| `e2e` | Playwright browser suites (needs `uv run playwright install chromium`) | yes |
| `slow` | replay/stress suites that exceed the quick loop (the 200-room stress run lives here from phase 12) | yes, excluded explicitly |
The everyday loop is `uv run pytest -m "not e2e"` (no browser launches). Before a phase
completes, and whenever `.agents/validate.sh` runs, the **full** suite runs — coverage gate
included — because the gate is "the entire project is green", not "my files are green".
## Running everything
```bash
uv sync --extra dev # dependencies + test toolchain
uv run playwright install chromium # once, before the first browser run
uv run pytest -q -m "not e2e" # quick loop
uv run pytest -q # everything, with the coverage floor
uv run pytest tests/unit -q -p no:cacheprovider # one file, no nested coverage writes
uv run ruff check . # lint (E,F,W,I,N,UP,B,C4,SIM,ASYNC,S,RUF,ARG,RET,PL,PERF)
uv run pyright # types
bash .agents/validate.sh # the pipeline gate, in that order + hygiene
```
Coverage is configured in `pyproject.toml`: `source = ["src/undercooked"]`, branch
coverage on, `--cov-fail-under=90` (AGENTS.md A20 asks for > 90 % on new/changed code; the
floor is the mechanical half of that).
Test processes never listen on a routable interface. The only fixture that binds a socket
is `live_server` (session-scoped, `tests/conftest.py`), which asks uvicorn for an
ephemeral `127.0.0.1` port and shuts the lifespan down cleanly.
## Artifact policy
- Test output is disposable and **never committed**: `test-results/`, `playwright-report/`,
`blob-report/`, `.playwright/`, `.pytest_cache/`, `.coverage`, `htmlcov/`,
`coverage.xml`, `coverage.json` are all gitignored.
- No test writes into `app/static/`, `levels/`, `src/`, or `docs/`; anything a test needs
is created under `tmp_path`.
- Nothing is downloaded during a test run: browsers are installed explicitly by a human
(or CI step), and no test reaches the network except against `127.0.0.1`.
- A test that cannot pass deterministically is a bug in the test — sleeps are bounded by
conditions, and the sim-facing suites are seeded through the `random_seed` fixture.