fix(offline): keep the local composition; feat(rules): no repeated words in Erudit #287

Open
developer wants to merge 5 commits from feature/no-repeat-words into development
Owner

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 — draftGet returned '' and draftSave was 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.ts STREAM_IDLE_TIMEOUT_MS), the reconnect 4 s later flips app.streamAlive back 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.

  • A play whose main word is already on the board is illegal, and is neither accepted nor generated.
  • A play whose cross-word is already there stands — incidental to laying the main word — but that cross-word scores nothing.
  • The played set is the game's move journal (main + cross of every play), compared decoded, so a blank spells the same word.
  • The rule sits in the game layer, not the solver: applied at submit, at the move preview and over generated moves (filtered and re-ranked, so neither the robot nor the hint can offer a play the engine would refuse). Mirrored in the JS port for the offline engine and the on-device preview.
  • The Erudit variant description now reads "слова без повтора" / "no repeated words" (one string, rendered on New Game, the lobby invitation card and Profile).

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.go closes an unreplayable game as a draw — and would rescore a play whose cross-word repeats an earlier word, shifting a live game's totals. So games.no_repeat_words is 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 — additive ALTER 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 -l across backend, gateway, pkg, platform/telegram
  • go test ./backend/... ./gateway/... ./pkg/... ./platform/telegram/...
  • go test -tags=integration ./backend/internal/inttest/ (full suite)
  • svelte-check (0 errors), vitest run (661 passed), pnpm build
  • codegen re-run and idempotent (pkg/Makefile fbs with 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.

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 — `draftGet` returned `''` and `draftSave` was 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.ts` `STREAM_IDLE_TIMEOUT_MS`), the reconnect 4 s later flips `app.streamAlive` back 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. - A play whose **main word** is already on the board is illegal, and is neither accepted nor generated. - A play whose **cross-word** is already there stands — incidental to laying the main word — but that cross-word scores nothing. - The played set is the game's move journal (main + cross of every play), compared decoded, so a blank spells the same word. - The rule sits in the game layer, not the solver: applied at submit, at the move preview and over generated moves (filtered and re-ranked, so neither the robot nor the hint can offer a play the engine would refuse). Mirrored in the JS port for the offline engine and the on-device preview. - The Erudit variant description now reads "слова без повтора" / "no repeated words" (one string, rendered on New Game, the lobby invitation card and Profile). ### 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.go` closes an unreplayable game **as a draw** — and would rescore a play whose cross-word repeats an earlier word, shifting a live game's totals. So `games.no_repeat_words` is 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` — additive `ALTER 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 -l` across backend, gateway, pkg, platform/telegram - `go test ./backend/... ./gateway/... ./pkg/... ./platform/telegram/...` - `go test -tags=integration ./backend/internal/inttest/` (full suite) - `svelte-check` (0 errors), `vitest run` (661 passed), `pnpm build` - codegen re-run and idempotent (`pkg/Makefile fbs` with 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`.
developer added 2 commits 2026-07-27 16:34:25 +00:00
A local (offline) game persisted no draft: draftGet returned an empty
string and draftSave was a no-op, on the assumption that the arrangement
is only ever rebuilt from the rack when the game is reopened. The game
screen, however, refetches the state on several triggers, and every one
of them applied that empty draft over the live composition — dropping the
player's pending tiles back into the rack and resetting the rack order.

Offline the most visible trigger fires on its own: with no network the
live-stream watchdog declares the stream dead after 25 s, the reconnect
4 s later flips app.streamAlive back to true, and the game screen's
reconnect effect refetches. Composing a move offline was therefore reset
roughly every half minute.

Store the draft in the game's own record instead, so a reload restores it
and it survives leaving and reopening the app, and clear it wherever the
turn advances (a committed move, a resignation, a host skip) so a hotseat
seat never inherits the previous player's arrangement. Gate the two
stream-driven refetches on the game being a network one: a local game
takes its events from the source, so reacting to the stream only cost it
a pointless reload.
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
d7337d24ea
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.
developer added 1 commit 2026-07-27 17:12:44 +00:00
fix(rules): name a repeated word in the move preview instead of scoring it
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
4f4768b092
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.
developer added 1 commit 2026-07-27 17:19:27 +00:00
build(ui): move the rule's played-word set into the lazy dict chunk
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
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 2m27s
e011b2ebe5
Building the set of words a game has already used belonged with the rest
of the rule, behind the same lazy import as the evaluator that consumes
it, rather than in the always-loaded game screen.

What is left in the app entry — the per-game flag on the decoded GameView
and the caption that names a rejected word — costs more than the budget
had free, so raise it by the usual kilobyte with the reason recorded
alongside the earlier raises.
developer added 1 commit 2026-07-27 17:24:16 +00:00
docs(agent): note the UI job's bundle-size gate as a local pre-push step
CI / gate (pull_request) Blocked by required conditions
CI / deploy (pull_request) Blocked by required conditions
CI / changes (pull_request) Successful in 3s
CI / conformance (pull_request) Waiting to run
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Has started running
5be8116607
Some checks are pending
CI / gate (pull_request) Blocked by required conditions
CI / deploy (pull_request) Blocked by required conditions
CI / changes (pull_request) Successful in 3s
CI / conformance (pull_request) Waiting to run
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Has started running
This pull request doesn't have enough required approvals yet. 0 of 1 approvals granted from users or teams on the allowlist.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feature/no-repeat-words:feature/no-repeat-words
git checkout feature/no-repeat-words
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/scrabble-game#287