package engine import ( "errors" "testing" "gitea.iliadenisov.ru/developer/scrabble-solver/scrabble" ) // newEruditNoRepeat builds a two-player Erudit game with the no-repeat-words rule set either // way, plus a helper resolving a letter to its alphabet index. func newEruditNoRepeat(t *testing.T, noRepeat bool) (*Game, func(string) byte) { t.Helper() g, err := New(testReg, Options{ Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1, MultipleWordsPerTurn: true, NoRepeatWords: noRepeat, }) if err != nil { t.Fatalf("new erudit game: %v", err) } return g, func(s string) byte { i, err := g.rules.Alphabet.Index(s) if err != nil { t.Fatalf("index %q: %v", s, err) } return i } } // playKotAtCentre commits the opening horizontal КОТ across the centre square, so that the word // is genuinely in the game's move log rather than only on the board. func playKotAtCentre(t *testing.T, g *Game, idx func(string) byte) (row, col int) { t.Helper() row, col = centre(g.rules) g.hands[0] = []byte{idx("к"), idx("о"), idx("т")} if _, err := g.Play(scrabble.Horizontal, placementsForWord(t, g.rules, row, col, scrabble.Horizontal, "кот")); err != nil { t.Fatalf("opening кот: %v", err) } return row, col } // TestNoRepeatRejectsAReplayedMainWord is the rule's headline case: КОТ laid once may not be laid // again, here as a vertical play hooking the opening word's К. Without the rule the very same play // is legal, which pins the rejection on the rule and not on the position. func TestNoRepeatRejectsAReplayedMainWord(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) } // The second play reuses the opening К and adds О and Т below it, forming КОТ downwards. // Neither new tile has a horizontal neighbour, so the main word is the only word formed. replay := func(t *testing.T, g *Game, idx func(string) byte, row, col int) error { t.Helper() g.hands[g.toMove] = []byte{idx("о"), idx("т")} _, err := g.Play(scrabble.Vertical, []scrabble.Placement{ {Row: row + 1, Col: col, Letter: idx("о")}, {Row: row + 2, Col: col, Letter: idx("т")}, }) return err } t.Run("with the rule the replayed word is illegal", func(t *testing.T) { g, idx := newEruditNoRepeat(t, true) row, col := playKotAtCentre(t, g, idx) if err := replay(t, g, idx, row, col); !errors.Is(err, ErrIllegalPlay) { t.Fatalf("replaying кот: err = %v, want ErrIllegalPlay", err) } }) t.Run("without the rule the same play is accepted", func(t *testing.T) { g, idx := newEruditNoRepeat(t, false) row, col := playKotAtCentre(t, g, idx) if err := replay(t, g, idx, row, col); err != nil { t.Fatalf("replaying кот without the rule: %v", err) } }) } // TestNoRepeatPreviewRejectsAReplayedMainWord pins the preview to the same answer as submitting: // EvaluatePlay must reject what Play rejects, or the player would be shown a score for a move the // server will refuse. func TestNoRepeatPreviewRejectsAReplayedMainWord(t *testing.T) { g, idx := newEruditNoRepeat(t, true) row, col := playKotAtCentre(t, g, idx) g.hands[g.toMove] = []byte{idx("о"), idx("т")} _, err := g.EvaluatePlay([]TileRecord{ {Row: row + 1, Col: col, Letter: "о"}, {Row: row + 2, Col: col, Letter: "т"}, }) if !errors.Is(err, ErrIllegalPlay) { t.Fatalf("previewing a replayed кот: err = %v, want ErrIllegalPlay", err) } } // TestNoRepeatDropsTheScoreOfAReplayedCrossWord covers the other half of the rule: a play whose // perpendicular cross-word is already on the board stays legal — the cross-word is incidental to // laying the main word — but that cross-word scores nothing. The position places РОТ so that its // Т completes a standing КО downwards into a second КОТ. func TestNoRepeatDropsTheScoreOfAReplayedCrossWord(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) } // The main word РОТ runs along row 12; only its last tile has vertical neighbours, so КОТ // (К and О standing at rows 10 and 11 of column 5) is the play's single cross-word. evaluate := func(t *testing.T, noRepeat bool) MoveRecord { t.Helper() g, idx := newEruditNoRepeat(t, noRepeat) playKotAtCentre(t, g, idx) scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{ {Row: 10, Col: 5, Letter: idx("к")}, {Row: 11, Col: 5, Letter: idx("о")}, }}) g.hands[g.toMove] = []byte{idx("р"), idx("о"), idx("т")} rec, err := g.EvaluatePlay([]TileRecord{ {Row: 12, Col: 3, Letter: "р"}, {Row: 12, Col: 4, Letter: "о"}, {Row: 12, Col: 5, Letter: "т"}, }) if err != nil { t.Fatalf("evaluating рот (noRepeat=%v): %v", noRepeat, err) } return rec } unrestricted, restricted := evaluate(t, false), evaluate(t, true) if restricted.Score >= unrestricted.Score { t.Errorf("score with the rule = %d, want less than %d without it", restricted.Score, unrestricted.Score) } // The word is still formed and still reported — it simply earns nothing. if len(restricted.Words) != len(unrestricted.Words) { t.Errorf("words with the rule = %v, want the same words as without it (%v)", restricted.Words, unrestricted.Words) } } // TestNoRepeatFiltersGeneratedMoves keeps the robot and the hint honest: a play the rule forbids // must never be generated, or the engine would offer a move it would then refuse. func TestNoRepeatFiltersGeneratedMoves(t *testing.T) { g, idx := newEruditNoRepeat(t, true) playKotAtCentre(t, g, idx) g.hands[g.toMove] = []byte{idx("к"), idx("о"), idx("т"), idx("а"), idx("н"), idx("с"), idx("л")} moves := g.GenerateMoves() if len(moves) == 0 { t.Fatal("no legal replies generated; the position cannot exercise the filter") } for _, m := range moves { if w := g.word(m.Main); w == "кот" { t.Fatalf("generated a play of кот, which is already on the board") } } // The list stays ranked by the adjusted score, which the hint relies on. for i := 1; i < len(moves); i++ { if moves[i-1].Score < moves[i].Score { t.Fatalf("generated moves not ranked: move %d scores %d, move %d scores %d", i-1, moves[i-1].Score, i, moves[i].Score) } } } // TestNoRepeatOffLeavesTheGameUnchanged is the compatibility guard for every game started before // the rule existed (and for both Scrabble variants, which never take it): nothing is filtered, // nothing is rescored. func TestNoRepeatOffLeavesTheGameUnchanged(t *testing.T) { g, idx := newEruditNoRepeat(t, false) playKotAtCentre(t, g, idx) g.hands[g.toMove] = []byte{idx("к"), idx("о"), idx("т"), idx("а"), idx("н"), idx("с"), idx("л")} generated := g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts()) if got, want := len(g.GenerateMoves()), len(generated); got != want { t.Errorf("generated %d moves, want the solver's %d untouched", got, want) } }