docs: drop the design-spec artifact (content lives in ARCHITECTURE/FUNCTIONAL/PRERELEASE)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
This commit is contained in:
@@ -1,161 +0,0 @@
|
|||||||
# 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