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.
This commit is contained in:
Ilia Denisov
2026-06-01 16:07:32 +02:00
parent f51a1fe2f2
commit 15c7959d96
43 changed files with 3406 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
package scrabble
import (
"scrabble-solver/board"
"scrabble-solver/internal/encoding"
)
// Apply places a move's newly-placed tiles on the board. The move must be legal for the
// board (as produced by a generator, or validated); Apply does not re-check it.
func Apply(b *board.Board, m Move) {
for _, t := range m.Tiles {
b.Set(t.Row, t.Col, encoding.Cell(t.Letter, t.Blank))
}
}
+108
View File
@@ -0,0 +1,108 @@
package scrabble
import (
dawg "github.com/iliadenisov/dafsa"
"scrabble-solver/board"
"scrabble-solver/internal/encoding"
)
// letterSet is a bit set over alphabet letter indexes (alphabets are at most 63
// letters, so a uint64 suffices). It encodes a square's cross-set: the letters that,
// placed on the square, form a legal perpendicular word.
type letterSet uint64
func (s letterSet) has(l byte) bool { return s&(letterSet(1)<<l) != 0 }
// fullSet is the cross-set of a square with no perpendicular neighbours: every letter
// is allowed.
func fullSet(size int) letterSet { return letterSet(uint64(1)<<uint(size)) - 1 }
// columnContext returns the contiguous run of filled cells immediately above and below
// the empty square (r, c), each read top to bottom, as alphabet letter indexes. These
// are the tiles a perpendicular (vertical) word through (r, c) would include.
func columnContext(b *board.Board, r, c int) (above, below []byte) {
start := r
for start-1 >= 0 && b.Filled(start-1, c) {
start--
}
for rr := start; rr < r; rr++ {
above = append(above, encoding.Letter(b.At(rr, c)))
}
end := r
for end+1 < b.Rows() && b.Filled(end+1, c) {
end++
}
for rr := r + 1; rr <= end; rr++ {
below = append(below, encoding.Letter(b.At(rr, c)))
}
return above, below
}
// completers returns the letters X (< size) that complete a word when followed from
// state: those whose arc leads directly to an accepting node. It is a single arc
// enumeration — the deterministic cross-set primitive.
func completers(cur *dawg.Cursor, state dawg.Node, size int) letterSet {
var set letterSet
lim := byte(size)
cur.Arcs(state, func(a dawg.Arc) bool {
if a.Final && a.Label < lim {
set |= letterSet(1) << a.Label
}
return true
})
return set
}
// walk follows word left to right from the cursor's root.
func walk(cur *dawg.Cursor, word []byte) (dawg.Node, bool) {
n := cur.Root()
for _, l := range word {
var ok bool
if n, _, ok = cur.Next(n, l); !ok {
return n, false
}
}
return n, true
}
// dawgCrossSet returns the letters X for which above·X·below is a stored word. A right
// extension (no tiles below) is deterministic — X just completes the prefix above. A
// left extension (tiles below) is non-deterministic and must probe each X.
func dawgCrossSet(cur *dawg.Cursor, above, below []byte, size int) letterSet {
switch {
case len(above) == 0 && len(below) == 0:
return fullSet(size)
case len(below) == 0:
node, ok := walk(cur, above)
if !ok {
return 0
}
return completers(cur, node, size)
default:
node := cur.Root()
if len(above) > 0 {
var ok bool
if node, ok = walk(cur, above); !ok {
return 0
}
}
var set letterSet
for x := range size {
m, final, ok := cur.Next(node, byte(x))
if !ok {
continue
}
for _, l := range below {
if m, final, ok = cur.Next(m, l); !ok {
break
}
}
if ok && final {
set |= letterSet(1) << uint(x)
}
}
return set
}
}
+75
View File
@@ -0,0 +1,75 @@
package scrabble
import (
"testing"
"github.com/iliadenisov/alphabet"
dawg "github.com/iliadenisov/dafsa"
"scrabble-solver/internal/dictdawg"
"scrabble-solver/internal/wordlist"
)
func bruteCrossSet(words [][]byte, above, below []byte, size int) letterSet {
set := make(map[string]bool, len(words))
for _, w := range words {
set[string(w)] = true
}
var out letterSet
for x := range size {
w := make([]byte, 0, len(above)+1+len(below))
w = append(w, above...)
w = append(w, byte(x))
w = append(w, below...)
if set[string(w)] {
out |= letterSet(1) << uint(x)
}
}
return out
}
func TestDAWGCrossSetMatchesBruteForce(t *testing.T) {
const size = 26
words := wordlist.Encode(
[]string{"cat", "cot", "cut", "cap", "cab", "at", "it"},
alphabet.Latin(), 2, 15)
finder, err := dictdawg.Build(alphabet.Latin(), words)
if err != nil {
t.Fatal(err)
}
cur, err := dawg.NewCursor(finder)
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
above, below []byte
}{
{"c_t", []byte{2}, []byte{19}}, // expect {a,o,u}
{"_t", nil, []byte{19}}, // expect {a,i}
{"c_", []byte{2}, nil}, // expect {} (no two-letter c-words)
{"a_t", []byte{0}, []byte{19}}, // expect {}
}
for _, tc := range cases {
want := bruteCrossSet(words, tc.above, tc.below, size)
if got := dawgCrossSet(cur, tc.above, tc.below, size); got != want {
t.Errorf("%s: dawgCrossSet = %026b, want %026b", tc.name, got, want)
}
}
// c_t must be exactly {a(0), o(14), u(20)}.
want := letterSet(0)
for _, x := range []byte{0, 14, 20} {
want |= letterSet(1) << x
}
if got := dawgCrossSet(cur, []byte{2}, []byte{19}, size); got != want {
t.Errorf("c_t cross-set = %026b, want {a,o,u} = %026b", got, want)
}
// No perpendicular neighbours: every letter is allowed.
if got := dawgCrossSet(cur, nil, nil, size); got != fullSet(size) {
t.Errorf("empty context = %026b, want full", got)
}
}
+56
View File
@@ -0,0 +1,56 @@
package scrabble
import (
"scrabble-solver/board"
"scrabble-solver/rack"
"scrabble-solver/rules"
)
// generateBoth runs an across-generator on the board (for horizontal plays) and on its
// transpose (for vertical plays), as selected by mode, then scores and de-duplicates the
// results. runAcross reports placements in the coordinates of the board it is given; for
// the transpose pass they are mapped back to the real board.
func generateBoth(b *board.Board, rs *rules.Ruleset, rk rack.Rack, mode Mode,
runAcross func(bd *board.Board, rk rack.Rack, emit func([]Placement))) []Move {
rk = rk.Clone() // generation mutates the rack in place and restores it
var moves []Move
seen := make(map[string]struct{})
emit := func(dir Direction, placements []Placement) {
key := moveKey(dir, placements)
if _, dup := seen[key]; dup {
return
}
m, err := Evaluate(b, rs, dir, placements)
if err != nil {
return
}
seen[key] = struct{}{}
moves = append(moves, m)
}
if mode.Includes(Horizontal) {
runAcross(b, rk, func(p []Placement) { emit(Horizontal, p) })
}
if mode.Includes(Vertical) {
tb := b.Transpose()
runAcross(tb, rk, func(p []Placement) {
rp := make([]Placement, len(p))
for i, pl := range p {
rp[i] = Placement{Row: pl.Col, Col: pl.Row, Letter: pl.Letter, Blank: pl.Blank}
}
emit(Vertical, rp)
})
}
return moves
}
// centerFor returns the centre square in bd's coordinates. bd is either the real board
// or its transpose; the ruleset stores the centre on the real board.
func centerFor(bd *board.Board, rs *rules.Ruleset) (row, col int) {
r, c := rs.Center/rs.Cols, rs.Center%rs.Cols
if bd.Rows() == rs.Rows && bd.Cols() == rs.Cols {
return r, c
}
return c, r // transposed
}
+221
View File
@@ -0,0 +1,221 @@
package scrabble
import (
dawg "github.com/iliadenisov/dafsa"
"scrabble-solver/board"
"scrabble-solver/internal/encoding"
"scrabble-solver/rack"
"scrabble-solver/rules"
)
// DAWGGenerator generates moves with the Appel-Jacobson two-phase algorithm
// (LeftPart then ExtendRight) over a plain left-to-right DAWG.
type DAWGGenerator struct {
rules *rules.Ruleset
finder dawg.Finder
}
// NewDAWGGenerator builds a DAWG generator for the ruleset over the dictionary finder.
func NewDAWGGenerator(rs *rules.Ruleset, finder dawg.Finder) *DAWGGenerator {
return &DAWGGenerator{rules: rs, finder: finder}
}
// Name identifies the generator.
func (g *DAWGGenerator) Name() string { return "dawg" }
// GenerateMoves returns every legal play for rk on b in the modes' orientations.
func (g *DAWGGenerator) GenerateMoves(b *board.Board, rk rack.Rack, mode Mode) []Move {
return generateBoth(b, g.rules, rk, mode, g.runAcross)
}
// tileInfo is a tentatively placed left-part tile (its column is fixed only once the
// left part's length is known, at record time).
type tileInfo struct {
letter byte
blank bool
}
// acrossGen carries the state of one across-generation pass over a board.
type acrossGen struct {
bd *board.Board
cur *dawg.Cursor
rs *rules.Ruleset
rk rack.Rack
size int
cross func(r, c int) letterSet
emit func(placements []Placement) // placements in bd's coordinates
row int
left []tileInfo // left-part tiles, in word (left-to-right) order
right []Placement // right-part tiles, with their columns
}
// runAcross generates all across plays on bd (cross-sets are computed as vertical words
// on bd) and reports each via emit in bd's coordinates.
func (g *DAWGGenerator) runAcross(bd *board.Board, rk rack.Rack, emit func([]Placement)) {
cur, err := dawg.NewCursor(g.finder)
if err != nil {
return
}
size := g.rules.Size()
cross := make([]letterSet, bd.Rows()*bd.Cols())
known := make([]bool, bd.Rows()*bd.Cols())
crossFn := func(r, c int) letterSet {
i := r*bd.Cols() + c
if !known[i] {
above, below := columnContext(bd, r, c)
cross[i] = dawgCrossSet(cur, above, below, size)
known[i] = true
}
return cross[i]
}
ag := &acrossGen{bd: bd, cur: cur, rs: g.rules, rk: rk, size: size, cross: crossFn, emit: emit}
firstMove := bd.IsEmpty()
centerRow, centerCol := centerFor(bd, g.rules)
for row := range bd.Rows() {
ag.generateRow(row, firstMove, centerRow, centerCol)
}
}
func (g *acrossGen) generateRow(row int, firstMove bool, centerRow, centerCol int) {
g.row = row
limit := 0
for col := range g.bd.Cols() {
if !g.bd.Empty(row, col) {
limit = 0
continue
}
anchor := false
if firstMove {
anchor = row == centerRow && col == centerCol
} else {
anchor = g.hasFilledNeighbor(row, col)
}
if !anchor {
limit++
continue
}
g.left = g.left[:0]
g.right = g.right[:0]
if col > 0 && g.bd.Filled(row, col-1) {
if node, ok := g.walkPrefix(row, col); ok {
g.extendRight(node, col, col)
}
} else {
g.leftPart(g.cur.Root(), col, limit)
}
limit = 0
}
}
func (g *acrossGen) hasFilledNeighbor(r, c int) bool {
return g.bd.Filled(r-1, c) || g.bd.Filled(r+1, c) || g.bd.Filled(r, c-1) || g.bd.Filled(r, c+1)
}
// walkPrefix walks the DAWG through the contiguous filled run ending at col-1, returning
// the node reached and whether that prefix exists in the dictionary.
func (g *acrossGen) walkPrefix(row, col int) (dawg.Node, bool) {
start := col - 1
for start-1 >= 0 && g.bd.Filled(row, start-1) {
start--
}
node := g.cur.Root()
for c := start; c < col; c++ {
var ok bool
node, _, ok = g.cur.Next(node, encoding.Letter(g.bd.At(row, c)))
if !ok {
return node, false
}
}
return node, true
}
// leftPart places left-part tiles from the rack (up to limit, on the empty squares left
// of the anchor), calling extendRight after each prefix.
func (g *acrossGen) leftPart(node dawg.Node, anchorCol, limit int) {
g.extendRight(node, anchorCol, anchorCol)
if limit == 0 {
return
}
g.cur.Arcs(node, func(a dawg.Arc) bool {
l := a.Label
if g.rk.Has(l) {
g.rk.Remove(l)
g.left = append(g.left, tileInfo{letter: l})
g.leftPart(a.Dest, anchorCol, limit-1)
g.left = g.left[:len(g.left)-1]
g.rk.Add(l)
}
if g.rk.Blanks() > 0 {
g.rk.RemoveBlank()
g.left = append(g.left, tileInfo{letter: l, blank: true})
g.leftPart(a.Dest, anchorCol, limit-1)
g.left = g.left[:len(g.left)-1]
g.rk.AddBlank()
}
return true
})
}
// extendRight extends the word rightward from col, placing rack tiles on empty squares
// (constrained by cross-sets) and following tiles already on the board. A word is
// recorded only past the anchor, so the play covers the anchor square.
func (g *acrossGen) extendRight(node dawg.Node, col, anchorCol int) {
if col >= g.bd.Cols() {
if col > anchorCol && g.cur.Final(node) {
g.record(anchorCol)
}
return
}
if !g.bd.Empty(g.row, col) {
if dest, _, ok := g.cur.Next(node, encoding.Letter(g.bd.At(g.row, col))); ok {
g.extendRight(dest, col+1, anchorCol)
}
return
}
if col > anchorCol && g.cur.Final(node) {
g.record(anchorCol)
}
cross := g.cross(g.row, col)
g.cur.Arcs(node, func(a dawg.Arc) bool {
l := a.Label
if !cross.has(l) {
return true
}
if g.rk.Has(l) {
g.rk.Remove(l)
g.right = append(g.right, Placement{Row: g.row, Col: col, Letter: l})
g.extendRight(a.Dest, col+1, anchorCol)
g.right = g.right[:len(g.right)-1]
g.rk.Add(l)
}
if g.rk.Blanks() > 0 {
g.rk.RemoveBlank()
g.right = append(g.right, Placement{Row: g.row, Col: col, Letter: l, Blank: true})
g.extendRight(a.Dest, col+1, anchorCol)
g.right = g.right[:len(g.right)-1]
g.rk.AddBlank()
}
return true
})
}
// record assembles the play's placements (left part at fixed columns, then the right
// part) and reports it. It skips plays that lay no new tile.
func (g *acrossGen) record(anchorCol int) {
if len(g.left)+len(g.right) == 0 {
return
}
placements := make([]Placement, 0, len(g.left)+len(g.right))
leftStart := anchorCol - len(g.left)
for i, t := range g.left {
placements = append(placements, Placement{Row: g.row, Col: leftStart + i, Letter: t.letter, Blank: t.blank})
}
placements = append(placements, g.right...)
g.emit(placements)
}
+121
View File
@@ -0,0 +1,121 @@
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)
}
}
+18
View File
@@ -0,0 +1,18 @@
package scrabble
import (
"scrabble-solver/board"
"scrabble-solver/rack"
)
// Generator produces every legal play for a position. The DAWG generator
// (Appel-Jacobson) is the implementation; the interface keeps the self-play engine and
// the solver decoupled from the concrete type.
type Generator interface {
// GenerateMoves returns every legal play for rack r on board b in the modes'
// orientations. The result is unsorted; callers (or the Solver) rank it.
GenerateMoves(b *board.Board, r rack.Rack, mode Mode) []Move
// Name identifies the generator (e.g. "dawg").
Name() string
}
+36
View File
@@ -0,0 +1,36 @@
package scrabble
import (
"sort"
"strconv"
"strings"
)
// moveKey is a canonical string identifying a play (direction plus its placed tiles),
// used to de-duplicate and compare generated moves.
func moveKey(dir Direction, p []Placement) string {
ps := append([]Placement(nil), p...)
sort.Slice(ps, func(i, j int) bool {
if ps[i].Row != ps[j].Row {
return ps[i].Row < ps[j].Row
}
return ps[i].Col < ps[j].Col
})
var sb strings.Builder
sb.WriteByte('0' + byte(dir))
for _, pl := range ps {
sb.WriteByte(';')
sb.WriteString(strconv.Itoa(pl.Row))
sb.WriteByte(',')
sb.WriteString(strconv.Itoa(pl.Col))
sb.WriteByte(',')
sb.WriteString(strconv.Itoa(int(pl.Letter)))
if pl.Blank {
sb.WriteByte('*')
}
}
return sb.String()
}
// Key returns the canonical identifier of the move (direction plus its placed tiles).
func (m Move) Key() string { return moveKey(m.Dir, m.Tiles) }
+74
View File
@@ -0,0 +1,74 @@
// Package scrabble is the public library: it builds a move generator over a dictionary
// and a ruleset, generates every legal play for a position ranked by score, and scores
// or validates arbitrary plays. The generator is the DAWG algorithm (Appel-Jacobson).
package scrabble
// Direction is the orientation of a play's main word.
type Direction uint8
const (
// Horizontal is an across play (left to right along a row).
Horizontal Direction = iota
// Vertical is a down play (top to bottom along a column).
Vertical
)
// String renders the direction for diagnostics.
func (d Direction) String() string {
if d == Vertical {
return "vertical"
}
return "horizontal"
}
// Mode selects which orientations GenerateMoves produces. Russian "Эрудит" requires a
// single orientation per turn, which OnlyHorizontal / OnlyVertical express.
type Mode uint8
const (
// Both generates across plays (on the board) and down plays (on its transpose).
Both Mode = iota
// OnlyHorizontal generates across plays only.
OnlyHorizontal
// OnlyVertical generates down plays only.
OnlyVertical
)
// Includes reports whether the mode produces plays in direction d.
func (m Mode) Includes(d Direction) bool {
switch m {
case Both:
return true
case OnlyHorizontal:
return d == Horizontal
case OnlyVertical:
return d == Vertical
}
return false
}
// Placement is a single newly-placed tile.
type Placement struct {
Row, Col int
Letter byte // alphabet letter index
Blank bool // placed from a blank tile, so it scores 0
}
// Word is a word formed by a play, with its location and score.
type Word struct {
Row, Col int // square of the word's first letter
Dir Direction // orientation of the word
Letters []byte // alphabet indices of the whole word (existing + new tiles)
Blanks []bool // per letter: true if that tile is a blank (scores 0)
Score int // the word's score, with premiums from newly-placed tiles
}
// Move is a complete legal play with a full scoring breakdown.
type Move struct {
Dir Direction // orientation of the main word
Tiles []Placement // the newly-placed tiles, in main-word order
Main Word // the main word formed along Dir
Cross []Word // perpendicular words formed by the new tiles
Bonus int // all-tiles (bingo) bonus included in Score, or 0
Score int // total: Main.Score + Σ Cross.Score + Bonus
}
+147
View File
@@ -0,0 +1,147 @@
package scrabble
import (
"scrabble-solver/board"
"scrabble-solver/rack"
"scrabble-solver/rules"
)
// dict is a membership set of words (alphabet-index strings) for the oracle.
type dict map[string]bool
func makeDict(words [][]byte) dict {
d := make(dict, len(words))
for _, w := range words {
d[string(w)] = true
}
return d
}
func (d dict) has(letters []byte) bool { return d[string(letters)] }
func lineCoord(dir Direction, line, axis int) (r, c int) {
if dir == Horizontal {
return line, axis
}
return axis, line
}
func cellFilled(b *board.Board, dir Direction, line, axis int) bool {
r, c := lineCoord(dir, line, axis)
return b.Filled(r, c)
}
func coversCenter(dir Direction, line, start, end, cr, cc int) bool {
if dir == Horizontal {
return line == cr && start <= cc && cc <= end
}
return line == cc && start <= cr && cr <= end
}
// bruteForce returns every legal play for the position, keyed by moveKey, found by
// exhaustively trying every maximal window and every rack assignment, then validating
// against the dictionary, connectivity and the first-move centre rule. It is the slow,
// obviously-correct oracle for checking the generators on small inputs.
func bruteForce(b *board.Board, rs *rules.Ruleset, d dict, rk rack.Rack, mode Mode) map[string]Move {
out := map[string]Move{}
var dirs []Direction
if mode.Includes(Horizontal) {
dirs = append(dirs, Horizontal)
}
if mode.Includes(Vertical) {
dirs = append(dirs, Vertical)
}
firstMove := b.IsEmpty()
cr, cc := rs.Center/rs.Cols, rs.Center%rs.Cols
for _, dir := range dirs {
lines, span := b.Rows(), b.Cols()
if dir == Vertical {
lines, span = b.Cols(), b.Rows()
}
for line := range lines {
for start := range span {
for end := start + 1; end < span; end++ {
if cellFilled(b, dir, line, start-1) || cellFilled(b, dir, line, end+1) {
continue // not a maximal window
}
var empties []int
for a := start; a <= end; a++ {
if !cellFilled(b, dir, line, a) {
empties = append(empties, a)
}
}
if len(empties) == 0 {
continue
}
assign(b, rs, d, rk.Clone(), dir, line, start, end, empties, 0, nil,
firstMove, cr, cc, out)
}
}
}
}
return out
}
func assign(b *board.Board, rs *rules.Ruleset, d dict, rk rack.Rack, dir Direction,
line, start, end int, empties []int, idx int, placed []Placement,
firstMove bool, cr, cc int, out map[string]Move) {
if idx == len(empties) {
validate(b, rs, d, dir, line, start, end, placed, firstMove, cr, cc, out)
return
}
r, c := lineCoord(dir, line, empties[idx])
next := placed[:len(placed):len(placed)] // avoid aliasing across siblings
for l := byte(0); l < byte(rs.Size()); l++ {
if rk.Has(l) {
rk.Remove(l)
assign(b, rs, d, rk, dir, line, start, end, empties, idx+1,
append(next, Placement{Row: r, Col: c, Letter: l}), firstMove, cr, cc, out)
rk.Add(l)
}
}
if rk.Blanks() > 0 {
rk.RemoveBlank()
for l := byte(0); l < byte(rs.Size()); l++ {
assign(b, rs, d, rk, dir, line, start, end, empties, idx+1,
append(next, Placement{Row: r, Col: c, Letter: l, Blank: true}), firstMove, cr, cc, out)
}
rk.AddBlank()
}
}
func validate(b *board.Board, rs *rules.Ruleset, d dict, dir Direction,
line, start, end int, placed []Placement, firstMove bool, cr, cc int, out map[string]Move) {
m, err := Evaluate(b, rs, dir, placed)
if err != nil {
return
}
if !d.has(m.Main.Letters) {
return
}
for _, cw := range m.Cross {
if !d.has(cw.Letters) {
return
}
}
if firstMove {
if !coversCenter(dir, line, start, end, cr, cc) {
return
}
} else {
existing := false
for a := start; a <= end; a++ {
if cellFilled(b, dir, line, a) {
existing = true
break
}
}
if !existing && len(m.Cross) == 0 {
return // disconnected
}
}
out[moveKey(dir, placed)] = m
}
+206
View File
@@ -0,0 +1,206 @@
package scrabble
import (
"errors"
"fmt"
"sort"
"scrabble-solver/board"
"scrabble-solver/internal/encoding"
"scrabble-solver/rules"
)
// coord maps a line coordinate (fixed, axis) to a board (row, col) for direction dir.
// For Horizontal the fixed coordinate is the row and the axis runs along columns; for
// Vertical it is the reverse.
func coord(dir Direction, fixed, axis int) (row, col int) {
if dir == Horizontal {
return fixed, axis
}
return axis, fixed
}
// fixedAxis is the inverse of coord: it splits a (row, col) into the fixed and axis
// coordinates for direction dir.
func fixedAxis(dir Direction, row, col int) (fixed, axis int) {
if dir == Horizontal {
return row, col
}
return col, row
}
func perpendicular(d Direction) Direction {
if d == Horizontal {
return Vertical
}
return Horizontal
}
// Evaluate computes the words formed and the score for placing tiles on b in direction
// dir under ruleset rs. It validates geometry — the tiles lie on one line, on empty
// squares, and form a single contiguous run together with existing tiles — but does not
// check the dictionary or board connectivity; ValidatePlay layers those on top. tiles
// need not be sorted.
func Evaluate(b *board.Board, rs *rules.Ruleset, dir Direction, tiles []Placement) (Move, error) {
if len(tiles) == 0 {
return Move{}, errors.New("scrabble: empty play")
}
ts := append([]Placement(nil), tiles...)
sort.Slice(ts, func(i, j int) bool {
_, ai := fixedAxis(dir, ts[i].Row, ts[i].Col)
_, aj := fixedAxis(dir, ts[j].Row, ts[j].Col)
return ai < aj
})
fixed, _ := fixedAxis(dir, ts[0].Row, ts[0].Col)
prevAxis := 0
for i, t := range ts {
f, a := fixedAxis(dir, t.Row, t.Col)
if f != fixed {
return Move{}, errors.New("scrabble: tiles are not on one line")
}
if !b.InBounds(t.Row, t.Col) {
return Move{}, fmt.Errorf("scrabble: tile (%d,%d) off board", t.Row, t.Col)
}
if !b.Empty(t.Row, t.Col) {
return Move{}, fmt.Errorf("scrabble: square (%d,%d) is occupied", t.Row, t.Col)
}
if i > 0 && a == prevAxis {
return Move{}, errors.New("scrabble: two tiles on the same square")
}
prevAxis = a
}
main, err := buildMainWord(b, rs, dir, fixed, ts)
if err != nil {
return Move{}, err
}
move := Move{Dir: dir, Tiles: ts, Main: main, Score: main.Score}
for _, t := range ts {
if cw, ok := crossWord(b, rs, dir, t); ok {
move.Cross = append(move.Cross, cw)
move.Score += cw.Score
}
}
if len(ts) == rs.RackSize {
move.Bonus = rs.Bingo
move.Score += rs.Bingo
}
return move, nil
}
// buildMainWord assembles the word along dir through the (sorted) placements together
// with the existing tiles that extend and bridge them, and scores it. New tiles apply
// their squares' premiums; existing tiles score at face value.
func buildMainWord(b *board.Board, rs *rules.Ruleset, dir Direction, fixed int, ts []Placement) (Word, error) {
_, minA := fixedAxis(dir, ts[0].Row, ts[0].Col)
_, maxA := fixedAxis(dir, ts[len(ts)-1].Row, ts[len(ts)-1].Col)
start := minA
for {
r, c := coord(dir, fixed, start-1)
if !b.Filled(r, c) {
break
}
start--
}
end := maxA
for {
r, c := coord(dir, fixed, end+1)
if !b.Filled(r, c) {
break
}
end++
}
letters := make([]byte, 0, end-start+1)
blanks := make([]bool, 0, end-start+1)
letterSum, wordMult := 0, 1
ti := 0
for a := start; a <= end; a++ {
r, c := coord(dir, fixed, a)
if ti < len(ts) {
if _, ta := fixedAxis(dir, ts[ti].Row, ts[ti].Col); ta == a {
t := ts[ti]
ti++
prem := rs.Premium(r, c)
if !t.Blank {
letterSum += rs.Values[t.Letter] * prem.LetterMult()
}
wordMult *= prem.WordMult()
letters = append(letters, t.Letter)
blanks = append(blanks, t.Blank)
continue
}
}
if b.Filled(r, c) {
cell := b.At(r, c)
l, bl := encoding.Letter(cell), encoding.IsBlank(cell)
if !bl {
letterSum += rs.Values[l]
}
letters = append(letters, l)
blanks = append(blanks, bl)
continue
}
return Word{}, fmt.Errorf("scrabble: gap in the play at line position %d", a)
}
wr, wc := coord(dir, fixed, start)
return Word{Row: wr, Col: wc, Dir: dir, Letters: letters, Blanks: blanks, Score: letterSum * wordMult}, nil
}
// crossWord builds the perpendicular word formed by a single new tile, if any. It
// returns ok=false when the tile has no perpendicular neighbour.
func crossWord(b *board.Board, rs *rules.Ruleset, dir Direction, t Placement) (Word, bool) {
cdir := perpendicular(dir)
fixed, axis := fixedAxis(cdir, t.Row, t.Col)
start := axis
for {
r, c := coord(cdir, fixed, start-1)
if !b.Filled(r, c) {
break
}
start--
}
end := axis
for {
r, c := coord(cdir, fixed, end+1)
if !b.Filled(r, c) {
break
}
end++
}
if start == end {
return Word{}, false
}
letters := make([]byte, 0, end-start+1)
blanks := make([]bool, 0, end-start+1)
letterSum, wordMult := 0, 1
for a := start; a <= end; a++ {
r, c := coord(cdir, fixed, a)
if a == axis {
prem := rs.Premium(r, c)
if !t.Blank {
letterSum += rs.Values[t.Letter] * prem.LetterMult()
}
wordMult *= prem.WordMult()
letters = append(letters, t.Letter)
blanks = append(blanks, t.Blank)
} else {
cell := b.At(r, c)
l, bl := encoding.Letter(cell), encoding.IsBlank(cell)
if !bl {
letterSum += rs.Values[l]
}
letters = append(letters, l)
blanks = append(blanks, bl)
}
}
wr, wc := coord(cdir, fixed, start)
return Word{Row: wr, Col: wc, Dir: cdir, Letters: letters, Blanks: blanks, Score: letterSum * wordMult}, true
}
+138
View File
@@ -0,0 +1,138 @@
package scrabble
import (
"testing"
"scrabble-solver/board"
"scrabble-solver/internal/encoding"
"scrabble-solver/rules"
)
const plain7 = `.......
.......
.......
...+...
.......
.......
.......`
// plainRules is a 7x7 board with no premiums and English tile values, for isolating
// word-assembly and cross-word logic from premium multipliers.
func plainRules(t *testing.T) *rules.Ruleset {
t.Helper()
eng := rules.English()
rs, err := rules.FromTemplate("plain7", eng.Alphabet, eng.Values, eng.Counts, 2, 7, 50, plain7)
if err != nil {
t.Fatal(err)
}
return rs
}
// indices: a=0 c=2 o=14 t=19 x=23
func TestEvaluateSimpleWord(t *testing.T) {
rs := plainRules(t)
b := board.New(7, 7)
m, err := Evaluate(b, rs, Horizontal, []Placement{
{Row: 3, Col: 1, Letter: 2}, {Row: 3, Col: 2, Letter: 0}, {Row: 3, Col: 3, Letter: 19},
})
if err != nil {
t.Fatal(err)
}
if m.Main.Score != 5 || m.Score != 5 {
t.Errorf("cat: main=%d total=%d, want 5/5", m.Main.Score, m.Score)
}
if len(m.Cross) != 0 || m.Bonus != 0 {
t.Errorf("cat: cross=%d bonus=%d, want 0/0", len(m.Cross), m.Bonus)
}
}
func TestEvaluateCrossWord(t *testing.T) {
rs := plainRules(t)
b := board.New(7, 7)
b.Set(2, 3, encoding.Cell(14, false)) // o
b.Set(3, 3, encoding.Cell(23, false)) // x
// Play "at" horizontally on row 4; the 'a' on col 3 forms the cross word "oxa".
m, err := Evaluate(b, rs, Horizontal, []Placement{
{Row: 4, Col: 3, Letter: 0}, {Row: 4, Col: 4, Letter: 19},
})
if err != nil {
t.Fatal(err)
}
if m.Main.Score != 2 {
t.Errorf("main 'at' = %d, want 2", m.Main.Score)
}
if len(m.Cross) != 1 || m.Cross[0].Score != 10 {
t.Errorf("cross = %+v, want one word scoring 10 (oxa)", m.Cross)
}
if m.Score != 12 {
t.Errorf("total = %d, want 12", m.Score)
}
}
func TestEvaluatePremiums(t *testing.T) {
rs := rules.English()
// (0,3) is a double-letter square: c(3)*2 + a(1) + t(1) = 8.
b := board.New(15, 15)
m, err := Evaluate(b, rs, Horizontal, []Placement{
{Row: 0, Col: 3, Letter: 2}, {Row: 0, Col: 4, Letter: 0}, {Row: 0, Col: 5, Letter: 19},
})
if err != nil {
t.Fatal(err)
}
if m.Score != 8 {
t.Errorf("DL cat = %d, want 8", m.Score)
}
// (1,1) is a double-word square: (c(3) + a(1)) * 2 = 8.
b2 := board.New(15, 15)
m2, err := Evaluate(b2, rs, Horizontal, []Placement{
{Row: 1, Col: 1, Letter: 2}, {Row: 1, Col: 2, Letter: 0},
})
if err != nil {
t.Fatal(err)
}
if m2.Score != 8 {
t.Errorf("DW ca = %d, want 8", m2.Score)
}
}
func TestEvaluateBingo(t *testing.T) {
rs := plainRules(t)
b := board.New(7, 7)
tiles := make([]Placement, 7)
for c := range 7 {
tiles[c] = Placement{Row: 0, Col: c, Letter: 0} // seven a's
}
m, err := Evaluate(b, rs, Horizontal, tiles)
if err != nil {
t.Fatal(err)
}
if m.Bonus != 50 || m.Score != 7+50 {
t.Errorf("bingo: bonus=%d total=%d, want 50/57", m.Bonus, m.Score)
}
}
func TestEvaluateErrors(t *testing.T) {
rs := plainRules(t)
b := board.New(7, 7)
b.Set(2, 3, encoding.Cell(14, false))
if _, err := Evaluate(b, rs, Horizontal, nil); err == nil {
t.Error("empty play: want error")
}
if _, err := Evaluate(b, rs, Horizontal, []Placement{{Row: 2, Col: 3, Letter: 0}}); err == nil {
t.Error("occupied square: want error")
}
if _, err := Evaluate(b, rs, Horizontal, []Placement{
{Row: 3, Col: 1, Letter: 0}, {Row: 4, Col: 2, Letter: 0},
}); err == nil {
t.Error("non-collinear: want error")
}
if _, err := Evaluate(b, rs, Horizontal, []Placement{
{Row: 5, Col: 1, Letter: 0}, {Row: 5, Col: 3, Letter: 0},
}); err == nil {
t.Error("gap: want error")
}
}
+101
View File
@@ -0,0 +1,101 @@
package scrabble
import (
"errors"
"fmt"
"sort"
dawg "github.com/iliadenisov/dafsa"
"scrabble-solver/board"
"scrabble-solver/rack"
"scrabble-solver/rules"
)
// Solver is the high-level entry point: it generates ranked plays and scores or
// validates arbitrary plays for a ruleset over a dictionary.
type Solver struct {
rules *rules.Ruleset
finder dawg.Finder
gen *DAWGGenerator
}
// NewSolver returns a Solver for the ruleset over the dictionary finder.
func NewSolver(rs *rules.Ruleset, finder dawg.Finder) *Solver {
return &Solver{rules: rs, finder: finder, gen: NewDAWGGenerator(rs, finder)}
}
// Rules returns the solver's ruleset.
func (s *Solver) Rules() *rules.Ruleset { return s.rules }
// GenerateMoves returns every legal play for rack r on board b in the requested
// orientations, ranked by descending score (ties broken deterministically by the move's
// canonical key).
func (s *Solver) GenerateMoves(b *board.Board, r rack.Rack, mode Mode) []Move {
moves := s.gen.GenerateMoves(b, r, mode)
sort.Slice(moves, func(i, j int) bool {
if moves[i].Score != moves[j].Score {
return moves[i].Score > moves[j].Score
}
return moves[i].Key() < moves[j].Key()
})
return moves
}
// ScorePlay computes the words and score for placing tiles on b in direction dir. It
// checks geometry only (see Evaluate); use ValidatePlay to also check the dictionary and
// connectivity.
func (s *Solver) ScorePlay(b *board.Board, dir Direction, tiles []Placement) (Move, error) {
return Evaluate(b, s.rules, dir, tiles)
}
// ValidatePlay scores a play and verifies that every word it forms is in the dictionary
// and that it connects to the board (or covers the centre on the first move). It returns
// the scored move; the error is nil exactly when the play is legal.
func (s *Solver) ValidatePlay(b *board.Board, dir Direction, tiles []Placement) (Move, error) {
m, err := Evaluate(b, s.rules, dir, tiles)
if err != nil {
return Move{}, err
}
if len(m.Main.Letters) < 2 {
return m, errors.New("scrabble: play forms no word of length 2 or more")
}
if s.finder.IndexOfB(m.Main.Letters) < 0 {
return m, fmt.Errorf("scrabble: main word is not in the dictionary")
}
for _, cw := range m.Cross {
if s.finder.IndexOfB(cw.Letters) < 0 {
return m, fmt.Errorf("scrabble: a cross word is not in the dictionary")
}
}
if !s.connected(b, m) {
return m, errors.New("scrabble: play does not connect to the board")
}
return m, nil
}
// connected reports whether the play touches the existing position (or covers the centre
// on the first move).
func (s *Solver) connected(b *board.Board, m Move) bool {
if b.IsEmpty() {
cr, cc := s.rules.Center/s.rules.Cols, s.rules.Center%s.rules.Cols
return wordCovers(m.Main, cr, cc)
}
// The main word incorporated an existing tile, or a new tile formed a cross word.
return len(m.Main.Letters) > len(m.Tiles) || len(m.Cross) > 0
}
func wordCovers(w Word, r, c int) bool {
for i := range w.Letters {
rr, cc := w.Row, w.Col
if w.Dir == Horizontal {
cc += i
} else {
rr += i
}
if rr == r && cc == c {
return true
}
}
return false
}
+88
View File
@@ -0,0 +1,88 @@
package scrabble
import (
"testing"
"github.com/iliadenisov/alphabet"
"scrabble-solver/board"
"scrabble-solver/internal/dictdawg"
"scrabble-solver/internal/wordlist"
)
func newTestSolver(t *testing.T) *Solver {
t.Helper()
words := wordlist.Encode(testWords, alphabet.Latin(), 2, 15)
f, err := dictdawg.Build(alphabet.Latin(), words)
if err != nil {
t.Fatal(err)
}
return NewSolver(plainRulesShared, f)
}
func TestSolverGenerateMovesRanked(t *testing.T) {
s := newTestSolver(t)
b := board.New(s.rules.Rows, s.rules.Cols)
moves := s.GenerateMoves(b, makeRack("cat", 0), Both)
if len(moves) == 0 {
t.Fatal("no first moves generated")
}
for i := 1; i < len(moves); i++ {
if moves[i-1].Score < moves[i].Score {
t.Fatalf("moves not ranked: %d before %d", moves[i-1].Score, moves[i].Score)
}
}
}
func TestSolverValidatePlay(t *testing.T) {
s := newTestSolver(t)
// indices: c=2 a=0 t=19 z=25
cat := []Placement{{Row: 3, Col: 2, Letter: 2}, {Row: 3, Col: 3, Letter: 0}, {Row: 3, Col: 4, Letter: 19}}
// First move through the centre (3,3) is legal.
if _, err := s.ValidatePlay(board.New(s.rules.Rows, s.rules.Cols), Horizontal, cat); err != nil {
t.Errorf("valid first move rejected: %v", err)
}
// First move that misses the centre is rejected.
off := []Placement{{Row: 0, Col: 0, Letter: 2}, {Row: 0, Col: 1, Letter: 0}, {Row: 0, Col: 2, Letter: 19}}
if _, err := s.ValidatePlay(board.New(s.rules.Rows, s.rules.Cols), Horizontal, off); err == nil {
t.Error("first move off the centre was accepted")
}
// A non-word ("caz") is rejected.
caz := []Placement{{Row: 3, Col: 2, Letter: 2}, {Row: 3, Col: 3, Letter: 0}, {Row: 3, Col: 4, Letter: 25}}
if _, err := s.ValidatePlay(board.New(s.rules.Rows, s.rules.Cols), Horizontal, caz); err == nil {
t.Error("non-word 'caz' was accepted")
}
// A disconnected play on a non-empty board is rejected.
b := board.New(s.rules.Rows, s.rules.Cols)
placeWord(b, 3, 2, Horizontal, "cat")
disc := []Placement{{Row: 0, Col: 0, Letter: 0}, {Row: 0, Col: 1, Letter: 18}} // "as" far away
if _, err := s.ValidatePlay(b, Horizontal, disc); err == nil {
t.Error("disconnected play was accepted")
}
// Extending "cat" to "cats" connects and is a word.
cats := []Placement{{Row: 3, Col: 5, Letter: 18}} // s after cat
if m, err := s.ValidatePlay(b, Horizontal, cats); err != nil {
t.Errorf("valid extension rejected: %v", err)
} else if string(m.Main.Letters) != string([]byte{2, 0, 19, 18}) {
t.Errorf("main word = %v, want cats", m.Main.Letters)
}
}
func TestSolverScorePlay(t *testing.T) {
s := newTestSolver(t)
b := board.New(s.rules.Rows, s.rules.Cols)
m, err := s.ScorePlay(b, Horizontal, []Placement{
{Row: 3, Col: 2, Letter: 2}, {Row: 3, Col: 3, Letter: 0}, {Row: 3, Col: 4, Letter: 19},
})
if err != nil {
t.Fatal(err)
}
if m.Score != 5 { // c3 a1 t1, no premiums on the plain board
t.Errorf("cat score = %d, want 5", m.Score)
}
}