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

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:
Ilia Denisov
2026-06-20 08:47:18 +02:00
parent 76d4610e6f
commit caefc8f579
27 changed files with 1661 additions and 59 deletions
+22 -2
View File
@@ -327,6 +327,21 @@ Key points:
timed out while asleep. A game whose journal can no longer be replayed — a
committed move made illegal by a later rule change — is instead closed as a
**draw** (`aborted`) on the next open, never left unopenable (§9.1).
- **First move (who goes first)**: decided by the official draw — each seated player
draws one tile and the tile closest to "A" leads (a **blank supersedes all letters**);
players tied for the best tile re-draw until a single leader remains. Each draw uses
**fresh entropy** (`crypto/rand`), **not** the deterministic bag `seed`, so the draw is
genuinely random and its **record** — not a seed — is the only account of the outcome. The
leader takes **seat 0** (which moves first), the rest keeping their seating order, so the
engine and journal replay are unchanged (seat 0 always leads). A directly-seated game
(friend/AI) draws at creation. **Auto-match** draws when the game **opens**, with the
not-yet-arrived opponent as a synthetic placeholder (`uuid.Nil`, whose draw rows are
back-filled to the real opponent on join), so the opener's seat is fixed up front and they
may make their opening move while waiting with no later reseating. The draw is **recorded**
with the game (`game_setup_draws`, §9) and surfaced only in the admin console's
step-by-step game replay — **not shown to players** today; it is kept for future
tournaments, where it becomes a manual, per-tile external call. It is modelled as a discrete
"player N draws a tile" step so that API is a thin driver over the same component.
- **Players**: auto-match is always 2 players; friend games are 24 players.
`backend` owns turn order and the bag for any player count. A resignation or
timeout in a two-player game ends it with the other player winning. In a game
@@ -615,7 +630,8 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
the `kind` admitting `robot`),
`sessions` (revoke-only opaque-token hashes), the game tables
`games` (carrying the `dropout_tiles` disposition column), `game_players`,
`game_moves` (the move journal), `complaints`, `account_stats` and
`game_moves` (the move journal), `game_setup_draws` (the first-move draw record, §6),
`complaints`, `account_stats` and
`account_best_move`, and the
social/lobby tables `friendships` (the request/accept graph, its status admitting
`declined`), `blocks`
@@ -630,7 +646,11 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
Auto-match has no separate store: a game **awaiting an opponent** is an ordinary
`games` row with status `open` and a single seated `game_players` row (the empty
opponent seat is a null `account_id`, filled when a human or robot joins), plus an
`open_deadline_at` stamp the reaper scans for robot substitution.
`open_deadline_at` stamp the reaper scans for robot substitution. The **first-move draw**
(§6) is recorded in `game_setup_draws` when the game opens; the synthetic opponent's rows
carry a NULL `account_id` until a real opponent joins and back-fills them. The record is
dictionary-independent — a decoded letter, a blank flag and the numeric draw rank — like
the journal (§9.1), so it never depends on a dictionary or the solver's encoding.
- **Active games are event-sourced.** A game is a `games` row (pinned
`variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised
turn cursor) plus an append-only, decoded move journal (`game_moves`); the live
+3 -2
View File
@@ -271,8 +271,9 @@ at its end (the game could not be continued), and the same note rides the GCG ex
Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend
renders it; the gateway gates it with HTTP Basic Auth on its public listener and
proxies it verbatim. The console lists and inspects **users** (profile, statistics,
identities, their games) and **games** (summary + seats), works the **word-complaint
review queue** — resolving each as reject / accept-add / accept-remove — and exposes
identities, their games) and **games** summary, seats, the recorded **first-move tile
draw** (who drew what, deciding who leads), and a simple **step-by-step board replay** of the
whole game — works the **word-complaint review queue** — resolving each as reject / accept-add / accept-remove — and exposes
the **dictionary**: the active version new games pin, the resident versions per
variant, an online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar.gz`
release archive, preview the words added and removed per variant against the active
+3 -1
View File
@@ -277,7 +277,9 @@ UTC), суточного окна отсутствия (away; сетка по 10
Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит
backend; gateway закрывает её HTTP Basic Auth на публичном порту и проксирует
один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика,
identity, их игры) и **игры** (сводка + места), разбирать **очередь жалоб на слова**
identity, их игры) и **игры** сводка, места, записанная **жеребьёвка первого хода** (кто что
вытащил и кто ходит первым) и простой **пошаговый реплей доски** всей партии, — разбирать
**очередь жалоб на слова**
закрывая каждую как reject / accept-add / accept-remove — и управлять **словарём**:
активная версия, на которую опираются новые партии, резидентные версии по вариантам,
онлайн-**обновление словаря** (загрузить релизный архив `scrabble-dawg-vX.Y.Z.tar.gz`,
@@ -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 (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.