Files
undercooked/src/undercooked/server/codes.py
T
ducoterra f51ba919af phase: 05_anonymous_lobbies_and_room_lifecycle
All green. Final report:

**Phase 05 final verification pass — complete, 100% green.**

**Verified (all 5 tasks already in `complete/`):**
- `RoomSupervisor` import, `POST /api/rooms` → 5-char Crockford code (`VBE8Y`), `GET /api/rooms/{code}` → seat list, join/session/404 flows — all live over HTTP
- `docs/ROOMS.md` present with FSM diagram, crash-isolation and "What is lost on restart" sections
- Phase tests all pass: lifecycle 4-seat `WON`→`LOBBY` (7 tests), crash isolation, GC/idle via `FakeClock` (no sleeps), FSM, tick cadence, supervisor, sessions

**Results:**
- `uv run pytest` → **1506 passed, 0 failed**; coverage **98.89%** (>90% floor; `room.py` 99%, `rooms.py` 97%, `sessions.py` 99% — all ≥95% per-module gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run undercooked-levels validate` → content OK; server boots, `/healthz` 200, client at `/` 200
- e2e US-01 suite re-run after fixes: 10 passed

**Documentation — inconsistencies found and fixed:**
- `README.md`: status now lists phases 01–05 complete, next pending `06_realtime_websocket_protocol`; dropped "(phase 05, in progress)"; 3 stale "Not implemented until phase 05." references corrected
- `app/static/js/app.js` + `tests/e2e/test_us01_landing_surface.py`: placeholder string updated in lockstep to "Not implemented until phase 07." (phase 07 task 02 wires the buttons), comments updated to reflect phase-05 completion
- `AGENTS.md`: reviewed in full — §6 already documents all phase-05 modules accurately; no changes needed

**Decisions:** no code behavior changes beyond the user-visible placeholder string; no live-infrastructure touch (only a local loopback server, stopped).

**Next pending phase:** `06_realtime_websocket_protocol`.
2026-10-08 05:17:21 -04:00

112 lines
4.2 KiB
Python

"""Room codes: minting, collision retries and user-typed normalisation (A15).
A code is 5 characters of Crockford base32 — 32 symbols, so ~33.5 M rooms —
with `I`, `L`, `O` and `U` excluded: no letter can be mistaken for a digit
or another letter when it is shouted across a kitchen or thumb-typed on a
phone. `new_code` draws with `secrets.choice` (or an injected `random.Random`
in tests) and retries up to 100 times on a collision with an already-issued
code; a full space is a `CodeSpaceFull`, never a silently-reused code.
`normalize_code` is the entry for code *input* (typed, spoken, pasted): it
upper-cases, strips spaces and dashes, and maps the excluded characters to
the symbols they are most likely misreads of (`I`/`l` → `1`, `O` → `0`,
`U` → `V`). Anything else that is not exactly 5 alphabet characters yields
`None` — and the caller treats `None` exactly like "no such room" (US-02
criterion 4): a wrong format must never be distinguishable from an unknown
code, or the API becomes a room-existence oracle.
"""
from __future__ import annotations
import random
import secrets
import string
from collections.abc import Container
from typing import Final
__all__ = [
"CODE_LEN",
"CROCKFORD",
"CodeSpaceFull",
"new_code",
"normalize_code",
]
#: The 32 minting symbols, in Crockford order — digits first, then the
#: letters that survive the I/L/O/U exclusion.
CROCKFORD: Final = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
#: Every code is exactly this many characters (shoutable, thumb-typeable).
CODE_LEN: Final = 5
#: How many collisions `new_code` retries before giving up. 100 draws from a
#: ~33.5 M space never exhausts it in a healthy process; a hit means the
#: process has minted essentially every code and should say so.
MAX_MINT_RETRIES: Final = 100
#: Confusables excluded from the alphabet, mapped to the symbol a person most
#: likely meant when they typed the excluded character.
_CONFUSABLE_MAP: Final = {
"I": "1", # and lowercase `l`, which upper-cases to `I` first
"L": "1",
"O": "0",
"U": "V",
}
#: Every character the normaliser may strip: ASCII whitespace plus the
#: three dash a keyboard offers (`-`, U+2013, U+2014) — the separators
#: people insert when a code is read out loud.
_STRIP_CHARS: Final = frozenset(string.whitespace + "-\u2013\u2014")
class CodeSpaceFull(RuntimeError): # noqa: N818 - name fixed by the phase task file
"""`new_code` retried 100 times and every draw collided: stop minting."""
def new_code(taken: Container[str], rng: random.Random | None = None) -> str:
"""Mint a code that is not in `taken`, or raise `CodeSpaceFull`.
Args:
taken: any `Container[str]` of already-issued codes (the supervisor
passes its registry keys).
rng: a `random.Random` for deterministic tests; `None` draws with
`secrets.choice`, the process's real entropy.
Raises:
CodeSpaceFull: all 100 draws collided.
"""
pick = rng.choice if rng is not None else secrets.choice
for _ in range(MAX_MINT_RETRIES):
code = "".join(pick(CROCKFORD) for _ in range(CODE_LEN))
if code not in taken:
return code
msg = f"room code space exhausted: {MAX_MINT_RETRIES} draws all collided"
raise CodeSpaceFull(msg)
def _map_confusables(text: str) -> str:
return "".join(_CONFUSABLE_MAP.get(char, char) for char in text)
def normalize_code(raw: str) -> str | None:
"""Canonicalise user-typed code input; `None` when it is not a code.
Upper-cases, strips spaces/dashes, maps the excluded confusables
(`I`/`l` → `1`, `O` → `0`, `U` → `V`), then requires exactly
`CODE_LEN` characters, every one of them in :data:`CROCKFORD`.
The contract for callers: `None` means "no such room", full stop. A
bad format is never reported as such (US-02 criterion 4), so this
function must stay a pure format check — it must not consult the
registry, or "unknown" and "malformed" would diverge.
"""
cleaned = "".join(
char for char in raw.upper() if char not in _STRIP_CHARS
)
if len(cleaned) != CODE_LEN:
return None
mapped = _map_confusables(cleaned)
if any(char not in CROCKFORD for char in mapped):
return None
return mapped