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
+13 -1
View File
@@ -276,6 +276,18 @@
handleError(e);
}
}
// playedWords is the no-repeat-words rule's lookup set for the on-device preview: every word this
// game has already formed, as the history carries them (decoded, lower-case). Only a game that
// takes the rule needs it; for any other the preview scores unrestricted, so it returns undefined.
function playedWords(): Set<string> | undefined {
if (!view?.game.noRepeatWords) return undefined;
const played = new Set<string>();
for (const m of moves) {
if (m.action === 'play') for (const w of m.words) played.add(w);
}
return played;
}
let draftSaveTimer: ReturnType<typeof setTimeout> | null = null;
// scheduleDraftSave persists the composition (rack order + pending tiles) after a short
// debounce; best-effort, so a failed save never interrupts play. The cache is updated at once
@@ -833,7 +845,7 @@
const reader = d.peekDawg(v.game.variant, v.game.dictVersion);
if (reader && hasAlphabet(v.game.variant)) {
try {
preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn);
preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn, playedWords());
notePreviewLocal();
return;
} catch {
+12 -2
View File
@@ -118,8 +118,13 @@ kind():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
noRepeatWords():boolean {
const offset = this.bb!.__offset(this.bb_pos, 36);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startGameView(builder:flatbuffers.Builder) {
builder.startObject(16);
builder.startObject(17);
}
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -198,12 +203,16 @@ static addKind(builder:flatbuffers.Builder, kind:number) {
builder.addFieldInt32(15, kind, 0);
}
static addNoRepeatWords(builder:flatbuffers.Builder, noRepeatWords:boolean) {
builder.addFieldInt8(16, +noRepeatWords, +false);
}
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean, kind:number):flatbuffers.Offset {
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean, kind:number, noRepeatWords:boolean):flatbuffers.Offset {
GameView.startGameView(builder);
GameView.addId(builder, idOffset);
GameView.addVariant(builder, variantOffset);
@@ -221,6 +230,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
GameView.addUnreadChat(builder, unreadChat);
GameView.addUnreadMessages(builder, unreadMessages);
GameView.addKind(builder, kind);
GameView.addNoRepeatWords(builder, noRepeatWords);
return GameView.endGameView(builder);
}
}
+4
View File
@@ -423,6 +423,7 @@ describe('codec', () => {
fb.GameView.addVsAi(b, true);
fb.GameView.addUnreadChat(b, true);
fb.GameView.addKind(b, 2);
fb.GameView.addNoRepeatWords(b, true);
const game = fb.GameView.endGameView(b);
const games = fb.GameList.createGamesVector(b, [game]);
fb.GameList.startGameList(b);
@@ -441,6 +442,9 @@ describe('codec', () => {
expect(gl.games[0].vsAi).toBe(true);
expect(gl.games[0].unreadChat).toBe(true);
expect(gl.games[0].kind).toBe(2);
// The Erudit no-repeat rule is pinned per game and rides the view: the on-device move
// preview needs it to score the way the server does.
expect(gl.games[0].noRepeatWords).toBe(true);
expect(gl.atGameLimit).toBe(true);
});
+2
View File
@@ -310,6 +310,7 @@ function decodeGameView(g: fb.GameView): GameView {
toMove: g.toMove(),
turnTimeoutSecs: g.turnTimeoutSecs(),
multipleWordsPerTurn: g.multipleWordsPerTurn(),
noRepeatWords: g.noRepeatWords(),
moveCount: g.moveCount(),
endReason: s(g.endReason()),
lastActivityUnix: Number(g.lastActivityUnix()),
@@ -1143,6 +1144,7 @@ function emptyGame(): GameView {
toMove: 0,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+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' };
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { wordKey, playedWordKeys, noRepeatScore } from './norepeat';
import type { Direction, Move, Word } from './validate';
import { Horizontal, Vertical } from './validate';
// word builds a scored Word; only its letters and score matter to the rule.
function word(letters: number[], score: number, dir: Direction = Horizontal): Word {
return { row: 0, col: 0, dir, letters, blanks: letters.map(() => false), score };
}
// move builds a scored play from a main word and its cross-words, with the total the engine
// would have computed (main + cross, no bingo).
function move(main: Word, cross: Word[] = []): Move {
return {
dir: Horizontal,
tiles: [],
main,
cross,
bonus: 0,
score: main.score + cross.reduce((sum, w) => sum + w.score, 0),
};
}
describe('no-repeat-words rule', () => {
it('keys a word by its letters, so a blank spells the same word as a real tile', () => {
// The letters are alphabet indices and the blank flag rides separately, so it never enters
// the key — matching the server, which compares the words decoded.
const withBlank: Word = { ...word([2, 14, 19], 5), blanks: [false, true, false] };
expect(wordKey(withBlank.letters)).toBe(wordKey([2, 14, 19]));
});
it('builds the played set from the journal words, ignoring empty ones', () => {
const played = playedWordKeys([[2, 14, 19], [], [3, 4]]);
expect(played.has(wordKey([2, 14, 19]))).toBe(true);
expect(played.has(wordKey([3, 4]))).toBe(true);
expect(played.size).toBe(2);
});
it('rejects a play whose main word is already on the board', () => {
const played = playedWordKeys([[2, 14, 19]]);
expect(noRepeatScore(move(word([2, 14, 19], 7)), played)).toBeNull();
});
it('accepts a play whose main word is new, at its full score', () => {
const played = playedWordKeys([[2, 14, 19]]);
expect(noRepeatScore(move(word([17, 14, 19], 6)), played)).toBe(6);
});
it('keeps a play whose cross-word repeats, but scores that cross-word at nothing', () => {
const played = playedWordKeys([[2, 14, 19]]);
const m = move(word([17, 14, 19], 6), [word([2, 14, 19], 4, Vertical), word([8, 9], 3, Vertical)]);
expect(m.score).toBe(13);
expect(noRepeatScore(m, played)).toBe(9); // the repeated cross-word's 4 points are dropped
});
it('takes a caller-supplied key, so a decoded history can be compared directly', () => {
// The online preview reads the words the server already decoded, so it keys on the decoded
// form rather than on alphabet indices.
const letters = ['', 'a', 'b', 'c'];
const decode = (l: readonly number[]) => l.map((i) => letters[i]).join('');
const played = new Set(['abc']);
expect(noRepeatScore(move(word([1, 2, 3], 7)), played, decode)).toBeNull();
expect(noRepeatScore(move(word([3, 2, 1], 7)), played, decode)).toBe(7);
});
});
+59
View File
@@ -0,0 +1,59 @@
// The no-repeat-words rule of Russian "Эрудит": a word laid on the board belongs to the game from
// then on and cannot be laid again. This is the client-side port of the server's rule
// (backend/internal/engine/norepeat.go) and must stay in step with it — it decides both what the
// offline engine accepts and what the online move preview scores.
//
// The rule applies in two different ways:
//
// - the play's main word must not already be there — such a play is illegal, and is neither
// accepted nor offered by the move generator;
// - a perpendicular cross-word that is already there is allowed, because it is incidental to
// laying the main word, but it scores nothing.
//
// It is a rule of that variant only, and it is pinned per game rather than derived from the
// variant, so a game started before the rule existed plays on without it.
import type { Move } from './validate';
/**
* wordKey identifies a word for the rule. Words are alphabet-index letters, so the key ignores
* which tile carried a letter: a word spelled with a blank is the same word as one spelled with
* real tiles, exactly as the server sees it (there the words are compared decoded).
*/
export function wordKey(letters: readonly number[]): string {
return letters.join(',');
}
/**
* playedWordKeys builds the rule's lookup set from a game's move journal — pass every play's
* words (the main word and its cross-words). A game with the rule off never needs it.
*/
export function playedWordKeys(words: Iterable<readonly number[]>): Set<string> {
const played = new Set<string>();
for (const w of words) {
if (w.length > 0) played.add(wordKey(w));
}
return played;
}
/**
* noRepeatScore applies the rule to an already validated move against the set of words already
* played. It returns null when the move's main word is one of them — the play is illegal — and
* otherwise the move's score with every already-played cross-word dropped from the total.
*
* keyOf turns a candidate word's letters into the key the played set is built with. The offline
* engine keeps its journal in alphabet-index space and uses the default; the online preview reads
* the history the server sent, whose words are already decoded, and passes a decoding key instead.
*/
export function noRepeatScore(
m: Move,
played: ReadonlySet<string>,
keyOf: (letters: readonly number[]) => string = wordKey,
): number | null {
if (played.has(keyOf(m.main.letters))) return null;
let score = m.score;
for (const cw of m.cross) {
if (played.has(keyOf(cw.letters))) score -= cw.score;
}
return score;
}
+1
View File
@@ -16,6 +16,7 @@ function gameView(id: string): GameView {
toMove: 0,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+1
View File
@@ -17,6 +17,7 @@ function gameView(moveCount: number, over = false): GameView {
toMove: 1,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount,
endReason: over ? 'standard' : '',
lastActivityUnix: 0,
+1
View File
@@ -20,6 +20,7 @@ function game(seats: Seat[], over = true): GameView {
toMove: 0,
turnTimeoutSecs: 300,
multipleWordsPerTurn: false,
noRepeatWords: false,
moveCount: 0,
endReason: over ? 'standard' : '',
lastActivityUnix: 1_782_997_629, // 2026-07-02 (UTC)
+1
View File
@@ -13,6 +13,7 @@ function game(kind: number, status: string, extra: Partial<GameView> = {}): Game
toMove: 0,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+1 -1
View File
@@ -79,7 +79,7 @@ export const en = {
'new.searching': 'Looking for an opponent…',
'new.rulesEnglish': '100 tiles · bingo +50',
'new.rulesRussian': '104 tiles · ё is a letter · bingo +50',
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15',
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15 · no repeated words',
'new.moveLimit': 'Move time: {n} h 00 min',
'new.searchHint':
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
+1 -1
View File
@@ -80,7 +80,7 @@ export const ru: Record<MessageKey, string> = {
'new.searching': 'Ищем соперника…',
'new.rulesEnglish': '100 фишек · бинго +50',
'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15 · слова без повтора',
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'new.searchHint':
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
+2 -1
View File
@@ -12,7 +12,7 @@ function invitation(id: string, status: string): Invitation {
hintsAllowed: true,
hintsPerPlayer: 0,
multipleWordsPerTurn: true,
dropoutTiles: 'remove',
dropoutTiles: 'remove',
status,
gameId: '',
expiresAtUnix: 0,
@@ -33,6 +33,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
toMove,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+1
View File
@@ -29,6 +29,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
toMove,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix,
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { Dawg } from '../dict/dawg';
import { RACK_SIZE } from '../premiums';
import { LocalGame } from './engine';
import { wordKey } from '../dict/norepeat';
import { decide } from '../robot/strategy';
import type { Move } from '../dict/validate';
// The no-repeat-words rule driven through the whole offline engine: two robots self-play a game
// with the rule on, and no word may ever be laid twice. This covers both halves of the wiring at
// once — generateMoves must not offer a forbidden play, and play must not accept one — over a long
// run of real positions rather than a hand-built one. The pinned semantics live in
// dict/norepeat.test.ts; the same rule on the server is engine/norepeat_test.go.
const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
function bestOpponentScore(game: LocalGame, seat: number): number {
let best = 0;
for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i));
return best;
}
// selfPlay runs a whole game of robot turns, returning every main word laid in order. The sample
// dictionary emits a one-letter word the validator rejects, so candidates are filtered the way
// the robot's own turn does (see engine.test.ts).
function selfPlay(noRepeatWords: boolean, seed: bigint): string[] {
const game = new LocalGame({
variant: 'scrabble_en',
version: 'sample',
seed,
players: 2,
dawg,
multipleWords: true,
noRepeatWords,
});
const mains: string[] = [];
for (let turn = 0; turn < 500 && !game.isOver; turn++) {
const seat = game.currentPlayer;
const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2);
const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength);
if (dec.kind === 'play') {
mains.push(wordKey(dec.move.main.letters));
game.play(dec.move.dir, dec.move.tiles);
} else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) {
game.exchange(dec.exchange);
} else {
game.pass();
}
}
return mains;
}
describe('offline engine under the no-repeat-words rule', () => {
it('never lays the same word twice, where the same run without the rule does', () => {
// Both halves of the wiring at once: generateMoves must not offer a forbidden play and play
// must not accept one. Running the same seed with the rule off is the anti-vacuity guard —
// it proves this position sequence really does offer repeats.
const seed = 20260727n;
const withRule = selfPlay(true, seed);
const withoutRule = selfPlay(false, seed);
expect(withoutRule.length).toBeGreaterThan(0);
expect(new Set(withoutRule).size).toBeLessThan(withoutRule.length); // repeats are on offer...
expect(new Set(withRule).size).toBe(withRule.length); // ...and the rule keeps every one out
});
});
+40 -7
View File
@@ -14,6 +14,7 @@ import { Bag } from './bag';
import { RULESETS } from './ruleset';
import { generateMoves, GenRack, Both } from '../dict/generate';
import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate';
import { noRepeatScore, playedWordKeys } from '../dict/norepeat';
import { playDirection } from '../dict/direction';
import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
import { BLANK_INDEX } from '../alphabet';
@@ -154,6 +155,10 @@ export interface LocalGameOptions {
players: number;
dawg: Dawg;
multipleWords: boolean;
/** Forbids laying a word that is already on the board (the Erudit rule, see dict/norepeat).
* Pinned per game, so a game started before the rule existed replays and scores unchanged;
* absent reads as off. */
noRepeatWords?: boolean;
dropoutTiles?: DropoutTiles;
}
@@ -175,6 +180,7 @@ export class LocalGame {
private readonly rackSize: number;
private readonly dawg: Dawg;
private readonly multipleWords: boolean;
private readonly noRepeatWords: boolean;
private readonly dropoutTiles: DropoutTiles;
private readonly board: LocalBoard;
@@ -197,6 +203,7 @@ export class LocalGame {
this.seed = opts.seed;
this.dawg = opts.dawg;
this.multipleWords = opts.multipleWords;
this.noRepeatWords = opts.noRepeatWords ?? false;
this.dropoutTiles = opts.dropoutTiles ?? 'remove';
this.vrs = buildRuleset(opts.variant, opts.multipleWords);
this.values = RULESETS[opts.variant].values;
@@ -250,17 +257,39 @@ export class LocalGame {
}
/** config returns the rule settings the game was created with the bits, alongside the seed and
* journal, needed to reconstruct it (see localgame/serialize). */
get config(): { multipleWords: boolean; dropoutTiles: DropoutTiles } {
return { multipleWords: this.multipleWords, dropoutTiles: this.dropoutTiles };
get config(): { multipleWords: boolean; noRepeatWords: boolean; dropoutTiles: DropoutTiles } {
return { multipleWords: this.multipleWords, noRepeatWords: this.noRepeatWords, dropoutTiles: this.dropoutTiles };
}
/** history returns a copy of the move log. */
get history(): LocalMove[] {
return this.log.map((m) => ({ ...m }));
}
/** generateMoves returns every legal play for the current player, ranked by descending score. */
/** generateMoves returns every legal play for the current player, ranked by descending score.
* Under the no-repeat-words rule the plays it forbids are dropped and the rest are rescored and
* re-ranked, so neither the robot nor the hint can offer a move the engine would then refuse. */
generateMoves(): Move[] {
return generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both);
const moves = generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both);
if (!this.noRepeatWords) return moves;
const played = this.playedKeys();
const out: Move[] = [];
for (const m of moves) {
const score = noRepeatScore(m, played);
if (score === null) continue;
out.push(score === m.score ? m : { ...m, score });
}
// A stable sort, so plays the generator ranked equally keep its order.
return out.sort((a, b) => b.score - a.score);
}
/** playedKeys is the no-repeat-words rule's lookup set: every word this game has already
* formed, main words and cross-words alike (see dict/norepeat). */
private playedKeys(): ReadonlySet<string> {
const words: number[][] = [];
for (const m of this.log) {
if (m.action === 'play' && m.words) words.push(...m.words);
}
return playedWordKeys(words);
}
/** evaluatePlay scores a candidate placement for the current position without committing it,
@@ -271,7 +300,9 @@ export class LocalGame {
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;
return { legal: true, score: m.score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir };
const score = this.noRepeatWords ? noRepeatScore(m, this.playedKeys()) : m.score;
if (score === null) return { legal: false, score: 0, words: [], dir };
return { legal: true, score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir };
}
/** dictionaryHas reports whether the word (alphabet-index letters) is in the game's dictionary. */
@@ -299,10 +330,12 @@ export class LocalGame {
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
if (!res.legal || !res.move) throw new GameError('illegal_play');
const move = res.move;
const score = this.noRepeatWords ? noRepeatScore(move, this.playedKeys()) : move.score;
if (score === null) throw new GameError('illegal_play');
for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank);
this.removeFromHand(player, used);
this.scores[player] += move.score;
this.scores[player] += score;
this.refill(player);
this.scorelessRun = 0;
@@ -314,7 +347,7 @@ export class LocalGame {
mainRow: move.main.row,
mainCol: move.main.col,
words: [move.main.letters, ...move.cross.map((w) => w.letters)],
score: move.score,
score,
total: this.scores[player],
};
this.log.push(rec);
+6
View File
@@ -35,6 +35,10 @@ export interface LocalGameRecord {
seed: string;
players: number;
multipleWords: boolean;
/** Forbids laying a word that is already on the board (the Erudit rule). Pinned at creation from
* the variant, so a game started before the rule existed (where it is absent, read as false)
* replays and scores exactly as it was played. */
noRepeatWords?: boolean;
dropoutTiles: DropoutTiles;
seats: Seat[];
/** The dictionary-independent, alphabet-index-space move journal. */
@@ -87,6 +91,7 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
seed: game.seed.toString(),
players: game.playerCount,
multipleWords: cfg.multipleWords,
noRepeatWords: cfg.noRepeatWords || undefined,
dropoutTiles: cfg.dropoutTiles,
seats: meta.seats,
journal: game.history,
@@ -113,6 +118,7 @@ export function replayGame(record: LocalGameRecord, dawg: Dawg): LocalGame {
players: record.players,
dawg,
multipleWords: record.multipleWords,
noRepeatWords: record.noRepeatWords,
dropoutTiles: record.dropoutTiles,
};
const game = new LocalGame(opts);
+3
View File
@@ -18,6 +18,7 @@ import { HINT_GATE_MS } from '../hints';
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
import { decide } from '../robot/strategy';
import { verifyPin, type PinLock } from '../pin';
import { noRepeatWordsFor } from '../variants';
import { GatewayError, type PlacedTile } from '../client';
import type {
EvalResult,
@@ -122,6 +123,7 @@ export class LocalSource implements GameLoopSource {
players: opts.seats.length,
dawg,
multipleWords: opts.multipleWords,
noRepeatWords: noRepeatWordsFor(opts.variant),
});
const now = nowUnix();
const record = serializeGame(game, {
@@ -483,6 +485,7 @@ export class LocalSource implements GameLoopSource {
toMove: game.currentPlayer,
turnTimeoutSecs: 0,
multipleWordsPerTurn: record.multipleWords,
noRepeatWords: !!record.noRepeatWords,
moveCount: game.moveCount,
endReason: game.isOver ? game.endReason : '',
lastActivityUnix: record.updatedAtUnix,
+3
View File
@@ -46,6 +46,7 @@ import type {
Catalog,
} from '../model';
import { valueForLetter } from '../alphabet';
import { noRepeatWordsFor } from '../variants';
import { seedMockAlphabets } from './alphabet';
import {
ME,
@@ -292,6 +293,7 @@ export class MockGateway implements GatewayClient {
toMove: 0,
turnTimeoutSecs: 604800,
multipleWordsPerTurn: multipleWords,
noRepeatWords: noRepeatWordsFor(variant),
moveCount: 0,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
@@ -326,6 +328,7 @@ export class MockGateway implements GatewayClient {
toMove: 0,
turnTimeoutSecs: 86400,
multipleWordsPerTurn: multipleWords,
noRepeatWords: noRepeatWordsFor(variant),
moveCount: 0,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
+4 -1
View File
@@ -113,7 +113,7 @@ export function mockInvitations(): Invitation[] {
hintsAllowed: true,
hintsPerPlayer: 1,
multipleWordsPerTurn: false,
dropoutTiles: 'remove',
dropoutTiles: 'remove',
status: 'pending',
gameId: '',
expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400,
@@ -198,6 +198,7 @@ function activeGame(): MockGame {
toMove: 0,
turnTimeoutSecs: 86400,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: G1_MOVES.length,
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000) - 7200,
@@ -237,6 +238,7 @@ function finishedG2(): MockGame {
toMove: 0,
turnTimeoutSecs: 86400,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 2,
endReason: 'normal',
lastActivityUnix: Math.floor(Date.now() / 1000) - 86400,
@@ -277,6 +279,7 @@ function finishedG3(): MockGame {
toMove: 0,
turnTimeoutSecs: 86400,
multipleWordsPerTurn: false,
noRepeatWords: false,
moveCount: 1,
endReason: 'resignation',
lastActivityUnix: Math.floor(Date.now() / 1000) - 172800,
+4
View File
@@ -48,6 +48,10 @@ export interface GameView {
turnTimeoutSecs: number;
/** true = standard Scrabble; false = the single-word rule (Russian games). */
multipleWordsPerTurn: boolean;
/** true = a word already on the board cannot be laid again (the Erudit rule). Pinned per game at
* creation, so a game started before the rule existed reports false. The local move preview needs
* it to score the way the server does. */
noRepeatWords: boolean;
moveCount: number;
endReason: string;
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */
+1
View File
@@ -28,6 +28,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
toMove: 0,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+1
View File
@@ -26,6 +26,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
toMove,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+9
View File
@@ -28,6 +28,15 @@ export function variantNameKey(v: Variant): MessageKey {
return ALL_VARIANTS.find((o) => o.id === v)?.label ?? 'new.english';
}
// noRepeatWordsFor reports whether a new game of variant v takes the no-repeat-words rule (see
// lib/dict/norepeat): a word already on the board cannot be laid again. It is a rule of Erudit
// alone — both Scrabble variants let a word be played as often as a player can manage. It mirrors
// the server's noRepeatWordsFor and is only ever consulted when a game is created; an existing
// game carries its own pinned answer (GameView.noRepeatWords / LocalGameRecord.noRepeatWords).
export function noRepeatWordsFor(v: Variant): boolean {
return v === 'erudit_ru';
}
// VARIANT_RULES is the i18n key for each variant's one-line rules summary on the New Game
// buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets.
export const VARIANT_RULES: Record<Variant, MessageKey> = {