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`.
112 lines
4.2 KiB
Python
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
|