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