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