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:
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user