Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1fd200d97 | |||
| a8a559993d |
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+12
-1
@@ -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
|
||||
|
||||
+50
-8
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user