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
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:
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user