package engine import ( "errors" "testing" "scrabble-solver/board" "scrabble-solver/scrabble" ) // newEnglishGame starts a two-player English game with the given seed. func newEnglishGame(t *testing.T, seed int64) *Game { t.Helper() g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 2, Seed: seed}) if err != nil { t.Fatalf("new game: %v", err) } return g } // openingGame returns a two-player English game whose opening rack has at least // one legal move, searching a deterministic range of seeds. func openingGame(t *testing.T) *Game { t.Helper() for seed := int64(1); seed <= 100; seed++ { g := newEnglishGame(t, seed) if len(g.GenerateMoves()) > 0 { return g } } t.Fatal("no opening move found in seeds 1..100") return nil } // boardsEqual reports whether two boards have identical dimensions and cells. func boardsEqual(a, b *board.Board) bool { if a.Rows() != b.Rows() || a.Cols() != b.Cols() { return false } for r := range a.Rows() { for c := range a.Cols() { if a.At(r, c) != b.At(r, c) { return false } } } return true } // TestNewDealsRacks checks the initial state of a fresh game. func TestNewDealsRacks(t *testing.T) { g := newEnglishGame(t, 1) if g.Players() != 2 { t.Errorf("players = %d, want 2", g.Players()) } if g.ToMove() != 0 { t.Errorf("to move = %d, want 0", g.ToMove()) } if g.Over() { t.Error("a fresh game must not be over") } if g.Score(0) != 0 || g.Score(1) != 0 { t.Errorf("scores = (%d, %d), want (0, 0)", g.Score(0), g.Score(1)) } rackSize := g.rules.RackSize if len(g.hands[0]) != rackSize || len(g.hands[1]) != rackSize { t.Fatalf("hand sizes = (%d, %d), want %d each", len(g.hands[0]), len(g.hands[1]), rackSize) } if want := len(allTiles(g.rules)) - 2*rackSize; g.BagLen() != want { t.Errorf("bag len = %d, want %d", g.BagLen(), want) } } // TestNewRejectsBadPlayerCount rejects player counts outside 2..4. func TestNewRejectsBadPlayerCount(t *testing.T) { for _, n := range []int{0, 1, 5} { if _, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: n, Seed: 1}); err == nil { t.Errorf("players=%d: expected an error", n) } } } // TestNewUnknownVariant surfaces the registry's not-found error. func TestNewUnknownVariant(t *testing.T) { if _, err := New(testReg, Options{Variant: Variant(99), Version: testVersion, Players: 2}); !errors.Is(err, ErrUnknownVariant) { t.Fatalf("got %v, want ErrUnknownVariant", err) } } // TestPlayScoresAndAdvances plays the top opening move and checks the score, // running total, refill and turn advance. func TestPlayScoresAndAdvances(t *testing.T) { g := openingGame(t) move := g.GenerateMoves()[0] played := len(move.Tiles) bagBefore := g.BagLen() rec, err := g.Play(move.Dir, move.Tiles) if err != nil { t.Fatalf("play: %v", err) } if rec.Action != ActionPlay { t.Errorf("action = %v, want play", rec.Action) } if rec.Score != move.Score || g.Score(0) != move.Score { t.Errorf("score: rec=%d game=%d, want %d", rec.Score, g.Score(0), move.Score) } if rec.Total != move.Score { t.Errorf("running total = %d, want %d", rec.Total, move.Score) } if len(rec.Tiles) != played { t.Errorf("recorded tiles = %d, want %d", len(rec.Tiles), played) } if g.ToMove() != 1 { t.Errorf("to move = %d, want 1", g.ToMove()) } if len(g.hands[0]) != g.rules.RackSize { t.Errorf("hand refilled to %d, want %d", len(g.hands[0]), g.rules.RackSize) } if g.BagLen() != bagBefore-played { t.Errorf("bag len = %d, want %d", g.BagLen(), bagBefore-played) } } // TestPlayRejectsTilesNotOnRack rejects a play using tiles the player lacks. func TestPlayRejectsTilesNotOnRack(t *testing.T) { g := newEnglishGame(t, 1) row, col := centre(g.rules) ps := placementsForWord(t, g.rules, row, col, scrabble.Horizontal, "cat") // Clear the hand so the player provably lacks the tiles; the holds check // must reject the play before any dictionary check. g.hands[0] = nil if _, err := g.Play(scrabble.Horizontal, ps); !errors.Is(err, ErrTilesNotOnRack) { t.Fatalf("got %v, want ErrTilesNotOnRack", err) } } // TestExchangeSwapsTiles exchanges two tiles and checks the bag and turn state. func TestExchangeSwapsTiles(t *testing.T) { g := newEnglishGame(t, 1) bagBefore := g.BagLen() swap := append([]byte(nil), g.hands[0][:2]...) rec, err := g.Exchange(swap) if err != nil { t.Fatalf("exchange: %v", err) } if rec.Action != ActionExchange || rec.Count != 2 { t.Errorf("record = %+v, want exchange of 2", rec) } if len(g.hands[0]) != g.rules.RackSize { t.Errorf("hand size = %d, want %d", len(g.hands[0]), g.rules.RackSize) } if g.BagLen() != bagBefore { t.Errorf("bag len = %d, want %d (draw and return cancel)", g.BagLen(), bagBefore) } if g.ToMove() != 1 { t.Errorf("to move = %d, want 1", g.ToMove()) } if g.scorelessRun != 1 { t.Errorf("scoreless run = %d, want 1", g.scorelessRun) } } // TestExchangeNeedsFullBag rejects an exchange once the bag is below a rack. func TestExchangeNeedsFullBag(t *testing.T) { g := newEnglishGame(t, 1) g.bag.Draw(g.bag.Len()) // drain the bag if _, err := g.Exchange(g.hands[0][:1]); !errors.Is(err, ErrNotEnoughTilesToExchange) { t.Fatalf("got %v, want ErrNotEnoughTilesToExchange", err) } } // TestPassEndsAfterSixScoreless ends the game after the scoreless limit and then // rejects further transitions. func TestPassEndsAfterSixScoreless(t *testing.T) { g := newEnglishGame(t, 1) for i := range scorelessLimit { if _, err := g.Pass(); err != nil { t.Fatalf("pass %d: %v", i, err) } } if !g.Over() { t.Fatal("game must be over after six scoreless turns") } if g.Reason() != EndScoreless { t.Errorf("reason = %v, want scoreless", g.Reason()) } if _, err := g.Pass(); !errors.Is(err, ErrGameOver) { t.Errorf("pass after end: got %v, want ErrGameOver", err) } } // TestGreedyPlaythroughEndsAndReplays drives a full greedy game to its end and // proves the dictionary-independent replay reproduces the final board. func TestGreedyPlaythroughEndsAndReplays(t *testing.T) { g := newEnglishGame(t, 20250602) const maxTurns = 600 for turn := 0; turn < maxTurns && !g.Over(); turn++ { if moves := g.GenerateMoves(); len(moves) > 0 { if _, err := g.Play(moves[0].Dir, moves[0].Tiles); err != nil { t.Fatalf("turn %d play: %v", turn, err) } continue } if _, err := g.Pass(); err != nil { t.Fatalf("turn %d pass: %v", turn, err) } } if !g.Over() { t.Fatalf("game did not finish within %d turns", maxTurns) } rs, err := Ruleset(VariantEnglish) if err != nil { t.Fatalf("ruleset: %v", err) } replayed, err := ReplayBoard(rs, g.Log()) if err != nil { t.Fatalf("replay: %v", err) } if !boardsEqual(replayed, g.BoardClone()) { t.Fatal("replayed board differs from the final board") } }