Files
scrabble-solver/rules/rules.go
T
Ilia Denisov 15c7959d96 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.
2026-06-01 16:07:32 +02:00

222 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}