feat(rules): forbid repeating a word already on the board in Erudit
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s

Russian "Эрудит" treats a word laid on the board as belonging to the
game: it cannot be laid again. Neither the solver, the backend nor the
offline JS port knew the rule, so a player (and the robot) could replay a
word freely. Official Scrabble places no such restriction, so both
Scrabble variants keep playing unrestricted.

The rule applies in two ways. A play whose main word is already on the
board is illegal, and is neither accepted nor generated. A play whose
perpendicular cross-word is already there stands — that word is
incidental to laying the main word — but scores nothing. The set of
played words is the game's own move journal, main words and cross-words
alike, compared decoded, so a word spelled with a blank is the same word.

It lives in the game layer, not the solver: only a game knows its
history, and the solver stays stateless and standard-rules. The backend
applies it at submit, at the move preview and over generated moves
(filtering and re-ranking them, so neither the robot nor the hint can
offer a play the engine would then refuse); the client port does the same
for the offline engine and for the on-device preview of an online game.

The rule is pinned per game (games.no_repeat_words, set from the variant
at creation) rather than keyed on the variant, because a game is replayed
from its journal on every open. Applied retroactively it would make an
already-played repeat illegal — closing that game as a draw — and would
rescore a play whose cross-word repeats an earlier word, shifting a live
game's totals. Games created before the rule keep playing without it. The
flag rides the wire as a trailing field because the client's preview must
score the way the server does, and offline games pin the same answer in
their own record.
This commit is contained in:
Ilia Denisov
2026-07-27 18:33:56 +02:00
parent ebd1f05da8
commit d7337d24ea
49 changed files with 802 additions and 26 deletions
+14 -4
View File
@@ -9,6 +9,7 @@
import { validatePlay, Horizontal, type Board as VBoard, type Ruleset, type Placement as VPlacement, type Dict } from './validate';
import { playDirection } from './direction';
import { noRepeatScore } from './norepeat';
import type { Board as ClientBoard } from '../board';
import type { PlacedTile } from '../client';
import type { EvalResult, Variant } from '../model';
@@ -64,7 +65,7 @@ function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset {
// decodeWord turns a word's alphabet indices back into a lower-cased string, the
// form the network EvalResult carries (the caption renders it directly).
function decodeWord(variant: Variant, letters: number[]): string {
function decodeWord(variant: Variant, letters: readonly number[]): string {
let s = '';
for (const i of letters) s += letterForIndex(variant, i);
return s.toLowerCase();
@@ -74,8 +75,12 @@ function decodeWord(variant: Variant, letters: number[]): string {
* evaluateLocal computes the move preview for tiles placed on board in the given
* game, returning the same shape as the network `evaluate`. dict is the loaded
* dictionary reader for the game's (variant, version); multipleWords is the game's
* cross-word rule. It may throw if the variant's alphabet table is not loaded — the
* caller guards with hasAlphabet and falls back to the network on any failure.
* cross-word rule. playedWords is the game's already-played words (decoded, as the
* history carries them) when the game takes the no-repeat-words rule, and absent
* otherwise — with it a play repeating a word is reported illegal and an already-played
* cross-word scores nothing, exactly as the server decides. It may throw if the
* variant's alphabet table is not loaded — the caller guards with hasAlphabet and falls
* back to the network on any failure.
*/
export function evaluateLocal(
dict: Dict,
@@ -83,6 +88,7 @@ export function evaluateLocal(
board: ClientBoard,
tiles: PlacedTile[],
multipleWords: boolean,
playedWords?: ReadonlySet<string>,
): EvalResult {
const vboard = buildBoard(variant, board);
const rs = buildRuleset(variant, multipleWords);
@@ -100,5 +106,9 @@ export function evaluateLocal(
}
const m = res.move;
const words = [m.main, ...m.cross].map((w) => decodeWord(variant, w.letters));
return { legal: true, score: m.score, words, dir: dir === Horizontal ? 'H' : 'V' };
const score = playedWords ? noRepeatScore(m, playedWords, (l) => decodeWord(variant, l)) : m.score;
if (score === null) {
return { legal: false, score: 0, words: [], dir: '' };
}
return { legal: true, score, words, dir: dir === Horizontal ? 'H' : 'V' };
}