fix(offline): keep the local composition; feat(rules): no repeated words in Erudit #287
Reference in New Issue
Block a user
Delete Branch "feature/no-repeat-words"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Two reported bugs from an offline vs_ai game.
1. The composition reset itself roughly every half minute (offline)
A local game persisted no draft —
draftGetreturned''anddraftSavewas a no-op, on the assumption that the arrangement is only ever rebuilt from the rack when the game is reopened. The game screen refetches the state on several triggers, and each applied that empty draft over the live composition, dropping the pending tiles back into the rack.Offline the trigger fires on its own: with no network the live-stream watchdog declares the stream dead after 25 s (
app.svelte.tsSTREAM_IDLE_TIMEOUT_MS), the reconnect 4 s later flipsapp.streamAliveback to true, and the screen's reconnect effect refetches.The draft now lives in the game's own record, so a reload restores it and it survives leaving and reopening the app; it is cleared wherever the turn advances (a committed move, a resignation, a host skip), so a hotseat seat never inherits the previous player's arrangement. The two stream-driven refetches are gated on the game being a network one.
2. A word already on the board could be laid again
Not an offline-only bug: the rule existed nowhere — not in the solver, not in the backend, not in the JS port — so online behaved the same. Official Scrabble has no such restriction, so this is an Erudit-only rule and both Scrabble variants are untouched.
Why it is pinned per game
A game is replayed from its journal on every open. Applying the rule retroactively would make an already-played repeat illegal — and
service.gocloses an unreplayable game as a draw — and would rescore a play whose cross-word repeats an earlier word, shifting a live game's totals. Sogames.no_repeat_wordsis set from the variant at creation and read back thereafter, never re-derived; games created before the rule keep playing without it. The flag rides the wire (trailing FBS field) because the client's preview must score the way the server does.Migration
00017_no_repeat_words.sql— additiveALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT false, applied forward by goose with no data rewrite and ignored by an image rollback. No contour wipe needed.Verification (all local, all green)
go build/go vet/gofmt -lacross backend, gateway, pkg, platform/telegramgo test ./backend/... ./gateway/... ./pkg/... ./platform/telegram/...go test -tags=integration ./backend/internal/inttest/(full suite)svelte-check(0 errors),vitest run(661 passed),pnpm buildpkg/Makefile fbswith the pinned flatc 23.5.26,ui pnpm codegen)New coverage:
engine/norepeat_test.go(each assertion checked to fail with the rule neutralised),inttest/norepeat_test.go(the pin round-trips and drives the engine, incl. an Erudit game with the pin cleared),dict/norepeat.test.ts,localgame/engine.norepeat.test.ts(self-play with the rule vs without, so it cannot pass vacuously), plus the GameView codec test.Docs baked in:
docs/ARCHITECTURE.md§6,docs/FUNCTIONAL.md+_ru.Composing an already-played word read as a perfectly legal move: the tiles drew as legal, the caption showed "БРА = 6" and the ✅ was enabled, and only the server refused the play with the bare "illegal move" toast. The on-device evaluator already applied the rule, but it reported the refusal as a plain illegal play, so nothing distinguished a repeated word from a misspelling and the caption fell back to the turn label. It now names the word it rejected, and the game screen reads "БРА: уже использовано" above the scores while the tiles stay marked invalid — the composition is wrong, but for a reason the player cannot read off the board, since the word itself is in the dictionary. The verdict is reached on the device before the play is ever offered to the server, in an online game exactly as in an offline one; the offline source reports the same reason through the same field. Only the main word is ever refused: a cross-word the game has already used still stands (it is incidental to laying the main word) and merely scores nothing, so the rule cannot falsely block a play in a game with several words per turn. The scoring of such a play is now pinned to the point on both sides: the engine test and the client test evaluate the same position — РОТ laid across a standing КО, completing an already-played КОТ — and assert the same totals (10 unrestricted, 5 with the rule), so a divergence between the two engines breaks one of them. The client test also replays the exact test-contour position this was reported from.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.