Files
scrabble-game/backend/internal/inttest/first_move_draw_test.go
T
Ilia Denisov caefc8f579
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
feat(game): official first-move tile draw + admin step-by-step replay
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.
2026-06-20 08:47:18 +02:00

155 lines
5.0 KiB
Go

//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// The first-move-draw suite covers the official seeding (docs/ARCHITECTURE.md §6): the draw is
// recorded with the game, decides seat 0 (the first mover), and — in auto-match — runs against
// a synthetic opponent at open time whose draw rows are back-filled when a real opponent joins.
// TestCreateRecordsFirstMoveDraws checks a directly-seated game records the draw against the
// real seated accounts and seats the drawn leader (the suite's deterministic draw elects the
// first-listed account) at seat 0.
func TestCreateRecordsFirstMoveDraws(t *testing.T) {
ctx := context.Background()
svc := newGameService()
a := provisionAccount(t)
b := provisionAccount(t)
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, TurnTimeout: 24 * time.Hour, Seed: 1})
if err != nil {
t.Fatalf("create: %v", err)
}
if g.Seats[0].AccountID != a || g.Seats[1].AccountID != b {
t.Fatalf("seats = [%s %s], want [a b]", g.Seats[0].AccountID, g.Seats[1].AccountID)
}
draws, err := svc.SetupDraws(ctx, g.ID)
if err != nil {
t.Fatalf("setup draws: %v", err)
}
// seatZeroFirstMove resolves in one round: the first contender (a) draws a blank and wins.
if len(draws) != 2 {
t.Fatalf("draws = %d, want 2 (one round, two players)", len(draws))
}
if draws[0].Account != a || !draws[0].Blank {
t.Fatalf("draw[0] = %+v, want a having drawn a blank", draws[0])
}
if draws[1].Account != b {
t.Fatalf("draw[1] account = %s, want b", draws[1].Account)
}
}
// TestAutoMatchDrawBackfilledOnJoin checks an open auto-match game records the draw at open
// time against the synthetic opponent (a NULL account) and back-fills those rows to the real
// opponent when they join.
func TestAutoMatchDrawBackfilledOnJoin(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
starter := provisionAccount(t)
g := openGame(t, svc, starter, evenOpeningSeed(t))
draws, err := svc.SetupDraws(ctx, g.ID)
if err != nil {
t.Fatalf("setup draws: %v", err)
}
if len(draws) != 2 {
t.Fatalf("open draws = %d, want 2", len(draws))
}
var nullSeen, starterSeen bool
for _, d := range draws {
switch d.Account {
case uuid.Nil:
nullSeen = true
case starter:
starterSeen = true
}
}
if !nullSeen || !starterSeen {
t.Fatalf("open draws = %+v, want the starter and a NULL synthetic opponent", draws)
}
joiner := provisionAccount(t)
if _, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute), nil); err != nil || !joined {
t.Fatalf("join = (joined %v, err %v), want joined", joined, err)
}
after, err := svc.SetupDraws(ctx, g.ID)
if err != nil {
t.Fatalf("setup draws after join: %v", err)
}
for _, d := range after {
if d.Account == uuid.Nil {
t.Fatalf("draw still NULL after join: %+v", d)
}
if d.Account != starter && d.Account != joiner {
t.Fatalf("draw account %s is neither player", d.Account)
}
}
}
// TestReplayTimeline checks the admin replay timeline reconstructs the dealt racks and one
// played move: the step count, the drawn tiles, the bag remainder, the running score and the
// turn cursor.
func TestReplayTimeline(t *testing.T) {
ctx := context.Background()
svc := newGameService()
a := provisionAccount(t)
b := provisionAccount(t)
seed := evenOpeningSeed(t)
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, TurnTimeout: 24 * time.Hour, Seed: seed})
if err != nil {
t.Fatalf("create: %v", err)
}
hint, ok := newMirror(t, seed, 2).HintView()
if !ok || len(hint.Tiles) == 0 {
t.Fatal("no opening move for the seed")
}
if _, err := svc.SubmitPlay(ctx, g.ID, a, hint.Tiles); err != nil {
t.Fatalf("play: %v", err)
}
tl, err := svc.ReplayTimeline(ctx, g.ID)
if err != nil {
t.Fatalf("replay timeline: %v", err)
}
if len(tl.Steps) != 2 {
t.Fatalf("steps = %d, want 2 (deal + one move)", len(tl.Steps))
}
deal := tl.Steps[0]
if deal.Move != nil {
t.Fatalf("step 0 must be the deal, got move %+v", deal.Move)
}
rackSize := len(deal.Racks[0])
if rackSize == 0 || len(deal.Racks) != 2 || len(deal.Racks[1]) != rackSize {
t.Fatalf("deal racks = %v, want two equal full racks", deal.Racks)
}
move := tl.Steps[1]
if move.Move == nil || move.Move.Action != "play" {
t.Fatalf("step 1 move = %+v, want a play", move.Move)
}
if len(move.Drawn) != len(hint.Tiles) {
t.Fatalf("drawn = %v, want %d refilled tiles", move.Drawn, len(hint.Tiles))
}
if move.BagLen != deal.BagLen-len(hint.Tiles) {
t.Fatalf("bag after play = %d, want %d", move.BagLen, deal.BagLen-len(hint.Tiles))
}
if len(move.Racks[0]) != rackSize {
t.Fatalf("mover rack after refill = %d, want %d", len(move.Racks[0]), rackSize)
}
if move.Scores[0] <= 0 {
t.Fatalf("seat 0 score after play = %d, want > 0", move.Scores[0])
}
if move.ToMove != 1 {
t.Fatalf("to_move after seat 0 play = %d, want 1", move.ToMove)
}
}