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
+29 -13
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,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}