2 Commits

Author SHA1 Message Date
developer a1fd200d97 Merge pull request 'scrabble: single-word-per-turn rule via PlayOptions' (#2) from feat/single-word-rule into master 2026-06-12 07:18:49 +00:00
Ilia Denisov a8a559993d 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.
2026-06-12 01:37:39 +02:00
9 changed files with 299 additions and 39 deletions
+5 -1
View File
@@ -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 - DAWG move generation (across / down / both orientations), with full tournament scoring
(cross-words, premiums, all-tiles bonus) and a per-tile breakdown. (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 - Rulesets: **English** Scrabble, **Russian** Scrabble, **Эрудит**; `rules.Ruleset` is
fully parameterizable (board, premiums, tile values/counts, blanks, rack, bonus). fully parameterizable (board, premiums, tile values/counts, blanks, rack, bonus).
- A GADDAG (Gordon) was implemented, benchmarked and then **removed** — for a scoring - A GADDAG (Gordon) was implemented, benchmarked and then **removed** — for a scoring
+39 -7
View File
@@ -31,7 +31,27 @@ func TestScoreRealGames(t *testing.T) {
t.Fatal("no GCG games in testdata/") t.Fatal("no GCG games in testdata/")
} }
for _, g := range games { 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 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) f, err := os.Open(path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -136,18 +156,30 @@ func replayGCG(t *testing.T, s *Solver, path string) {
switch row, col, dir, ok := parsePos(toks[1]); { switch row, col, dir, ok := parsePos(toks[1]); {
case ok: // a regular play: RACK POS WORD +SCORE CUMUL case ok: // a regular play: RACK POS WORD +SCORE CUMUL
ts := parseWord(toks[2], row, col, dir) 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 { if err != nil {
t.Fatalf("%s: ScorePlay %q at %s: %v", path, toks[2], toks[1], err) t.Fatalf("%s: ScorePlay %q at %s: %v", path, toks[2], toks[1], err)
} }
if m.Score != want { if m.Score != want {
t.Errorf("%s: %q at %s scored %d, want %d", path, toks[2], toks[1], 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 if opts.IgnoreCrossWords {
// player's rack; phonies (not in SOWPODS) are correctly never generated. // Single-word scoring must equal the standard scorer's main word (plus any
if _, verr := s.ValidatePlay(b, dir, ts); verr == nil { // 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 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 { if mv.Key() == key {
found = true found = true
if mv.Score != want { if mv.Score != want {
+5 -5
View File
@@ -10,8 +10,8 @@ import (
// transpose (for vertical plays), as selected by mode, then scores and de-duplicates the // 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 // 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. // the transpose pass they are mapped back to the real board.
func generateBoth(b *board.Board, rs *rules.Ruleset, rk rack.Rack, mode Mode, func generateBoth(b *board.Board, rs *rules.Ruleset, rk rack.Rack, mode Mode, opts PlayOptions,
runAcross func(bd *board.Board, rk rack.Rack, emit func([]Placement))) []Move { 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 rk = rk.Clone() // generation mutates the rack in place and restores it
var moves []Move 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 { if _, dup := seen[key]; dup {
return return
} }
m, err := Evaluate(b, rs, dir, placements) m, err := EvaluateOpts(b, rs, dir, placements, opts)
if err != nil { if err != nil {
return return
} }
@@ -30,11 +30,11 @@ func generateBoth(b *board.Board, rs *rules.Ruleset, rk rack.Rack, mode Mode,
} }
if mode.Includes(Horizontal) { 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) { if mode.Includes(Vertical) {
tb := b.Transpose() tb := b.Transpose()
runAcross(tb, rk, func(p []Placement) { runAcross(tb, rk, opts, func(p []Placement) {
rp := make([]Placement, len(p)) rp := make([]Placement, len(p))
for i, pl := range p { for i, pl := range p {
rp[i] = Placement{Row: pl.Col, Col: pl.Row, Letter: pl.Letter, Blank: pl.Blank} rp[i] = Placement{Row: pl.Col, Col: pl.Row, Letter: pl.Letter, Blank: pl.Blank}
+29 -13
View File
@@ -24,9 +24,18 @@ func NewDAWGGenerator(rs *rules.Ruleset, finder dawg.Finder) *DAWGGenerator {
// Name identifies the generator. // Name identifies the generator.
func (g *DAWGGenerator) Name() string { return "dawg" } 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 { 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 // 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 // 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. // on bd) and reports each via emit in bd's coordinates. With opts.IgnoreCrossWords the
func (g *DAWGGenerator) runAcross(bd *board.Board, rk rack.Rack, emit func([]Placement)) { // 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) cur, err := dawg.NewCursor(g.finder)
if err != nil { if err != nil {
return return
} }
size := g.rules.Size() size := g.rules.Size()
cross := make([]letterSet, bd.Rows()*bd.Cols()) var crossFn func(r, c int) letterSet
known := make([]bool, bd.Rows()*bd.Cols()) if opts.IgnoreCrossWords {
crossFn := func(r, c int) letterSet { full := fullSet(size)
i := r*bd.Cols() + c crossFn = func(int, int) letterSet { return full }
if !known[i] { } else {
above, below := columnContext(bd, r, c) cross := make([]letterSet, bd.Rows()*bd.Cols())
cross[i] = dawgCrossSet(cur, above, below, size) known := make([]bool, bd.Rows()*bd.Cols())
known[i] = true 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} ag := &acrossGen{bd: bd, cur: cur, rs: g.rules, rk: rk, size: size, cross: crossFn, emit: emit}
+11
View File
@@ -72,3 +72,14 @@ type Move struct {
Bonus int // all-tiles (bingo) bonus included in Score, or 0 Bonus int // all-tiles (bingo) bonus included in Score, or 0
Score int // total: Main.Score + Σ Cross.Score + Bonus 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
}
+16 -5
View File
@@ -40,8 +40,17 @@ func perpendicular(d Direction) Direction {
// dir under ruleset rs. It validates geometry — the tiles lie on one line, on empty // 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 // 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 // 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) { 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 { if len(tiles) == 0 {
return Move{}, errors.New("scrabble: empty play") 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} move := Move{Dir: dir, Tiles: ts, Main: main, Score: main.Score}
for _, t := range ts { if !opts.IgnoreCrossWords {
if cw, ok := crossWord(b, rs, dir, t); ok { for _, t := range ts {
move.Cross = append(move.Cross, cw) if cw, ok := crossWord(b, rs, dir, t); ok {
move.Score += cw.Score move.Cross = append(move.Cross, cw)
move.Score += cw.Score
}
} }
} }
if len(ts) == rs.RackSize { if len(ts) == rs.RackSize {
+50 -8
View File
@@ -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 // 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 // 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 { 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 { sort.Slice(moves, func(i, j int) bool {
if moves[i].Score != moves[j].Score { if moves[i].Score != moves[j].Score {
return 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 // 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 // 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) { 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 // 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 // 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) { 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 { if err != nil {
return Move{}, err 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 cr, cc := s.rules.Center/s.rules.Cols, s.rules.Center%s.rules.Cols
return wordCovers(m.Main, cr, cc) return wordCovers(m.Main, cr, cc)
} }
// The main word incorporated an existing tile, or a new tile formed a cross word. // The main word incorporated an existing tile, or a new tile abuts an existing one
return len(m.Main.Letters) > len(m.Tiles) || len(m.Cross) > 0 // 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 { func wordCovers(w Word, r, c int) bool {
+133
View File
@@ -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)
}
}
+11
View File
@@ -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