a8a559993d
Add an optional "single word per turn" rule: only the main word along the
play direction is validated and scored; perpendicular cross-words are ignored
— not formed, validated, scored, or used to constrain move generation. The
zero PlayOptions stays standard Scrabble, so existing callers are unchanged.
- PlayOptions{IgnoreCrossWords} threaded through new EvaluateOpts,
Solver.{ScorePlay,ValidatePlay,GenerateMoves}Opts and the DAWG generator
(relaxed cross-sets via fullSet, main-word-only scoring).
- connected() tests perpendicular adjacency directly instead of via
Move.Cross, so an all-new main word touching the board only sideways still
connects when cross-words are suppressed (behaviour-preserving for standard
play).
- Tests: focused corner-case suite (solver_opts_test.go) and a single-word
GCG fixture; the 17 real-game GCG fixtures stay green as the standard-rules
regression guard.
134 lines
6.1 KiB
Go
134 lines
6.1 KiB
Go
package scrabble
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
|
)
|
|
|
|
// singleWord is the PlayOptions for the "single word per turn" rule: only the main word is
|
|
// 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.
|
|
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}}
|
|
|
|
if _, err := s.ValidatePlay(b, Horizontal, art); err == nil {
|
|
t.Fatal("standard rules accepted a play forming the invalid cross-word \"ca\"")
|
|
}
|
|
m, err := s.ValidatePlayOpts(b, Horizontal, art, 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
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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}}
|
|
|
|
std, err := s.ValidatePlay(b, Horizontal, ta)
|
|
if err != nil {
|
|
t.Fatalf("standard rules rejected a play with valid cross-words: %v", err)
|
|
}
|
|
if std.Score != 6 { // main "ta" 2 + cross "ta" 2 + cross "at" 2
|
|
t.Errorf("standard score = %d, want 6", std.Score)
|
|
}
|
|
m, err := s.ValidatePlayOpts(b, Horizontal, ta, 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))
|
|
}
|
|
}
|
|
|
|
// TestValidatePlayOptsStillEnforcesMainAndCentre confirms the single-word rule relaxes only
|
|
// cross-words: the main word is still checked against the dictionary and the first move must
|
|
// still cover the centre.
|
|
func TestValidatePlayOptsStillEnforcesMainAndCentre(t *testing.T) {
|
|
s := newTestSolver(t)
|
|
empty := func() *board.Board { return board.New(s.rules.Rows, s.rules.Cols) }
|
|
// A valid first move through the centre (3,3) is accepted under the single-word rule.
|
|
cat := []Placement{{Row: 3, Col: 2, Letter: 2}, {Row: 3, Col: 3, Letter: 0}, {Row: 3, Col: 4, Letter: 19}}
|
|
if _, err := s.ValidatePlayOpts(empty(), Horizontal, cat, singleWord); err != nil {
|
|
t.Errorf("single-word rule rejected the valid first move \"cat\": %v", err)
|
|
}
|
|
// The main word is still validated: "caz" is rejected even though cross-words are ignored.
|
|
caz := []Placement{{Row: 3, Col: 2, Letter: 2}, {Row: 3, Col: 3, Letter: 0}, {Row: 3, Col: 4, Letter: 25}}
|
|
if _, err := s.ValidatePlayOpts(empty(), Horizontal, caz, singleWord); err == nil {
|
|
t.Error("single-word rule accepted the non-word main \"caz\"")
|
|
}
|
|
// An off-centre first move is still rejected under the single-word rule.
|
|
off := []Placement{{Row: 0, Col: 0, Letter: 2}, {Row: 0, Col: 1, Letter: 0}, {Row: 0, Col: 2, Letter: 19}}
|
|
if _, err := s.ValidatePlayOpts(empty(), Horizontal, off, singleWord); err == nil {
|
|
t.Error("single-word rule accepted a first move that misses the centre")
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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)
|
|
}
|
|
}
|
|
// "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)
|
|
if _, ok := std[key]; ok {
|
|
t.Error("standard generation unexpectedly produced \"art\" beside \"cat\"")
|
|
}
|
|
rm, ok := rel[key]
|
|
if !ok {
|
|
t.Fatal("relaxed generation did not produce \"art\" beside \"cat\"")
|
|
}
|
|
if rm.Score != 3 {
|
|
t.Errorf("relaxed \"art\" scored %d, want 3", rm.Score)
|
|
}
|
|
}
|