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