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
+105
View File
@@ -0,0 +1,105 @@
// Package board holds the compact game board: a row-major grid of cell bytes encoded
// per internal/encoding (0 = empty, letter+1, with 0x80 marking a blank). It is
// otherwise alphabet-agnostic.
package board
import (
"fmt"
"unicode"
"github.com/iliadenisov/alphabet"
"scrabble-solver/internal/encoding"
)
// Board is a row-major grid of encoded cells.
type Board struct {
rows, cols int
cells []byte
}
// New returns an empty rows×cols board.
func New(rows, cols int) *Board {
return &Board{rows: rows, cols: cols, cells: make([]byte, rows*cols)}
}
// Rows returns the number of rows.
func (b *Board) Rows() int { return b.rows }
// Cols returns the number of columns.
func (b *Board) Cols() int { return b.cols }
// At returns the encoded cell at (r, c).
func (b *Board) At(r, c int) byte { return b.cells[r*b.cols+c] }
// Set stores the encoded cell v at (r, c).
func (b *Board) Set(r, c int, v byte) { b.cells[r*b.cols+c] = v }
// InBounds reports whether (r, c) is on the board.
func (b *Board) InBounds(r, c int) bool {
return r >= 0 && r < b.rows && c >= 0 && c < b.cols
}
// Empty reports whether (r, c) is an empty square.
func (b *Board) Empty(r, c int) bool { return encoding.IsEmpty(b.cells[r*b.cols+c]) }
// Filled reports whether (r, c) is on the board and occupied.
func (b *Board) Filled(r, c int) bool {
return b.InBounds(r, c) && !encoding.IsEmpty(b.cells[r*b.cols+c])
}
// IsEmpty reports whether the whole board is empty (used for the first move).
func (b *Board) IsEmpty() bool {
for _, c := range b.cells {
if !encoding.IsEmpty(c) {
return false
}
}
return true
}
// Clone returns a deep copy of the board.
func (b *Board) Clone() *Board {
cp := &Board{rows: b.rows, cols: b.cols, cells: make([]byte, len(b.cells))}
copy(cp.cells, b.cells)
return cp
}
// Transpose returns a new board with rows and columns swapped, turning vertical lines
// into horizontal ones. Down-play generation runs on the transpose.
func (b *Board) Transpose() *Board {
t := &Board{rows: b.cols, cols: b.rows, cells: make([]byte, len(b.cells))}
for r := range b.rows {
for c := range b.cols {
t.cells[c*t.cols+r] = b.cells[r*b.cols+c]
}
}
return t
}
// Parse builds a board from text rows: '.' (or space) is an empty square, a lowercase
// letter is a normal tile, and an uppercase letter is a blank standing for that letter.
// Letters are resolved through idx.
func Parse(rows []string, idx alphabet.Indexer) (*Board, error) {
if len(rows) == 0 {
return nil, fmt.Errorf("board: no rows")
}
cols := len([]rune(rows[0]))
b := New(len(rows), cols)
for r, line := range rows {
runes := []rune(line)
for c := 0; c < cols && c < len(runes); c++ {
ch := runes[c]
if ch == '.' || ch == ' ' {
continue
}
blank := unicode.IsUpper(ch)
li, err := idx.Index(string(unicode.ToLower(ch)))
if err != nil {
return nil, fmt.Errorf("board: row %d col %d %q: %w", r, c, string(ch), err)
}
b.Set(r, c, encoding.Cell(li, blank))
}
}
return b, nil
}