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
+156
View File
@@ -0,0 +1,156 @@
package game
import (
"crypto/rand"
"fmt"
"math/big"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// maxSeedingRounds caps the first-move draw's tie re-draws. With a real bag a tie
// breaks with positive probability each round, so this is reached only by a
// degenerate (e.g. test) entropy source that ties forever; the cap turns that into
// an error instead of an infinite loop.
const maxSeedingRounds = 1000
// SetupDraw is one recorded tile draw of the first-move seeding — one row of
// game_setup_draws (docs/ARCHITECTURE.md §6, §9): the round, the draw order within
// it, the seated account that drew, and the decoded tile with its rank. It is
// dictionary-independent: the letter, the blank flag and the numeric rank describe
// the draw without any alphabet table.
type SetupDraw struct {
Round int
PickNo int
Account uuid.UUID
Letter string
Blank bool
Rank int
}
// seedingResult is the outcome of the first-move seeding: the winning account, the
// seated accounts rotated so the winner leads (seat 0), and the full draw log to
// persist.
type seedingResult struct {
winner uuid.UUID
order []uuid.UUID
draws []SetupDraw
}
// drawIntn returns a uniformly random integer in [0, n) for n > 0. It is the
// entropy seam of the first-move seeding: production uses crypto/rand (cryptoIntn),
// so every draw is honestly random with no single seed; tests inject a
// deterministic source.
type drawIntn func(n int) (int, error)
// cryptoIntn draws a uniform integer in [0, n) from crypto/rand — the honest,
// seedless entropy the first-move draw requires.
func cryptoIntn(n int) (int, error) {
v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
if err != nil {
return 0, fmt.Errorf("game: first-move draw entropy: %w", err)
}
return int(v.Int64()), nil
}
// seedFirstMove runs the official first-move draw over accounts for variant v,
// drawing tiles with the entropy source intn. Each round every contender draws one
// tile (without replacement) from a fresh full bag; the tile closest to "A" wins, a
// blank beating every letter; contenders tied for the best tile re-draw in the next
// round until a single leader remains. It returns the leader, the rotation that
// seats the leader first (preserving the others' seating order), and every draw for
// the record. It performs no I/O beyond calling intn.
func seedFirstMove(v engine.Variant, accounts []uuid.UUID, intn drawIntn) (seedingResult, error) {
if len(accounts) < 2 {
return seedingResult{}, fmt.Errorf("game: first-move seeding needs at least 2 accounts, got %d", len(accounts))
}
full, err := engine.SetupBag(v)
if err != nil {
return seedingResult{}, err
}
contenders := append([]uuid.UUID(nil), accounts...)
var draws []SetupDraw
for round := 1; ; round++ {
if round > maxSeedingRounds {
return seedingResult{}, fmt.Errorf("game: first-move seeding unresolved after %d rounds", maxSeedingRounds)
}
bag := append([]engine.SetupTile(nil), full...)
picks := make([]engine.SetupTile, len(contenders))
for i, acc := range contenders {
tile, rest, err := drawSetupTile(bag, intn)
if err != nil {
return seedingResult{}, err
}
bag = rest
picks[i] = tile
draws = append(draws, SetupDraw{
Round: round, PickNo: i, Account: acc,
Letter: tile.Letter, Blank: tile.Blank, Rank: tile.Rank,
})
}
winners := bestContenders(contenders, picks)
if len(winners) == 1 {
return seedingResult{winner: winners[0], order: rotateToFirst(accounts, winners[0]), draws: draws}, nil
}
contenders = winners
}
}
// drawSetupTile removes one uniformly random tile from bag using the entropy source
// intn and returns it with the shrunk bag (a fresh slice, leaving bag untouched).
// It is the per-tile draw primitive — the seam a future manual "player N draws a
// tile" tournament API will drive, one call per external request.
func drawSetupTile(bag []engine.SetupTile, intn drawIntn) (engine.SetupTile, []engine.SetupTile, error) {
if len(bag) == 0 {
return engine.SetupTile{}, nil, fmt.Errorf("game: first-move draw from an empty bag")
}
i, err := intn(len(bag))
if err != nil {
return engine.SetupTile{}, nil, err
}
if i < 0 || i >= len(bag) {
return engine.SetupTile{}, nil, fmt.Errorf("game: first-move draw index %d out of range %d", i, len(bag))
}
tile := bag[i]
rest := make([]engine.SetupTile, 0, len(bag)-1)
rest = append(rest, bag[:i]...)
rest = append(rest, bag[i+1:]...)
return tile, rest, nil
}
// bestContenders returns the contenders whose drawn tile has the lowest (best)
// rank — the sole winner if one, else the tied set that re-draws. picks is aligned
// with contenders by index.
func bestContenders(contenders []uuid.UUID, picks []engine.SetupTile) []uuid.UUID {
best := picks[0].Rank
for _, p := range picks[1:] {
if p.Rank < best {
best = p.Rank
}
}
var winners []uuid.UUID
for i, p := range picks {
if p.Rank == best {
winners = append(winners, contenders[i])
}
}
return winners
}
// rotateToFirst returns accounts rotated cyclically so winner sits first (seat 0),
// preserving the seating order of the rest (docs/ARCHITECTURE.md §6). winner must be
// present in accounts.
func rotateToFirst(accounts []uuid.UUID, winner uuid.UUID) []uuid.UUID {
i := 0
for ; i < len(accounts); i++ {
if accounts[i] == winner {
break
}
}
out := make([]uuid.UUID, 0, len(accounts))
out = append(out, accounts[i:]...)
out = append(out, accounts[:i]...)
return out
}