fix(engine): single-word rule connects along the play line
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

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.
This commit is contained in:
Ilia Denisov
2026-06-14 14:49:16 +02:00
parent c7e177f911
commit ff87a3bf62
9 changed files with 194 additions and 16 deletions
+1
View File
@@ -26,6 +26,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| R7 | Final stress run + tuning | 9b | **done** | | R7 | Final stress run + tuning | 9b | **done** |
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** | | UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** |
| MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** | | MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** |
| MW2 | Single-word rule connectivity fix: the word must run along its own line through an existing tile (perpendicular-only contact no longer connects); single-tile direction picks the best legal word (engine v1.1.1) | owner ad-hoc | **done** |
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90180 s) | owner ad-hoc | **done** | | OW | Open auto-match: enter the game at once and wait inside it (robot after 90180 s) | owner ad-hoc | **done** |
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** | | DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
+1 -1
View File
@@ -3,7 +3,7 @@ module scrabble/backend
go 1.26.3 go 1.26.3
require ( require (
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0 gitea.iliadenisov.ru/developer/scrabble-solver v1.1.1
github.com/XSAM/otelsql v0.42.0 github.com/XSAM/otelsql v0.42.0
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-jet/jet/v2 v2.14.1 github.com/go-jet/jet/v2 v2.14.1
+29 -2
View File
@@ -62,7 +62,7 @@ func (g *Game) SubmitPlay(tiles []TileRecord) (MoveRecord, error) {
if err != nil { if err != nil {
return MoveRecord{}, err return MoveRecord{}, err
} }
return g.Play(resolveDirection(g.board, placements), placements) return g.Play(g.playDirection(placements), placements)
} }
// SubmitPlayDir is SubmitPlay with the orientation supplied rather than inferred. // SubmitPlayDir is SubmitPlay with the orientation supplied rather than inferred.
@@ -106,7 +106,7 @@ func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
if err != nil { if err != nil {
return MoveRecord{}, err return MoveRecord{}, err
} }
move, err := g.solver.ValidatePlayOpts(g.board, resolveDirection(g.board, placements), placements, g.playOpts()) move, err := g.solver.ValidatePlayOpts(g.board, g.playDirection(placements), placements, g.playOpts())
if err != nil { if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
} }
@@ -169,6 +169,33 @@ func (g *Game) placements(tiles []TileRecord) ([]scrabble.Placement, error) {
return out, nil return out, nil
} }
// playDirection resolves the orientation for a live play. resolveDirection infers it from
// geometry alone, preferring the longer word when a single tile abuts the board on both
// axes; under the single-word rule that can pick an orientation whose word is not in the
// dictionary while the other orientation's is. So for a single tile under that rule the
// engine tries both orientations through the solver and keeps the higher-scoring legal one
// (horizontal breaks a tie). Multi-tile plays, and every play under the standard rule, keep
// the geometric resolution: a multi-tile play's orientation is fixed by the line its tiles
// share, and under the standard rule every word the play forms must be valid regardless of
// which one is named the main word.
func (g *Game) playDirection(placements []scrabble.Placement) scrabble.Direction {
geo := resolveDirection(g.board, placements)
if len(placements) != 1 || g.multipleWords {
return geo
}
best, found, bestScore := geo, false, 0
for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} {
m, err := g.solver.ValidatePlayOpts(g.board, dir, placements, g.playOpts())
if err != nil {
continue
}
if !found || m.Score > bestScore {
best, found, bestScore = dir, true, m.Score
}
}
return best
}
// encodeTiles encodes decoded exchange tiles ("?" for a blank, otherwise a // encodeTiles encodes decoded exchange tiles ("?" for a blank, otherwise a
// concrete letter) into the internal byte form, wrapping a bad letter as // concrete letter) into the internal byte form, wrapping a bad letter as
// ErrTilesNotOnRack (the caller cannot hold a tile it cannot name). // ErrTilesNotOnRack (the caller cannot hold a tile it cannot name).
+147 -6
View File
@@ -33,9 +33,12 @@ func TestSingleWordRuleWiring(t *testing.T) {
t.Error("single-word game must ignore cross-words") t.Error("single-word game must ignore cross-words")
} }
// Play the same opening (the standard game's top move) in both games, then compare // Play the same opening (the standard game's top move) in both games. Both share the
// the next player's candidate moves. Both games share the seed, so the next rack is // seed, so the next rack is identical and both still have legal replies. The single-word
// identical; relaxed (single-word) generation never drops a legal standard move. // 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() hint, ok := std.HintView()
if !ok { if !ok {
t.Fatal("opening game has no hint") t.Fatal("opening game has no hint")
@@ -46,9 +49,8 @@ func TestSingleWordRuleWiring(t *testing.T) {
if _, err := single.SubmitPlay(hint.Tiles); err != nil { if _, err := single.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("single-word opening: %v", err) t.Fatalf("single-word opening: %v", err)
} }
stdMoves, singleMoves := len(std.GenerateMoves()), len(single.GenerateMoves()) if len(std.GenerateMoves()) == 0 || len(single.GenerateMoves()) == 0 {
if singleMoves < stdMoves { t.Error("both games should have legal replies after the opening")
t.Errorf("single-word generation produced %d moves, want >= standard %d", singleMoves, stdMoves)
} }
} }
@@ -137,6 +139,145 @@ func TestEvaluatePlayHonorsSingleWordRule(t *testing.T) {
}) })
} }
// 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 // TestSingleWordRuleRobotCandidates proves the robot opponent never trips the same
// cross-word check while searching for its move: its move source, Candidates -> // cross-word check while searching for its move: its move source, Candidates ->
// GenerateMovesOpts, already honours the rule. Under the single-word rule the bridged // GenerateMovesOpts, already honours the rule. Under the single-word rule the bridged
+9 -4
View File
@@ -301,10 +301,15 @@ Key points:
**single-word rule**, chosen on New Game (default **off** = single word; on = standard **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 — Scrabble). Off, only the **main word** along the play direction is validated and scored —
perpendicular cross-words are ignored, including in robot move generation and the 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 unlimited move preview; on, every cross-word must be a real word and is scored. The main
engine threads it as word must still run **through an existing tile along its own line** to connect: a play that
`scrabble.PlayOptions{IgnoreCrossWords}` (solver `v1.1.0`); connectivity and the forms no word along the direction it is laid — touching the board only perpendicular to
first-move centre rule are unaffected. The "Russian-only" limit is a **UI affordance**: itself — is illegal even though its cross-word is never checked, and for a single tile that
abuts the board on both axes the engine plays the higher-scoring legal orientation. The
single-word rule is therefore **not a superset** of the standard rule: it forbids parallel
plays the standard rule allows and admits in-line plays whose cross-words are invalid. The
engine threads it as `scrabble.PlayOptions{IgnoreCrossWords}` (solver `v1.1.1`); the
first-move centre rule is unaffected. The "Russian-only" limit is a **UI affordance**:
the backend and engine are variant-agnostic about the flag, and English games always send the backend and engine are variant-agnostic about the flag, and English games always send
it on (standard). For auto-match the rule is part of the matchmaking key, so only players it on (standard). For auto-match the rule is part of the matchmaking key, so only players
who chose the same rule are paired (the rule field rides every create/enqueue request, so who chose the same rule are paired (the rule field rides every create/enqueue request, so
+2 -1
View File
@@ -93,7 +93,8 @@ takes the empty seat after **1.53 minutes**, so a game always starts — and
you can close the app while you wait and come back later. For Russian games (auto-match or friend you can close the app while you wait and come back later. For Russian games (auto-match or friend
invitation), New Game also offers **"Multiple words per turn"** (default **off**): off plays invitation), New Game also offers **"Multiple words per turn"** (default **off**): off plays
the simplified **single-word rule** — only the word laid along the player's line must be a the simplified **single-word rule** — only the word laid along the player's line must be a
real word, and any incidental perpendicular words are ignored and not scored — while on is real word (and it must still cross or extend letters already on the board along that line),
and any incidental perpendicular words are ignored and not scored — while on is
standard Scrabble. English games are always standard and show no such toggle. In auto-match standard Scrabble. English games are always standard and show no such toggle. In auto-match
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (24) are the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (24) are
formed by inviting players from the friend list (an invitation, like a friend code, formed by inviting players from the friend list (an invitation, like a friend code,
+2 -1
View File
@@ -96,7 +96,8 @@ nudge) приходят от бота **этой партии** — по язы
позже. Для русских игр (авто-подбор или приглашение) на экране позже. Для русских игр (авто-подбор или приглашение) на экране
новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена — новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена —
упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное
вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются; вдоль линии хода (и оно должно пересекать или продолжать уже стоящие на доске буквы вдоль
этой линии), а случайные перпендикулярные слова игнорируются и не засчитываются;
включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми, показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
кто выбрал то же правило. Игры с друзьями (2–4) кто выбрал то же правило. Игры с друзьями (2–4)
+2
View File
@@ -8,6 +8,8 @@ gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0 h1:ntN6m4cOB+4FelleO2nkAIZ
gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY= gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY=
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0 h1:92jWbAZ5IK3ROrn1g3FjY0wZjeYpVWOJsl/GGT5HN1U= gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0 h1:92jWbAZ5IK3ROrn1g3FjY0wZjeYpVWOJsl/GGT5HN1U=
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY= gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY=
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.1 h1:3HpPw1gFKX05/O4u3sQ93dxob+ANfyqOSUcpbNqm0Yo=
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.1/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.26.3
require ( require (
connectrpc.com/connect v1.19.2 connectrpc.com/connect v1.19.2
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0 gitea.iliadenisov.ru/developer/scrabble-solver v1.1.1
github.com/google/flatbuffers v23.5.26+incompatible github.com/google/flatbuffers v23.5.26+incompatible
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/iliadenisov/dafsa v1.1.0 github.com/iliadenisov/dafsa v1.1.0