fix(engine): EvaluatePlay honors the single-word rule
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s

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.
This commit is contained in:
Ilia Denisov
2026-06-12 11:14:20 +02:00
parent f1e77b5826
commit 5fa51d04d9
3 changed files with 128 additions and 8 deletions
+118 -1
View File
@@ -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)")
}
}