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

Merged
developer merged 6 commits from feature/no-repeat-words into development 2026-07-27 17:46:35 +00:00

6 Commits

Author SHA1 Message Date
Ilia Denisov f3c08e031e fix(rules): case-fold the played words, so the rule fires online too
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The no-repeat-words rule never fired in an online game. The move preview
builds its set of already-used words from the history the client holds,
and the codec upper-cases those words for display, while the evaluator
decodes the candidate word to lower — so "БРА" was never found to equal
"бра" and every repeat scored as an ordinary play, right up to the server
refusing it.

Offline was unaffected: that engine compares alphabet-index keys taken
from its own journal, which carry no case at all.

The tests missed it because they hand-built the played set in the form
the server sends rather than deriving it the way the game screen does.
They now go through playedWordsFromHistory with history records shaped as
the codec produces them — upper-cased words and all — which is the path
that was broken; three of the five fail without this fix.
2026-07-27 19:37:34 +02:00
Ilia Denisov 5be8116607 docs(agent): note the UI job's bundle-size gate as a local pre-push step
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 1m50s
2026-07-27 19:21:20 +02:00
Ilia Denisov e011b2ebe5 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
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.
2026-07-27 19:19:26 +02:00
Ilia Denisov 4f4768b092 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
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.
2026-07-27 19:12:37 +02:00
Ilia Denisov d7337d24ea 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
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.
2026-07-27 18:33:56 +02:00
Ilia Denisov ebd1f05da8 fix(offline): keep the local game's composition across a state reload
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.
2026-07-27 18:12:09 +02:00