Files
scrabble-solver/scrabble/gen_dawg_test.go
T
Ilia Denisov 15c7959d96 Implement Scrabble move generator (DAWG) with English and Russian rules
A Go library that returns every legal play ranked by score and scores or
validates plays, using the Appel-Jacobson DAWG algorithm over
github.com/iliadenisov/dafsa v1.1.0.

- DAWG move generation (across / down / both), full tournament scoring with a
  per-tile breakdown; public Solver: GenerateMoves (ranked), ScorePlay,
  ValidatePlay.
- Rulesets: English Scrabble, Russian Scrabble, Эрудит (parameterizable Ruleset).
- cmd/builddict (build the DAWG from the dictionaries submodule), cmd/stress
  (self-play benchmark), selfplay engine; brute-force test oracle.
- A GADDAG was implemented, benchmarked and removed (the DAWG was smaller and
  faster for a scoring solver); see RESULTS.md and ALGORITHM.md.
2026-06-01 16:07:32 +02:00

122 lines
3.2 KiB
Go

package scrabble
import (
"testing"
"github.com/iliadenisov/alphabet"
"scrabble-solver/board"
"scrabble-solver/internal/dictdawg"
"scrabble-solver/internal/encoding"
"scrabble-solver/internal/wordlist"
"scrabble-solver/rack"
"scrabble-solver/rules"
)
func makeRack(letters string, blanks int) rack.Rack {
r := rack.New(26)
for i := range len(letters) {
r.Add(letters[i] - 'a')
}
for range blanks {
r.AddBlank()
}
return r
}
func placeWord(b *board.Board, r, c int, dir Direction, word string) {
for i := range len(word) {
rr, cc := r, c+i
if dir == Vertical {
rr, cc = r+i, c
}
b.Set(rr, cc, encoding.Cell(word[i]-'a', false))
}
}
func genMoves(moves []Move) map[string]Move {
out := make(map[string]Move, len(moves))
for _, m := range moves {
out[moveKey(m.Dir, m.Tiles)] = m
}
return out
}
// testWords is a small lexicon with enough overlaps to form across and cross plays.
var testWords = []string{
"aa", "ace", "act", "arc", "are", "art", "as", "at", "ate",
"cab", "cap", "car", "care", "cars", "cart", "cat", "cats", "cot",
"oat", "oats", "ta", "tar", "tare", "tat", "tea", "teat",
}
func compareToBrute(t *testing.T, name string, gen Generator, b *board.Board, d dict, rk rack.Rack, mode Mode) {
t.Helper()
want := bruteForce(b, plainRulesShared, d, rk, mode)
got := genMoves(gen.GenerateMoves(b, rk, mode))
for k, wm := range want {
gm, ok := got[k]
if !ok {
t.Errorf("%s [%s]: %s missing %s (score %d)", name, gen.Name(), gen.Name(), k, wm.Score)
continue
}
if gm.Score != wm.Score {
t.Errorf("%s [%s]: %s score %d, want %d", name, gen.Name(), k, gm.Score, wm.Score)
}
}
for k := range got {
if _, ok := want[k]; !ok {
t.Errorf("%s [%s]: extra move %s", name, gen.Name(), k)
}
}
if len(got) != len(want) {
t.Errorf("%s [%s]: %d moves, oracle has %d", name, gen.Name(), len(got), len(want))
}
}
func mustPlainRules() *rules.Ruleset {
eng := rules.English()
rs, err := rules.FromTemplate("plain7", eng.Alphabet, eng.Values, eng.Counts, 2, 7, 50, plain7)
if err != nil {
panic(err)
}
return rs
}
var plainRulesShared = mustPlainRules()
type scenario struct {
name string
setup func(*board.Board)
rack rack.Rack
mode Mode
}
func genScenarios() []scenario {
return []scenario{
{"first move", func(*board.Board) {}, makeRack("cat", 0), Both},
{"first move blank", func(*board.Board) {}, makeRack("ca", 1), Both},
{"extend cat", func(b *board.Board) { placeWord(b, 3, 1, Horizontal, "cat") }, makeRack("srs", 0), Both},
{"cross cat", func(b *board.Board) { placeWord(b, 1, 3, Horizontal, "cat") }, makeRack("aort", 0), Both},
{"only horizontal", func(b *board.Board) { placeWord(b, 3, 1, Horizontal, "cat") }, makeRack("aser", 0), OnlyHorizontal},
{"only vertical", func(b *board.Board) { placeWord(b, 1, 3, Vertical, "cat") }, makeRack("aser", 0), OnlyVertical},
}
}
func TestDAWGGeneratorVsBruteForce(t *testing.T) {
rs := plainRulesShared
words := wordlist.Encode(testWords, alphabet.Latin(), 2, 15)
f, err := dictdawg.Build(alphabet.Latin(), words)
if err != nil {
t.Fatal(err)
}
gen := NewDAWGGenerator(rs, f)
d := makeDict(words)
for _, c := range genScenarios() {
b := board.New(rs.Rows, rs.Cols)
c.setup(b)
compareToBrute(t, c.name, gen, b, d, c.rack, c.mode)
}
}