Files
scrabble-solver/scrabble/solver.go
T
Ilia Denisov d70bd35470 scrabble: single-word rule requires in-line connection
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.
2026-06-14 14:38:49 +02:00

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
}