6d0dd4fb14
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).
226 lines
6.7 KiB
Go
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")
|
|
}
|
|
}
|