diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6b3fe4c..351436a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -97,6 +97,11 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde both the FBS schema and the backend DTO. - **Seat display name is built in two places** — `game.Service` live events **and** the server REST DTOs. Change both or they drift. +- **The client codec UPPER-CASES history words** (`lib/codec.ts` `decodeMove`) for display, while the + engine/evaluator decodes candidate words to lower. Anything comparing the two must case-fold, or it + silently never matches — this is how the Erudit no-repeat rule shipped dead on the online path + while working offline (offline compares alphabet-index keys, so it has no case at all). A test that + hand-builds such a set instead of deriving it the way the screen does will pass vacuously. - **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST, then bumps it from the live event. Don't expect it on the shared broadcast. diff --git a/ui/src/lib/dict/eval.norepeat.test.ts b/ui/src/lib/dict/eval.norepeat.test.ts index b6f0cd5..e2e948d 100644 --- a/ui/src/lib/dict/eval.norepeat.test.ts +++ b/ui/src/lib/dict/eval.norepeat.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { evaluateLocal } from './eval'; +import { playedWordsFromHistory } from './norepeat'; import { seedMockAlphabets } from '../mock/alphabet'; import { emptyBoard, type Board } from '../board'; import type { Dict } from './validate'; import type { PlacedTile } from '../client'; +import type { MoveRecord } from '../model'; // The no-repeat-words rule as the game screen sees it: through evaluateLocal, the on-device move // preview, which decides the rule before a play ever reaches the server. The dictionary is stubbed @@ -21,6 +23,31 @@ function tile(row: number, col: number, letter: string): PlacedTile { return { row, col, letter, blank: false }; } +// playRecord is one history entry as the client holds it — note the UPPER-CASE words, which is how +// the codec decodes them for display (lib/codec decodeMove). Building the played set from records +// in this shape is what the game screen actually does, so these tests go through it rather than +// hand-writing the set; comparing the codec's case against the evaluator's would otherwise silently +// never match and the rule would never fire. +function playRecord(...words: string[]): MoveRecord { + return { + player: 0, + action: 'play', + dir: 'H', + mainRow: 0, + mainCol: 0, + tiles: [], + words: words.map((w) => w.toUpperCase()), + count: 0, + score: 0, + total: 0, + }; +} + +// passRecord is a non-play entry, which contributes no words. +function passRecord(): MoveRecord { + return { player: 0, action: 'pass', dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 }; +} + beforeAll(() => seedMockAlphabets()); describe('evaluateLocal under the no-repeat-words rule', () => { @@ -44,7 +71,12 @@ describe('evaluateLocal under the no-repeat-words rule', () => { } // Б above the standing Р and А below it spell БРА downwards. const bra: PlacedTile[] = [tile(10, 3, 'б'), tile(12, 3, 'а')]; - const played = new Set(['травля', 'марал', 'кот', 'наем', 'бра', 'пойка', 'овен', 'джеб', 'путы', 'мысик', 'суши', 'лист', 'плуг', 'принт']); + const history: MoveRecord[] = [ + playRecord('травля'), playRecord('марал'), playRecord('кот'), playRecord('наем'), playRecord('бра'), + playRecord('пойка'), playRecord('овен'), playRecord('джеб'), playRecord('путы'), playRecord('мысик'), + playRecord('суши'), playRecord('лист'), playRecord('плуг'), playRecord('принт'), passRecord(), + ]; + const played = playedWordsFromHistory(history); it('scores the play when the game does not take the rule', () => { // The control: the same play in a game without the rule is a perfectly ordinary six-pointer. @@ -64,7 +96,7 @@ describe('evaluateLocal under the no-repeat-words rule', () => { }); it('leaves the play alone when the rule is in force but the word is new', () => { - const res = evaluateLocal(anyWord, 'erudit_ru', contourBoard(), bra, false, new Set(['кот'])); + const res = evaluateLocal(anyWord, 'erudit_ru', contourBoard(), bra, false, playedWordsFromHistory([playRecord('кот')])); expect(res.legal).toBe(true); expect(res.score).toBe(6); }); @@ -89,7 +121,7 @@ describe('evaluateLocal under the rule with several words per turn', () => { expect(free.words).toEqual(['рот', 'кот']); expect(free.score).toBe(10); // РОТ plus the КОТ cross-word - const restricted = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true, new Set(['кот'])); + const restricted = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true, playedWordsFromHistory([playRecord('кот')])); expect(restricted.legal).toBe(true); expect(restricted.repeatedWord).toBeUndefined(); // The cross-word is still formed and still reported — it just earns nothing. @@ -98,7 +130,7 @@ describe('evaluateLocal under the rule with several words per turn', () => { }); it('rejects the play when the main word repeats, whatever its cross-words do', () => { - const res = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true, new Set(['рот', 'кот'])); + const res = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true, playedWordsFromHistory([playRecord('рот', 'кот')])); expect(res.legal).toBe(false); expect(res.repeatedWord).toBe('рот'); }); diff --git a/ui/src/lib/dict/norepeat.ts b/ui/src/lib/dict/norepeat.ts index 95ca255..ba0da6e 100644 --- a/ui/src/lib/dict/norepeat.ts +++ b/ui/src/lib/dict/norepeat.ts @@ -39,14 +39,17 @@ export function playedWordKeys(words: Iterable): Set /** * 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. + * us: every word its plays have formed, main words and cross-words alike. It lives here, with the + * rest of the rule and behind the same lazy import as the evaluator, rather than in the game screen. + * + * The history is case-folded to lower, because the codec upper-cases those words for display + * (lib/codec decodeMove) while evaluateLocal decodes a candidate word to lower — comparing the two + * raw would never match, and the rule would silently never fire. */ 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); + if (m.action === 'play') for (const w of m.words) played.add(w.toLowerCase()); } return played; }