Rebuilding on the join-flow design. QDD order: quals first (red), then server, then client. The quals file gets a full rewrite since the API surface changes:
Red confirmed. Now [server.js](server.js) on the lobby/deal model:
Done and verified end to end. The dev server at http://localhost:3210 picked up everything via --watch; a smoke-test game is sitting on it (lobby: Danny, Faire — prune with the ×es or just press start fresh; a server restart also wipes it).
The rebuild: [server.js](server.js) now implements the lobby/deal split. POST /join binds name↔device once, at creation, by the device's owner — the only binding gesture that exists. POST /start (joined devices only, 2–12 players) freezes a deal: game, location, mole, lobby-at-that-instant. POST /remove prunes any lobby row from any device (public roster, and it's the ghost-row recovery path), never touching the current deal. /state and /getrole are read-only and device-keyed; no path returns another seat's role, so the peeking leak is closed at the API level. [client.js](pub/client.js): the star is now a <span>, not a button — gold on your row, grey elsewhere — the join form grays out once you're in, and Start The Game grays until you're joined and the lobby is 2–12.
Quals: [quals/server-quals.js](quals/server-quals.js) rewritten first — 15 quals, confirmed red against the claim-era server, then green. New coverage: the lobby/deal split (mid-game joins and removals leave dealt roles untouched), only-joined-devices-start, no duplicate names or devices, the ghost-row recovery flow, re-press-start-to-redeal, plus the ported idempotent-refresh, one-mole, last-role-repeats, and garbage-in-500-out quals.
Nominology delta (for sign-off): *lobby* (mutable, public list of {name, device} rows), *deal* (frozen snapshot at start-press), *row* (1-based lobby position) vs *seat* (1-based deal position) — distinct because they diverge after mid-game lobby edits.
If-statement inventory: unchanged game logic (seat === spy, i > spy), asserts (anti-postel), two ternaries (seatof, state.you), require.main guard. Client: the empty-name alert guard (mirrors the old pre-POST validation pattern), the loud-POST-failure alert in postjson (polls stay quiet on purpose — a 2s alert loop otherwise), the role === null ternary, and disabled-graying via boolean assignment.
Prose ledger: "Start The Game" preserved byte-identical, now on the start button. Removed human copy, flagging per rule 5: the "Need 2-12 players! (Really at least 3…)" alert — its trigger no longer exists since the start button grays out instead of erroring; restorable if a grayed button feels too mute. New Latin with TODO comments: the hint (*type your name to join; when everyone's present, someone starts the game*), placeholder *nomen tuum*, join button *Adiunge*, alerts *Nomen tuum primum scribe!* (empty name) and *Non successit!* (rejected POST, e.g. duplicate name). The instructions paragraph and refresh-button section in index.html remain untouched and still describe the old flow — human pen required.
Known wrinkle, accepted: × removals are by row index, so two simultaneous prunes within one 2s poll window could remove the wrong row. Tauction solves this with stable row ids; for a family in a room it seemed like YAGNI, but it's a small change if wanted.