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
3 changed files with 48 additions and 8 deletions
Showing only changes of commit f3c08e031e - Show all commits
+5
View File
@@ -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. both the FBS schema and the backend DTO.
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST - **Seat display name is built in two places** — `game.Service` live events **and** the server REST
DTOs. Change both or they drift. 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, - **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. then bumps it from the live event. Don't expect it on the shared broadcast.
+36 -4
View File
@@ -1,9 +1,11 @@
import { describe, it, expect, beforeAll } from 'vitest'; import { describe, it, expect, beforeAll } from 'vitest';
import { evaluateLocal } from './eval'; import { evaluateLocal } from './eval';
import { playedWordsFromHistory } from './norepeat';
import { seedMockAlphabets } from '../mock/alphabet'; import { seedMockAlphabets } from '../mock/alphabet';
import { emptyBoard, type Board } from '../board'; import { emptyBoard, type Board } from '../board';
import type { Dict } from './validate'; import type { Dict } from './validate';
import type { PlacedTile } from '../client'; 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 // 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 // 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 }; 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()); beforeAll(() => seedMockAlphabets());
describe('evaluateLocal under the no-repeat-words rule', () => { 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. // Б above the standing Р and А below it spell БРА downwards.
const bra: PlacedTile[] = [tile(10, 3, 'б'), tile(12, 3, 'а')]; 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', () => { 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. // 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', () => { 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.legal).toBe(true);
expect(res.score).toBe(6); 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.words).toEqual(['рот', 'кот']);
expect(free.score).toBe(10); // РОТ plus the КОТ cross-word 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.legal).toBe(true);
expect(restricted.repeatedWord).toBeUndefined(); expect(restricted.repeatedWord).toBeUndefined();
// The cross-word is still formed and still reported — it just earns nothing. // 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', () => { 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.legal).toBe(false);
expect(res.repeatedWord).toBe('рот'); expect(res.repeatedWord).toBe('рот');
}); });
+7 -4
View File
@@ -39,14 +39,17 @@ export function playedWordKeys(words: Iterable<readonly number[]>): Set<string>
/** /**
* playedWordsFromHistory is the rule's lookup set for a game whose history the server decoded for * 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 * us: every word its plays have formed, main words and cross-words alike. It lives here, with the
* form evaluateLocal decodes a candidate word into. It lives here, with the rest of the rule and * rest of the rule and behind the same lazy import as the evaluator, rather than in the game screen.
* 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<string> { export function playedWordsFromHistory(moves: readonly MoveRecord[]): Set<string> {
const played = new Set<string>(); const played = new Set<string>();
for (const m of moves) { 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; return played;
} }