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).
79 lines
2.0 KiB
Go
79 lines
2.0 KiB
Go
package engine
|
|
|
|
import (
|
|
"maps"
|
|
"slices"
|
|
"testing"
|
|
|
|
"scrabble-solver/rules"
|
|
)
|
|
|
|
// allTiles returns the full multiset of tiles a bag is filled from, in ruleset
|
|
// order (letters then blanks).
|
|
func allTiles(rs *rules.Ruleset) []byte {
|
|
var ts []byte
|
|
for i, n := range rs.Counts {
|
|
for range n {
|
|
ts = append(ts, byte(i))
|
|
}
|
|
}
|
|
for range rs.Blanks {
|
|
ts = append(ts, blankTile)
|
|
}
|
|
return ts
|
|
}
|
|
|
|
// TestBagDeterministic checks that two bags with the same seed draw identically.
|
|
func TestBagDeterministic(t *testing.T) {
|
|
rs := rules.English()
|
|
a, b := NewBag(rs, 42), NewBag(rs, 42)
|
|
if a.Len() != b.Len() {
|
|
t.Fatalf("len mismatch: %d vs %d", a.Len(), b.Len())
|
|
}
|
|
for a.Len() > 0 {
|
|
if da, db := a.Draw(3), b.Draw(3); !slices.Equal(da, db) {
|
|
t.Fatalf("same seed drew differently: %v vs %v", da, db)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBagReturnConservesMultiset checks Len accounting and that Return puts the
|
|
// exact tiles back, leaving the bag's multiset unchanged.
|
|
func TestBagReturnConservesMultiset(t *testing.T) {
|
|
rs := rules.English()
|
|
want := tileCounts(allTiles(rs))
|
|
total := len(allTiles(rs))
|
|
|
|
b := NewBag(rs, 7)
|
|
if b.Len() != total {
|
|
t.Fatalf("new bag len = %d, want %d", b.Len(), total)
|
|
}
|
|
drawn := b.Draw(rs.RackSize)
|
|
if b.Len() != total-rs.RackSize {
|
|
t.Fatalf("after draw len = %d, want %d", b.Len(), total-rs.RackSize)
|
|
}
|
|
b.Return(drawn)
|
|
if b.Len() != total {
|
|
t.Fatalf("after return len = %d, want %d", b.Len(), total)
|
|
}
|
|
if got := tileCounts(b.Draw(b.Len())); !maps.Equal(got, want) {
|
|
t.Fatalf("multiset changed across draw/return")
|
|
}
|
|
}
|
|
|
|
// TestBagDrawAll returns everything once the bag is exhausted and never panics.
|
|
func TestBagDrawAll(t *testing.T) {
|
|
rs := rules.English()
|
|
b := NewBag(rs, 1)
|
|
all := b.Draw(b.Len() + 10) // asking for more than present returns all
|
|
if len(all) != len(allTiles(rs)) {
|
|
t.Fatalf("drew %d, want %d", len(all), len(allTiles(rs)))
|
|
}
|
|
if b.Len() != 0 {
|
|
t.Fatalf("bag len = %d, want 0", b.Len())
|
|
}
|
|
if got := b.Draw(1); len(got) != 0 {
|
|
t.Fatalf("draw from empty bag returned %d tiles", len(got))
|
|
}
|
|
}
|