d70bd35470
Under PlayOptions.IgnoreCrossWords a play connected to the board through any perpendicular contact, even one forming a non-word, because connected() reused the standard rule's touchesPerpendicular test while cross-words were suppressed. That let an all-new word merely abut the board sideways and be accepted, and the generator, with relaxed cross-sets, offered such plays. Require the main word to run through an existing tile along the play direction when cross-words are ignored, and filter generated moves by the same predicate so generation agrees with ValidatePlayOpts. Standard rules are unchanged.
164 lines
6.3 KiB
Go
164 lines
6.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)
|
|
// Keep only moves that connect under the active rule, so generation agrees with
|
|
// ValidatePlayOpts. Standard generation already guarantees this (every play covers an
|
|
// anchor and forms valid cross-words), so the filter only bites under the single-word
|
|
// rule, where relaxed cross-sets let the generator lay an all-new word that merely abuts
|
|
// the board perpendicular to its own line.
|
|
kept := moves[:0]
|
|
for _, m := range moves {
|
|
if s.connected(b, m, opts) {
|
|
kept = append(kept, m)
|
|
}
|
|
}
|
|
moves = kept
|
|
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; it must still connect by running through an existing tile along
|
|
// the play direction (see connected), and the first-move centre rule still applies.
|
|
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, opts) {
|
|
return m, errors.New("scrabble: play does not connect to the board")
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// connected reports whether the play connects to the existing position (or covers the
|
|
// centre on the first move).
|
|
func (s *Solver) connected(b *board.Board, m Move, opts PlayOptions) 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 runs through an existing tile when it is longer than the tiles just
|
|
// laid. Under the single-word rule (opts.IgnoreCrossWords) that is the only way to
|
|
// connect: the play forms its one word along the play direction, so a tile that merely
|
|
// abuts the board perpendicular to that line — forming a cross-word the rule does not
|
|
// check — does not connect.
|
|
if opts.IgnoreCrossWords {
|
|
return len(m.Main.Letters) > len(m.Tiles)
|
|
}
|
|
// Standard rules also accept a new tile abutting an existing one perpendicular to the
|
|
// main word: that contact forms a cross-word, which ValidatePlayOpts has already checked.
|
|
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
|
|
}
|