4 Commits

Author SHA1 Message Date
developer bf48d3c915 Merge pull request 'scrabble: single-word rule requires in-line connection' (#3) from feature/single-word-connectivity into master 2026-06-14 12:46:10 +00:00
Ilia Denisov d70bd35470 scrabble: single-word rule requires in-line connection
Under PlayOptions.IgnoreCrossWords a play connected to the board through any
perpendicular contact, even one forming a non-word, because connected() reused
the standard rule's touchesPerpendicular test while cross-words were suppressed.
That let an all-new word merely abut the board sideways and be accepted, and the
generator, with relaxed cross-sets, offered such plays.

Require the main word to run through an existing tile along the play direction
when cross-words are ignored, and filter generated moves by the same predicate so
generation agrees with ValidatePlayOpts. Standard rules are unchanged.
2026-06-14 14:38:49 +02:00
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 371 additions and 43 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
(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
+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 {
+5 -5
View File
@@ -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}
+21 -5
View File
@@ -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,17 +61,23 @@ 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()
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 {
crossFn = func(r, c int) letterSet {
i := r*bd.Cols() + c
if !known[i] {
above, below := columnContext(bd, r, c)
@@ -71,6 +86,7 @@ func (g *DAWGGenerator) runAcross(bd *board.Board, rk rack.Rack, emit func([]Pla
}
return cross[i]
}
}
ag := &acrossGen{bd: bd, cur: cur, rs: g.rules, rk: rk, size: size, cross: crossFn, emit: emit}
+12
View File
@@ -72,3 +72,15 @@ 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. That main word must still connect by running through
// an existing tile along its line — a tile that only abuts the board perpendicular to
// that line does not connect — and the first-move centre rule still applies.
IgnoreCrossWords bool
}
+12 -1
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
// 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,12 +87,14 @@ func Evaluate(b *board.Board, rs *rules.Ruleset, dir Direction, tiles []Placemen
}
move := Move{Dir: dir, Tiles: ts, Main: main, Score: main.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 {
move.Bonus = rs.Bingo
move.Score += rs.Bingo
+74 -12
View File
@@ -30,9 +30,30 @@ 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)
// Keep only moves that connect under the active rule, so generation agrees with
// ValidatePlayOpts. Standard generation already guarantees this (every play covers an
// anchor and forms valid cross-words), so the filter only bites under the single-word
// rule, where relaxed cross-sets let the generator lay an all-new word that merely abuts
// the board perpendicular to its own line.
kept := moves[:0]
for _, m := range moves {
if s.connected(b, m, opts) {
kept = append(kept, m)
}
}
moves = kept
sort.Slice(moves, func(i, j int) bool {
if moves[i].Score != moves[j].Score {
return moves[i].Score > moves[j].Score
@@ -44,16 +65,30 @@ 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; it must still connect by running through an existing tile along
// the play direction (see connected), and the first-move centre rule still applies.
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
}
@@ -68,21 +103,48 @@ func (s *Solver) ValidatePlay(b *board.Board, dir Direction, tiles []Placement)
return m, fmt.Errorf("scrabble: a cross word is not in the dictionary")
}
}
if !s.connected(b, m) {
if !s.connected(b, m, opts) {
return m, errors.New("scrabble: play does not connect to the board")
}
return m, nil
}
// connected reports whether the play touches the existing position (or covers the centre
// on the first move).
func (s *Solver) connected(b *board.Board, m Move) bool {
// connected reports whether the play connects to the existing position (or covers the
// centre on the first move).
func (s *Solver) connected(b *board.Board, m Move, opts PlayOptions) bool {
if b.IsEmpty() {
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 runs through an existing tile when it is longer than the tiles just
// laid. Under the single-word rule (opts.IgnoreCrossWords) that is the only way to
// connect: the play forms its one word along the play direction, so a tile that merely
// abuts the board perpendicular to that line — forming a cross-word the rule does not
// check — does not connect.
if opts.IgnoreCrossWords {
return len(m.Main.Letters) > len(m.Tiles)
}
// Standard rules also accept a new tile abutting an existing one perpendicular to the
// main word: that contact forms a cross-word, which ValidatePlayOpts has already checked.
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 {
+180
View File
@@ -0,0 +1,180 @@
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 legal play
// (its main word runs through an existing tile) whose only flaw is an invalid perpendicular
// cross-word is rejected under the standard rules yet accepted, and scored by its main word
// only, under IgnoreCrossWords. "tea" laid down the column through the existing 't' of "cat"
// bridges that tile, so it connects; the new 'e' abuts a lone 'b', spelling the non-word "eb".
func TestValidatePlayOptsIgnoresCrossWords(t *testing.T) {
s := newTestSolver(t)
b := board.New(s.rules.Rows, s.rules.Cols)
placeWord(b, 3, 1, Horizontal, "cat") // c(3,1) a(3,2) t(3,3)
placeWord(b, 4, 4, Horizontal, "b") // a lone tile so the new 'e' forms a cross-word
tea := []Placement{{Row: 4, Col: 3, Letter: 4}, {Row: 5, Col: 3, Letter: 0}} // e(4,3) a(5,3)
if _, err := s.ValidatePlay(b, Vertical, tea); err == nil {
t.Fatal("standard rules accepted a play forming the invalid cross-word \"eb\"")
}
m, err := s.ValidatePlayOpts(b, Vertical, tea, 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 { // t1 e1 a1, no premiums on the plain board, no cross-words
t.Errorf("single-word score = %d, want 3", m.Score)
}
// The same placement scores 7 under standard geometry (main "tea" 3 + cross "eb" 4), so
// the 4 dropped points are exactly the ignored cross-word.
if std, _ := s.ScorePlay(b, Vertical, tea); std.Main.Score != 3 || std.Score != 7 {
t.Errorf("standard score: main %d total %d, want main 3 total 7", std.Main.Score, std.Score)
}
}
// TestScorePlayOptsExcludesValidCrossWords checks that even a *valid* cross-word is dropped
// from the score under the single-word rule. "tat" laid down the column through the existing
// 't' of "cat" connects in-line; the new 'a' abuts a lone 'a', spelling the valid word "aa",
// which both rules accept but only the standard rule scores.
func TestScorePlayOptsExcludesValidCrossWords(t *testing.T) {
s := newTestSolver(t)
b := board.New(s.rules.Rows, s.rules.Cols)
placeWord(b, 3, 1, Horizontal, "cat") // c(3,1) a(3,2) t(3,3)
placeWord(b, 4, 4, Horizontal, "a") // a lone tile so the new 'a' forms the cross "aa"
tat := []Placement{{Row: 4, Col: 3, Letter: 0}, {Row: 5, Col: 3, Letter: 19}} // a(4,3) t(5,3)
std, err := s.ValidatePlay(b, Vertical, tat)
if err != nil {
t.Fatalf("standard rules rejected a play with a valid cross-word: %v", err)
}
if std.Score != 5 { // main "tat" 3 + cross "aa" 2
t.Errorf("standard score = %d, want 5", std.Score)
}
m, err := s.ValidatePlayOpts(b, Vertical, tat, singleWord)
if err != nil {
t.Fatalf("single-word rule rejected the play: %v", err)
}
if m.Score != 3 || len(m.Cross) != 0 { // main "tat" only
t.Errorf("single-word score = %d with %d cross-words, want 3 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")
}
}
// TestSingleWordRuleRequiresInlineConnection is the contour regression. Under the single-word
// rule a play must still form its word ALONG the play direction together with an existing
// tile: a multi-tile play whose main word is all-new and that touches the board only
// perpendicular to itself does not connect — exactly as under the standard rules, the
// difference being only that the (invalid) cross-word is never formed. A play whose main word
// runs through an existing tile still connects.
func TestSingleWordRuleRequiresInlineConnection(t *testing.T) {
s := newTestSolver(t)
// "art" all-new on the row below "cat" touches the board only perpendicular to its line
// (the non-words "ca"/"ar"/"tt"); it forms no word along its own row, so it does not
// connect — rejected under BOTH rules.
b := board.New(s.rules.Rows, s.rules.Cols)
placeWord(b, 3, 1, Horizontal, "cat")
art := []Placement{{Row: 4, Col: 1, Letter: 0}, {Row: 4, Col: 2, Letter: 17}, {Row: 4, Col: 3, Letter: 19}}
if _, err := s.ValidatePlayOpts(b, Horizontal, art, singleWord); err == nil {
t.Error("single-word rule accepted a parallel play that forms no word along its line")
}
if _, err := s.ValidatePlay(b, Horizontal, art); err == nil {
t.Error("standard rules accepted the same parallel play")
}
// "car" laid down the column through the existing 'c' of "cat" forms its word along its
// own line and reuses that tile, so it connects and is accepted under the single-word rule.
b2 := board.New(s.rules.Rows, s.rules.Cols)
placeWord(b2, 3, 1, Horizontal, "cat") // c(3,1)
car := []Placement{{Row: 4, Col: 1, Letter: 0}, {Row: 5, Col: 1, Letter: 17}} // a(4,1) r(5,1)
if _, err := s.ValidatePlayOpts(b2, Vertical, car, singleWord); err != nil {
t.Errorf("single-word rule rejected an in-line play \"car\": %v", err)
}
}
// TestGenerateMovesOptsConnectivity confirms single-word generation obeys the same
// connectivity rule as validation: it never offers a parallel play that forms no word along
// its own line (such a play would be rejected on submit), so every generated move validates.
// The "art" beside "cat" play is therefore absent.
func TestGenerateMovesOptsConnectivity(t *testing.T) {
s := newTestSolver(t)
b := board.New(s.rules.Rows, s.rules.Cols)
placeWord(b, 3, 1, Horizontal, "cat")
moves := s.GenerateMovesOpts(b, makeRack("art", 0), Both, singleWord)
if len(moves) == 0 {
t.Fatal("single-word generation produced no moves")
}
// "art" on the row below "cat" forms no word along its row, so generation must not offer it.
art := []Placement{{Row: 4, Col: 1, Letter: 0}, {Row: 4, Col: 2, Letter: 17}, {Row: 4, Col: 3, Letter: 19}}
if _, ok := genMoves(moves)[moveKey(Horizontal, art)]; ok {
t.Error("single-word generation produced \"art\" beside \"cat\" (forms no word along its line)")
}
// Every generated single-word move must pass single-word validation.
for _, m := range moves {
if _, err := s.ValidatePlayOpts(b, m.Dir, m.Tiles, singleWord); err != nil {
t.Errorf("generated move %s fails its own validation: %v", moveKey(m.Dir, m.Tiles), err)
}
}
}
// TestGenerateMovesOptsRelaxesCrossChecks confirms relaxed generation still relaxes the
// cross-word checks for IN-LINE plays: "tar" laid down the column through the existing 't' of
// "cat" connects through that tile, but its new 'r' abuts a lone 'w', spelling the non-word
// "rw". Standard generation omits it; single-word generation offers it, scored on 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") // c(3,1) a(3,2) t(3,3)
placeWord(b, 5, 4, Horizontal, "w") // a lone tile so the new 'r' forms the cross "rw"
std := genMoves(s.GenerateMoves(b, makeRack("ar", 0), Both))
rel := genMoves(s.GenerateMovesOpts(b, makeRack("ar", 0), Both, singleWord))
// "tar" down through the existing 't': a(4,3) r(5,3).
tar := []Placement{{Row: 4, Col: 3, Letter: 0}, {Row: 5, Col: 3, Letter: 17}}
key := moveKey(Vertical, tar)
if _, ok := std[key]; ok {
t.Error("standard generation produced \"tar\" despite its invalid cross-word \"rw\"")
}
rm, ok := rel[key]
if !ok {
t.Fatal("single-word generation did not produce the in-line \"tar\" (its cross-word is ignored)")
}
if rm.Score != 3 { // main "tar" only: t1 a1 r1
t.Errorf("single-word \"tar\" 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