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