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.
This commit is contained in:
Ilia Denisov
2026-06-14 14:38:49 +02:00
parent a1fd200d97
commit d70bd35470
3 changed files with 134 additions and 66 deletions
+28 -8
View File
@@ -42,6 +42,18 @@ func (s *Solver) GenerateMoves(b *board.Board, r rack.Rack, mode Mode) []Move {
// 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
@@ -73,7 +85,8 @@ func (s *Solver) ValidatePlay(b *board.Board, dir Direction, tiles []Placement)
// 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.
// 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 {
@@ -90,22 +103,29 @@ func (s *Solver) ValidatePlayOpts(b *board.Board, dir Direction, tiles []Placeme
return m, fmt.Errorf("scrabble: a cross word is not in the dictionary")
}
}
if !s.connected(b, m) {
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 touches the existing position (or covers the centre
// on the first move).
func (s *Solver) connected(b *board.Board, m Move) bool {
// 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 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).
// 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)
}