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