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
+57
View File
@@ -0,0 +1,57 @@
// Package rack represents a player's rack as per-letter tile counts plus blanks.
package rack
// Rack holds tile counts: one slot per alphabet letter index plus a final slot for
// blanks. Like a Go slice or map, a Rack value shares its underlying storage with its
// copies; use Clone for an independent rack. The move generator mutates a single Rack
// in place (removing a tile, recursing, putting it back).
type Rack struct {
counts []int
}
// New returns an empty rack for an alphabet of the given size.
func New(alphabetSize int) Rack {
return Rack{counts: make([]int, alphabetSize+1)}
}
func (r Rack) blankIdx() int { return len(r.counts) - 1 }
// Count returns how many tiles of the given letter index are on the rack.
func (r Rack) Count(letter byte) int { return r.counts[letter] }
// Has reports whether at least one tile of the given letter index is on the rack.
func (r Rack) Has(letter byte) bool { return r.counts[letter] > 0 }
// Blanks returns the number of blank tiles on the rack.
func (r Rack) Blanks() int { return r.counts[r.blankIdx()] }
// Total returns the number of tiles on the rack, blanks included.
func (r Rack) Total() int {
n := 0
for _, c := range r.counts {
n += c
}
return n
}
// Empty reports whether the rack holds no tiles.
func (r Rack) Empty() bool { return r.Total() == 0 }
// Add puts a tile of the given letter index onto the rack.
func (r Rack) Add(letter byte) { r.counts[letter]++ }
// AddBlank puts a blank tile onto the rack.
func (r Rack) AddBlank() { r.counts[r.blankIdx()]++ }
// Remove takes one tile of the given letter index off the rack.
func (r Rack) Remove(letter byte) { r.counts[letter]-- }
// RemoveBlank takes one blank tile off the rack.
func (r Rack) RemoveBlank() { r.counts[r.blankIdx()]-- }
// Clone returns an independent copy of the rack.
func (r Rack) Clone() Rack {
c := make([]int, len(r.counts))
copy(c, r.counts)
return Rack{counts: c}
}
+51
View File
@@ -0,0 +1,51 @@
package rack
import "testing"
func TestRackBasics(t *testing.T) {
r := New(26)
if !r.Empty() || r.Total() != 0 {
t.Fatal("new rack not empty")
}
r.Add(0) // a
r.Add(0)
r.Add(2) // c
r.AddBlank()
if r.Count(0) != 2 {
t.Errorf("Count(a) = %d, want 2", r.Count(0))
}
if !r.Has(2) || r.Has(1) {
t.Errorf("Has c=%v b=%v, want true,false", r.Has(2), r.Has(1))
}
if r.Blanks() != 1 {
t.Errorf("Blanks = %d, want 1", r.Blanks())
}
if r.Total() != 4 {
t.Errorf("Total = %d, want 4", r.Total())
}
r.Remove(0)
if r.Count(0) != 1 {
t.Errorf("after Remove, Count(a) = %d, want 1", r.Count(0))
}
r.RemoveBlank()
if r.Blanks() != 0 {
t.Errorf("after RemoveBlank, Blanks = %d, want 0", r.Blanks())
}
}
func TestRackCloneIndependent(t *testing.T) {
r := New(26)
r.Add(0)
cp := r.Clone()
cp.Add(0)
cp.AddBlank()
if r.Count(0) != 1 || r.Blanks() != 0 {
t.Errorf("mutating clone changed original: a=%d blanks=%d", r.Count(0), r.Blanks())
}
if cp.Count(0) != 2 || cp.Blanks() != 1 {
t.Errorf("clone wrong: a=%d blanks=%d", cp.Count(0), cp.Blanks())
}
}