//go:build integration package inttest import ( "context" "testing" "time" "github.com/google/uuid" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" ) // newDraftGame creates a started two-player English game on an opening seed and returns the // service, game id, seats, and the opening play (from a mirror) used to drive a real commit. func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) { t.Helper() ctx := context.Background() svc := newGameService() seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} seed := openingSeed(t) g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, 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") } return svc, g.ID, seats, hint } // TestDraftPersistAndConflictReset covers Stage 17 draft persistence: a round-trip of the // rack order + board tiles, the actor's own draft cleared on their move, and an opponent's // board draft reset when a committed play overlaps one of its cells (the rack order kept). func TestDraftPersistAndConflictReset(t *testing.T) { ctx := context.Background() svc, gameID, seats, hint := newDraftGame(t) // Round-trip seat 0's rack order + a board draft. d0 := game.Draft{RackOrder: "QANIWE?", BoardTiles: []game.DraftTile{{Row: 1, Col: 1, Letter: "Q"}}} if err := svc.SaveDraft(ctx, gameID, seats[0], d0); err != nil { t.Fatalf("save draft 0: %v", err) } if got, err := svc.GetDraft(ctx, gameID, seats[0]); err != nil || got.RackOrder != "QANIWE?" || len(got.BoardTiles) != 1 || got.BoardTiles[0].Letter != "Q" { t.Fatalf("get draft 0 = %+v (err %v)", got, err) } // Seat 1 drafts a board tile on a cell the opening play will commit. overlap := hint.Tiles[0] if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{ RackOrder: "ABCDEFG", BoardTiles: []game.DraftTile{{Row: overlap.Row, Col: overlap.Col, Letter: "X"}}, }); err != nil { t.Fatalf("save draft 1: %v", err) } if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil { t.Fatalf("seat0 play: %v", err) } // Seat 0's own draft is cleared by their move. if d, _ := svc.GetDraft(ctx, gameID, seats[0]); d.RackOrder != "" || len(d.BoardTiles) != 0 { t.Errorf("actor draft not cleared: %+v", d) } // Seat 1's board draft overlapped the play and is reset; the rack order is kept. if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 0 || d.RackOrder != "ABCDEFG" { t.Errorf("conflicting draft not reset (or rack order lost): %+v", d) } } // TestDraftSurvivesNonConflictingMove checks an opponent's board draft is kept when a // committed play does not touch any of its cells. func TestDraftSurvivesNonConflictingMove(t *testing.T) { ctx := context.Background() svc, gameID, seats, hint := newDraftGame(t) // Seat 1 drafts a far corner tile the central opening play cannot reach. if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{ BoardTiles: []game.DraftTile{{Row: 0, Col: 0, Letter: "Z"}}, }); err != nil { t.Fatalf("save draft 1: %v", err) } if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil { t.Fatalf("seat0 play: %v", err) } if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 1 || d.BoardTiles[0].Letter != "Z" { t.Errorf("non-conflicting draft should survive: %+v", d) } } // TestSaveDraftRejectsOutsider checks only a seated player may save a draft. func TestSaveDraftRejectsOutsider(t *testing.T) { ctx := context.Background() svc, gameID, _, _ := newDraftGame(t) if err := svc.SaveDraft(ctx, gameID, provisionAccount(t), game.Draft{RackOrder: "X"}); err == nil { t.Fatal("outsider SaveDraft should fail") } }