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
+47
View File
@@ -0,0 +1,47 @@
package engine
import (
"errors"
"testing"
)
func TestSetupBagEnglish(t *testing.T) {
bag, err := SetupBag(VariantEnglish)
if err != nil {
t.Fatalf("SetupBag: %v", err)
}
// English Scrabble: 98 lettered tiles + 2 blanks = 100.
if len(bag) != 100 {
t.Fatalf("bag size = %d, want 100", len(bag))
}
blanks, aCount := 0, 0
for _, tl := range bag {
switch {
case tl.Blank:
blanks++
if tl.Rank != BlankRank {
t.Errorf("blank rank = %d, want %d", tl.Rank, BlankRank)
}
if tl.Letter != blankLetter {
t.Errorf("blank letter = %q, want %q", tl.Letter, blankLetter)
}
case tl.Letter == "a":
aCount++
if tl.Rank != 0 {
t.Errorf("'a' rank = %d, want 0 (closest to A)", tl.Rank)
}
}
}
if blanks != 2 {
t.Errorf("blanks = %d, want 2", blanks)
}
if aCount != 9 {
t.Errorf("'a' count = %d, want 9", aCount)
}
}
func TestSetupBagUnknownVariant(t *testing.T) {
if _, err := SetupBag(Variant(99)); !errors.Is(err, ErrUnknownVariant) {
t.Fatalf("err = %v, want ErrUnknownVariant", err)
}
}