a8a559993d
Add an optional "single word per turn" rule: only the main word along the
play direction is validated and scored; perpendicular cross-words are ignored
— not formed, validated, scored, or used to constrain move generation. The
zero PlayOptions stays standard Scrabble, so existing callers are unchanged.
- PlayOptions{IgnoreCrossWords} threaded through new EvaluateOpts,
Solver.{ScorePlay,ValidatePlay,GenerateMoves}Opts and the DAWG generator
(relaxed cross-sets via fullSet, main-word-only scoring).
- connected() tests perpendicular adjacency directly instead of via
Move.Cross, so an all-new main word touching the board only sideways still
connects when cross-words are suppressed (behaviour-preserving for standard
play).
- Tests: focused corner-case suite (solver_opts_test.go) and a single-word
GCG fixture; the 17 real-game GCG fixtures stay green as the standard-rules
regression guard.
144 lines
5.3 KiB
Go
144 lines
5.3 KiB
Go
package scrabble
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
|
|
dawg "github.com/iliadenisov/dafsa"
|
|
|
|
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
|
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
|
|
"gitea.iliadenisov.ru/developer/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). It generates under standard rules; use GenerateMovesOpts for rule
|
|
// variations.
|
|
func (s *Solver) GenerateMoves(b *board.Board, r rack.Rack, mode Mode) []Move {
|
|
return s.GenerateMovesOpts(b, r, mode, PlayOptions{})
|
|
}
|
|
|
|
// GenerateMovesOpts is GenerateMoves with optional rule variations (PlayOptions). With
|
|
// opts.IgnoreCrossWords generation is not constrained by cross-words and each move is
|
|
// scored by its main word only, yielding the plays legal under the "single word per turn"
|
|
// rule.
|
|
func (s *Solver) GenerateMovesOpts(b *board.Board, r rack.Rack, mode Mode, opts PlayOptions) []Move {
|
|
moves := s.gen.GenerateMovesOpts(b, r, mode, opts)
|
|
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. It scores under standard rules; use ScorePlayOpts for rule variations.
|
|
func (s *Solver) ScorePlay(b *board.Board, dir Direction, tiles []Placement) (Move, error) {
|
|
return s.ScorePlayOpts(b, dir, tiles, PlayOptions{})
|
|
}
|
|
|
|
// ScorePlayOpts is ScorePlay with optional rule variations (PlayOptions); see EvaluateOpts.
|
|
func (s *Solver) ScorePlayOpts(b *board.Board, dir Direction, tiles []Placement, opts PlayOptions) (Move, error) {
|
|
return EvaluateOpts(b, s.rules, dir, tiles, opts)
|
|
}
|
|
|
|
// 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. It validates under
|
|
// standard rules; use ValidatePlayOpts for rule variations.
|
|
func (s *Solver) ValidatePlay(b *board.Board, dir Direction, tiles []Placement) (Move, error) {
|
|
return s.ValidatePlayOpts(b, dir, tiles, PlayOptions{})
|
|
}
|
|
|
|
// ValidatePlayOpts is ValidatePlay with optional rule variations (PlayOptions). With
|
|
// opts.IgnoreCrossWords the play forms no cross-words, so only the main word is checked
|
|
// against the dictionary; connectivity and the first-move centre rule still apply.
|
|
func (s *Solver) ValidatePlayOpts(b *board.Board, dir Direction, tiles []Placement, opts PlayOptions) (Move, error) {
|
|
m, err := EvaluateOpts(b, s.rules, dir, tiles, opts)
|
|
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 abuts an existing one
|
|
// perpendicular to it. The latter is tested directly (not via m.Cross) so it holds
|
|
// even when cross-words are suppressed (PlayOptions.IgnoreCrossWords).
|
|
return len(m.Main.Letters) > len(m.Tiles) || touchesPerpendicular(b, m)
|
|
}
|
|
|
|
// touchesPerpendicular reports whether any newly-placed tile sits immediately next to an
|
|
// existing tile along the perpendicular of the main word — the contact by which an all-new
|
|
// main word connects to the board. It mirrors crossWord's neighbour test without forming or
|
|
// scoring the cross-word.
|
|
func touchesPerpendicular(b *board.Board, m Move) bool {
|
|
cdir := perpendicular(m.Dir)
|
|
for _, t := range m.Tiles {
|
|
fixed, axis := fixedAxis(cdir, t.Row, t.Col)
|
|
if r, c := coord(cdir, fixed, axis-1); b.Filled(r, c) {
|
|
return true
|
|
}
|
|
if r, c := coord(cdir, fixed, axis+1); b.Filled(r, c) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|