15c7959d96
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.
222 lines
7.3 KiB
Go
222 lines
7.3 KiB
Go
// 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
|
||
}
|