d7337d24ea
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Russian "Эрудит" treats a word laid on the board as belonging to the game: it cannot be laid again. Neither the solver, the backend nor the offline JS port knew the rule, so a player (and the robot) could replay a word freely. Official Scrabble places no such restriction, so both Scrabble variants keep playing unrestricted. The rule applies in two ways. A play whose main word is already on the board is illegal, and is neither accepted nor generated. A play whose perpendicular cross-word is already there stands — that word is incidental to laying the main word — but scores nothing. The set of played words is the game's own move journal, main words and cross-words alike, compared decoded, so a word spelled with a blank is the same word. It lives in the game layer, not the solver: only a game knows its history, and the solver stays stateless and standard-rules. The backend applies it at submit, at the move preview and over generated moves (filtering and re-ranking them, so neither the robot nor the hint can offer a play the engine would then refuse); the client port does the same for the offline engine and for the on-device preview of an online game. The rule is pinned per game (games.no_repeat_words, set from the variant at creation) rather than keyed on the variant, because a game is replayed from its journal on every open. Applied retroactively it would make an already-played repeat illegal — closing that game as a draw — and would rescore a play whose cross-word repeats an earlier word, shifting a live game's totals. Games created before the rule keep playing without it. The flag rides the wire as a trailing field because the client's preview must score the way the server does, and offline games pin the same answer in their own record.
176 lines
7.0 KiB
Go
176 lines
7.0 KiB
Go
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)
|
||
}
|
||
}
|