scrabble: add single-word-per-turn rule via PlayOptions

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.
This commit is contained in:
Ilia Denisov
2026-06-12 01:37:39 +02:00
parent 256999b42c
commit a8a559993d
9 changed files with 299 additions and 39 deletions
+39 -7
View File
@@ -31,7 +31,27 @@ func TestScoreRealGames(t *testing.T) {
t.Fatal("no GCG games in testdata/")
}
for _, g := range games {
t.Run(filepath.Base(g), func(t *testing.T) { replayGCG(t, s, g) })
t.Run(filepath.Base(g), func(t *testing.T) { replayGCGOpts(t, s, g, PlayOptions{}) })
}
}
// TestSingleWordGames replays single-word ("multiple words per turn" off) fixtures, in
// which some plays form invalid cross-words that the standard rules would reject. The
// replay (replayGCGOpts with IgnoreCrossWords) checks each move's single-word score against
// the trusted standard scorer's main word, so the rule is exercised end to end: scoring,
// validation and relaxed generation.
func TestSingleWordGames(t *testing.T) {
finder, err := dictdawg.Load("../dawg/en_sowpods.dawg")
if err != nil {
t.Skipf("need dawg/en_sowpods.dawg: %v", err)
}
s := NewSolver(rules.English(), finder)
games, _ := filepath.Glob("testdata/singleword/*.gcg")
if len(games) == 0 {
t.Fatal("no single-word GCG games in testdata/singleword/")
}
for _, g := range games {
t.Run(filepath.Base(g), func(t *testing.T) { replayGCGOpts(t, s, g, PlayOptions{IgnoreCrossWords: true}) })
}
}
@@ -105,7 +125,7 @@ func parseWord(word string, row, col int, dir Direction) []Placement {
return ts
}
func replayGCG(t *testing.T, s *Solver, path string) {
func replayGCGOpts(t *testing.T, s *Solver, path string, opts PlayOptions) {
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
@@ -136,18 +156,30 @@ func replayGCG(t *testing.T, s *Solver, path string) {
switch row, col, dir, ok := parsePos(toks[1]); {
case ok: // a regular play: RACK POS WORD +SCORE CUMUL
ts := parseWord(toks[2], row, col, dir)
m, err := s.ScorePlay(b, dir, ts)
m, err := s.ScorePlayOpts(b, dir, ts, opts)
if err != nil {
t.Fatalf("%s: ScorePlay %q at %s: %v", path, toks[2], toks[1], err)
}
if m.Score != want {
t.Errorf("%s: %q at %s scored %d, want %d", path, toks[2], toks[1], m.Score, want)
}
// A dictionary-valid play must also be produced by the generator from the
// player's rack; phonies (not in SOWPODS) are correctly never generated.
if _, verr := s.ValidatePlay(b, dir, ts); verr == nil {
if opts.IgnoreCrossWords {
// Single-word scoring must equal the standard scorer's main word (plus any
// all-tiles bonus) and form no cross-words. Checking against the trusted
// standard scorer keeps the fixture's numbers from drifting silently.
std, _ := s.ScorePlay(b, dir, ts)
if wantMain := std.Main.Score + std.Bonus; m.Score != wantMain {
t.Errorf("%s: %q at %s single-word scored %d, want main+bonus %d", path, toks[2], toks[1], m.Score, wantMain)
}
if len(m.Cross) != 0 {
t.Errorf("%s: %q at %s formed %d cross-words in single-word mode", path, toks[2], toks[1], len(m.Cross))
}
}
// A dictionary-valid play (under the active rules) must also be produced by the
// generator from the player's rack; phonies are correctly never generated.
if _, verr := s.ValidatePlayOpts(b, dir, ts, opts); verr == nil {
key, found := moveKey(dir, ts), false
for _, mv := range s.GenerateMoves(b, makeRack(parseRack(toks[0])), Both) {
for _, mv := range s.GenerateMovesOpts(b, makeRack(parseRack(toks[0])), Both, opts) {
if mv.Key() == key {
found = true
if mv.Score != want {