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