diff --git a/docs/superpowers/specs/2026-06-19-first-move-draw-design.md b/docs/superpowers/specs/2026-06-19-first-move-draw-design.md deleted file mode 100644 index a35cda5..0000000 --- a/docs/superpowers/specs/2026-06-19-first-move-draw-design.md +++ /dev/null @@ -1,161 +0,0 @@ -# First-move tile-draw seeding + admin game replay — design - -- Date: 2026-06-19 -- Branch: `feature/first-move-draw` -- Status: approved (owner sign-off 2026-06-19) - -## Goal - -Two linked pieces. - -1. **First-move selection by the official rule.** Each seated player draws one tile - from the bag; the tile alphabetically closest to "A" wins, a blank superseding all - letters. Players tied for the best tile re-draw until a single leader remains. Each - draw uses fresh entropy — **not** the game's deterministic seed. The whole draw - process is persisted with the game (not shown to players yet; needed later for - tournaments, where the draw becomes a manual, per-tile external "player N draws a - tile" call). A game must not start until the draw has produced a leader. The future - API is not designed here, but the code exposes the draw as a discrete step. - -2. **Admin replay on `/_gm/games/:id`.** A simple board that steps move-by-move: - column (A–O) and row (1–15) headers, highlighted premium squares, placed letters - drawn upper-case with the tile value as a subscript (0 → no number), rack panels - around the board (seat 0 top, 1 bottom, 2 left, 3 right) labelled by player name - with a profile link and the current player's rack highlighted, prev/next stepping, - and a per-move log line with the move, the tiles drawn from the bag, and the bag - remainder. The first-move draw is shown as a flat list of actions, not stepped on - the board. - -## Decisions (owner interview, 2026-06-19) - -- **Comparison key:** alphabetical (alphabet index; closest to "A" wins; blank - supersedes). Not tile point value. -- **The draw decides only the first player;** the rest keep their seating order (the - circle is rotated so the winner leads). -- **Persistence:** a dedicated table `game_setup_draws` (one row per draw), - tournament / manual-API ready. -- **Lifecycle:** a code seam only (a seeding component with a per-draw method); no - persisted `seeding` status yet (the auto path runs it synchronously at the active - transition). - -## Design - -### Seeding (`internal/game/seating.go`) - -- A component shaped as a finite state machine, ready for a future per-tile external - call. Entropy is a parameter (interface / `io.Reader`); production uses - `crypto/rand`, tests inject a deterministic source so the ranking / tie / rotation - logic is unit-testable. -- **A round:** honestly shuffle the variant's full tile multiset (`rules.Counts` + - blanks) with fresh entropy and draw one tile **without replacement** for each - participant. This realises "shuffle before each draw, not a single seed". -- **Rank:** blank = best (`-1`); letter = alphabet index (`0` = closest to "A"). - Lower rank moves earlier. -- **Tie:** participants sharing the best rank re-draw in the next round (others drop - out) until a single leader remains. -- **Result:** the winner plus the full draw log. - -### Turn order — seat rotation - -The draw affects the game only through who occupies **seat 0** (seat 0 moves first; -the engine/replay invariant is unchanged). Rotate the seated-account circle so the -winner sits at seat 0, preserving the cyclic order of the rest. This generalises the -current `seed&1` swap (2 players) and replaces both `seed&1` (auto-match) and -"`Seats[0]` first" (friend/AI). It applies to every new game. - -The engine deals racks by seat **index** and is account-agnostic, and journal replay -starts at seat 0, so **no engine or replay change is needed** — only the seat→account -mapping in `game_players`. - -### Persistence — `game_setup_draws` (migration `00013`) - -```sql -CREATE TABLE backend.game_setup_draws ( - game_id uuid NOT NULL REFERENCES backend.games (game_id) ON DELETE CASCADE, - round smallint NOT NULL, - pick_no smallint NOT NULL, - account_id uuid NOT NULL REFERENCES backend.accounts (account_id), - letter text NOT NULL, - is_blank boolean NOT NULL DEFAULT false, - rank smallint NOT NULL, - created_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (game_id, round, pick_no) -); -``` - -Self-contained and dictionary-independent (decoded letter + blank flag + numeric -rank), like the §9.1 journal. The first mover is visible as seat 0 in `game_players`, -so there is no redundant column. Additive — existing games have no rows (no backfill). -The future tournament API appends one row per draw call. - -### Wiring — the active transition - -- **Create (friend/AI):** run the seeding over `params.Seats`, persist the draw rows, - rotate the account list so the winner is index 0, then proceed unchanged - (`engine.New` takes only the seat count). All within the create transaction. -- **Auto-match:** the second player is unknown until the seat is filled, so the - seeding runs at `fillOpenSeat` (human join) and `AttachRobot` (robot) inside the - fill transaction. The seat assignment is written from the draw outcome (seat 0 = - winner) rather than only filling the empty seat; the open-time `seed&1` pre-seating - is dropped. - -The auto path calls the seeding component synchronously in a loop. No persisted -`seeding` status; the seam in code is what a future manual API drives. - -### Admin replay - -- **Data:** a new service method `ReplayTimeline(gameID)` reuses the existing - `replay()` (engine rebuilt from seed + journal) and snapshots after each move: every - seat's rack (`Hand(i)`), score (`Score(i)`), the seat to move (`ToMove()`), the bag - remainder (`BagLen()`), plus per move the action, placements, words, and the tiles - drawn from the bag (multiset diff: `hand_after − (hand_before − used)`; `hand_before` - from the journal). Step 0 is the dealt racks. The board at step k is the placements - of plays 1..k applied onto an empty grid (no dictionary — §9.1 visual replay). -- **UI:** server-rendered Go `html/template` plus a small page-scoped vanilla-JS - stepper over the timeline embedded as JSON (no per-step round-trip). The board - renders headers, highlighted premium squares (a standard fixed 15×15 layout - duplicated as a Go constant — `premiums.ts` is itself such a constant; the admin is - not coupled to solver internals), upper-case letters with the value subscript (0 → no - number) in tile-styled cells. Rack panels: seat 0 top, 1 bottom, 2 left, 3 right; - name + profile link; the current player's rack highlighted. A log accumulates by move - (play / pass / exchange + drawn tiles + bag remainder), the current step highlighted. - The seeding is a separate flat list ("Round N: player X — E(rank), … → Y leads - first"), with profile links. - -## Tests - -- **Seeding:** ranking (blank beats letters; closer to "A" wins), tie chains, rotation - for 2/3/4 seats, record determinism under an injected entropy source. -- **ReplayTimeline:** drawn tiles / bag remainder / racks / scores on a known short - game. -- **Store / integration:** write+read `game_setup_draws`; seat 0 = winner for both - Create and auto-match. - -## Out of scope - -The tournament draw API itself (only the seam). No change to the game bag's -determinism or replay. No reuse / rewrite of the Svelte board in the admin. The board -design is deliberately minimal. - -## Docs to bake back (same PR) - -`docs/ARCHITECTURE.md` (§6 seeding procedure, §9 table, §9.1-style record invariant), -`docs/FUNCTIONAL.md` (+ `_ru`) first-move-selection story, backend / admin READMEs, Go -Doc comments, `PRERELEASE.md` note. - -## Update (2026-06-20) — final auto-match design - -During implementation the owner replaced the auto-match approach. The original plan above ran -the draw at **fill** time (when the opponent joins) and **reseated** the winner to seat 0. That -conflicts with the existing open-game feature where the opener may play their opening move -while waiting: reseating after a move would corrupt the journal's seat↔move mapping. - -The final design runs the draw when the game **opens**, with the not-yet-arrived opponent as a -synthetic placeholder (`uuid.Nil`). The draw fixes the opener's seat up front; the opponent -joins into the already-decided empty seat with **no reseating**, so the open-game pre-move is -preserved and the journal is never disturbed. The opponent's draw rows are persisted with a -NULL `account_id` (so `game_setup_draws.account_id` is **nullable**) and **back-filled** to the -real opponent (or robot) on join. The directly-seated (friend/AI) path is unchanged from the -plan (draw at create, real accounts, no synthetic). Consequently there is **no play-gating and -no rack-hiding** while a game is open. See `docs/ARCHITECTURE.md` §6 / §9 for the authoritative -description.