Files
scrabble-game/backend/internal/engine/game_test.go
T
Ilia Denisov 6d0dd4fb14
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 7s
Stage 2: engine package over scrabble-solver (registry, bag, Game, replay)
backend/internal/engine wraps the sibling scrabble-solver library in-process:

- Registry: versioned DAWG load via dafsa.Load, keyed by (variant, dict_version),
  latest-per-variant; English / Russian / Эрудит handled uniformly.
- Bag: own deterministic, seeded tile bag with Draw + Return (for exchanges),
  since the solver's self-play bag cannot return tiles.
- Game: pure rules engine — deal, play/pass/exchange/resign, refill, per-move
  scoring, turn order, and end-condition detection (empty bag + empty rack, six
  scoreless turns, resignation) with end-game rack adjustment.
- decode/ReplayBoard: dictionary-independent MoveRecords and board replay via
  scrabble.Apply (no internal/encoding), realising ARCHITECTURE §9.1.

Wiring: go.work gains "replace scrabble-solver => ../scrabble-solver"; backend
requires scrabble-solver (placeholder) and github.com/iliadenisov/dafsa directly.
Both Go CI workflows clone the public solver sibling (master HEAD, no token) and
set BACKEND_DICT_DIR.

Docs: ARCHITECTURE §5/§14, TESTING engine layer, backend README, and PLAN
refinements + deferred TODOs (publish/version solver; split engine vs dictionary
generator).
2026-06-02 15:10:08 +02:00

226 lines
6.7 KiB
Go

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