From e011b2ebe5c2f9f35a6e218e017d7f78093f4ddf Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 27 Jul 2026 19:19:26 +0200 Subject: [PATCH] build(ui): move the rule's played-word set into the lazy dict chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/scripts/bundle-size.mjs | 8 ++++++-- ui/src/game/Game.svelte | 17 ++++------------- ui/src/lib/dict/index.ts | 1 + ui/src/lib/dict/norepeat.ts | 15 +++++++++++++++ 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 7ded7f3..170da9a 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -41,8 +41,12 @@ const DIST = 'dist'; // + the update-available nudge) and the unified-lobby / create-flow wiring ride the always-loaded // transport / App / Lobby / New Game screens (the dict-availability guard's `getDawg` stays lazy). The // heavy parts — the dict loader, the move generator and the preload orchestration — still stay in lazy -// chunks. Scoped CSS lands in the CSS chunk, not this JS budget. -const BUDGET = { app: 130, shared: 31, landing: 5 }; +// chunks. Raised to 131 for the Erudit no-repeat-words rule: the per-game flag rides the decoded +// GameView (codec + model), and the always-loaded game screen has to name the word its preview +// rejected, since a repeated word is a real word and the board alone cannot say why the play is +// refused. The rule's own logic — the played-word set and the rescoring — sits with the evaluator in +// the lazy dict chunk. Scoped CSS lands in the CSS chunk, not this JS budget. +const BUDGET = { app: 131, shared: 31, landing: 5 }; // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // local file (e.g. the Telegram SDK loaded from a CDN) or is missing. diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index aecec1e..7e238f3 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -276,18 +276,6 @@ handleError(e); } } - // playedWords is the no-repeat-words rule's lookup set for the on-device preview: every word this - // game has already formed, as the history carries them (decoded, lower-case). Only a game that - // takes the rule needs it; for any other the preview scores unrestricted, so it returns undefined. - function playedWords(): Set | undefined { - if (!view?.game.noRepeatWords) return undefined; - const played = new Set(); - for (const m of moves) { - if (m.action === 'play') for (const w of m.words) played.add(w); - } - return played; - } - let draftSaveTimer: ReturnType | null = null; // scheduleDraftSave persists the composition (rack order + pending tiles) after a short // debounce; best-effort, so a failed save never interrupts play. The cache is updated at once @@ -845,7 +833,10 @@ const reader = d.peekDawg(v.game.variant, v.game.dictVersion); if (reader && hasAlphabet(v.game.variant)) { try { - preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn, playedWords()); + // Under the no-repeat-words rule the evaluator also needs the words this game has + // already formed; both it and that set live behind the same lazy import. + const played = v.game.noRepeatWords ? d.playedWordsFromHistory(moves) : undefined; + preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn, played); notePreviewLocal(); return; } catch { diff --git a/ui/src/lib/dict/index.ts b/ui/src/lib/dict/index.ts index cc1d230..5f5d9bc 100644 --- a/ui/src/lib/dict/index.ts +++ b/ui/src/lib/dict/index.ts @@ -3,5 +3,6 @@ // of the initial app bundle — it is a progressive enhancement over the network // preview, which remains the fallback. export { evaluateLocal } from './eval'; +export { playedWordsFromHistory } from './norepeat'; export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader'; export { clearDictCache, listCachedDicts } from './store'; diff --git a/ui/src/lib/dict/norepeat.ts b/ui/src/lib/dict/norepeat.ts index 38e2884..95ca255 100644 --- a/ui/src/lib/dict/norepeat.ts +++ b/ui/src/lib/dict/norepeat.ts @@ -14,6 +14,7 @@ // variant, so a game started before the rule existed plays on without it. import type { Move } from './validate'; +import type { MoveRecord } from '../model'; /** * wordKey identifies a word for the rule. Words are alphabet-index letters, so the key ignores @@ -36,6 +37,20 @@ export function playedWordKeys(words: Iterable): Set return played; } +/** + * playedWordsFromHistory is the rule's lookup set for a game whose history the server decoded for + * us: every word its plays have formed, main words and cross-words alike, in the same lower-case + * form evaluateLocal decodes a candidate word into. It lives here, with the rest of the rule and + * behind the same lazy import as the evaluator, rather than in the game screen. + */ +export function playedWordsFromHistory(moves: readonly MoveRecord[]): Set { + const played = new Set(); + for (const m of moves) { + if (m.action === 'play') for (const w of m.words) played.add(w); + } + return played; +} + /** * noRepeatScore applies the rule to an already validated move against the set of words already * played. It returns null when the move's main word is one of them — the play is illegal — and