Files
Ilia Denisov 256999b42c Publish as versioned Gitea module; move dictionary pipeline out
- Rename module to gitea.iliadenisov.ru/developer/scrabble-solver so it can be
  consumed as a versioned dependency (no go.work replace / CI clone).
- De-internalize wordlist and dictdawg as public packages.
- Remove cmd/builddict, dictprep/, the dictionaries submodule and the dawg
  Makefile: the word-list parsing and DAWG build now live in the separate
  scrabble-dictionary repository, which publishes the DAWG set as a release artifact.
- internal/dict loads the committed dawg/en_sowpods.dawg fixture for cmd/stress.
- Update README/CLAUDE docs accordingly.
2026-06-04 19:11:46 +02:00

222 lines
6.3 KiB
Go

package scrabble
import (
dawg "github.com/iliadenisov/dafsa"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/internal/encoding"
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
"gitea.iliadenisov.ru/developer/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)
}