feat(game): official first-move tile draw + admin step-by-step replay
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
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
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.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user