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 }