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, then compare // the next player's candidate moves. Both games share the seed, so the next rack is // identical; relaxed (single-word) generation never drops a legal standard move. 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) } stdMoves, singleMoves := len(std.GenerateMoves()), len(single.GenerateMoves()) if singleMoves < stdMoves { 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)") } }