From a8a559993dbcf994c633d502b84c97453298f340 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 12 Jun 2026 01:37:39 +0200 Subject: [PATCH] scrabble: add single-word-per-turn rule via PlayOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 6 +- scrabble/gcg_test.go | 46 +++++- scrabble/gen.go | 10 +- scrabble/gen_dawg.go | 42 ++++-- scrabble/move.go | 11 ++ scrabble/score.go | 21 ++- scrabble/solver.go | 58 ++++++-- scrabble/solver_opts_test.go | 133 ++++++++++++++++++ .../testdata/singleword/cross_invalid.gcg | 11 ++ 9 files changed, 299 insertions(+), 39 deletions(-) create mode 100644 scrabble/solver_opts_test.go create mode 100644 scrabble/testdata/singleword/cross_invalid.gcg diff --git a/README.md b/README.md index 0f29553..4da4541 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,11 @@ See [`ALGORITHM.md`](ALGORITHM.md) for the algorithm (the single source of truth - DAWG move generation (across / down / both orientations), with full tournament scoring (cross-words, premiums, all-tiles bonus) and a per-tile breakdown. -- Public `Solver`: `GenerateMoves` (ranked), `ScorePlay`, `ValidatePlay`. +- Public `Solver`: `GenerateMoves` (ranked), `ScorePlay`, `ValidatePlay`, each with an + `*Opts` variant taking `PlayOptions` for optional rule variations. +- Optional **single word per turn** rule (`PlayOptions{IgnoreCrossWords: true}`): only the + main word is validated and scored — perpendicular cross-words are ignored, including in + move generation. The zero `PlayOptions` is standard Scrabble. - Rulesets: **English** Scrabble, **Russian** Scrabble, **Эрудит**; `rules.Ruleset` is fully parameterizable (board, premiums, tile values/counts, blanks, rack, bonus). - A GADDAG (Gordon) was implemented, benchmarked and then **removed** — for a scoring diff --git a/scrabble/gcg_test.go b/scrabble/gcg_test.go index 8346f15..20fc7f2 100644 --- a/scrabble/gcg_test.go +++ b/scrabble/gcg_test.go @@ -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 { diff --git a/scrabble/gen.go b/scrabble/gen.go index f1a1d23..2be3577 100644 --- a/scrabble/gen.go +++ b/scrabble/gen.go @@ -10,8 +10,8 @@ import ( // transpose (for vertical plays), as selected by mode, then scores and de-duplicates the // results. runAcross reports placements in the coordinates of the board it is given; for // the transpose pass they are mapped back to the real board. -func generateBoth(b *board.Board, rs *rules.Ruleset, rk rack.Rack, mode Mode, - runAcross func(bd *board.Board, rk rack.Rack, emit func([]Placement))) []Move { +func generateBoth(b *board.Board, rs *rules.Ruleset, rk rack.Rack, mode Mode, opts PlayOptions, + runAcross func(bd *board.Board, rk rack.Rack, opts PlayOptions, emit func([]Placement))) []Move { rk = rk.Clone() // generation mutates the rack in place and restores it var moves []Move @@ -21,7 +21,7 @@ func generateBoth(b *board.Board, rs *rules.Ruleset, rk rack.Rack, mode Mode, if _, dup := seen[key]; dup { return } - m, err := Evaluate(b, rs, dir, placements) + m, err := EvaluateOpts(b, rs, dir, placements, opts) if err != nil { return } @@ -30,11 +30,11 @@ func generateBoth(b *board.Board, rs *rules.Ruleset, rk rack.Rack, mode Mode, } if mode.Includes(Horizontal) { - runAcross(b, rk, func(p []Placement) { emit(Horizontal, p) }) + runAcross(b, rk, opts, func(p []Placement) { emit(Horizontal, p) }) } if mode.Includes(Vertical) { tb := b.Transpose() - runAcross(tb, rk, func(p []Placement) { + runAcross(tb, rk, opts, func(p []Placement) { rp := make([]Placement, len(p)) for i, pl := range p { rp[i] = Placement{Row: pl.Col, Col: pl.Row, Letter: pl.Letter, Blank: pl.Blank} diff --git a/scrabble/gen_dawg.go b/scrabble/gen_dawg.go index c54d09c..a9e1a40 100644 --- a/scrabble/gen_dawg.go +++ b/scrabble/gen_dawg.go @@ -24,9 +24,18 @@ func NewDAWGGenerator(rs *rules.Ruleset, finder dawg.Finder) *DAWGGenerator { // Name identifies the generator. func (g *DAWGGenerator) Name() string { return "dawg" } -// GenerateMoves returns every legal play for rk on b in the modes' orientations. +// GenerateMoves returns every legal play for rk on b in the modes' orientations under +// standard rules. It satisfies the Generator interface; GenerateMovesOpts adds optional +// rule variations. func (g *DAWGGenerator) GenerateMoves(b *board.Board, rk rack.Rack, mode Mode) []Move { - return generateBoth(b, g.rules, rk, mode, g.runAcross) + return g.GenerateMovesOpts(b, rk, mode, PlayOptions{}) +} + +// GenerateMovesOpts is GenerateMoves with optional rule variations (PlayOptions). With +// opts.IgnoreCrossWords the cross-sets are unconstrained (every letter that extends the +// main word is allowed) and each move is scored by its main word only. +func (g *DAWGGenerator) GenerateMovesOpts(b *board.Board, rk rack.Rack, mode Mode, opts PlayOptions) []Move { + return generateBoth(b, g.rules, rk, mode, opts, g.runAcross) } // tileInfo is a tentatively placed left-part tile (its column is fixed only once the @@ -52,24 +61,31 @@ type acrossGen struct { } // runAcross generates all across plays on bd (cross-sets are computed as vertical words -// on bd) and reports each via emit in bd's coordinates. -func (g *DAWGGenerator) runAcross(bd *board.Board, rk rack.Rack, emit func([]Placement)) { +// on bd) and reports each via emit in bd's coordinates. With opts.IgnoreCrossWords the +// cross-sets are unconstrained, so any letter that extends the main word may be placed. +func (g *DAWGGenerator) runAcross(bd *board.Board, rk rack.Rack, opts PlayOptions, emit func([]Placement)) { cur, err := dawg.NewCursor(g.finder) if err != nil { return } size := g.rules.Size() - cross := make([]letterSet, bd.Rows()*bd.Cols()) - known := make([]bool, bd.Rows()*bd.Cols()) - crossFn := func(r, c int) letterSet { - i := r*bd.Cols() + c - if !known[i] { - above, below := columnContext(bd, r, c) - cross[i] = dawgCrossSet(cur, above, below, size) - known[i] = true + var crossFn func(r, c int) letterSet + if opts.IgnoreCrossWords { + full := fullSet(size) + crossFn = func(int, int) letterSet { return full } + } else { + cross := make([]letterSet, bd.Rows()*bd.Cols()) + known := make([]bool, bd.Rows()*bd.Cols()) + crossFn = func(r, c int) letterSet { + i := r*bd.Cols() + c + if !known[i] { + above, below := columnContext(bd, r, c) + cross[i] = dawgCrossSet(cur, above, below, size) + known[i] = true + } + return cross[i] } - return cross[i] } ag := &acrossGen{bd: bd, cur: cur, rs: g.rules, rk: rk, size: size, cross: crossFn, emit: emit} diff --git a/scrabble/move.go b/scrabble/move.go index e0ab79d..5324db4 100644 --- a/scrabble/move.go +++ b/scrabble/move.go @@ -72,3 +72,14 @@ type Move struct { Bonus int // all-tiles (bingo) bonus included in Score, or 0 Score int // total: Main.Score + Σ Cross.Score + Bonus } + +// PlayOptions tunes optional rule variations applied when evaluating, validating or +// generating plays. The zero value is standard Scrabble. +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. + IgnoreCrossWords bool +} diff --git a/scrabble/score.go b/scrabble/score.go index d8ce1ad..025724e 100644 --- a/scrabble/score.go +++ b/scrabble/score.go @@ -40,8 +40,17 @@ func perpendicular(d Direction) Direction { // dir under ruleset rs. It validates geometry — the tiles lie on one line, on empty // squares, and form a single contiguous run together with existing tiles — but does not // check the dictionary or board connectivity; ValidatePlay layers those on top. tiles -// need not be sorted. +// need not be sorted. It evaluates under standard rules; use EvaluateOpts to apply optional +// rule variations. func Evaluate(b *board.Board, rs *rules.Ruleset, dir Direction, tiles []Placement) (Move, error) { + return EvaluateOpts(b, rs, dir, tiles, PlayOptions{}) +} + +// EvaluateOpts is Evaluate with optional rule variations (PlayOptions). With +// opts.IgnoreCrossWords set, the play's perpendicular cross-words are neither formed nor +// scored: the returned move carries no Cross words and its Score counts only the main word +// (plus any all-tiles bonus). +func EvaluateOpts(b *board.Board, rs *rules.Ruleset, dir Direction, tiles []Placement, opts PlayOptions) (Move, error) { if len(tiles) == 0 { return Move{}, errors.New("scrabble: empty play") } @@ -78,10 +87,12 @@ func Evaluate(b *board.Board, rs *rules.Ruleset, dir Direction, tiles []Placemen } move := Move{Dir: dir, Tiles: ts, Main: main, Score: main.Score} - for _, t := range ts { - if cw, ok := crossWord(b, rs, dir, t); ok { - move.Cross = append(move.Cross, cw) - move.Score += cw.Score + if !opts.IgnoreCrossWords { + for _, t := range ts { + if cw, ok := crossWord(b, rs, dir, t); ok { + move.Cross = append(move.Cross, cw) + move.Score += cw.Score + } } } if len(ts) == rs.RackSize { diff --git a/scrabble/solver.go b/scrabble/solver.go index b050364..174b2c1 100644 --- a/scrabble/solver.go +++ b/scrabble/solver.go @@ -30,9 +30,18 @@ func (s *Solver) Rules() *rules.Ruleset { return s.rules } // GenerateMoves returns every legal play for rack r on board b in the requested // orientations, ranked by descending score (ties broken deterministically by the move's -// canonical key). +// canonical key). It generates under standard rules; use GenerateMovesOpts for rule +// variations. func (s *Solver) GenerateMoves(b *board.Board, r rack.Rack, mode Mode) []Move { - moves := s.gen.GenerateMoves(b, r, mode) + return s.GenerateMovesOpts(b, r, mode, PlayOptions{}) +} + +// GenerateMovesOpts is GenerateMoves with optional rule variations (PlayOptions). With +// opts.IgnoreCrossWords generation is not constrained by cross-words and each move is +// scored by its main word only, yielding the plays legal under the "single word per turn" +// rule. +func (s *Solver) GenerateMovesOpts(b *board.Board, r rack.Rack, mode Mode, opts PlayOptions) []Move { + moves := s.gen.GenerateMovesOpts(b, r, mode, opts) sort.Slice(moves, func(i, j int) bool { if moves[i].Score != moves[j].Score { return moves[i].Score > moves[j].Score @@ -44,16 +53,29 @@ func (s *Solver) GenerateMoves(b *board.Board, r rack.Rack, mode Mode) []Move { // ScorePlay computes the words and score for placing tiles on b in direction dir. It // checks geometry only (see Evaluate); use ValidatePlay to also check the dictionary and -// connectivity. +// connectivity. It scores under standard rules; use ScorePlayOpts for rule variations. func (s *Solver) ScorePlay(b *board.Board, dir Direction, tiles []Placement) (Move, error) { - return Evaluate(b, s.rules, dir, tiles) + return s.ScorePlayOpts(b, dir, tiles, PlayOptions{}) +} + +// ScorePlayOpts is ScorePlay with optional rule variations (PlayOptions); see EvaluateOpts. +func (s *Solver) ScorePlayOpts(b *board.Board, dir Direction, tiles []Placement, opts PlayOptions) (Move, error) { + return EvaluateOpts(b, s.rules, dir, tiles, opts) } // ValidatePlay scores a play and verifies that every word it forms is in the dictionary // and that it connects to the board (or covers the centre on the first move). It returns -// the scored move; the error is nil exactly when the play is legal. +// the scored move; the error is nil exactly when the play is legal. It validates under +// standard rules; use ValidatePlayOpts for rule variations. func (s *Solver) ValidatePlay(b *board.Board, dir Direction, tiles []Placement) (Move, error) { - m, err := Evaluate(b, s.rules, dir, tiles) + return s.ValidatePlayOpts(b, dir, tiles, PlayOptions{}) +} + +// 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. +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 { return Move{}, err } @@ -81,8 +103,28 @@ func (s *Solver) connected(b *board.Board, m Move) bool { 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 formed a cross word. - return len(m.Main.Letters) > len(m.Tiles) || len(m.Cross) > 0 + // 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). + return len(m.Main.Letters) > len(m.Tiles) || touchesPerpendicular(b, m) +} + +// touchesPerpendicular reports whether any newly-placed tile sits immediately next to an +// existing tile along the perpendicular of the main word — the contact by which an all-new +// main word connects to the board. It mirrors crossWord's neighbour test without forming or +// scoring the cross-word. +func touchesPerpendicular(b *board.Board, m Move) bool { + cdir := perpendicular(m.Dir) + for _, t := range m.Tiles { + fixed, axis := fixedAxis(cdir, t.Row, t.Col) + if r, c := coord(cdir, fixed, axis-1); b.Filled(r, c) { + return true + } + if r, c := coord(cdir, fixed, axis+1); b.Filled(r, c) { + return true + } + } + return false } func wordCovers(w Word, r, c int) bool { diff --git a/scrabble/solver_opts_test.go b/scrabble/solver_opts_test.go new file mode 100644 index 0000000..b75e07c --- /dev/null +++ b/scrabble/solver_opts_test.go @@ -0,0 +1,133 @@ +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) + } +} diff --git a/scrabble/testdata/singleword/cross_invalid.gcg b/scrabble/testdata/singleword/cross_invalid.gcg new file mode 100644 index 0000000..af56393 --- /dev/null +++ b/scrabble/testdata/singleword/cross_invalid.gcg @@ -0,0 +1,11 @@ +#character-encoding UTF-8 +#player1 p1 Player One +#player2 p2 Player Two +#note Single-word rule fixture ("multiple words per turn" off). After the opening, each AT +#note stacks under the previous one, forming an invalid vertical cross-word (TT, then AAA / +#note TTT, ...). Standard rules reject these; the single-word rule accepts them and scores +#note the main word only. The +SCORE/CUMUL columns are main-word-only scores. +>p1: ATEIOUN 8H AT +4 4 +>p2: ATRSEOD 9H AT +3 3 +>p1: ATLNGUP 10H AT +2 6 +>p2: ATCHIMB 11H AT +2 5