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
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.
307 lines
12 KiB
Go
307 lines
12 KiB
Go
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)")
|
||
}
|
||
}
|