Scaffold project and author 14-phase roadmap
Browser co-op kitchen game: FastAPI + native WebSockets, server-authoritative 20 Hz simulation, in-memory anonymous lobbies, data-driven YAML levels, Canvas 2D vanilla ES-module client with locally authored SVG art and no external CDNs. .agents/PLAN.md records the LOCKED decisions; .agents/user_stories/ holds the 12 flow contracts; .agents/phases/todo/ breaks them into 14 sequential, test-gated phases (71 task files), each leaving the app launchable and the suite green. LLM-Generated: true
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# Phase 01 — Runnable service scaffold
|
||||
|
||||
**Story:** `US-01_create_anonymous_lobby` (partial — landing surface only)
|
||||
**Context:** `.agents/PLAN.md` §4 (app/static/HTTP boundary), A1, A2, A12, A17, A18, A20
|
||||
|
||||
## Objective
|
||||
Turn the empty repository into a launchable FastAPI service that serves the client
|
||||
shell, exposes `/healthz`, enforces the no-external-asset rule, and has its quality
|
||||
toolchain wired end to end. Nothing about gameplay exists yet; the app must still boot,
|
||||
serve, and pass every check.
|
||||
|
||||
## Dependencies
|
||||
- none (first phase; `pyproject.toml`, `Containerfile`, `compose.yaml`, `README.md`,
|
||||
`AGENTS.md`, `.agents/PLAN.md` already exist in the repo)
|
||||
|
||||
## Tasks
|
||||
1. `01_lock_toolchain_and_packages.md` — deps resolve, `uv sync` clean, test tree + markers, first passing test
|
||||
2. `02_config_and_debugpy.md` — typed env config, `.env.example`, debugpy bootstrap
|
||||
3. `03_fastapi_app_and_static_shell.md` — app factory, CLI entry point, static shell page, security headers
|
||||
4. `04_no_cdn_guard_and_docs.md` — no-external-asset test, header tests, README/AGENTS verified
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: config parsing/defaults/overrides; static asset scan finds zero external URLs;
|
||||
security headers present; `/healthz` returns 200 with a JSON body.
|
||||
- Integration: app boots through `create_app()` and serves `/` and `/static/*`.
|
||||
- Coverage: **>90%** on new/modified code (`config.py` and `server/app.py` fully covered).
|
||||
- The phase is "Complete" only when the **entire** project test suite passes 100%
|
||||
(every test in the project, not just this phase's).
|
||||
- Gate commands, all green: `uv run ruff check .`, `uv run pyright`, `uv run pytest`.
|
||||
|
||||
## Documentation & Consistency
|
||||
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
|
||||
will stand after this phase — fixing every inconsistency found, **including
|
||||
pre-existing ones not caused by this phase** (e.g. commands or variable names that
|
||||
drifted from `config.py`).
|
||||
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are
|
||||
user-visible (setup, commands, configuration, features, conventions).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv sync --extra dev` succeeds and `uv.lock` is committed
|
||||
- [ ] `uv run undercooked --host 127.0.0.1 --port 8000 &` then
|
||||
`curl -fsS http://127.0.0.1:8000/healthz` → `{"status":"ok"}`; `curl -fsS http://127.0.0.1:8000/` → the shell HTML
|
||||
- [ ] `DEBUGPY=1 uv run undercooked` opens port 5678 and still serves `/healthz`
|
||||
- [ ] `uv run pytest -q` → 100% passing, coverage ≥ 90%
|
||||
- [ ] `uv run ruff check .` and `uv run pyright` → zero errors
|
||||
- [ ] no file under `app/static/` references an external URL (test-enforced)
|
||||
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
|
||||
- [ ] no behavior change in completed phases (n/a — first phase)
|
||||
@@ -0,0 +1,50 @@
|
||||
# Task 01 — Lock toolchain and packages
|
||||
|
||||
**Phase:** `01_runnable_service_scaffold` · **Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
Make `uv` resolve the locked dependency set and establish the test tree, markers, and
|
||||
first green test run, so every later phase starts from a working harness.
|
||||
|
||||
## Work
|
||||
1. `pyproject.toml` — verify it matches A1/A13/A20: runtime deps (`fastapi`,
|
||||
`uvicorn[standard]`, `pydantic`, `python-dotenv`, `pyyaml`, `websockets`), dev extras
|
||||
(`debugpy`, `ruff`, `pyright`, `pytest`, `pytest-asyncio`, `pytest-cov`, `httpx`,
|
||||
`playwright`). Add nothing else; if something is missing, add it and note it in the
|
||||
report. Confirm `[project.scripts]` entries point at modules created in tasks 02–03
|
||||
(`undercooked.__main__:main`, `undercooked.tools.levels:main`) — the second one is
|
||||
created in phase 02, so temporarily drop it **only if** it breaks `uv sync`; otherwise
|
||||
leave it and let the console script fail lazily.
|
||||
2. Run `uv sync --extra dev`; commit `uv.lock` (it is tracked — verify `.gitignore` does
|
||||
not exclude it).
|
||||
3. Create the package skeleton: `src/undercooked/__init__.py` (`__version__ = "0.1.0"`,
|
||||
empty `__all__`), `src/undercooked/server/__init__.py`, `src/undercooked/sim/__init__.py`,
|
||||
`src/undercooked/tools/__init__.py`.
|
||||
4. Create the test tree with `tests/__init__.py`? **No** — keep tests a non-package tree;
|
||||
add `tests/conftest.py` with: a `settings` fixture factory (`make_settings(**overrides)`),
|
||||
an `app`/`client` fixture pair (`fastapi.testclient.TestClient`), and a
|
||||
`random_seed` fixture. Add empty `__init__.py`-free dirs `tests/unit/`,
|
||||
`tests/integration/`, `tests/sim/`, `tests/e2e/` each with a `README.md` one-liner
|
||||
describing what belongs there.
|
||||
5. `tests/unit/test_harness.py` — asserts `undercooked.__version__`, that the four test
|
||||
markers are registered (`--strict-markers` passes), and that
|
||||
`pytest -m e2e --collect-only` collects zero items without error (no e2e tests yet).
|
||||
6. `tests/conftest.py` — add an autouse fixture that sets `UNDERCOOKED_LEVELS_DIR` to a
|
||||
test fixture dir (`tests/fixtures/levels/`, created empty with a `.gitkeep`) so no
|
||||
test can accidentally read the real content before phase 02 exists.
|
||||
7. Install the Playwright browser once and record the command in `README.md`
|
||||
(`uv run playwright install chromium`); do not add a conftest hook that downloads
|
||||
browsers during unit runs.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_harness.py` passes; `uv run pytest -q` green.
|
||||
- Coverage: **>90%** on this task's new/modified code (trivial modules may hit 100%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv sync --extra dev` exits 0; `uv run python -c "import fastapi, yaml, websockets"` exits 0
|
||||
- [ ] `uv run pytest -q` → all collected tests pass, coverage gate met
|
||||
- [ ] `uv run ruff check .` and `uv run pyright` → zero errors
|
||||
- [ ] `git status --porcelain uv.lock` shows the lock file tracked (staged by the owner later)
|
||||
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work
|
||||
@@ -0,0 +1,49 @@
|
||||
# Task 02 — Typed config and debugpy bootstrap
|
||||
|
||||
**Phase:** `01_runnable_service_scaffold` · **Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
Give the project one authoritative, env-driven `Settings` object (A17) plus a
|
||||
`DEBUGPY`-gated debugger hook (A18), so no later phase ever reads `os.environ` inline.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/config.py`:
|
||||
- `@dataclass(frozen=True, slots=True) class Settings` with fields matching the
|
||||
README table: `host`, `port`, `tick_hz`, `snapshot_hz`, `max_players`, `max_rooms`,
|
||||
`levels_dir: Path`, `lobby_idle_timeout_s`, `guest_idle_timeout_s`,
|
||||
`max_message_bytes`, `max_inputs_per_s`, `sim_latency_ms`, `debugpy: bool`,
|
||||
`debugpy_port`, `allowed_origins: tuple[str, ...]`.
|
||||
- `load_settings(env: Mapping[str, str] | None = None) -> Settings` reading
|
||||
`UNDERCOOKED_*` / `DEBUGPY*` keys, calling `dotenv.load_dotenv()` once when
|
||||
`env is None`, with the README's defaults, `Path` expansion for `levels_dir`,
|
||||
comma-splitting for `ALLOWED_ORIGINS`, and validation that raises
|
||||
`ConfigError` (defined here) on: non-integer values, `tick_hz` outside 5–60,
|
||||
`snapshot_hz` > `tick_hz`, `max_players` outside 1–4, `max_rooms` < 1, negative
|
||||
timeouts, `max_message_bytes` < 256.
|
||||
- `get_settings()` cached accessor (`functools.lru_cache` on a private `_load`) plus
|
||||
`reset_settings_cache()` for tests. No module-level import-time env reads that
|
||||
break test isolation.
|
||||
2. `.env.example` — every variable, defaulted, commented (one line each). No secrets, no
|
||||
real hostnames.
|
||||
3. `src/undercooked/debug.py` — `maybe_start_debugpy(settings) -> bool`: returns `False`
|
||||
and does nothing when `debugpy` is false; otherwise imports debugpy inside the
|
||||
function, `listen(("0.0.0.0", settings.debugpy_port))`, `wait_for_client=False`, logs
|
||||
one line, and never raises (a debug failure must not stop the server).
|
||||
4. `tests/unit/test_config.py` — defaults, per-var overrides, `Path` handling,
|
||||
comma-separated origins, every `ConfigError` branch, cache/reset behavior,
|
||||
`.env` file loading via `monkeypatch` + `tmp_path`.
|
||||
5. `tests/unit/test_debug.py` — `maybe_start_debugpy` is a no-op when disabled; when
|
||||
enabled it calls `listen` exactly once (patch `debugpy` via `sys.modules` so the real
|
||||
debugger never binds a port in CI) and swallows import/listen errors.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: all `ConfigError` branches, default/override matrix, debugpy on/off/error paths.
|
||||
- Coverage: **>90%** on `config.py` and `debug.py` (aim 100%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_config.py tests/unit/test_debug.py -q` passes
|
||||
- [ ] `UNDERCOOKED_TICK_HZ=99 uv run python -c "from undercooked.config import load_settings; load_settings()"` fails with a clear `ConfigError` message
|
||||
- [ ] `uv run python -c "from undercooked.config import load_settings; print(load_settings().tick_hz)"` prints `20` with no `.env` present
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work
|
||||
@@ -0,0 +1,68 @@
|
||||
# Task 03 — FastAPI app factory, CLI entry point, static shell
|
||||
|
||||
**Phase:** `01_runnable_service_scaffold` · **Story:** `US-01_create_anonymous_lobby` (landing surface)
|
||||
|
||||
## Objective
|
||||
Create the process that later phases attach rooms and sockets to: `create_app()`,
|
||||
`python -m undercooked`, security headers, and the client shell page served from
|
||||
`app/static/`.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/app.py`:
|
||||
- `create_app(settings: Settings | None = None) -> FastAPI` — builds the app, stores
|
||||
`app.state.settings`, installs middleware (see 3), registers `GET /healthz`
|
||||
returning `{"status":"ok","version":__version__,"rooms":0}` (`rooms` is a placeholder
|
||||
literal until phase 05 wires the supervisor), mounts
|
||||
`app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")`, and adds
|
||||
`GET /` returning `FileResponse(STATIC_DIR / "index.html")`.
|
||||
- `STATIC_DIR` resolved from the package location (`Path(__file__).resolve().parents[3] / "app" / "static"`)
|
||||
with an env override `UNDERCOOKED_STATIC_DIR` for containers; add it to `Settings`
|
||||
(extend `.env.example` and the README table in this task).
|
||||
- Startup hook: log the resolved settings (never log `.env` contents), and
|
||||
`maybe_start_debugpy(settings)`.
|
||||
2. `src/undercooked/__main__.py` — `main()` with argparse: `--host`, `--port`, `--reload`,
|
||||
`--levels-dir`, precedence CLI > env > default; calls
|
||||
`uvicorn.run("undercooked.server.app:app", ...)` (reload-capable string form) and
|
||||
module-attribute `app = create_app()` guarded so import-time work stays cheap.
|
||||
Exit code 0 on clean shutdown, 2 on `ConfigError` (usage/config error) with a one-line
|
||||
message on stderr.
|
||||
3. `src/undercooked/server/headers.py` — middleware adding:
|
||||
`Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' ws: wss:; font-src 'self'; base-uri 'none'; frame-ancestors 'none'`,
|
||||
`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`,
|
||||
`X-Frame-Options: DENY`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`.
|
||||
No `Report-To`/remote endpoints.
|
||||
4. Client shell (vanilla ES modules, zero external references):
|
||||
- `app/static/index.html` — `<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">`,
|
||||
a `#screen-lobby` section with heading, *Create lobby* button, *Join* form
|
||||
(`inputmode="text"`, `autocapitalize="characters"`, `maxlength="5"`, label,
|
||||
visible focus styles), a `#status` `aria-live="polite"` region, and
|
||||
`<script type="module" src="/static/js/app.js">`. Buttons wired to
|
||||
`console.log`-free placeholders that render "not implemented until phase 05" into
|
||||
`#status`.
|
||||
- `app/static/css/base.css` — CSS custom properties for the palette, system font
|
||||
stack, `prefers-reduced-motion` block, 44 px minimum hit targets, ≥ 4.5:1 text
|
||||
contrast, 360 px → 4K fluid layout, `#kcanvas` sized by JS later.
|
||||
- `app/static/js/app.js` — imports nothing external; small `mount()` that reads the
|
||||
form, and a `getToken()` helper using `sessionStorage`
|
||||
(`secrets`-style random via `crypto.getRandomValues`, base64url) storing
|
||||
`uc.token`; keeps `uc.lastCode` in `sessionStorage`.
|
||||
5. `tests/integration/test_app.py` — `create_app()` boots; `/healthz` 200 + JSON shape;
|
||||
`/` serves HTML containing `/static/js/app.js`; `/static/css/base.css` 200 with
|
||||
`text/css`; unknown path 404; `/static/../pyproject.toml` cannot escape (404/400);
|
||||
every response carries the five security headers; `create_app(Settings(...))` honors
|
||||
an overridden `max_players`.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: routes, static mount, traversal attempt, header presence on 3 route types.
|
||||
- Unit: `__main__._resolve()` precedence logic (CLI > env > default) extracted as a pure
|
||||
function so it is testable without spawning uvicorn.
|
||||
- Coverage: **>90%** on `app.py`, `headers.py`, `__main__.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run undercooked --port 8137 &` then `curl -fsSD- http://127.0.0.1:8137/healthz` → 200 with the security headers visible
|
||||
- [ ] `curl -fsS http://127.0.0.1:8137/ | grep -c '/static/js/app.js'` → ≥ 1
|
||||
- [ ] `curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8137/static/../pyproject.toml` → 404 or 400
|
||||
- [ ] `uv run pytest -q` → all green, coverage ≥ 90%
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work
|
||||
@@ -0,0 +1,61 @@
|
||||
# Task 04 — No-CDN guard, gate wiring, docs verification
|
||||
|
||||
**Phase:** `01_runnable_service_scaffold` · **Story:** `n/a`
|
||||
|
||||
## Objective
|
||||
Make the no-external-asset rule (A12) mechanically enforced, adapt the pipeline gate to
|
||||
this project's real checks, and prove the README/AGENTS instructions are true as written.
|
||||
|
||||
## Work
|
||||
1. `tests/unit/test_no_external_assets.py`:
|
||||
- walk `app/static/**` (html, css, js, svg) and fail on `http://`, `https://`,
|
||||
`//cdn.`, `@import url(`, `<link rel="stylesheet" href="//`, `srcset`, `fetch(` with
|
||||
an absolute URL, `import "` with an absolute URL, `WebSocket("ws`, and
|
||||
`new EventSource`.
|
||||
- parse `index.html` for `<script src>`/`<link href>`/`<img src>` and assert each is
|
||||
a same-origin `/static/...` path.
|
||||
- fail if any `.html`/`.js` file contains `googleapis`, `gstatic`, `jsdelivr`,
|
||||
`unpkg`, `cdnjs`, or `tailwindcss`.
|
||||
- resolve the static root the same way `app.py` does (import `STATIC_DIR`), so the
|
||||
test cannot pass by scanning the wrong directory.
|
||||
2. `tests/unit/test_docs_commands.py` — extract fenced `bash` blocks from `README.md`,
|
||||
and assert every command line starts with a known-safe prefix
|
||||
(`uv `, `podman `, `bash .agents/validate.sh`, `curl `, `cd `, `#`), i.e. the docs do
|
||||
not drift into unrunnable pseudo-commands; assert every `UNDERCOOKED_*` variable named
|
||||
in README also appears in `config.py` or `.env.example` (and vice versa).
|
||||
3. `.agents/validate.sh` — if the phased-execution harness has not installed it yet, copy
|
||||
its template; then adapt it to this project, in this order, failing fast with clear
|
||||
output: `uv run ruff check .`, `uv run pyright`, `uv run undercooked-levels validate`
|
||||
(skip with a notice only if `src/undercooked/tools/levels.py` does not exist yet —
|
||||
phase 02 makes it real), `uv run pytest -q` (full suite, coverage gate on), plus the
|
||||
template's repo-hygiene checks (tracked files matching `.gitignore`, log-drop/key
|
||||
patterns). Keep `set -euo pipefail` and make it runnable from the repo root or the
|
||||
`.agents/` directory.
|
||||
4. Run `bash .agents/validate.sh` and fix everything it reports.
|
||||
5. Create the doc stubs the PLAN §13 documentation index promises, each with a one-paragraph
|
||||
purpose and a "filled in by phase NN" marker so no later phase references a missing file:
|
||||
`docs/DESIGN.md`, `docs/TESTING.md`, `docs/DEPLOYMENT.md`. `docs/TESTING.md` documents the
|
||||
`tests/{unit,sim,server,integration,e2e}` layout, the marker set (`slow`, `e2e`), and the
|
||||
artifact policy (`test-results/` is never committed).
|
||||
6. Add a `tests/unit/test_docs_index.py` guard: every `docs/*.md` listed in PLAN §13's
|
||||
documentation index exists, and no `docs/` file exists that the index does not list — so
|
||||
the doc set stays exactly the promised one.
|
||||
7. Docs pass for this phase: confirm README's Quickstart block, config table (add
|
||||
`UNDERCOOKED_STATIC_DIR`, `UNDERCOOKED_ALLOWED_ORIGINS`), and Layout section match
|
||||
the code as it now stands; confirm AGENTS.md §5's test path
|
||||
`tests/unit/test_no_external_assets.py` matches the file created here.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the guard itself (temp fixture dirs with a planted external URL must fail —
|
||||
`tmp_path` + monkeypatched root; assert the failure message names the offending file).
|
||||
- Coverage: **>90%** on new test-support code; content tests are self-verifying.
|
||||
- Full suite green; `bash .agents/validate.sh` exits 0.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `bash .agents/validate.sh` exits 0 from both the repo root and `.agents/`
|
||||
- [ ] planting `https://cdn.example.com/x.js` in a temp static file makes `uv run pytest tests/unit/test_no_external_assets.py -q` fail (then removed)
|
||||
- [ ] `uv run pytest -q` → 100% passing, coverage ≥ 90%
|
||||
- [ ] every README command block runs as written on a clean `uv sync --extra dev` checkout
|
||||
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
|
||||
- [ ] full test suite **100% passing** (every test in the project, not just this phase's)
|
||||
- [ ] no behavior change in completed phases
|
||||
Reference in New Issue
Block a user