//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) } }