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
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:
@@ -206,6 +206,12 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
|
||||
if !strings.Contains(body, "~40%") {
|
||||
t.Error("robot play-to-win target caption missing")
|
||||
}
|
||||
if !strings.Contains(body, "First-move draw") {
|
||||
t.Error("first-move draw section missing from the game detail")
|
||||
}
|
||||
if !strings.Contains(body, `id="replay-board"`) {
|
||||
t.Error("replay board container missing from the game detail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleThrottledViewAndFlagClear drives the rate-limit surface end to
|
||||
|
||||
@@ -134,11 +134,13 @@ func TestDictionaryUpdateFlow(t *testing.T) {
|
||||
// newGameServiceOn builds a game service over the shared pool but a caller-supplied
|
||||
// dictionary directory, version and registry, for the isolated dictionary tests.
|
||||
func newGameServiceOn(dir, version string, reg *engine.Registry) *game.Service {
|
||||
return game.NewService(
|
||||
svc := game.NewService(
|
||||
game.NewStore(testDB), account.NewStore(testDB), reg,
|
||||
game.Config{DictDir: dir, DictVersion: version, TimeoutSweepInterval: time.Minute, CacheTTL: time.Hour},
|
||||
zap.NewNop(),
|
||||
)
|
||||
svc.SetFirstMoveEntropy(seatZeroFirstMove)
|
||||
return svc
|
||||
}
|
||||
|
||||
// seedDawgs copies the committed DAWG of every variant from src into dst (flat).
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,10 @@ import (
|
||||
// assembly, and the stats reader. Helpers used by a single test file stay in
|
||||
// that file; everything reused across files lives here.
|
||||
|
||||
// newGameService builds a game service over the shared pool and registry.
|
||||
// newGameService builds a game service over the shared pool and registry, with a
|
||||
// deterministic first-move draw so the suite keeps a stable turn order.
|
||||
func newGameService() *game.Service {
|
||||
return game.NewService(
|
||||
svc := game.NewService(
|
||||
game.NewStore(testDB),
|
||||
account.NewStore(testDB),
|
||||
testRegistry,
|
||||
@@ -40,6 +41,39 @@ func newGameService() *game.Service {
|
||||
},
|
||||
zap.NewNop(),
|
||||
)
|
||||
svc.SetFirstMoveEntropy(seatZeroFirstMove)
|
||||
return svc
|
||||
}
|
||||
|
||||
// seatZeroFirstMove is a first-move-draw entropy factory that always elects the first
|
||||
// listed account as the leader, keeping the integration suite's turn order stable
|
||||
// despite the real draw's randomness: the first contender draws a blank — the best
|
||||
// possible tile — and wins outright, the rest draw the first remaining tile. It is a
|
||||
// factory so each game restarts the one-shot "blank" pick.
|
||||
func seatZeroFirstMove() func(n int) (int, error) {
|
||||
first := true
|
||||
return func(n int) (int, error) {
|
||||
if first {
|
||||
first = false
|
||||
return n - 1, nil // a blank sits last in the bag → best rank
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
// seatOneFirstMove is a first-move-draw entropy factory that elects the second contender (in
|
||||
// auto-match, the synthetic opponent) as the leader, so the caller is seated at seat 1: the
|
||||
// first contender draws the first remaining tile and the second draws a blank, winning.
|
||||
func seatOneFirstMove() func(n int) (int, error) {
|
||||
pick := 0
|
||||
return func(n int) (int, error) {
|
||||
i := 0
|
||||
if pick == 1 {
|
||||
i = n - 1 // the second contender draws a blank → best rank
|
||||
}
|
||||
pick++
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
|
||||
// newSocialService builds a social service over the shared pool, reading game
|
||||
|
||||
@@ -18,8 +18,9 @@ import (
|
||||
// The open-game suite covers an auto-match game that a player enters immediately and
|
||||
// waits inside (status 'open', the opponent seat empty) until a human or a robot joins.
|
||||
|
||||
// evenOpeningSeed returns an even seed (so OpenOrJoin seats the starter at seat 0, which
|
||||
// moves first) whose fresh two-player English opening rack has a legal move.
|
||||
// evenOpeningSeed returns a seed whose fresh two-player English opening rack (seat 0) has a
|
||||
// legal move, for tests that drive or attempt an opening play. It scans even seeds; seat
|
||||
// order no longer depends on the seed — the first-move draw decides it (docs/ARCHITECTURE.md §6).
|
||||
func evenOpeningSeed(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
for seed := int64(2); seed <= 400; seed += 2 {
|
||||
@@ -59,9 +60,9 @@ func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) ga
|
||||
return g
|
||||
}
|
||||
|
||||
// TestOpenGameStarterMovesThenWaits checks the starter (seat 0) may move on their turn
|
||||
// while the game is open, after which it is the empty opponent seat's turn and the
|
||||
// starter just waits.
|
||||
// TestOpenGameStarterMovesThenWaits checks the starter may move on their turn while the game
|
||||
// is open (the first-move draw, run when the game opened, put them at seat 0), after which it
|
||||
// is the empty opponent seat's turn and the starter just waits.
|
||||
func TestOpenGameStarterMovesThenWaits(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
@@ -89,28 +90,30 @@ func TestOpenGameStarterMovesThenWaits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the starter is seated
|
||||
// at seat 1 (odd seed), the still-empty seat 0 is to move, so the starter cannot act.
|
||||
// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the first-move draw seats
|
||||
// the starter at seat 1 (the synthetic opponent won the open draw), the still-empty seat 0 is
|
||||
// to move, so the starter cannot act until an opponent fills it.
|
||||
func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
svc := newGameService()
|
||||
svc.SetFirstMoveEntropy(seatOneFirstMove) // the synthetic opponent wins → the caller sits at seat 1
|
||||
starter := provisionAccount(t)
|
||||
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if g.ToMove != 0 || g.Seats[0].AccountID != uuid.Nil {
|
||||
t.Fatalf("odd-seed open game: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID)
|
||||
t.Fatalf("opponent-won open draw: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID)
|
||||
}
|
||||
if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) {
|
||||
t.Fatalf("starter acting on the empty seat's turn = %v, want ErrNotYourTurn", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after
|
||||
// the starter has already made their first move, landing on the board mid-opening and
|
||||
// able to act on their turn.
|
||||
// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after the
|
||||
// starter has already made their first move: the seats were fixed by the draw at open, so the
|
||||
// joiner takes the empty seat without disturbing the journal, and can act on their turn.
|
||||
func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
|
||||
Reference in New Issue
Block a user