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,86 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// createTwoSeatGame starts a plain two-human game of the given variant.
|
||||
func createTwoSeatGame(t *testing.T, svc *game.Service, variant engine.Variant) game.Game {
|
||||
t.Helper()
|
||||
g, err := svc.Create(context.Background(), game.CreateParams{
|
||||
Variant: variant,
|
||||
Seats: []uuid.UUID{provisionAccount(t), provisionAccount(t)},
|
||||
HintsAllowed: true,
|
||||
HintsPerPlayer: 1,
|
||||
MultipleWordsPerTurn: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create %s game: %v", variant, err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// TestNoRepeatWordsPinnedFromTheVariant checks the rule is written into games.no_repeat_words at
|
||||
// creation from the variant — on for Erudit, off for both Scrabble variants — and read back with
|
||||
// the game rather than re-derived.
|
||||
func TestNoRepeatWordsPinnedFromTheVariant(t *testing.T) {
|
||||
svc := newGameService()
|
||||
for _, tc := range []struct {
|
||||
variant engine.Variant
|
||||
want bool
|
||||
}{
|
||||
{engine.VariantErudit, true},
|
||||
{engine.VariantRussianScrabble, false},
|
||||
{engine.VariantEnglish, false},
|
||||
} {
|
||||
t.Run(tc.variant.String(), func(t *testing.T) {
|
||||
created := createTwoSeatGame(t, svc, tc.variant)
|
||||
if created.NoRepeatWords != tc.want {
|
||||
t.Errorf("created game NoRepeatWords = %v, want %v", created.NoRepeatWords, tc.want)
|
||||
}
|
||||
// Re-read it: the value must come from the row, not from the create call.
|
||||
reloaded, err := svc.GameByID(context.Background(), created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get game: %v", err)
|
||||
}
|
||||
if reloaded.NoRepeatWords != tc.want {
|
||||
t.Errorf("reloaded game NoRepeatWords = %v, want %v", reloaded.NoRepeatWords, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoRepeatWordsHonoursThePinNotTheVariant is the compatibility guard for every Erudit game
|
||||
// that existed before the rule: those rows carry no_repeat_words false (the migration's default),
|
||||
// and such a game must keep playing unrestricted. Flipping the stored flag stands in for one.
|
||||
func TestNoRepeatWordsHonoursThePinNotTheVariant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
g := createTwoSeatGame(t, svc, engine.VariantErudit)
|
||||
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.games SET no_repeat_words = false WHERE game_id = $1`, g.ID); err != nil {
|
||||
t.Fatalf("clear the pin: %v", err)
|
||||
}
|
||||
|
||||
reloaded, err := svc.GameByID(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get game: %v", err)
|
||||
}
|
||||
if reloaded.NoRepeatWords {
|
||||
t.Fatal("an Erudit game whose row has the rule off must report it off")
|
||||
}
|
||||
// The rule reaches the engine from the row, so the reconstructed game plays unrestricted:
|
||||
// its generated moves are the solver's full list, none filtered away.
|
||||
if _, err := svc.Hint(ctx, reloaded.ID, reloaded.Seats[reloaded.ToMove].AccountID); err != nil {
|
||||
t.Fatalf("hint on an unrestricted Erudit game: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user