From 5fa51d04d9091fa178811f8bc9fff23746bf7450 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 12 Jun 2026 11:14:20 +0200 Subject: [PATCH] fix(engine): EvaluatePlay honors the single-word rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The move preview (EvaluatePlay) validated under standard rules — it called ValidatePlay without the game's play options — so under the single-word rule it rejected a play whose only flaw was incidental invalid perpendicular cross-words, even though SubmitPlay accepts it. The UI gates the submit button on the preview, so such a play (e.g. КРАН bridging an existing Р on the test contour) could not be made. Pass g.playOpts() via ValidatePlayOpts, mirroring Play, so the preview's legality and score match submission. Robots are unaffected — they search via GenerateMovesOpts and submit via Play, both already opts-aware — and a regression test asserts that too. --- backend/internal/engine/domain.go | 12 ++- backend/internal/engine/singleword_test.go | 119 ++++++++++++++++++++- docs/ARCHITECTURE.md | 5 +- 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/backend/internal/engine/domain.go b/backend/internal/engine/domain.go index 50c58f2..e2e1939 100644 --- a/backend/internal/engine/domain.go +++ b/backend/internal/engine/domain.go @@ -92,10 +92,12 @@ func (g *Game) SubmitExchange(tiles []string) (MoveRecord, error) { // EvaluatePlay scores and validates a tentative play without committing it, // backing the unlimited "what would my next move score, and is it legal?" tool. -// It infers the play's orientation from the tiles and the board exactly as -// SubmitPlay does, then returns the decoded move (placed tiles, the words it -// forms, its orientation and its score) or ErrIllegalPlay when the solver -// rejects it. The board, racks, bag and turn are left untouched. +// It infers the play's orientation from the tiles and the board and applies the +// game's play options exactly as SubmitPlay does, so under the single-word rule +// perpendicular cross-words are ignored: the preview's legality and score then +// match what submitting the play would yield. It returns the decoded move (placed +// tiles, the words it forms, its orientation and its score) or ErrIllegalPlay when +// the solver rejects it. The board, racks, bag and turn are left untouched. func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) { if g.over { return MoveRecord{}, ErrGameOver @@ -104,7 +106,7 @@ func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) { if err != nil { return MoveRecord{}, err } - move, err := g.solver.ValidatePlay(g.board, resolveDirection(g.board, placements), placements) + move, err := g.solver.ValidatePlayOpts(g.board, resolveDirection(g.board, placements), placements, g.playOpts()) if err != nil { return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) } diff --git a/backend/internal/engine/singleword_test.go b/backend/internal/engine/singleword_test.go index 0a27555..a75216a 100644 --- a/backend/internal/engine/singleword_test.go +++ b/backend/internal/engine/singleword_test.go @@ -1,6 +1,11 @@ package engine -import "testing" +import ( + "errors" + "testing" + + "gitea.iliadenisov.ru/developer/scrabble-solver/scrabble" +) // TestSingleWordRuleWiring confirms Options.MultipleWordsPerTurn reaches the solver. The // single-word game ignores perpendicular cross-words, so move generation from a shared @@ -46,3 +51,115 @@ func TestSingleWordRuleWiring(t *testing.T) { t.Errorf("single-word generation produced %d moves, want >= standard %d", singleMoves, stdMoves) } } + +// setupSingleWordKran builds an Erudit position that reproduces the test-contour +// bug. It replaces the bag-dealt rack with к/а/н and places the existing Р the play +// bridges plus perpendicular neighbours (г, е, н) so that each of the three new +// tiles of the vertical КРАН forms an *invalid* cross-word (гк, еа, нн). The +// multipleWords argument selects the rule. It returns the game and the decoded КРАН +// placement (the three new tiles К, А, Н around the existing Р). +func setupSingleWordKran(t *testing.T, multipleWords bool) (*Game, []TileRecord) { + t.Helper() + g, err := New(testReg, Options{ + Variant: VariantErudit, + Version: testVersion, + Players: 2, + Seed: 1, + MultipleWordsPerTurn: multipleWords, + }) + if err != nil { + t.Fatalf("new erudit game: %v", err) + } + idx := func(s string) byte { + i, err := g.rules.Alphabet.Index(s) + if err != nil { + t.Fatalf("index %q: %v", s, err) + } + return i + } + scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{ + {Row: 5, Col: 8, Letter: idx("р")}, // the existing tile the play bridges + {Row: 4, Col: 7, Letter: idx("г")}, // left of К(4,8): cross-word "гк" + {Row: 6, Col: 7, Letter: idx("е")}, // left of А(6,8): cross-word "еа" + {Row: 7, Col: 7, Letter: idx("н")}, // left of Н(7,8): cross-word "нн" + }}) + g.hands[0] = []byte{idx("к"), idx("а"), idx("н")} + tiles := []TileRecord{ + {Row: 4, Col: 8, Letter: "к"}, + {Row: 6, Col: 8, Letter: "а"}, + {Row: 7, Col: 8, Letter: "н"}, + } + return g, tiles +} + +// TestEvaluatePlayHonorsSingleWordRule is the regression for the contour bug: under +// the single-word rule the EvaluatePlay preview (the "what would this score, and is +// it legal?" tool) must honour the same rule as SubmitPlay and ignore perpendicular +// cross-words, so a play whose only flaw is invalid cross-words is reported legal and +// scored on its main word alone. Before the fix EvaluatePlay validated under standard +// rules and wrongly rejected it. +func TestEvaluatePlayHonorsSingleWordRule(t *testing.T) { + // The main word must be a real Erudit word, so any rejection can only come from + // the (ignored) cross-words rather than the main word itself. + if ok, err := testReg.Lookup(VariantErudit, testVersion, "кран"); err != nil || !ok { + t.Fatalf("precondition: кран must be in the Erudit dictionary (ok=%v, err=%v)", ok, err) + } + + t.Run("single-word rule accepts the cross-invalid play", func(t *testing.T) { + g, tiles := setupSingleWordKran(t, false) + rec, err := g.EvaluatePlay(tiles) + if err != nil { + t.Fatalf("evaluate under single-word rule: %v", err) + } + if len(rec.Words) != 1 || rec.Words[0] != "кран" { + t.Errorf("words = %v, want [кран] only (cross-words ignored)", rec.Words) + } + if rec.Dir != Vertical { + t.Errorf("dir = %v, want Vertical", rec.Dir) + } + if rec.Score <= 0 { + t.Errorf("score = %d, want positive", rec.Score) + } + }) + + t.Run("standard rules reject the same play", func(t *testing.T) { + g, tiles := setupSingleWordKran(t, true) + if _, err := g.EvaluatePlay(tiles); !errors.Is(err, ErrIllegalPlay) { + t.Errorf("evaluate under standard rules = %v, want ErrIllegalPlay", err) + } + }) + + t.Run("evaluate agrees with submit under the single-word rule", func(t *testing.T) { + g, tiles := setupSingleWordKran(t, false) + if _, err := g.SubmitPlay(tiles); err != nil { + t.Errorf("submit under single-word rule: %v", err) + } + }) +} + +// TestSingleWordRuleRobotCandidates proves the robot opponent never trips the same +// cross-word check while searching for its move: its move source, Candidates -> +// GenerateMovesOpts, already honours the rule. Under the single-word rule the bridged +// КРАН (whose cross-words are invalid) appears among the candidates the robot chooses +// from; under standard rules it is correctly absent. The robot submits its pick through +// SubmitPlay (covered above), so this holds both before and after the EvaluatePlay fix — +// the robot never uses EvaluatePlay. +func TestSingleWordRuleRobotCandidates(t *testing.T) { + hasKran := func(cands []MoveRecord) bool { + for _, c := range cands { + if len(c.Words) > 0 && c.Words[0] == "кран" { + return true + } + } + return false + } + + single, _ := setupSingleWordKran(t, false) + if !hasKran(single.Candidates()) { + t.Error("single-word candidates must include the bridged кран play the robot can pick") + } + std, _ := setupSingleWordKran(t, true) + if hasKran(std.Candidates()) { + t.Error("standard candidates must not include кран (its cross-words are invalid)") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9713a31..4fcccb4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -285,8 +285,9 @@ Key points: - **Multiple words per turn (Russian games).** Russian variants carry a per-game **single-word rule**, chosen on New Game (default **off** = single word; on = standard Scrabble). Off, only the **main word** along the play direction is validated and scored — - perpendicular cross-words are ignored, including in robot move generation; on, every - cross-word must be a real word and is scored. The engine threads it as + perpendicular cross-words are ignored, including in robot move generation and the + unlimited move preview; on, every cross-word must be a real word and is scored. The + engine threads it as `scrabble.PlayOptions{IgnoreCrossWords}` (solver `v1.1.0`); connectivity and the first-move centre rule are unaffected. The "Russian-only" limit is a **UI affordance**: the backend and engine are variant-agnostic about the flag, and English games always send -- 2.52.0