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
+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
}
}