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
+85 -13
View File
@@ -127,11 +127,14 @@ type seatInsert struct {
displayName string
}
// CreateGame inserts the games row and one game_players row per seat (seat 0
// first) inside a single transaction.
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert) error {
// CreateGame inserts the games row, one game_players row per seat (seat 0 first) and
// the first-move seeding draws inside a single transaction.
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert, draws []SetupDraw) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
return insertGameTx(ctx, tx, ins, seats)
if err := insertGameTx(ctx, tx, ins, seats); err != nil {
return err
}
return insertSetupDrawsTx(ctx, tx, ins.id, draws)
})
}
@@ -173,6 +176,30 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
return nil
}
// insertSetupDrawsTx appends the first-move seeding draws for game gameID on tx —
// the dictionary-independent record of how the first player was chosen
// (docs/ARCHITECTURE.md §6). The games row must already exist on tx (the foreign
// key), so it runs after insertGameTx. An empty draws slice is a no-op.
func insertSetupDrawsTx(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, draws []SetupDraw) error {
for _, d := range draws {
// A uuid.Nil account marks the synthetic opponent of an auto-match draw, persisted as
// a NULL account_id and back-filled when a real opponent joins.
var acc any = d.Account
if d.Account == uuid.Nil {
acc = postgres.NULL
}
di := table.GameSetupDraws.INSERT(
table.GameSetupDraws.GameID, table.GameSetupDraws.Round, table.GameSetupDraws.PickNo,
table.GameSetupDraws.AccountID, table.GameSetupDraws.Letter, table.GameSetupDraws.IsBlank,
table.GameSetupDraws.DrawRank,
).VALUES(gameID, d.Round, d.PickNo, acc, d.Letter, d.Blank, d.Rank)
if _, err := di.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert setup draw round %d pick %d: %w", d.Round, d.PickNo, err)
}
}
return nil
}
// openMatchKey hashes an auto-match bucket (variant + per-turn word rule) into the
// advisory-lock key that serialises concurrent enqueues for that bucket, so two
// players never both open a game instead of pairing.
@@ -199,8 +226,9 @@ func openMatchKey(variant string, multipleWords bool) int64 {
// arrangement (the caller and uuid.Nil for the still-empty opponent, in the chosen
// order) used only when a game is created; callerName is the caller's display-name
// snapshot, stamped on their seat whether they open a fresh game or fill another
// player's open one.
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) {
// player's open one. seats and draws (the first-move draw recorded against the synthetic
// opponent, docs/ARCHITECTURE.md §6) are used only when a game is created.
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID, draws []SetupDraw) (gameID uuid.UUID, joined, created bool, err error) {
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
@@ -222,7 +250,7 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName
LIMIT 1 FOR UPDATE SKIP LOCKED`,
ins.variant, ins.multipleWordsPerTurn, accountID, uuidArrayLiteral(exclude)).Scan(&other); {
case e == nil:
if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil {
if er := fillAndActivate(ctx, tx, other, accountID, callerName); er != nil {
return er
}
gameID, joined = other, true
@@ -230,10 +258,15 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName
case !errors.Is(e, sql.ErrNoRows):
return fmt.Errorf("find open game: %w", e)
}
// 2. None waiting — open a fresh game seating the caller (the other seat empty).
// 2. None waiting — open a fresh game seating the caller (the other seat empty) and
// recording the first-move draw; the synthetic opponent's rows carry a NULL account
// until a real opponent joins and back-fills them.
if e := insertGameTx(ctx, tx, ins, seats); e != nil {
return e
}
if e := insertSetupDrawsTx(ctx, tx, ins.id, draws); e != nil {
return e
}
gameID, created = ins.id, true
return nil
})
@@ -258,7 +291,7 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, disp
if status != StatusOpen {
return nil
}
if e := fillOpenSeat(ctx, tx, gameID, robotID, displayName); e != nil {
if e := fillAndActivate(ctx, tx, gameID, robotID, displayName); e != nil {
return e
}
attached = true
@@ -267,15 +300,23 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, disp
return attached, err
}
// fillOpenSeat seats accountID in an open game's empty opponent seat — stamping
// displayName as the seat's display-name snapshot — and flips the game to active with a
// fresh turn clock. The caller holds the game row.
func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error {
// fillAndActivate seats accountID in an open game's empty opponent seat — stamping
// displayName as the seat's snapshot — back-fills the first-move draw rows the open game
// recorded for the then-unknown opponent (docs/ARCHITECTURE.md §6), and flips the game to
// active with a fresh turn clock. The seat the joiner takes was already fixed by the draw at
// open time, so the journal (any opening move the starter made while waiting) is never
// disturbed. The caller holds the game row.
func fillAndActivate(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error {
if _, err := tx.ExecContext(ctx,
`UPDATE backend.game_players SET account_id = $2, display_name = $3 WHERE game_id = $1 AND account_id IS NULL`,
gameID, accountID, displayName); err != nil {
return fmt.Errorf("fill opponent seat: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE backend.game_setup_draws SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`,
gameID, accountID); err != nil {
return fmt.Errorf("back-fill opponent draws: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE backend.games SET status = 'active', open_deadline_at = NULL, turn_started_at = now(), updated_at = now()
WHERE game_id = $1`, gameID); err != nil {
@@ -587,6 +628,37 @@ func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, er
return out, nil
}
// SetupDraws loads the ordered first-move seeding draws for a game (round then pick
// order), or an empty slice for a game created before the draw was recorded. It
// backs the admin console's first-move section.
func (s *Store) SetupDraws(ctx context.Context, id uuid.UUID) ([]SetupDraw, error) {
stmt := postgres.SELECT(table.GameSetupDraws.AllColumns).
FROM(table.GameSetupDraws).
WHERE(table.GameSetupDraws.GameID.EQ(postgres.UUID(id))).
ORDER_BY(table.GameSetupDraws.Round.ASC(), table.GameSetupDraws.PickNo.ASC())
var rows []model.GameSetupDraws
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: get setup draws %s: %w", id, err)
}
out := make([]SetupDraw, len(rows))
for i, r := range rows {
// A NULL account is the synthetic opponent of an auto-match draw not yet back-filled.
acc := uuid.Nil
if r.AccountID != nil {
acc = *r.AccountID
}
out[i] = SetupDraw{
Round: int(r.Round),
PickNo: int(r.PickNo),
Account: acc,
Letter: r.Letter,
Blank: r.IsBlank,
Rank: int(r.DrawRank),
}
}
return out, nil
}
// CommitMove appends the move and applies the post-move game state — the turn
// cursor and per-seat scores, plus the finish stamp and statistics when the move
// ended the game — in one transaction.