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,14 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/internal/encoding"
|
||||
)
|
||||
|
||||
// Apply places a move's newly-placed tiles on the board. The move must be legal for the
|
||||
// board (as produced by a generator, or validated); Apply does not re-check it.
|
||||
func Apply(b *board.Board, m Move) {
|
||||
for _, t := range m.Tiles {
|
||||
b.Set(t.Row, t.Col, encoding.Cell(t.Letter, t.Blank))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/internal/encoding"
|
||||
)
|
||||
|
||||
// letterSet is a bit set over alphabet letter indexes (alphabets are at most 63
|
||||
// letters, so a uint64 suffices). It encodes a square's cross-set: the letters that,
|
||||
// placed on the square, form a legal perpendicular word.
|
||||
type letterSet uint64
|
||||
|
||||
func (s letterSet) has(l byte) bool { return s&(letterSet(1)<<l) != 0 }
|
||||
|
||||
// fullSet is the cross-set of a square with no perpendicular neighbours: every letter
|
||||
// is allowed.
|
||||
func fullSet(size int) letterSet { return letterSet(uint64(1)<<uint(size)) - 1 }
|
||||
|
||||
// columnContext returns the contiguous run of filled cells immediately above and below
|
||||
// the empty square (r, c), each read top to bottom, as alphabet letter indexes. These
|
||||
// are the tiles a perpendicular (vertical) word through (r, c) would include.
|
||||
func columnContext(b *board.Board, r, c int) (above, below []byte) {
|
||||
start := r
|
||||
for start-1 >= 0 && b.Filled(start-1, c) {
|
||||
start--
|
||||
}
|
||||
for rr := start; rr < r; rr++ {
|
||||
above = append(above, encoding.Letter(b.At(rr, c)))
|
||||
}
|
||||
|
||||
end := r
|
||||
for end+1 < b.Rows() && b.Filled(end+1, c) {
|
||||
end++
|
||||
}
|
||||
for rr := r + 1; rr <= end; rr++ {
|
||||
below = append(below, encoding.Letter(b.At(rr, c)))
|
||||
}
|
||||
return above, below
|
||||
}
|
||||
|
||||
// completers returns the letters X (< size) that complete a word when followed from
|
||||
// state: those whose arc leads directly to an accepting node. It is a single arc
|
||||
// enumeration — the deterministic cross-set primitive.
|
||||
func completers(cur *dawg.Cursor, state dawg.Node, size int) letterSet {
|
||||
var set letterSet
|
||||
lim := byte(size)
|
||||
cur.Arcs(state, func(a dawg.Arc) bool {
|
||||
if a.Final && a.Label < lim {
|
||||
set |= letterSet(1) << a.Label
|
||||
}
|
||||
return true
|
||||
})
|
||||
return set
|
||||
}
|
||||
|
||||
// walk follows word left to right from the cursor's root.
|
||||
func walk(cur *dawg.Cursor, word []byte) (dawg.Node, bool) {
|
||||
n := cur.Root()
|
||||
for _, l := range word {
|
||||
var ok bool
|
||||
if n, _, ok = cur.Next(n, l); !ok {
|
||||
return n, false
|
||||
}
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// dawgCrossSet returns the letters X for which above·X·below is a stored word. A right
|
||||
// extension (no tiles below) is deterministic — X just completes the prefix above. A
|
||||
// left extension (tiles below) is non-deterministic and must probe each X.
|
||||
func dawgCrossSet(cur *dawg.Cursor, above, below []byte, size int) letterSet {
|
||||
switch {
|
||||
case len(above) == 0 && len(below) == 0:
|
||||
return fullSet(size)
|
||||
case len(below) == 0:
|
||||
node, ok := walk(cur, above)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return completers(cur, node, size)
|
||||
default:
|
||||
node := cur.Root()
|
||||
if len(above) > 0 {
|
||||
var ok bool
|
||||
if node, ok = walk(cur, above); !ok {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
var set letterSet
|
||||
for x := range size {
|
||||
m, final, ok := cur.Next(node, byte(x))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, l := range below {
|
||||
if m, final, ok = cur.Next(m, l); !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok && final {
|
||||
set |= letterSet(1) << uint(x)
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/alphabet"
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
|
||||
"scrabble-solver/internal/dictdawg"
|
||||
"scrabble-solver/internal/wordlist"
|
||||
)
|
||||
|
||||
func bruteCrossSet(words [][]byte, above, below []byte, size int) letterSet {
|
||||
set := make(map[string]bool, len(words))
|
||||
for _, w := range words {
|
||||
set[string(w)] = true
|
||||
}
|
||||
var out letterSet
|
||||
for x := range size {
|
||||
w := make([]byte, 0, len(above)+1+len(below))
|
||||
w = append(w, above...)
|
||||
w = append(w, byte(x))
|
||||
w = append(w, below...)
|
||||
if set[string(w)] {
|
||||
out |= letterSet(1) << uint(x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestDAWGCrossSetMatchesBruteForce(t *testing.T) {
|
||||
const size = 26
|
||||
words := wordlist.Encode(
|
||||
[]string{"cat", "cot", "cut", "cap", "cab", "at", "it"},
|
||||
alphabet.Latin(), 2, 15)
|
||||
|
||||
finder, err := dictdawg.Build(alphabet.Latin(), words)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cur, err := dawg.NewCursor(finder)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
above, below []byte
|
||||
}{
|
||||
{"c_t", []byte{2}, []byte{19}}, // expect {a,o,u}
|
||||
{"_t", nil, []byte{19}}, // expect {a,i}
|
||||
{"c_", []byte{2}, nil}, // expect {} (no two-letter c-words)
|
||||
{"a_t", []byte{0}, []byte{19}}, // expect {}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
want := bruteCrossSet(words, tc.above, tc.below, size)
|
||||
if got := dawgCrossSet(cur, tc.above, tc.below, size); got != want {
|
||||
t.Errorf("%s: dawgCrossSet = %026b, want %026b", tc.name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// c_t must be exactly {a(0), o(14), u(20)}.
|
||||
want := letterSet(0)
|
||||
for _, x := range []byte{0, 14, 20} {
|
||||
want |= letterSet(1) << x
|
||||
}
|
||||
if got := dawgCrossSet(cur, []byte{2}, []byte{19}, size); got != want {
|
||||
t.Errorf("c_t cross-set = %026b, want {a,o,u} = %026b", got, want)
|
||||
}
|
||||
|
||||
// No perpendicular neighbours: every letter is allowed.
|
||||
if got := dawgCrossSet(cur, nil, nil, size); got != fullSet(size) {
|
||||
t.Errorf("empty context = %026b, want full", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/rack"
|
||||
"scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// generateBoth runs an across-generator on the board (for horizontal plays) and on its
|
||||
// transpose (for vertical plays), as selected by mode, then scores and de-duplicates the
|
||||
// results. runAcross reports placements in the coordinates of the board it is given; for
|
||||
// the transpose pass they are mapped back to the real board.
|
||||
func generateBoth(b *board.Board, rs *rules.Ruleset, rk rack.Rack, mode Mode,
|
||||
runAcross func(bd *board.Board, rk rack.Rack, emit func([]Placement))) []Move {
|
||||
|
||||
rk = rk.Clone() // generation mutates the rack in place and restores it
|
||||
var moves []Move
|
||||
seen := make(map[string]struct{})
|
||||
emit := func(dir Direction, placements []Placement) {
|
||||
key := moveKey(dir, placements)
|
||||
if _, dup := seen[key]; dup {
|
||||
return
|
||||
}
|
||||
m, err := Evaluate(b, rs, dir, placements)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
moves = append(moves, m)
|
||||
}
|
||||
|
||||
if mode.Includes(Horizontal) {
|
||||
runAcross(b, rk, func(p []Placement) { emit(Horizontal, p) })
|
||||
}
|
||||
if mode.Includes(Vertical) {
|
||||
tb := b.Transpose()
|
||||
runAcross(tb, rk, func(p []Placement) {
|
||||
rp := make([]Placement, len(p))
|
||||
for i, pl := range p {
|
||||
rp[i] = Placement{Row: pl.Col, Col: pl.Row, Letter: pl.Letter, Blank: pl.Blank}
|
||||
}
|
||||
emit(Vertical, rp)
|
||||
})
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
// centerFor returns the centre square in bd's coordinates. bd is either the real board
|
||||
// or its transpose; the ruleset stores the centre on the real board.
|
||||
func centerFor(bd *board.Board, rs *rules.Ruleset) (row, col int) {
|
||||
r, c := rs.Center/rs.Cols, rs.Center%rs.Cols
|
||||
if bd.Rows() == rs.Rows && bd.Cols() == rs.Cols {
|
||||
return r, c
|
||||
}
|
||||
return c, r // transposed
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/internal/encoding"
|
||||
"scrabble-solver/rack"
|
||||
"scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// DAWGGenerator generates moves with the Appel-Jacobson two-phase algorithm
|
||||
// (LeftPart then ExtendRight) over a plain left-to-right DAWG.
|
||||
type DAWGGenerator struct {
|
||||
rules *rules.Ruleset
|
||||
finder dawg.Finder
|
||||
}
|
||||
|
||||
// NewDAWGGenerator builds a DAWG generator for the ruleset over the dictionary finder.
|
||||
func NewDAWGGenerator(rs *rules.Ruleset, finder dawg.Finder) *DAWGGenerator {
|
||||
return &DAWGGenerator{rules: rs, finder: finder}
|
||||
}
|
||||
|
||||
// Name identifies the generator.
|
||||
func (g *DAWGGenerator) Name() string { return "dawg" }
|
||||
|
||||
// GenerateMoves returns every legal play for rk on b in the modes' orientations.
|
||||
func (g *DAWGGenerator) GenerateMoves(b *board.Board, rk rack.Rack, mode Mode) []Move {
|
||||
return generateBoth(b, g.rules, rk, mode, g.runAcross)
|
||||
}
|
||||
|
||||
// tileInfo is a tentatively placed left-part tile (its column is fixed only once the
|
||||
// left part's length is known, at record time).
|
||||
type tileInfo struct {
|
||||
letter byte
|
||||
blank bool
|
||||
}
|
||||
|
||||
// acrossGen carries the state of one across-generation pass over a board.
|
||||
type acrossGen struct {
|
||||
bd *board.Board
|
||||
cur *dawg.Cursor
|
||||
rs *rules.Ruleset
|
||||
rk rack.Rack
|
||||
size int
|
||||
cross func(r, c int) letterSet
|
||||
emit func(placements []Placement) // placements in bd's coordinates
|
||||
|
||||
row int
|
||||
left []tileInfo // left-part tiles, in word (left-to-right) order
|
||||
right []Placement // right-part tiles, with their columns
|
||||
}
|
||||
|
||||
// runAcross generates all across plays on bd (cross-sets are computed as vertical words
|
||||
// on bd) and reports each via emit in bd's coordinates.
|
||||
func (g *DAWGGenerator) runAcross(bd *board.Board, rk rack.Rack, emit func([]Placement)) {
|
||||
cur, err := dawg.NewCursor(g.finder)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
size := g.rules.Size()
|
||||
|
||||
cross := make([]letterSet, bd.Rows()*bd.Cols())
|
||||
known := make([]bool, bd.Rows()*bd.Cols())
|
||||
crossFn := func(r, c int) letterSet {
|
||||
i := r*bd.Cols() + c
|
||||
if !known[i] {
|
||||
above, below := columnContext(bd, r, c)
|
||||
cross[i] = dawgCrossSet(cur, above, below, size)
|
||||
known[i] = true
|
||||
}
|
||||
return cross[i]
|
||||
}
|
||||
|
||||
ag := &acrossGen{bd: bd, cur: cur, rs: g.rules, rk: rk, size: size, cross: crossFn, emit: emit}
|
||||
|
||||
firstMove := bd.IsEmpty()
|
||||
centerRow, centerCol := centerFor(bd, g.rules)
|
||||
for row := range bd.Rows() {
|
||||
ag.generateRow(row, firstMove, centerRow, centerCol)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *acrossGen) generateRow(row int, firstMove bool, centerRow, centerCol int) {
|
||||
g.row = row
|
||||
limit := 0
|
||||
for col := range g.bd.Cols() {
|
||||
if !g.bd.Empty(row, col) {
|
||||
limit = 0
|
||||
continue
|
||||
}
|
||||
anchor := false
|
||||
if firstMove {
|
||||
anchor = row == centerRow && col == centerCol
|
||||
} else {
|
||||
anchor = g.hasFilledNeighbor(row, col)
|
||||
}
|
||||
if !anchor {
|
||||
limit++
|
||||
continue
|
||||
}
|
||||
g.left = g.left[:0]
|
||||
g.right = g.right[:0]
|
||||
if col > 0 && g.bd.Filled(row, col-1) {
|
||||
if node, ok := g.walkPrefix(row, col); ok {
|
||||
g.extendRight(node, col, col)
|
||||
}
|
||||
} else {
|
||||
g.leftPart(g.cur.Root(), col, limit)
|
||||
}
|
||||
limit = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (g *acrossGen) hasFilledNeighbor(r, c int) bool {
|
||||
return g.bd.Filled(r-1, c) || g.bd.Filled(r+1, c) || g.bd.Filled(r, c-1) || g.bd.Filled(r, c+1)
|
||||
}
|
||||
|
||||
// walkPrefix walks the DAWG through the contiguous filled run ending at col-1, returning
|
||||
// the node reached and whether that prefix exists in the dictionary.
|
||||
func (g *acrossGen) walkPrefix(row, col int) (dawg.Node, bool) {
|
||||
start := col - 1
|
||||
for start-1 >= 0 && g.bd.Filled(row, start-1) {
|
||||
start--
|
||||
}
|
||||
node := g.cur.Root()
|
||||
for c := start; c < col; c++ {
|
||||
var ok bool
|
||||
node, _, ok = g.cur.Next(node, encoding.Letter(g.bd.At(row, c)))
|
||||
if !ok {
|
||||
return node, false
|
||||
}
|
||||
}
|
||||
return node, true
|
||||
}
|
||||
|
||||
// leftPart places left-part tiles from the rack (up to limit, on the empty squares left
|
||||
// of the anchor), calling extendRight after each prefix.
|
||||
func (g *acrossGen) leftPart(node dawg.Node, anchorCol, limit int) {
|
||||
g.extendRight(node, anchorCol, anchorCol)
|
||||
if limit == 0 {
|
||||
return
|
||||
}
|
||||
g.cur.Arcs(node, func(a dawg.Arc) bool {
|
||||
l := a.Label
|
||||
if g.rk.Has(l) {
|
||||
g.rk.Remove(l)
|
||||
g.left = append(g.left, tileInfo{letter: l})
|
||||
g.leftPart(a.Dest, anchorCol, limit-1)
|
||||
g.left = g.left[:len(g.left)-1]
|
||||
g.rk.Add(l)
|
||||
}
|
||||
if g.rk.Blanks() > 0 {
|
||||
g.rk.RemoveBlank()
|
||||
g.left = append(g.left, tileInfo{letter: l, blank: true})
|
||||
g.leftPart(a.Dest, anchorCol, limit-1)
|
||||
g.left = g.left[:len(g.left)-1]
|
||||
g.rk.AddBlank()
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// extendRight extends the word rightward from col, placing rack tiles on empty squares
|
||||
// (constrained by cross-sets) and following tiles already on the board. A word is
|
||||
// recorded only past the anchor, so the play covers the anchor square.
|
||||
func (g *acrossGen) extendRight(node dawg.Node, col, anchorCol int) {
|
||||
if col >= g.bd.Cols() {
|
||||
if col > anchorCol && g.cur.Final(node) {
|
||||
g.record(anchorCol)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !g.bd.Empty(g.row, col) {
|
||||
if dest, _, ok := g.cur.Next(node, encoding.Letter(g.bd.At(g.row, col))); ok {
|
||||
g.extendRight(dest, col+1, anchorCol)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if col > anchorCol && g.cur.Final(node) {
|
||||
g.record(anchorCol)
|
||||
}
|
||||
cross := g.cross(g.row, col)
|
||||
g.cur.Arcs(node, func(a dawg.Arc) bool {
|
||||
l := a.Label
|
||||
if !cross.has(l) {
|
||||
return true
|
||||
}
|
||||
if g.rk.Has(l) {
|
||||
g.rk.Remove(l)
|
||||
g.right = append(g.right, Placement{Row: g.row, Col: col, Letter: l})
|
||||
g.extendRight(a.Dest, col+1, anchorCol)
|
||||
g.right = g.right[:len(g.right)-1]
|
||||
g.rk.Add(l)
|
||||
}
|
||||
if g.rk.Blanks() > 0 {
|
||||
g.rk.RemoveBlank()
|
||||
g.right = append(g.right, Placement{Row: g.row, Col: col, Letter: l, Blank: true})
|
||||
g.extendRight(a.Dest, col+1, anchorCol)
|
||||
g.right = g.right[:len(g.right)-1]
|
||||
g.rk.AddBlank()
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// record assembles the play's placements (left part at fixed columns, then the right
|
||||
// part) and reports it. It skips plays that lay no new tile.
|
||||
func (g *acrossGen) record(anchorCol int) {
|
||||
if len(g.left)+len(g.right) == 0 {
|
||||
return
|
||||
}
|
||||
placements := make([]Placement, 0, len(g.left)+len(g.right))
|
||||
leftStart := anchorCol - len(g.left)
|
||||
for i, t := range g.left {
|
||||
placements = append(placements, Placement{Row: g.row, Col: leftStart + i, Letter: t.letter, Blank: t.blank})
|
||||
}
|
||||
placements = append(placements, g.right...)
|
||||
g.emit(placements)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/alphabet"
|
||||
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/internal/dictdawg"
|
||||
"scrabble-solver/internal/encoding"
|
||||
"scrabble-solver/internal/wordlist"
|
||||
"scrabble-solver/rack"
|
||||
"scrabble-solver/rules"
|
||||
)
|
||||
|
||||
func makeRack(letters string, blanks int) rack.Rack {
|
||||
r := rack.New(26)
|
||||
for i := range len(letters) {
|
||||
r.Add(letters[i] - 'a')
|
||||
}
|
||||
for range blanks {
|
||||
r.AddBlank()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func placeWord(b *board.Board, r, c int, dir Direction, word string) {
|
||||
for i := range len(word) {
|
||||
rr, cc := r, c+i
|
||||
if dir == Vertical {
|
||||
rr, cc = r+i, c
|
||||
}
|
||||
b.Set(rr, cc, encoding.Cell(word[i]-'a', false))
|
||||
}
|
||||
}
|
||||
|
||||
func genMoves(moves []Move) map[string]Move {
|
||||
out := make(map[string]Move, len(moves))
|
||||
for _, m := range moves {
|
||||
out[moveKey(m.Dir, m.Tiles)] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// testWords is a small lexicon with enough overlaps to form across and cross plays.
|
||||
var testWords = []string{
|
||||
"aa", "ace", "act", "arc", "are", "art", "as", "at", "ate",
|
||||
"cab", "cap", "car", "care", "cars", "cart", "cat", "cats", "cot",
|
||||
"oat", "oats", "ta", "tar", "tare", "tat", "tea", "teat",
|
||||
}
|
||||
|
||||
func compareToBrute(t *testing.T, name string, gen Generator, b *board.Board, d dict, rk rack.Rack, mode Mode) {
|
||||
t.Helper()
|
||||
want := bruteForce(b, plainRulesShared, d, rk, mode)
|
||||
got := genMoves(gen.GenerateMoves(b, rk, mode))
|
||||
|
||||
for k, wm := range want {
|
||||
gm, ok := got[k]
|
||||
if !ok {
|
||||
t.Errorf("%s [%s]: %s missing %s (score %d)", name, gen.Name(), gen.Name(), k, wm.Score)
|
||||
continue
|
||||
}
|
||||
if gm.Score != wm.Score {
|
||||
t.Errorf("%s [%s]: %s score %d, want %d", name, gen.Name(), k, gm.Score, wm.Score)
|
||||
}
|
||||
}
|
||||
for k := range got {
|
||||
if _, ok := want[k]; !ok {
|
||||
t.Errorf("%s [%s]: extra move %s", name, gen.Name(), k)
|
||||
}
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("%s [%s]: %d moves, oracle has %d", name, gen.Name(), len(got), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
func mustPlainRules() *rules.Ruleset {
|
||||
eng := rules.English()
|
||||
rs, err := rules.FromTemplate("plain7", eng.Alphabet, eng.Values, eng.Counts, 2, 7, 50, plain7)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return rs
|
||||
}
|
||||
|
||||
var plainRulesShared = mustPlainRules()
|
||||
|
||||
type scenario struct {
|
||||
name string
|
||||
setup func(*board.Board)
|
||||
rack rack.Rack
|
||||
mode Mode
|
||||
}
|
||||
|
||||
func genScenarios() []scenario {
|
||||
return []scenario{
|
||||
{"first move", func(*board.Board) {}, makeRack("cat", 0), Both},
|
||||
{"first move blank", func(*board.Board) {}, makeRack("ca", 1), Both},
|
||||
{"extend cat", func(b *board.Board) { placeWord(b, 3, 1, Horizontal, "cat") }, makeRack("srs", 0), Both},
|
||||
{"cross cat", func(b *board.Board) { placeWord(b, 1, 3, Horizontal, "cat") }, makeRack("aort", 0), Both},
|
||||
{"only horizontal", func(b *board.Board) { placeWord(b, 3, 1, Horizontal, "cat") }, makeRack("aser", 0), OnlyHorizontal},
|
||||
{"only vertical", func(b *board.Board) { placeWord(b, 1, 3, Vertical, "cat") }, makeRack("aser", 0), OnlyVertical},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDAWGGeneratorVsBruteForce(t *testing.T) {
|
||||
rs := plainRulesShared
|
||||
words := wordlist.Encode(testWords, alphabet.Latin(), 2, 15)
|
||||
f, err := dictdawg.Build(alphabet.Latin(), words)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gen := NewDAWGGenerator(rs, f)
|
||||
d := makeDict(words)
|
||||
|
||||
for _, c := range genScenarios() {
|
||||
b := board.New(rs.Rows, rs.Cols)
|
||||
c.setup(b)
|
||||
compareToBrute(t, c.name, gen, b, d, c.rack, c.mode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/rack"
|
||||
)
|
||||
|
||||
// Generator produces every legal play for a position. The DAWG generator
|
||||
// (Appel-Jacobson) is the implementation; the interface keeps the self-play engine and
|
||||
// the solver decoupled from the concrete type.
|
||||
type Generator interface {
|
||||
// GenerateMoves returns every legal play for rack r on board b in the modes'
|
||||
// orientations. The result is unsorted; callers (or the Solver) rank it.
|
||||
GenerateMoves(b *board.Board, r rack.Rack, mode Mode) []Move
|
||||
|
||||
// Name identifies the generator (e.g. "dawg").
|
||||
Name() string
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// moveKey is a canonical string identifying a play (direction plus its placed tiles),
|
||||
// used to de-duplicate and compare generated moves.
|
||||
func moveKey(dir Direction, p []Placement) string {
|
||||
ps := append([]Placement(nil), p...)
|
||||
sort.Slice(ps, func(i, j int) bool {
|
||||
if ps[i].Row != ps[j].Row {
|
||||
return ps[i].Row < ps[j].Row
|
||||
}
|
||||
return ps[i].Col < ps[j].Col
|
||||
})
|
||||
var sb strings.Builder
|
||||
sb.WriteByte('0' + byte(dir))
|
||||
for _, pl := range ps {
|
||||
sb.WriteByte(';')
|
||||
sb.WriteString(strconv.Itoa(pl.Row))
|
||||
sb.WriteByte(',')
|
||||
sb.WriteString(strconv.Itoa(pl.Col))
|
||||
sb.WriteByte(',')
|
||||
sb.WriteString(strconv.Itoa(int(pl.Letter)))
|
||||
if pl.Blank {
|
||||
sb.WriteByte('*')
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Key returns the canonical identifier of the move (direction plus its placed tiles).
|
||||
func (m Move) Key() string { return moveKey(m.Dir, m.Tiles) }
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package scrabble is the public library: it builds a move generator over a dictionary
|
||||
// and a ruleset, generates every legal play for a position ranked by score, and scores
|
||||
// or validates arbitrary plays. The generator is the DAWG algorithm (Appel-Jacobson).
|
||||
package scrabble
|
||||
|
||||
// Direction is the orientation of a play's main word.
|
||||
type Direction uint8
|
||||
|
||||
const (
|
||||
// Horizontal is an across play (left to right along a row).
|
||||
Horizontal Direction = iota
|
||||
// Vertical is a down play (top to bottom along a column).
|
||||
Vertical
|
||||
)
|
||||
|
||||
// String renders the direction for diagnostics.
|
||||
func (d Direction) String() string {
|
||||
if d == Vertical {
|
||||
return "vertical"
|
||||
}
|
||||
return "horizontal"
|
||||
}
|
||||
|
||||
// Mode selects which orientations GenerateMoves produces. Russian "Эрудит" requires a
|
||||
// single orientation per turn, which OnlyHorizontal / OnlyVertical express.
|
||||
type Mode uint8
|
||||
|
||||
const (
|
||||
// Both generates across plays (on the board) and down plays (on its transpose).
|
||||
Both Mode = iota
|
||||
// OnlyHorizontal generates across plays only.
|
||||
OnlyHorizontal
|
||||
// OnlyVertical generates down plays only.
|
||||
OnlyVertical
|
||||
)
|
||||
|
||||
// Includes reports whether the mode produces plays in direction d.
|
||||
func (m Mode) Includes(d Direction) bool {
|
||||
switch m {
|
||||
case Both:
|
||||
return true
|
||||
case OnlyHorizontal:
|
||||
return d == Horizontal
|
||||
case OnlyVertical:
|
||||
return d == Vertical
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Placement is a single newly-placed tile.
|
||||
type Placement struct {
|
||||
Row, Col int
|
||||
Letter byte // alphabet letter index
|
||||
Blank bool // placed from a blank tile, so it scores 0
|
||||
}
|
||||
|
||||
// Word is a word formed by a play, with its location and score.
|
||||
type Word struct {
|
||||
Row, Col int // square of the word's first letter
|
||||
Dir Direction // orientation of the word
|
||||
Letters []byte // alphabet indices of the whole word (existing + new tiles)
|
||||
Blanks []bool // per letter: true if that tile is a blank (scores 0)
|
||||
Score int // the word's score, with premiums from newly-placed tiles
|
||||
}
|
||||
|
||||
// Move is a complete legal play with a full scoring breakdown.
|
||||
type Move struct {
|
||||
Dir Direction // orientation of the main word
|
||||
Tiles []Placement // the newly-placed tiles, in main-word order
|
||||
Main Word // the main word formed along Dir
|
||||
Cross []Word // perpendicular words formed by the new tiles
|
||||
Bonus int // all-tiles (bingo) bonus included in Score, or 0
|
||||
Score int // total: Main.Score + Σ Cross.Score + Bonus
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/rack"
|
||||
"scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// dict is a membership set of words (alphabet-index strings) for the oracle.
|
||||
type dict map[string]bool
|
||||
|
||||
func makeDict(words [][]byte) dict {
|
||||
d := make(dict, len(words))
|
||||
for _, w := range words {
|
||||
d[string(w)] = true
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (d dict) has(letters []byte) bool { return d[string(letters)] }
|
||||
|
||||
func lineCoord(dir Direction, line, axis int) (r, c int) {
|
||||
if dir == Horizontal {
|
||||
return line, axis
|
||||
}
|
||||
return axis, line
|
||||
}
|
||||
|
||||
func cellFilled(b *board.Board, dir Direction, line, axis int) bool {
|
||||
r, c := lineCoord(dir, line, axis)
|
||||
return b.Filled(r, c)
|
||||
}
|
||||
|
||||
func coversCenter(dir Direction, line, start, end, cr, cc int) bool {
|
||||
if dir == Horizontal {
|
||||
return line == cr && start <= cc && cc <= end
|
||||
}
|
||||
return line == cc && start <= cr && cr <= end
|
||||
}
|
||||
|
||||
// bruteForce returns every legal play for the position, keyed by moveKey, found by
|
||||
// exhaustively trying every maximal window and every rack assignment, then validating
|
||||
// against the dictionary, connectivity and the first-move centre rule. It is the slow,
|
||||
// obviously-correct oracle for checking the generators on small inputs.
|
||||
func bruteForce(b *board.Board, rs *rules.Ruleset, d dict, rk rack.Rack, mode Mode) map[string]Move {
|
||||
out := map[string]Move{}
|
||||
var dirs []Direction
|
||||
if mode.Includes(Horizontal) {
|
||||
dirs = append(dirs, Horizontal)
|
||||
}
|
||||
if mode.Includes(Vertical) {
|
||||
dirs = append(dirs, Vertical)
|
||||
}
|
||||
firstMove := b.IsEmpty()
|
||||
cr, cc := rs.Center/rs.Cols, rs.Center%rs.Cols
|
||||
|
||||
for _, dir := range dirs {
|
||||
lines, span := b.Rows(), b.Cols()
|
||||
if dir == Vertical {
|
||||
lines, span = b.Cols(), b.Rows()
|
||||
}
|
||||
for line := range lines {
|
||||
for start := range span {
|
||||
for end := start + 1; end < span; end++ {
|
||||
if cellFilled(b, dir, line, start-1) || cellFilled(b, dir, line, end+1) {
|
||||
continue // not a maximal window
|
||||
}
|
||||
var empties []int
|
||||
for a := start; a <= end; a++ {
|
||||
if !cellFilled(b, dir, line, a) {
|
||||
empties = append(empties, a)
|
||||
}
|
||||
}
|
||||
if len(empties) == 0 {
|
||||
continue
|
||||
}
|
||||
assign(b, rs, d, rk.Clone(), dir, line, start, end, empties, 0, nil,
|
||||
firstMove, cr, cc, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assign(b *board.Board, rs *rules.Ruleset, d dict, rk rack.Rack, dir Direction,
|
||||
line, start, end int, empties []int, idx int, placed []Placement,
|
||||
firstMove bool, cr, cc int, out map[string]Move) {
|
||||
|
||||
if idx == len(empties) {
|
||||
validate(b, rs, d, dir, line, start, end, placed, firstMove, cr, cc, out)
|
||||
return
|
||||
}
|
||||
r, c := lineCoord(dir, line, empties[idx])
|
||||
next := placed[:len(placed):len(placed)] // avoid aliasing across siblings
|
||||
|
||||
for l := byte(0); l < byte(rs.Size()); l++ {
|
||||
if rk.Has(l) {
|
||||
rk.Remove(l)
|
||||
assign(b, rs, d, rk, dir, line, start, end, empties, idx+1,
|
||||
append(next, Placement{Row: r, Col: c, Letter: l}), firstMove, cr, cc, out)
|
||||
rk.Add(l)
|
||||
}
|
||||
}
|
||||
if rk.Blanks() > 0 {
|
||||
rk.RemoveBlank()
|
||||
for l := byte(0); l < byte(rs.Size()); l++ {
|
||||
assign(b, rs, d, rk, dir, line, start, end, empties, idx+1,
|
||||
append(next, Placement{Row: r, Col: c, Letter: l, Blank: true}), firstMove, cr, cc, out)
|
||||
}
|
||||
rk.AddBlank()
|
||||
}
|
||||
}
|
||||
|
||||
func validate(b *board.Board, rs *rules.Ruleset, d dict, dir Direction,
|
||||
line, start, end int, placed []Placement, firstMove bool, cr, cc int, out map[string]Move) {
|
||||
|
||||
m, err := Evaluate(b, rs, dir, placed)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !d.has(m.Main.Letters) {
|
||||
return
|
||||
}
|
||||
for _, cw := range m.Cross {
|
||||
if !d.has(cw.Letters) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if firstMove {
|
||||
if !coversCenter(dir, line, start, end, cr, cc) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
existing := false
|
||||
for a := start; a <= end; a++ {
|
||||
if cellFilled(b, dir, line, a) {
|
||||
existing = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !existing && len(m.Cross) == 0 {
|
||||
return // disconnected
|
||||
}
|
||||
}
|
||||
out[moveKey(dir, placed)] = m
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/internal/encoding"
|
||||
"scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// coord maps a line coordinate (fixed, axis) to a board (row, col) for direction dir.
|
||||
// For Horizontal the fixed coordinate is the row and the axis runs along columns; for
|
||||
// Vertical it is the reverse.
|
||||
func coord(dir Direction, fixed, axis int) (row, col int) {
|
||||
if dir == Horizontal {
|
||||
return fixed, axis
|
||||
}
|
||||
return axis, fixed
|
||||
}
|
||||
|
||||
// fixedAxis is the inverse of coord: it splits a (row, col) into the fixed and axis
|
||||
// coordinates for direction dir.
|
||||
func fixedAxis(dir Direction, row, col int) (fixed, axis int) {
|
||||
if dir == Horizontal {
|
||||
return row, col
|
||||
}
|
||||
return col, row
|
||||
}
|
||||
|
||||
func perpendicular(d Direction) Direction {
|
||||
if d == Horizontal {
|
||||
return Vertical
|
||||
}
|
||||
return Horizontal
|
||||
}
|
||||
|
||||
// Evaluate computes the words formed and the score for placing tiles on b in direction
|
||||
// dir under ruleset rs. It validates geometry — the tiles lie on one line, on empty
|
||||
// squares, and form a single contiguous run together with existing tiles — but does not
|
||||
// check the dictionary or board connectivity; ValidatePlay layers those on top. tiles
|
||||
// need not be sorted.
|
||||
func Evaluate(b *board.Board, rs *rules.Ruleset, dir Direction, tiles []Placement) (Move, error) {
|
||||
if len(tiles) == 0 {
|
||||
return Move{}, errors.New("scrabble: empty play")
|
||||
}
|
||||
|
||||
ts := append([]Placement(nil), tiles...)
|
||||
sort.Slice(ts, func(i, j int) bool {
|
||||
_, ai := fixedAxis(dir, ts[i].Row, ts[i].Col)
|
||||
_, aj := fixedAxis(dir, ts[j].Row, ts[j].Col)
|
||||
return ai < aj
|
||||
})
|
||||
|
||||
fixed, _ := fixedAxis(dir, ts[0].Row, ts[0].Col)
|
||||
prevAxis := 0
|
||||
for i, t := range ts {
|
||||
f, a := fixedAxis(dir, t.Row, t.Col)
|
||||
if f != fixed {
|
||||
return Move{}, errors.New("scrabble: tiles are not on one line")
|
||||
}
|
||||
if !b.InBounds(t.Row, t.Col) {
|
||||
return Move{}, fmt.Errorf("scrabble: tile (%d,%d) off board", t.Row, t.Col)
|
||||
}
|
||||
if !b.Empty(t.Row, t.Col) {
|
||||
return Move{}, fmt.Errorf("scrabble: square (%d,%d) is occupied", t.Row, t.Col)
|
||||
}
|
||||
if i > 0 && a == prevAxis {
|
||||
return Move{}, errors.New("scrabble: two tiles on the same square")
|
||||
}
|
||||
prevAxis = a
|
||||
}
|
||||
|
||||
main, err := buildMainWord(b, rs, dir, fixed, ts)
|
||||
if err != nil {
|
||||
return Move{}, err
|
||||
}
|
||||
|
||||
move := Move{Dir: dir, Tiles: ts, Main: main, Score: main.Score}
|
||||
for _, t := range ts {
|
||||
if cw, ok := crossWord(b, rs, dir, t); ok {
|
||||
move.Cross = append(move.Cross, cw)
|
||||
move.Score += cw.Score
|
||||
}
|
||||
}
|
||||
if len(ts) == rs.RackSize {
|
||||
move.Bonus = rs.Bingo
|
||||
move.Score += rs.Bingo
|
||||
}
|
||||
return move, nil
|
||||
}
|
||||
|
||||
// buildMainWord assembles the word along dir through the (sorted) placements together
|
||||
// with the existing tiles that extend and bridge them, and scores it. New tiles apply
|
||||
// their squares' premiums; existing tiles score at face value.
|
||||
func buildMainWord(b *board.Board, rs *rules.Ruleset, dir Direction, fixed int, ts []Placement) (Word, error) {
|
||||
_, minA := fixedAxis(dir, ts[0].Row, ts[0].Col)
|
||||
_, maxA := fixedAxis(dir, ts[len(ts)-1].Row, ts[len(ts)-1].Col)
|
||||
|
||||
start := minA
|
||||
for {
|
||||
r, c := coord(dir, fixed, start-1)
|
||||
if !b.Filled(r, c) {
|
||||
break
|
||||
}
|
||||
start--
|
||||
}
|
||||
end := maxA
|
||||
for {
|
||||
r, c := coord(dir, fixed, end+1)
|
||||
if !b.Filled(r, c) {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
|
||||
letters := make([]byte, 0, end-start+1)
|
||||
blanks := make([]bool, 0, end-start+1)
|
||||
letterSum, wordMult := 0, 1
|
||||
ti := 0
|
||||
for a := start; a <= end; a++ {
|
||||
r, c := coord(dir, fixed, a)
|
||||
if ti < len(ts) {
|
||||
if _, ta := fixedAxis(dir, ts[ti].Row, ts[ti].Col); ta == a {
|
||||
t := ts[ti]
|
||||
ti++
|
||||
prem := rs.Premium(r, c)
|
||||
if !t.Blank {
|
||||
letterSum += rs.Values[t.Letter] * prem.LetterMult()
|
||||
}
|
||||
wordMult *= prem.WordMult()
|
||||
letters = append(letters, t.Letter)
|
||||
blanks = append(blanks, t.Blank)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if b.Filled(r, c) {
|
||||
cell := b.At(r, c)
|
||||
l, bl := encoding.Letter(cell), encoding.IsBlank(cell)
|
||||
if !bl {
|
||||
letterSum += rs.Values[l]
|
||||
}
|
||||
letters = append(letters, l)
|
||||
blanks = append(blanks, bl)
|
||||
continue
|
||||
}
|
||||
return Word{}, fmt.Errorf("scrabble: gap in the play at line position %d", a)
|
||||
}
|
||||
|
||||
wr, wc := coord(dir, fixed, start)
|
||||
return Word{Row: wr, Col: wc, Dir: dir, Letters: letters, Blanks: blanks, Score: letterSum * wordMult}, nil
|
||||
}
|
||||
|
||||
// crossWord builds the perpendicular word formed by a single new tile, if any. It
|
||||
// returns ok=false when the tile has no perpendicular neighbour.
|
||||
func crossWord(b *board.Board, rs *rules.Ruleset, dir Direction, t Placement) (Word, bool) {
|
||||
cdir := perpendicular(dir)
|
||||
fixed, axis := fixedAxis(cdir, t.Row, t.Col)
|
||||
|
||||
start := axis
|
||||
for {
|
||||
r, c := coord(cdir, fixed, start-1)
|
||||
if !b.Filled(r, c) {
|
||||
break
|
||||
}
|
||||
start--
|
||||
}
|
||||
end := axis
|
||||
for {
|
||||
r, c := coord(cdir, fixed, end+1)
|
||||
if !b.Filled(r, c) {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
if start == end {
|
||||
return Word{}, false
|
||||
}
|
||||
|
||||
letters := make([]byte, 0, end-start+1)
|
||||
blanks := make([]bool, 0, end-start+1)
|
||||
letterSum, wordMult := 0, 1
|
||||
for a := start; a <= end; a++ {
|
||||
r, c := coord(cdir, fixed, a)
|
||||
if a == axis {
|
||||
prem := rs.Premium(r, c)
|
||||
if !t.Blank {
|
||||
letterSum += rs.Values[t.Letter] * prem.LetterMult()
|
||||
}
|
||||
wordMult *= prem.WordMult()
|
||||
letters = append(letters, t.Letter)
|
||||
blanks = append(blanks, t.Blank)
|
||||
} else {
|
||||
cell := b.At(r, c)
|
||||
l, bl := encoding.Letter(cell), encoding.IsBlank(cell)
|
||||
if !bl {
|
||||
letterSum += rs.Values[l]
|
||||
}
|
||||
letters = append(letters, l)
|
||||
blanks = append(blanks, bl)
|
||||
}
|
||||
}
|
||||
wr, wc := coord(cdir, fixed, start)
|
||||
return Word{Row: wr, Col: wc, Dir: cdir, Letters: letters, Blanks: blanks, Score: letterSum * wordMult}, true
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/internal/encoding"
|
||||
"scrabble-solver/rules"
|
||||
)
|
||||
|
||||
const plain7 = `.......
|
||||
.......
|
||||
.......
|
||||
...+...
|
||||
.......
|
||||
.......
|
||||
.......`
|
||||
|
||||
// plainRules is a 7x7 board with no premiums and English tile values, for isolating
|
||||
// word-assembly and cross-word logic from premium multipliers.
|
||||
func plainRules(t *testing.T) *rules.Ruleset {
|
||||
t.Helper()
|
||||
eng := rules.English()
|
||||
rs, err := rules.FromTemplate("plain7", eng.Alphabet, eng.Values, eng.Counts, 2, 7, 50, plain7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rs
|
||||
}
|
||||
|
||||
// indices: a=0 c=2 o=14 t=19 x=23
|
||||
func TestEvaluateSimpleWord(t *testing.T) {
|
||||
rs := plainRules(t)
|
||||
b := board.New(7, 7)
|
||||
m, err := Evaluate(b, rs, Horizontal, []Placement{
|
||||
{Row: 3, Col: 1, Letter: 2}, {Row: 3, Col: 2, Letter: 0}, {Row: 3, Col: 3, Letter: 19},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Main.Score != 5 || m.Score != 5 {
|
||||
t.Errorf("cat: main=%d total=%d, want 5/5", m.Main.Score, m.Score)
|
||||
}
|
||||
if len(m.Cross) != 0 || m.Bonus != 0 {
|
||||
t.Errorf("cat: cross=%d bonus=%d, want 0/0", len(m.Cross), m.Bonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateCrossWord(t *testing.T) {
|
||||
rs := plainRules(t)
|
||||
b := board.New(7, 7)
|
||||
b.Set(2, 3, encoding.Cell(14, false)) // o
|
||||
b.Set(3, 3, encoding.Cell(23, false)) // x
|
||||
|
||||
// Play "at" horizontally on row 4; the 'a' on col 3 forms the cross word "oxa".
|
||||
m, err := Evaluate(b, rs, Horizontal, []Placement{
|
||||
{Row: 4, Col: 3, Letter: 0}, {Row: 4, Col: 4, Letter: 19},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Main.Score != 2 {
|
||||
t.Errorf("main 'at' = %d, want 2", m.Main.Score)
|
||||
}
|
||||
if len(m.Cross) != 1 || m.Cross[0].Score != 10 {
|
||||
t.Errorf("cross = %+v, want one word scoring 10 (oxa)", m.Cross)
|
||||
}
|
||||
if m.Score != 12 {
|
||||
t.Errorf("total = %d, want 12", m.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluatePremiums(t *testing.T) {
|
||||
rs := rules.English()
|
||||
|
||||
// (0,3) is a double-letter square: c(3)*2 + a(1) + t(1) = 8.
|
||||
b := board.New(15, 15)
|
||||
m, err := Evaluate(b, rs, Horizontal, []Placement{
|
||||
{Row: 0, Col: 3, Letter: 2}, {Row: 0, Col: 4, Letter: 0}, {Row: 0, Col: 5, Letter: 19},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Score != 8 {
|
||||
t.Errorf("DL cat = %d, want 8", m.Score)
|
||||
}
|
||||
|
||||
// (1,1) is a double-word square: (c(3) + a(1)) * 2 = 8.
|
||||
b2 := board.New(15, 15)
|
||||
m2, err := Evaluate(b2, rs, Horizontal, []Placement{
|
||||
{Row: 1, Col: 1, Letter: 2}, {Row: 1, Col: 2, Letter: 0},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m2.Score != 8 {
|
||||
t.Errorf("DW ca = %d, want 8", m2.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateBingo(t *testing.T) {
|
||||
rs := plainRules(t)
|
||||
b := board.New(7, 7)
|
||||
tiles := make([]Placement, 7)
|
||||
for c := range 7 {
|
||||
tiles[c] = Placement{Row: 0, Col: c, Letter: 0} // seven a's
|
||||
}
|
||||
m, err := Evaluate(b, rs, Horizontal, tiles)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Bonus != 50 || m.Score != 7+50 {
|
||||
t.Errorf("bingo: bonus=%d total=%d, want 50/57", m.Bonus, m.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateErrors(t *testing.T) {
|
||||
rs := plainRules(t)
|
||||
b := board.New(7, 7)
|
||||
b.Set(2, 3, encoding.Cell(14, false))
|
||||
|
||||
if _, err := Evaluate(b, rs, Horizontal, nil); err == nil {
|
||||
t.Error("empty play: want error")
|
||||
}
|
||||
if _, err := Evaluate(b, rs, Horizontal, []Placement{{Row: 2, Col: 3, Letter: 0}}); err == nil {
|
||||
t.Error("occupied square: want error")
|
||||
}
|
||||
if _, err := Evaluate(b, rs, Horizontal, []Placement{
|
||||
{Row: 3, Col: 1, Letter: 0}, {Row: 4, Col: 2, Letter: 0},
|
||||
}); err == nil {
|
||||
t.Error("non-collinear: want error")
|
||||
}
|
||||
if _, err := Evaluate(b, rs, Horizontal, []Placement{
|
||||
{Row: 5, Col: 1, Letter: 0}, {Row: 5, Col: 3, Letter: 0},
|
||||
}); err == nil {
|
||||
t.Error("gap: want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/rack"
|
||||
"scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// Solver is the high-level entry point: it generates ranked plays and scores or
|
||||
// validates arbitrary plays for a ruleset over a dictionary.
|
||||
type Solver struct {
|
||||
rules *rules.Ruleset
|
||||
finder dawg.Finder
|
||||
gen *DAWGGenerator
|
||||
}
|
||||
|
||||
// NewSolver returns a Solver for the ruleset over the dictionary finder.
|
||||
func NewSolver(rs *rules.Ruleset, finder dawg.Finder) *Solver {
|
||||
return &Solver{rules: rs, finder: finder, gen: NewDAWGGenerator(rs, finder)}
|
||||
}
|
||||
|
||||
// Rules returns the solver's ruleset.
|
||||
func (s *Solver) Rules() *rules.Ruleset { return s.rules }
|
||||
|
||||
// GenerateMoves returns every legal play for rack r on board b in the requested
|
||||
// orientations, ranked by descending score (ties broken deterministically by the move's
|
||||
// canonical key).
|
||||
func (s *Solver) GenerateMoves(b *board.Board, r rack.Rack, mode Mode) []Move {
|
||||
moves := s.gen.GenerateMoves(b, r, mode)
|
||||
sort.Slice(moves, func(i, j int) bool {
|
||||
if moves[i].Score != moves[j].Score {
|
||||
return moves[i].Score > moves[j].Score
|
||||
}
|
||||
return moves[i].Key() < moves[j].Key()
|
||||
})
|
||||
return moves
|
||||
}
|
||||
|
||||
// ScorePlay computes the words and score for placing tiles on b in direction dir. It
|
||||
// checks geometry only (see Evaluate); use ValidatePlay to also check the dictionary and
|
||||
// connectivity.
|
||||
func (s *Solver) ScorePlay(b *board.Board, dir Direction, tiles []Placement) (Move, error) {
|
||||
return Evaluate(b, s.rules, dir, tiles)
|
||||
}
|
||||
|
||||
// ValidatePlay scores a play and verifies that every word it forms is in the dictionary
|
||||
// and that it connects to the board (or covers the centre on the first move). It returns
|
||||
// the scored move; the error is nil exactly when the play is legal.
|
||||
func (s *Solver) ValidatePlay(b *board.Board, dir Direction, tiles []Placement) (Move, error) {
|
||||
m, err := Evaluate(b, s.rules, dir, tiles)
|
||||
if err != nil {
|
||||
return Move{}, err
|
||||
}
|
||||
if len(m.Main.Letters) < 2 {
|
||||
return m, errors.New("scrabble: play forms no word of length 2 or more")
|
||||
}
|
||||
if s.finder.IndexOfB(m.Main.Letters) < 0 {
|
||||
return m, fmt.Errorf("scrabble: main word is not in the dictionary")
|
||||
}
|
||||
for _, cw := range m.Cross {
|
||||
if s.finder.IndexOfB(cw.Letters) < 0 {
|
||||
return m, fmt.Errorf("scrabble: a cross word is not in the dictionary")
|
||||
}
|
||||
}
|
||||
if !s.connected(b, m) {
|
||||
return m, errors.New("scrabble: play does not connect to the board")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// connected reports whether the play touches the existing position (or covers the centre
|
||||
// on the first move).
|
||||
func (s *Solver) connected(b *board.Board, m Move) bool {
|
||||
if b.IsEmpty() {
|
||||
cr, cc := s.rules.Center/s.rules.Cols, s.rules.Center%s.rules.Cols
|
||||
return wordCovers(m.Main, cr, cc)
|
||||
}
|
||||
// The main word incorporated an existing tile, or a new tile formed a cross word.
|
||||
return len(m.Main.Letters) > len(m.Tiles) || len(m.Cross) > 0
|
||||
}
|
||||
|
||||
func wordCovers(w Word, r, c int) bool {
|
||||
for i := range w.Letters {
|
||||
rr, cc := w.Row, w.Col
|
||||
if w.Dir == Horizontal {
|
||||
cc += i
|
||||
} else {
|
||||
rr += i
|
||||
}
|
||||
if rr == r && cc == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package scrabble
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/alphabet"
|
||||
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/internal/dictdawg"
|
||||
"scrabble-solver/internal/wordlist"
|
||||
)
|
||||
|
||||
func newTestSolver(t *testing.T) *Solver {
|
||||
t.Helper()
|
||||
words := wordlist.Encode(testWords, alphabet.Latin(), 2, 15)
|
||||
f, err := dictdawg.Build(alphabet.Latin(), words)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewSolver(plainRulesShared, f)
|
||||
}
|
||||
|
||||
func TestSolverGenerateMovesRanked(t *testing.T) {
|
||||
s := newTestSolver(t)
|
||||
b := board.New(s.rules.Rows, s.rules.Cols)
|
||||
moves := s.GenerateMoves(b, makeRack("cat", 0), Both)
|
||||
if len(moves) == 0 {
|
||||
t.Fatal("no first moves generated")
|
||||
}
|
||||
for i := 1; i < len(moves); i++ {
|
||||
if moves[i-1].Score < moves[i].Score {
|
||||
t.Fatalf("moves not ranked: %d before %d", moves[i-1].Score, moves[i].Score)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolverValidatePlay(t *testing.T) {
|
||||
s := newTestSolver(t)
|
||||
// indices: c=2 a=0 t=19 z=25
|
||||
cat := []Placement{{Row: 3, Col: 2, Letter: 2}, {Row: 3, Col: 3, Letter: 0}, {Row: 3, Col: 4, Letter: 19}}
|
||||
|
||||
// First move through the centre (3,3) is legal.
|
||||
if _, err := s.ValidatePlay(board.New(s.rules.Rows, s.rules.Cols), Horizontal, cat); err != nil {
|
||||
t.Errorf("valid first move rejected: %v", err)
|
||||
}
|
||||
|
||||
// First move that misses the centre is rejected.
|
||||
off := []Placement{{Row: 0, Col: 0, Letter: 2}, {Row: 0, Col: 1, Letter: 0}, {Row: 0, Col: 2, Letter: 19}}
|
||||
if _, err := s.ValidatePlay(board.New(s.rules.Rows, s.rules.Cols), Horizontal, off); err == nil {
|
||||
t.Error("first move off the centre was accepted")
|
||||
}
|
||||
|
||||
// A non-word ("caz") is rejected.
|
||||
caz := []Placement{{Row: 3, Col: 2, Letter: 2}, {Row: 3, Col: 3, Letter: 0}, {Row: 3, Col: 4, Letter: 25}}
|
||||
if _, err := s.ValidatePlay(board.New(s.rules.Rows, s.rules.Cols), Horizontal, caz); err == nil {
|
||||
t.Error("non-word 'caz' was accepted")
|
||||
}
|
||||
|
||||
// A disconnected play on a non-empty board is rejected.
|
||||
b := board.New(s.rules.Rows, s.rules.Cols)
|
||||
placeWord(b, 3, 2, Horizontal, "cat")
|
||||
disc := []Placement{{Row: 0, Col: 0, Letter: 0}, {Row: 0, Col: 1, Letter: 18}} // "as" far away
|
||||
if _, err := s.ValidatePlay(b, Horizontal, disc); err == nil {
|
||||
t.Error("disconnected play was accepted")
|
||||
}
|
||||
|
||||
// Extending "cat" to "cats" connects and is a word.
|
||||
cats := []Placement{{Row: 3, Col: 5, Letter: 18}} // s after cat
|
||||
if m, err := s.ValidatePlay(b, Horizontal, cats); err != nil {
|
||||
t.Errorf("valid extension rejected: %v", err)
|
||||
} else if string(m.Main.Letters) != string([]byte{2, 0, 19, 18}) {
|
||||
t.Errorf("main word = %v, want cats", m.Main.Letters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolverScorePlay(t *testing.T) {
|
||||
s := newTestSolver(t)
|
||||
b := board.New(s.rules.Rows, s.rules.Cols)
|
||||
m, err := s.ScorePlay(b, Horizontal, []Placement{
|
||||
{Row: 3, Col: 2, Letter: 2}, {Row: 3, Col: 3, Letter: 0}, {Row: 3, Col: 4, Letter: 19},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Score != 5 { // c3 a1 t1, no premiums on the plain board
|
||||
t.Errorf("cat score = %d, want 5", m.Score)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user