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.
This commit is contained in:
Ilia Denisov
2026-07-27 19:12:37 +02:00
parent d7337d24ea
commit 4f4768b092
11 changed files with 162 additions and 14 deletions
+5
View File
@@ -1580,6 +1580,11 @@
{resultText()}
{:else if preview?.legal && !recallOverRack}
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score}
{:else if preview?.repeatedWord && !recallOverRack}
<!-- A real word, rejected only because this game has already used it (the Erudit
no-repeat rule): the tiles read invalid, and the caption says which word and why —
otherwise the player is left staring at a word they know is in the dictionary. -->
{preview.repeatedWord.toUpperCase()}{': '}{t('game.wordAlreadyUsed')}
{:else}
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
{/if}
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { evaluateLocal } from './eval';
import { seedMockAlphabets } from '../mock/alphabet';
import { emptyBoard, type Board } from '../board';
import type { Dict } from './validate';
import type { PlacedTile } from '../client';
// 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
// to accept every word, so what these tests pin is the rule and the letter<->index adapter around
// it, not the word list (the Go/JS agreement on plain scoring is pinned by the parity suites).
const anyWord: Dict = { indexOf: () => 0 };
// place fills a board from "row,col,letter" triples.
function place(board: Board, cells: [number, number, string][]): Board {
for (const [row, col, letter] of cells) board[row][col] = { letter, blank: false };
return board;
}
function tile(row: number, col: number, letter: string): PlacedTile {
return { row, col, letter, blank: false };
}
beforeAll(() => seedMockAlphabets());
describe('evaluateLocal under the no-repeat-words rule', () => {
// The position is a real one from the test contour, where the rule rejected the play on the
// server while the composition still read as legal on the board. БРА was laid on move 4 (row 5),
// and this play hooks the Р of ПРИНТ to spell it again downwards in column 3.
function contourBoard(): Board {
return place(emptyBoard(), [
[0, 7, 'о'], [1, 7, 'в'], [2, 7, 'е'],
[2, 5, 'д'], [2, 6, 'ж'], [2, 8, 'б'],
[3, 7, 'н'], [3, 8, 'а'], [3, 9, 'е'], [3, 10, 'м'],
[4, 10, 'а'],
[5, 3, 'п'], [5, 4, 'о'], [5, 5, 'й'], [5, 6, 'к'], [5, 7, 'а'], [5, 9, 'б'], [5, 10, 'р'], [5, 11, 'а'],
[6, 0, 'п'], [6, 3, 'у'], [6, 6, 'о'], [6, 10, 'а'],
[7, 0, 'л'], [7, 1, 'и'], [7, 2, 'с'], [7, 3, 'т'], [7, 6, 'т'], [7, 7, 'р'], [7, 8, 'а'], [7, 9, 'в'], [7, 10, 'л'], [7, 11, 'я'],
[8, 0, 'у'], [8, 2, 'м'], [8, 3, 'ы'], [8, 4, 'с'], [8, 5, 'и'], [8, 6, 'к'],
[9, 0, 'г'], [9, 4, 'у'],
[10, 4, 'ш'],
[11, 2, 'п'], [11, 3, 'р'], [11, 4, 'и'], [11, 5, 'н'], [11, 6, 'т'],
]);
}
// Б above the standing Р and А below it spell БРА downwards.
const bra: PlacedTile[] = [tile(10, 3, 'б'), tile(12, 3, 'а')];
const played = new Set(['травля', 'марал', 'кот', 'наем', 'бра', 'пойка', 'овен', 'джеб', 'путы', 'мысик', 'суши', 'лист', 'плуг', 'принт']);
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.
const res = evaluateLocal(anyWord, 'erudit_ru', contourBoard(), bra, false);
expect(res.legal).toBe(true);
expect(res.words).toEqual(['бра']);
expect(res.score).toBe(6);
expect(res.repeatedWord).toBeUndefined();
});
it('rejects the play and names the word when the game has already used it', () => {
const res = evaluateLocal(anyWord, 'erudit_ru', contourBoard(), bra, false, played);
expect(res.legal).toBe(false);
expect(res.score).toBe(0);
// Named, so the caption can read "БРА: уже использовано" instead of the bare turn label.
expect(res.repeatedWord).toBe('бра');
});
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(['кот']));
expect(res.legal).toBe(true);
expect(res.score).toBe(6);
});
});
describe('evaluateLocal under the rule with several words per turn', () => {
// The same position the server engine scores in backend/internal/engine/norepeat_test.go
// (TestNoRepeatDropsTheScoreOfAReplayedCrossWord), pinned to the same two totals: КОТ stands
// across the centre and again down column 5, and РОТ laid across row 12 completes the second
// one as a cross-word. Both engines must agree to the point, or one of the two tests breaks.
function crossBoard(): Board {
return place(emptyBoard(), [
[7, 7, 'к'], [7, 8, 'о'], [7, 9, 'т'], // the opening КОТ, away from the play
[10, 5, 'к'], [11, 5, 'о'], // the standing КО the play completes
]);
}
const rot: PlacedTile[] = [tile(12, 3, 'р'), tile(12, 4, 'о'), tile(12, 5, 'т')];
it('keeps the play legal when only a cross-word repeats, and stops scoring that word', () => {
const free = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true);
expect(free.legal).toBe(true);
expect(free.words).toEqual(['рот', 'кот']);
expect(free.score).toBe(10); // РОТ plus the КОТ cross-word
const restricted = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true, new Set(['кот']));
expect(restricted.legal).toBe(true);
expect(restricted.repeatedWord).toBeUndefined();
// The cross-word is still formed and still reported — it just earns nothing.
expect(restricted.words).toEqual(['рот', 'кот']);
expect(restricted.score).toBe(5);
});
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(['рот', 'кот']));
expect(res.legal).toBe(false);
expect(res.repeatedWord).toBe('рот');
});
});
+3 -1
View File
@@ -108,7 +108,9 @@ export function evaluateLocal(
const words = [m.main, ...m.cross].map((w) => decodeWord(variant, w.letters));
const score = playedWords ? noRepeatScore(m, playedWords, (l) => decodeWord(variant, l)) : m.score;
if (score === null) {
return { legal: false, score: 0, words: [], dir: '' };
// A real word, but one this game has already used: illegal, and named so the caption can say
// why rather than leaving the player staring at a word they know is in the dictionary.
return { legal: false, score: 0, words: [], dir: '', repeatedWord: words[0] };
}
return { legal: true, score, words, dir: dir === Horizontal ? 'H' : 'V' };
}
+1
View File
@@ -99,6 +99,7 @@ export const en = {
'game.bagCount': 'In the bag: {n}',
'game.bagEmpty': 'Bag is empty',
'game.hints': 'Hints {n}',
'game.wordAlreadyUsed': 'already used',
'game.yourTurn': 'Your turn',
'game.yourTurnBy': '{name}: Your turn!',
'game.opponentsTurn': "Opponent's turn",
+1
View File
@@ -99,6 +99,7 @@ export const ru: Record<MessageKey, string> = {
'game.bagCount': 'В мешке: {n}',
'game.bagEmpty': 'Мешок пуст',
'game.hints': 'Подсказки {n}',
'game.wordAlreadyUsed': 'уже использовано',
'game.yourTurn': 'Ваш ход',
'game.yourTurnBy': '{name}: Ваш ход!',
'game.opponentsTurn': 'Ход соперника',
+10 -2
View File
@@ -295,13 +295,21 @@ export class LocalGame {
/** evaluatePlay scores a candidate placement for the current position without committing it,
* returning its legality (dictionary + connectivity), score, the words it forms (as alphabet-index
* arrays, main first) and the inferred direction. Backs the local move preview. */
evaluatePlay(tiles: Placement[]): { legal: boolean; score: number; words: number[][]; dir: Direction } {
evaluatePlay(tiles: Placement[]): {
legal: boolean;
score: number;
words: number[][];
dir: Direction;
/** The main word when the no-repeat-words rule is what rejects the play: a real word the game
* has already used. Absent on every other outcome. */
repeated?: number[];
} {
const dir = playDirection(this.board, this.vrs, this.dawg, tiles);
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir };
const m = res.move;
const score = this.noRepeatWords ? noRepeatScore(m, this.playedKeys()) : m.score;
if (score === null) return { legal: false, score: 0, words: [], dir };
if (score === null) return { legal: false, score: 0, words: [], dir, repeated: m.main.letters };
return { legal: true, score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir };
}
+3 -1
View File
@@ -272,11 +272,13 @@ export class LocalSource implements GameLoopSource {
async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult> {
const { game } = await this.load(gameId);
const r = game.evaluatePlay(encodePlacements(variant, tiles));
const decode = (w: readonly number[]) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase();
return {
legal: r.legal,
score: r.score,
words: r.words.map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase()),
words: r.words.map(decode),
dir: r.legal ? (r.dir === 0 ? 'H' : 'V') : '',
repeatedWord: r.repeated ? decode(r.repeated) : undefined,
};
}
+5
View File
@@ -129,6 +129,11 @@ export interface EvalResult {
words: string[];
/** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */
dir: string;
/** The play's main word when it is a real word the game has already used, so the rule (and not
* the dictionary) is what makes the play illegal — the composition reads as invalid but the
* caption says so in those words. Set only by the on-device evaluator, which decides the rule
* before the play ever reaches the server; absent on every other outcome. */
repeatedWord?: string;
}
export interface WordCheckResult {