Compare commits
4 Commits
v1.0.0
..
bf48d3c915
| Author | SHA1 | Date | |
|---|---|---|---|
| bf48d3c915 | |||
| d70bd35470 | |||
| 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}
|
||||
|
||||
+29
-13
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+16
-5
@@ -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 {
|
||||
|
||||
+74
-12
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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