Files
scrabble-game/backend/internal/engine/singleword_test.go
T
Ilia Denisov ff87a3bf62
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
fix(engine): single-word rule connects along the play line
Bumps the engine to scrabble-solver v1.1.1, where a single-word-per-turn play
must form its word along its own line through an existing tile: a multi-tile play
that touches the board only perpendicular to itself (the contour РЮМ/КЕД/ОР cases)
no longer connects. For a single tile that abuts the board on both axes the engine
now plays the higher-scoring legal orientation instead of the geometrically longer
one (playDirection), so a real word is never rejected in favour of a non-word.

Reworks the single-word solver/engine tests for the corrected rule (no longer a
superset of standard play) and updates ARCHITECTURE/FUNCTIONAL/PRERELEASE.
2026-06-14 14:49:16 +02:00

307 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package engine
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
// position is a superset of the standard game's; the standard game does not relax them.
func TestSingleWordRuleWiring(t *testing.T) {
const seed = 7
mk := func(multipleWords bool) *Game {
g, err := New(testReg, Options{
Variant: VariantEnglish,
Version: testVersion,
Players: 2,
Seed: seed,
MultipleWordsPerTurn: multipleWords,
})
if err != nil {
t.Fatalf("new game: %v", err)
}
return g
}
std, single := mk(true), mk(false)
if std.playOpts().IgnoreCrossWords {
t.Error("standard game must not ignore cross-words")
}
if !single.playOpts().IgnoreCrossWords {
t.Error("single-word game must ignore cross-words")
}
// Play the same opening (the standard game's top move) in both games. Both share the
// seed, so the next rack is identical and both still have legal replies. The single-word
// rule is not a superset of the standard one — it forbids parallel plays the standard
// rule allows and admits in-line plays whose cross-words are invalid — so here the two
// move sets only need to be non-empty; their rule-specific differences are covered by the
// cross-word and connectivity tests.
hint, ok := std.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
if _, err := std.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("standard opening: %v", err)
}
if _, err := single.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("single-word opening: %v", err)
}
if len(std.GenerateMoves()) == 0 || len(single.GenerateMoves()) == 0 {
t.Error("both games should have legal replies after the opening")
}
}
// 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)
}
})
}
// TestSingleWordRuleSingleTileDirection covers the single-tile half of the single-word
// rule: when a lone tile abuts the board on both axes, the engine picks the orientation that
// forms a real word, not the geometrically longer one. The lone 'о' spells the non-word
// "фоф" across (length 3) but the real word "до" down (length 2); the geometric resolver
// prefers the longer "фоф", so before the fix the play was wrongly rejected.
func TestSingleWordRuleSingleTileDirection(t *testing.T) {
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)
}
if ok, _ := testReg.Lookup(VariantErudit, testVersion, "фоф"); ok {
t.Fatal("precondition: \"фоф\" must not be a word")
}
g, err := New(testReg, Options{
Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1, MultipleWordsPerTurn: false,
})
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: 4, Col: 8, Letter: idx("д")}, // above 'о': the vertical word "до"
{Row: 5, Col: 7, Letter: idx("ф")}, // left of 'о': the across non-word "фоф"
{Row: 5, Col: 9, Letter: idx("ф")}, // right of 'о'
}})
g.hands[0] = []byte{idx("о")}
tiles := []TileRecord{{Row: 5, Col: 8, Letter: "о"}}
rec, err := g.EvaluatePlay(tiles)
if err != nil {
t.Fatalf("evaluate the single tile under the single-word rule: %v", err)
}
if rec.Dir != Vertical {
t.Errorf("dir = %v, want Vertical (the real word \"до\")", rec.Dir)
}
if len(rec.Words) != 1 || rec.Words[0] != "до" {
t.Errorf("words = %v, want [до]", rec.Words)
}
if _, err := g.SubmitPlay(tiles); err != nil {
t.Errorf("submit the single tile under the single-word rule: %v", err)
}
}
// TestSingleWordRuleSingleTileBestScore covers the rest of rule (2): when a single tile
// forms a real word on BOTH axes, the engine keeps the higher-scoring orientation (and
// horizontal on a tie), overriding the geometric resolver's tie preference. The lone 'с'
// spells "ас" across and "юс" down; the engine must pick whichever scores more.
func TestSingleWordRuleSingleTileBestScore(t *testing.T) {
for _, w := range []string{"ас", "юс"} {
if ok, err := testReg.Lookup(VariantErudit, testVersion, w); err != nil || !ok {
t.Fatalf("precondition: %q must be in the Erudit dictionary (ok=%v, err=%v)", w, ok, err)
}
}
g, err := New(testReg, Options{Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1})
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: 7, Letter: idx("а")}, // left of 'с': the across word "ас"
{Row: 4, Col: 8, Letter: idx("ю")}, // above 'с': the down word "юс"
}})
g.hands[0] = []byte{idx("с")}
tiles := []TileRecord{{Row: 5, Col: 8, Letter: "с"}}
ps := []scrabble.Placement{{Row: 5, Col: 8, Letter: idx("с")}}
across, aerr := g.solver.ValidatePlayOpts(g.board, scrabble.Horizontal, ps, g.playOpts())
down, derr := g.solver.ValidatePlayOpts(g.board, scrabble.Vertical, ps, g.playOpts())
if aerr != nil || derr != nil {
t.Fatalf("both orientations should be legal: across=%v down=%v", aerr, derr)
}
wantDir, wantScore := Horizontal, across.Score
if down.Score > across.Score {
wantDir, wantScore = Vertical, down.Score
}
rec, err := g.EvaluatePlay(tiles)
if err != nil {
t.Fatalf("evaluate the single tile: %v", err)
}
if rec.Dir != wantDir {
t.Errorf("dir = %v, want %v (higher of \"ас\"=%d, \"юс\"=%d)", rec.Dir, wantDir, across.Score, down.Score)
}
if rec.Score != wantScore {
t.Errorf("score = %d, want %d", rec.Score, wantScore)
}
}
// TestSingleWordRuleRejectsPerpendicularOnlyContour is the backend regression for the
// reported contour bug: a multi-tile play whose main word is a real word but which touches
// the board only perpendicular to its own line — forming a cross-word, not a word along that
// line — does not connect under the single-word rule and is rejected by both the preview and
// the submit path. Existing "до" sits down column 8; "кот" laid across row 6 is all-new along
// its row and touches the board only through the 'о' below the existing 'о'.
func TestSingleWordRuleRejectsPerpendicularOnlyContour(t *testing.T) {
if ok, err := testReg.Lookup(VariantErudit, testVersion, "кот"); err != nil || !ok {
t.Fatalf("precondition: \"кот\" must be a real word (ok=%v, err=%v)", ok, err)
}
g, err := New(testReg, Options{Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1})
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: 4, Col: 8, Letter: idx("д")},
{Row: 5, Col: 8, Letter: idx("о")},
}})
g.hands[0] = []byte{idx("к"), idx("о"), idx("т")}
tiles := []TileRecord{
{Row: 6, Col: 7, Letter: "к"},
{Row: 6, Col: 8, Letter: "о"},
{Row: 6, Col: 9, Letter: "т"},
}
if _, err := g.EvaluatePlay(tiles); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("EvaluatePlay = %v, want ErrIllegalPlay (connects only perpendicular)", err)
}
if _, err := g.SubmitPlay(tiles); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("SubmitPlay = %v, want ErrIllegalPlay (connects only perpendicular)", 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)")
}
}