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
+103 -56
View File
@@ -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)
}
}