From d70bd35470629c8a75af3d754d72c9426e68eb45 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 14:38:49 +0200 Subject: [PATCH] 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. --- scrabble/move.go | 5 +- scrabble/solver.go | 36 ++++++-- scrabble/solver_opts_test.go | 159 +++++++++++++++++++++++------------ 3 files changed, 134 insertions(+), 66 deletions(-) diff --git a/scrabble/move.go b/scrabble/move.go index 5324db4..aa72e5a 100644 --- a/scrabble/move.go +++ b/scrabble/move.go @@ -79,7 +79,8 @@ type PlayOptions struct { // IgnoreCrossWords makes a play's perpendicular cross-words irrelevant: they are not // formed, validated, scored or used to constrain move generation. It expresses the // "single word per turn" house rule, under which only the main word along the play - // direction must be a valid word; board connectivity and the first-move centre rule - // still apply. + // direction must be a valid word. That main word must still connect by running through + // an existing tile along its line — a tile that only abuts the board perpendicular to + // that line does not connect — and the first-move centre rule still applies. IgnoreCrossWords bool } diff --git a/scrabble/solver.go b/scrabble/solver.go index 174b2c1..6c358be 100644 --- a/scrabble/solver.go +++ b/scrabble/solver.go @@ -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) } diff --git a/scrabble/solver_opts_test.go b/scrabble/solver_opts_test.go index b75e07c..d4bd1bd 100644 --- a/scrabble/solver_opts_test.go +++ b/scrabble/solver_opts_test.go @@ -10,64 +10,62 @@ import ( // validated and scored, perpendicular cross-words are ignored. var singleWord = PlayOptions{IgnoreCrossWords: true} -// TestValidatePlayOptsIgnoresCrossWords is the core of the single-word rule: a play whose -// main word is valid but which forms an invalid cross-word is rejected under the standard -// rules yet accepted, and scored by its main word only, under IgnoreCrossWords. The play is -// also the connectivity regression case — "art" is all-new tiles that touch the board only -// perpendicular to the main word, so it exercises connected() without a recorded cross-word. +// TestValidatePlayOptsIgnoresCrossWords is the core of the single-word rule: a legal play +// (its main word runs through an existing tile) whose only flaw is an invalid perpendicular +// cross-word is rejected under the standard rules yet accepted, and scored by its main word +// only, under IgnoreCrossWords. "tea" laid down the column through the existing 't' of "cat" +// bridges that tile, so it connects; the new 'e' abuts a lone 'b', spelling the non-word "eb". func TestValidatePlayOptsIgnoresCrossWords(t *testing.T) { s := newTestSolver(t) b := board.New(s.rules.Rows, s.rules.Cols) - placeWord(b, 3, 1, Horizontal, "cat") - // "art" on the row below "cat": the main word "art" is valid, but the columns spell - // the non-words "ca", "ar" and "tt". - art := []Placement{{Row: 4, Col: 1, Letter: 0}, {Row: 4, Col: 2, Letter: 17}, {Row: 4, Col: 3, Letter: 19}} + placeWord(b, 3, 1, Horizontal, "cat") // c(3,1) a(3,2) t(3,3) + placeWord(b, 4, 4, Horizontal, "b") // a lone tile so the new 'e' forms a cross-word + tea := []Placement{{Row: 4, Col: 3, Letter: 4}, {Row: 5, Col: 3, Letter: 0}} // e(4,3) a(5,3) - if _, err := s.ValidatePlay(b, Horizontal, art); err == nil { - t.Fatal("standard rules accepted a play forming the invalid cross-word \"ca\"") + if _, err := s.ValidatePlay(b, Vertical, tea); err == nil { + t.Fatal("standard rules accepted a play forming the invalid cross-word \"eb\"") } - m, err := s.ValidatePlayOpts(b, Horizontal, art, singleWord) + m, err := s.ValidatePlayOpts(b, Vertical, tea, singleWord) if err != nil { t.Fatalf("single-word rule rejected a valid main word: %v", err) } if len(m.Cross) != 0 { t.Errorf("single-word move carries %d cross-words, want 0", len(m.Cross)) } - if m.Score != 3 { // a1 r1 t1, no premiums on the plain board, no cross-words + if m.Score != 3 { // t1 e1 a1, no premiums on the plain board, no cross-words t.Errorf("single-word score = %d, want 3", m.Score) } - // The same placement scores 11 under standard geometry (main 3 + crosses "ca" 4, - // "ar" 2, "tt" 2), so the 8 dropped points are exactly the cross-words. - if std, _ := s.ScorePlay(b, Horizontal, art); std.Main.Score != 3 || std.Score != 11 { - t.Errorf("standard score: main %d total %d, want main 3 total 11", std.Main.Score, std.Score) + // The same placement scores 7 under standard geometry (main "tea" 3 + cross "eb" 4), so + // the 4 dropped points are exactly the ignored cross-word. + if std, _ := s.ScorePlay(b, Vertical, tea); std.Main.Score != 3 || std.Score != 7 { + t.Errorf("standard score: main %d total %d, want main 3 total 7", std.Main.Score, std.Score) } } -// TestScorePlayOptsExcludesValidCrossWords checks that even valid cross-words are dropped -// from the score under the single-word rule, and that a play connecting to the board purely -// through a (valid) perpendicular contact is still accepted under the standard rules — the -// other half of the connected() regression. +// TestScorePlayOptsExcludesValidCrossWords checks that even a *valid* cross-word is dropped +// from the score under the single-word rule. "tat" laid down the column through the existing +// 't' of "cat" connects in-line; the new 'a' abuts a lone 'a', spelling the valid word "aa", +// which both rules accept but only the standard rule scores. func TestScorePlayOptsExcludesValidCrossWords(t *testing.T) { s := newTestSolver(t) b := board.New(s.rules.Rows, s.rules.Cols) - placeWord(b, 3, 1, Horizontal, "cat") - // "ta" above the "at" of "cat": main "ta" plus the valid cross-words "ta" (col 2) and - // "at" (col 3). Both modes accept it; only the score differs. - ta := []Placement{{Row: 2, Col: 2, Letter: 19}, {Row: 2, Col: 3, Letter: 0}} + placeWord(b, 3, 1, Horizontal, "cat") // c(3,1) a(3,2) t(3,3) + placeWord(b, 4, 4, Horizontal, "a") // a lone tile so the new 'a' forms the cross "aa" + tat := []Placement{{Row: 4, Col: 3, Letter: 0}, {Row: 5, Col: 3, Letter: 19}} // a(4,3) t(5,3) - std, err := s.ValidatePlay(b, Horizontal, ta) + std, err := s.ValidatePlay(b, Vertical, tat) if err != nil { - t.Fatalf("standard rules rejected a play with valid cross-words: %v", err) + t.Fatalf("standard rules rejected a play with a valid cross-word: %v", err) } - if std.Score != 6 { // main "ta" 2 + cross "ta" 2 + cross "at" 2 - t.Errorf("standard score = %d, want 6", std.Score) + if std.Score != 5 { // main "tat" 3 + cross "aa" 2 + t.Errorf("standard score = %d, want 5", std.Score) } - m, err := s.ValidatePlayOpts(b, Horizontal, ta, singleWord) + m, err := s.ValidatePlayOpts(b, Vertical, tat, singleWord) if err != nil { t.Fatalf("single-word rule rejected the play: %v", err) } - if m.Score != 2 || len(m.Cross) != 0 { // main "ta" only - t.Errorf("single-word score = %d with %d cross-words, want 2 and 0", m.Score, len(m.Cross)) + if m.Score != 3 || len(m.Cross) != 0 { // main "tat" only + t.Errorf("single-word score = %d with %d cross-words, want 3 and 0", m.Score, len(m.Cross)) } } @@ -94,40 +92,89 @@ func TestValidatePlayOptsStillEnforcesMainAndCentre(t *testing.T) { } } -// TestGenerateMovesOptsRelaxesCrossChecks confirms relaxed generation is a superset of -// standard generation (it never drops a move and never scores a shared move higher), and -// that it produces a play standard generation cannot — one that forms an invalid cross-word -// — scored by its main word only. -func TestGenerateMovesOptsRelaxesCrossChecks(t *testing.T) { +// TestSingleWordRuleRequiresInlineConnection is the contour regression. Under the single-word +// rule a play must still form its word ALONG the play direction together with an existing +// tile: a multi-tile play whose main word is all-new and that touches the board only +// perpendicular to itself does not connect — exactly as under the standard rules, the +// difference being only that the (invalid) cross-word is never formed. A play whose main word +// runs through an existing tile still connects. +func TestSingleWordRuleRequiresInlineConnection(t *testing.T) { + s := newTestSolver(t) + + // "art" all-new on the row below "cat" touches the board only perpendicular to its line + // (the non-words "ca"/"ar"/"tt"); it forms no word along its own row, so it does not + // connect — rejected under BOTH rules. + b := board.New(s.rules.Rows, s.rules.Cols) + placeWord(b, 3, 1, Horizontal, "cat") + art := []Placement{{Row: 4, Col: 1, Letter: 0}, {Row: 4, Col: 2, Letter: 17}, {Row: 4, Col: 3, Letter: 19}} + if _, err := s.ValidatePlayOpts(b, Horizontal, art, singleWord); err == nil { + t.Error("single-word rule accepted a parallel play that forms no word along its line") + } + if _, err := s.ValidatePlay(b, Horizontal, art); err == nil { + t.Error("standard rules accepted the same parallel play") + } + + // "car" laid down the column through the existing 'c' of "cat" forms its word along its + // own line and reuses that tile, so it connects and is accepted under the single-word rule. + b2 := board.New(s.rules.Rows, s.rules.Cols) + placeWord(b2, 3, 1, Horizontal, "cat") // c(3,1) + car := []Placement{{Row: 4, Col: 1, Letter: 0}, {Row: 5, Col: 1, Letter: 17}} // a(4,1) r(5,1) + if _, err := s.ValidatePlayOpts(b2, Vertical, car, singleWord); err != nil { + t.Errorf("single-word rule rejected an in-line play \"car\": %v", err) + } +} + +// TestGenerateMovesOptsConnectivity confirms single-word generation obeys the same +// connectivity rule as validation: it never offers a parallel play that forms no word along +// its own line (such a play would be rejected on submit), so every generated move validates. +// The "art" beside "cat" play is therefore absent. +func TestGenerateMovesOptsConnectivity(t *testing.T) { s := newTestSolver(t) b := board.New(s.rules.Rows, s.rules.Cols) placeWord(b, 3, 1, Horizontal, "cat") - std := genMoves(s.GenerateMoves(b, makeRack("art", 0), Both)) - rel := genMoves(s.GenerateMovesOpts(b, makeRack("art", 0), Both, singleWord)) - - for k, sm := range std { - rm, ok := rel[k] - if !ok { - t.Errorf("relaxed generation dropped the standard move %s", k) - continue - } - if rm.Score > sm.Score { - t.Errorf("relaxed move %s scored %d > standard %d", k, rm.Score, sm.Score) + moves := s.GenerateMovesOpts(b, makeRack("art", 0), Both, singleWord) + if len(moves) == 0 { + t.Fatal("single-word generation produced no moves") + } + // "art" on the row below "cat" forms no word along its row, so generation must not offer it. + art := []Placement{{Row: 4, Col: 1, Letter: 0}, {Row: 4, Col: 2, Letter: 17}, {Row: 4, Col: 3, Letter: 19}} + if _, ok := genMoves(moves)[moveKey(Horizontal, art)]; ok { + t.Error("single-word generation produced \"art\" beside \"cat\" (forms no word along its line)") + } + // Every generated single-word move must pass single-word validation. + for _, m := range moves { + if _, err := s.ValidatePlayOpts(b, m.Dir, m.Tiles, singleWord); err != nil { + t.Errorf("generated move %s fails its own validation: %v", moveKey(m.Dir, m.Tiles), err) } } - // "art" on the row below "cat" forms the invalid cross-word "ca", so standard - // generation cannot produce it, but relaxed generation does — scored by its main word. - art := []Placement{{Row: 4, Col: 1, Letter: 0}, {Row: 4, Col: 2, Letter: 17}, {Row: 4, Col: 3, Letter: 19}} - key := moveKey(Horizontal, art) +} + +// TestGenerateMovesOptsRelaxesCrossChecks confirms relaxed generation still relaxes the +// cross-word checks for IN-LINE plays: "tar" laid down the column through the existing 't' of +// "cat" connects through that tile, but its new 'r' abuts a lone 'w', spelling the non-word +// "rw". Standard generation omits it; single-word generation offers it, scored on its main +// word only. +func TestGenerateMovesOptsRelaxesCrossChecks(t *testing.T) { + s := newTestSolver(t) + b := board.New(s.rules.Rows, s.rules.Cols) + placeWord(b, 3, 1, Horizontal, "cat") // c(3,1) a(3,2) t(3,3) + placeWord(b, 5, 4, Horizontal, "w") // a lone tile so the new 'r' forms the cross "rw" + + std := genMoves(s.GenerateMoves(b, makeRack("ar", 0), Both)) + rel := genMoves(s.GenerateMovesOpts(b, makeRack("ar", 0), Both, singleWord)) + + // "tar" down through the existing 't': a(4,3) r(5,3). + tar := []Placement{{Row: 4, Col: 3, Letter: 0}, {Row: 5, Col: 3, Letter: 17}} + key := moveKey(Vertical, tar) if _, ok := std[key]; ok { - t.Error("standard generation unexpectedly produced \"art\" beside \"cat\"") + t.Error("standard generation produced \"tar\" despite its invalid cross-word \"rw\"") } rm, ok := rel[key] if !ok { - t.Fatal("relaxed generation did not produce \"art\" beside \"cat\"") + t.Fatal("single-word generation did not produce the in-line \"tar\" (its cross-word is ignored)") } - if rm.Score != 3 { - t.Errorf("relaxed \"art\" scored %d, want 3", rm.Score) + if rm.Score != 3 { // main "tar" only: t1 a1 r1 + t.Errorf("single-word \"tar\" scored %d, want 3", rm.Score) } }