feat(rules): forbid repeating a word already on the board in Erudit
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
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.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user