Files
scrabble-game/docs/superpowers/specs/2026-06-19-first-move-draw-design.md
T
Ilia Denisov caefc8f579
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
feat(game): official first-move tile draw + admin step-by-step replay
Decide who moves first by the official rule: each seated player draws one
tile, the one closest to "A" leads (a blank beats every letter), ties
re-drawing until a single leader remains. Each draw uses honest per-draw
crypto/rand entropy (not the deterministic bag seed), so the recorded draw —
not a seed — is the only account of the outcome. The leader takes seat 0, so
the engine and journal replay are unchanged.

The draw is recorded with the game (game_setup_draws, migration 00013) for
future tournaments, designed as a discrete "player N draws a tile" step.
Friend/AI games draw at creation. Auto-match draws when the game opens,
against a synthetic uuid.Nil opponent whose draw rows (NULL account_id) are
back-filled to the real opponent on join — so the opener's seat is fixed up
front and the existing open-game pre-move is preserved with no reseating.

Admin /_gm/games/:id gains the recorded draw list and a simple step-by-step
board replay (game.ReplayTimeline + a vanilla-JS stepper): a board with
A-O/1-15 headers and highlighted premium squares, placed letters with their
tile value as a subscript, rack panels around the board (seat 0 top, 1
bottom, 2 left, 3 right) with the current player highlighted, and a per-move
log with the tiles drawn and the bag remainder.

Docs: ARCHITECTURE §6/§9, FUNCTIONAL (+_ru), PRERELEASE (FM row), design spec.
2026-06-20 08:47:18 +02:00

162 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (AO) and row (115) 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.