# 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.