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
+61
View File
@@ -0,0 +1,61 @@
package rules
import "testing"
func sumCounts(c []int) int {
s := 0
for _, v := range c {
s += v
}
return s
}
func TestRussianScrabble(t *testing.T) {
rs := RussianScrabble()
if err := rs.Validate(); err != nil {
t.Fatal(err)
}
if rs.Size() != 33 {
t.Errorf("alphabet size %d, want 33", rs.Size())
}
if n := sumCounts(rs.Counts); n != 102 || n+rs.Blanks != 104 {
t.Errorf("bag = %d letters + %d blanks, want 102+2=104", n, rs.Blanks)
}
if rs.Bingo != 50 {
t.Errorf("bonus %d, want 50", rs.Bingo)
}
if rs.Premium(7, 7) != DW {
t.Errorf("centre premium %d, want DW", rs.Premium(7, 7))
}
if rs.Values[6] != 3 || rs.Counts[6] != 1 { // ё
t.Errorf("ё = value %d count %d, want 3/1", rs.Values[6], rs.Counts[6])
}
}
func TestErudit(t *testing.T) {
rs := Erudit()
if err := rs.Validate(); err != nil {
t.Fatal(err)
}
if rs.Size() != 33 {
t.Errorf("alphabet size %d, want 33", rs.Size())
}
if n := sumCounts(rs.Counts); n != 128 || n+rs.Blanks != 131 {
t.Errorf("bag = %d letters + %d blanks, want 128+3=131", n, rs.Blanks)
}
if rs.Bingo != 15 {
t.Errorf("bonus %d, want 15", rs.Bingo)
}
if rs.Center != 7*15+7 {
t.Errorf("centre index %d, want %d", rs.Center, 7*15+7)
}
if rs.Premium(7, 7) != None {
t.Errorf("centre premium %d, want None (Эрудит centre does not double)", rs.Premium(7, 7))
}
if rs.Counts[6] != 0 { // ё has no tile
t.Errorf("ё count %d, want 0", rs.Counts[6])
}
if rs.Premium(0, 0) != TW {
t.Errorf("corner premium %d, want TW (board otherwise standard)", rs.Premium(0, 0))
}
}
+221
View File
@@ -0,0 +1,221 @@
// Package rules describes a Scrabble variant: board geometry, premium-square layout,
// the letter alphabet, per-letter tile values and bag counts, blanks, rack size and
// the all-tiles bonus. English() returns standard English Scrabble; the Ruleset type
// is general enough for other variants such as Russian "Эрудит" (same board, different
// tile values/counts and alphabet).
package rules
import (
"fmt"
"strings"
"github.com/iliadenisov/alphabet"
)
// Premium is the bonus kind of a board square.
type Premium uint8
const (
None Premium = iota
DL // double letter
TL // triple letter
DW // double word
TW // triple word
)
// LetterMult is the multiplier a premium applies to the tile placed on it.
func (p Premium) LetterMult() int {
switch p {
case DL:
return 2
case TL:
return 3
default:
return 1
}
}
// WordMult is the multiplier a premium applies to a word passing through it.
func (p Premium) WordMult() int {
switch p {
case DW:
return 2
case TW:
return 3
default:
return 1
}
}
// Ruleset is a complete description of a Scrabble variant.
type Ruleset struct {
Name string
Rows, Cols int
Alphabet alphabet.Indexer // letter alphabet (no separator)
Values []int // tile value per letter index; len == Alphabet.Size()
Counts []int // bag count per letter index; len == Alphabet.Size()
Blanks int // number of blank tiles in the bag
RackSize int // tiles drawn to a full rack
Bingo int // bonus for using the whole rack in one play
Center int // row-major index of the centre square (first-move anchor)
premiums []Premium // row-major premium per square
}
// Premium returns the premium of square (r, c).
func (rs *Ruleset) Premium(r, c int) Premium { return rs.premiums[r*rs.Cols+c] }
// PremiumAt returns the premium of the row-major square index i.
func (rs *Ruleset) PremiumAt(i int) Premium { return rs.premiums[i] }
// Size returns the number of letters in the alphabet (excluding blanks).
func (rs *Ruleset) Size() int { return rs.Alphabet.Size() }
// Validate checks that the slices are consistent with the alphabet and board.
func (rs *Ruleset) Validate() error {
n := rs.Alphabet.Size()
if len(rs.Values) != n {
return fmt.Errorf("rules %q: %d values for %d letters", rs.Name, len(rs.Values), n)
}
if len(rs.Counts) != n {
return fmt.Errorf("rules %q: %d counts for %d letters", rs.Name, len(rs.Counts), n)
}
if len(rs.premiums) != rs.Rows*rs.Cols {
return fmt.Errorf("rules %q: %d premiums for a %dx%d board", rs.Name, len(rs.premiums), rs.Rows, rs.Cols)
}
if rs.Center < 0 || rs.Center >= rs.Rows*rs.Cols {
return fmt.Errorf("rules %q: centre %d out of range", rs.Name, rs.Center)
}
return nil
}
// standardBoard is the classic 15x15 premium layout: T=triple word, D=double word,
// t=triple letter, d=double letter, .=plain, *=centre (a double word).
const standardBoard = `T..d...T...d..T
.D...t...t...D.
..D...d.d...D..
d..D...d...D..d
....D.....D....
.t...t...t...t.
..d...d.d...d..
T..d...*...d..T
..d...d.d...d..
.t...t...t...t.
....D.....D....
d..D...d...D..d
..D...d.d...D..
.D...t...t...D.
T..d...T...d..T`
// parsePremiums turns a board template into a premium grid and the centre index.
func parsePremiums(s string) (rows, cols int, prem []Premium, center int) {
lines := strings.Split(strings.TrimSpace(s), "\n")
rows = len(lines)
cols = len(strings.TrimRight(lines[0], "\r"))
prem = make([]Premium, rows*cols)
center = -1
for r, line := range lines {
line = strings.TrimRight(line, "\r")
for c := 0; c < cols && c < len(line); c++ {
var p Premium
switch line[c] {
case 'd':
p = DL
case 't':
p = TL
case 'D':
p = DW
case 'T':
p = TW
case '*': // centre square, a double word
p = DW
center = r*cols + c
case '+': // centre square with no premium
center = r*cols + c
}
prem[r*cols+c] = p
}
}
return rows, cols, prem, center
}
// FromTemplate builds a ruleset from a premium-layout template (see standardBoard for
// the character legend; '+' marks a centre square with no premium). It returns an error
// if the resulting ruleset is inconsistent.
func FromTemplate(name string, idx alphabet.Indexer, values, counts []int, blanks, rackSize, bingo int, template string) (*Ruleset, error) {
rows, cols, prem, center := parsePremiums(template)
rs := &Ruleset{
Name: name, Rows: rows, Cols: cols, Alphabet: idx,
Values: values, Counts: counts,
Blanks: blanks, RackSize: rackSize, Bingo: bingo,
Center: center, premiums: prem,
}
if err := rs.Validate(); err != nil {
return nil, err
}
return rs, nil
}
// English returns the standard English Scrabble ruleset (15x15, the classic premium
// layout, English tile values and distribution, 2 blanks, a 7-tile rack and a 50-point
// bingo bonus).
func English() *Ruleset {
rs, err := FromTemplate("English Scrabble", alphabet.Latin(),
// a b c d e f g h i j k l m n o p q r s t u v w x y z
[]int{1, 3, 3, 2, 1, 4, 2, 4, 1, 8, 5, 1, 3, 1, 1, 3, 10, 1, 1, 1, 1, 4, 4, 8, 4, 10},
[]int{9, 2, 2, 4, 12, 2, 3, 2, 9, 1, 1, 4, 2, 6, 8, 2, 1, 6, 4, 6, 4, 2, 2, 1, 2, 1},
2, 7, 50, standardBoard)
if err != nil {
panic(err) // a programming error in this package, not a runtime condition
}
return rs
}
// eruditBoard is the standard 15x15 layout but with a non-doubling centre ('+'), as in
// the Russian "Эрудит" variant.
const eruditBoard = `T..d...T...d..T
.D...t...t...D.
..D...d.d...D..
d..D...d...D..d
....D.....D....
.t...t...t...t.
..d...d.d...d..
T..d...+...d..T
..d...d.d...d..
.t...t...t...t.
....D.....D....
d..D...d...D..d
..D...d.d...D..
.D...t...t...D.
T..d...T...d..T`
// russian returns the embedded 33-letter Russian alphabet (а..я including ё at index 6).
func russian() alphabet.Indexer { return alphabet.Embedded(alphabet.Langs.LangRu) }
// RussianScrabble returns the Russian Scrabble ruleset: the 33-letter alphabet, the
// standard board, 2 blanks, a 7-tile rack and a 50-point bonus.
func RussianScrabble() *Ruleset {
rs, err := FromTemplate("Russian Scrabble", russian(),
// а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я
[]int{1, 3, 1, 3, 2, 1, 3, 5, 5, 1, 4, 2, 2, 2, 1, 1, 2, 1, 1, 1, 2, 10, 5, 5, 5, 8, 10, 10, 4, 3, 8, 8, 3},
[]int{8, 2, 4, 2, 4, 8, 1, 1, 2, 5, 1, 4, 4, 3, 5, 10, 4, 5, 5, 5, 4, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 2},
2, 7, 50, standardBoard)
if err != nil {
panic(err)
}
return rs
}
// Erudit returns the Russian "Эрудит" ruleset. Ё carries no tiles (count 0); fold Ё→Е
// when preparing the dictionary (see wordlist.FoldYo). The centre square does not double
// the word, there are 3 blanks (each scoring 0), and the all-tiles bonus is 15.
func Erudit() *Ruleset {
rs, err := FromTemplate("Эрудит", russian(),
// а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я
[]int{1, 3, 2, 3, 2, 1, 0, 5, 5, 1, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 3, 10, 5, 10, 5, 10, 10, 10, 5, 5, 10, 10, 3},
[]int{10, 3, 5, 3, 5, 9, 0, 2, 2, 8, 4, 6, 4, 5, 8, 10, 6, 6, 6, 5, 3, 1, 2, 1, 2, 1, 1, 1, 2, 2, 1, 1, 3},
3, 7, 15, eruditBoard)
if err != nil {
panic(err)
}
return rs
}
+82
View File
@@ -0,0 +1,82 @@
package rules
import "testing"
func TestEnglishConsistency(t *testing.T) {
rs := English()
if err := rs.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
if rs.Rows != 15 || rs.Cols != 15 {
t.Errorf("board = %dx%d, want 15x15", rs.Rows, rs.Cols)
}
if rs.Size() != 26 {
t.Errorf("alphabet size = %d, want 26", rs.Size())
}
if rs.Center != 7*15+7 {
t.Errorf("centre = %d, want %d", rs.Center, 7*15+7)
}
letters := 0
for _, c := range rs.Counts {
letters += c
}
if letters != 98 {
t.Errorf("sum(Counts) = %d, want 98", letters)
}
if rs.Blanks != 2 || letters+rs.Blanks != 100 {
t.Errorf("bag = %d letters + %d blanks, want 98+2=100", letters, rs.Blanks)
}
points := 0
for i := range rs.Values {
points += rs.Values[i] * rs.Counts[i]
}
if points != 187 {
t.Errorf("total bag points = %d, want 187", points)
}
}
func TestEnglishPremiums(t *testing.T) {
rs := English()
spot := []struct {
r, c int
want Premium
}{
{0, 0, TW}, {0, 7, TW}, {0, 14, TW}, {7, 0, TW}, {14, 7, TW},
{7, 7, DW}, {1, 1, DW}, {4, 4, DW},
{1, 5, TL}, {5, 5, TL}, {9, 9, TL},
{0, 3, DL}, {3, 0, DL}, {6, 6, DL},
{0, 1, None}, {7, 1, None},
}
for _, s := range spot {
if got := rs.Premium(s.r, s.c); got != s.want {
t.Errorf("Premium(%d,%d) = %d, want %d", s.r, s.c, got, s.want)
}
}
// Census of premium squares for the standard board.
census := map[Premium]int{}
for i := range rs.Rows * rs.Cols {
census[rs.PremiumAt(i)]++
}
want := map[Premium]int{None: 164, DL: 24, TL: 12, DW: 17, TW: 8}
for p, n := range want {
if census[p] != n {
t.Errorf("premium %d count = %d, want %d", p, census[p], n)
}
}
// The standard board is symmetric under transpose and 180° rotation.
for r := range rs.Rows {
for c := range rs.Cols {
if rs.Premium(r, c) != rs.Premium(c, r) {
t.Errorf("not transpose-symmetric at (%d,%d)", r, c)
}
if rs.Premium(r, c) != rs.Premium(rs.Rows-1-r, rs.Cols-1-c) {
t.Errorf("not 180°-symmetric at (%d,%d)", r, c)
}
}
}
}