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.
- **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.
+36 -4
View File
@@ -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('рот');
});
+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
* 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<string> {
const played = new Set<string>();
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;
}