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:
+39
-7
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user