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
+130
View File
@@ -0,0 +1,130 @@
package game
import (
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// scriptIntn returns a drawIntn that yields the scripted indices in order, failing
// the test if the script is exhausted or an index is out of range. It lets a test
// drive the first-move draw deterministically (English SetupBag order: 9 'a' at
// 0..8, then 'b' …, blanks last).
func scriptIntn(t *testing.T, seq ...int) drawIntn {
t.Helper()
i := 0
return func(n int) (int, error) {
if i >= len(seq) {
t.Fatalf("intn script exhausted (asked for [0,%d))", n)
}
v := seq[i]
i++
if v < 0 || v >= n {
t.Fatalf("intn script value %d out of range [0,%d)", v, n)
}
return v, nil
}
}
func TestSeedFirstMoveDirectWinner(t *testing.T) {
a, b := uuid.New(), uuid.New()
// a draws bag[0]='a' (rank 0); b draws bag[8]='b' (rank 1) → a wins, no tie.
res, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b}, scriptIntn(t, 0, 8))
if err != nil {
t.Fatalf("seedFirstMove: %v", err)
}
if res.winner != a {
t.Fatalf("winner = %v, want %v", res.winner, a)
}
if got := res.order; len(got) != 2 || got[0] != a || got[1] != b {
t.Fatalf("order = %v, want [a b]", got)
}
if len(res.draws) != 2 {
t.Fatalf("draws = %d, want 2", len(res.draws))
}
}
func TestSeedFirstMoveBlankSupersedes(t *testing.T) {
a, b := uuid.New(), uuid.New()
// a draws 'a' (rank 0); after the draw the two blanks sit at 97,98 — b draws
// bag[97], a blank, which beats every letter.
res, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b}, scriptIntn(t, 0, 97))
if err != nil {
t.Fatalf("seedFirstMove: %v", err)
}
if res.winner != b {
t.Fatalf("winner = %v, want %v (blank supersedes)", res.winner, b)
}
last := res.draws[len(res.draws)-1]
if !last.Blank || last.Rank != engine.BlankRank || last.Letter != "?" {
t.Fatalf("winning draw = %+v, want a blank (rank %d, '?')", last, engine.BlankRank)
}
}
func TestSeedFirstMoveTieRedraw(t *testing.T) {
a, b, c := uuid.New(), uuid.New(), uuid.New()
// Round 1: a→bag[0]='a'(0), b→bag[0]='a'(0), c→bag[7]='b'(1) → a,b tie best.
// Round 2 (a,b only): a→bag[0]='a'(0), b→bag[8]='b'(1) → a wins.
res, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b, c}, scriptIntn(t, 0, 0, 7, 0, 8))
if err != nil {
t.Fatalf("seedFirstMove: %v", err)
}
if res.winner != a {
t.Fatalf("winner = %v, want %v", res.winner, a)
}
if len(res.draws) != 5 {
t.Fatalf("draws = %d, want 5 (3 in round 1, 2 in round 2)", len(res.draws))
}
if res.draws[2].Round != 1 || res.draws[3].Round != 2 {
t.Fatalf("round boundaries wrong: %+v", res.draws)
}
// The full table keeps every account's seating order, winner first.
if got := res.order; got[0] != a || got[1] != b || got[2] != c {
t.Fatalf("order = %v, want [a b c]", got)
}
}
func TestSeedFirstMoveTooFewAccounts(t *testing.T) {
if _, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{uuid.New()}, scriptIntn(t)); err == nil {
t.Fatal("want error for a single account")
}
}
func TestSeedFirstMovePerpetualTieCapped(t *testing.T) {
a, b := uuid.New(), uuid.New()
// Always drawing bag[0] gives both players an 'a' every round — a tie that never
// resolves; the round cap must turn it into an error, not an infinite loop.
alwaysZero := drawIntn(func(int) (int, error) { return 0, nil })
if _, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b}, alwaysZero); err == nil {
t.Fatal("want error when ties never resolve")
}
}
func TestRotateToFirst(t *testing.T) {
a, b, c, d := uuid.New(), uuid.New(), uuid.New(), uuid.New()
tests := []struct {
name string
accounts []uuid.UUID
winner uuid.UUID
want []uuid.UUID
}{
{"two winner second", []uuid.UUID{a, b}, b, []uuid.UUID{b, a}},
{"three winner first", []uuid.UUID{a, b, c}, a, []uuid.UUID{a, b, c}},
{"four winner third", []uuid.UUID{a, b, c, d}, c, []uuid.UUID{c, d, a, b}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := rotateToFirst(tt.accounts, tt.winner)
if len(got) != len(tt.want) {
t.Fatalf("len = %d, want %d", len(got), len(tt.want))
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("rotate = %v, want %v", got, tt.want)
}
}
})
}
}