fix(offline): keep the local composition; feat(rules): no repeated words in Erudit #287

Merged
developer merged 6 commits from feature/no-repeat-words into development 2026-07-27 17:46:35 +00:00
11 changed files with 162 additions and 14 deletions
Showing only changes of commit 4f4768b092 - Show all commits
+21 -8
View File
@@ -109,10 +109,7 @@ func TestNoRepeatDropsTheScoreOfAReplayedCrossWord(t *testing.T) {
t.Helper()
g, idx := newEruditNoRepeat(t, noRepeat)
playKotAtCentre(t, g, idx)
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
{Row: 10, Col: 5, Letter: idx("к")},
{Row: 11, Col: 5, Letter: idx("о")},
}})
applyScaffold(t, g, idx)
g.hands[g.toMove] = []byte{idx("р"), idx("о"), idx("т")}
rec, err := g.EvaluatePlay([]TileRecord{
{Row: 12, Col: 3, Letter: "р"},
@@ -126,12 +123,19 @@ func TestNoRepeatDropsTheScoreOfAReplayedCrossWord(t *testing.T) {
}
unrestricted, restricted := evaluate(t, false), evaluate(t, true)
if restricted.Score >= unrestricted.Score {
t.Errorf("score with the rule = %d, want less than %d without it", restricted.Score, unrestricted.Score)
// Exact totals, not merely "less": the client port scores this very position in
// ui/src/lib/dict/eval.norepeat.test.ts and is pinned to the same two numbers, so a scoring
// divergence between the two engines breaks one of the two tests.
if unrestricted.Score != 10 {
t.Errorf("score without the rule = %d, want 10 (РОТ plus the КОТ cross-word)", unrestricted.Score)
}
if restricted.Score != 5 {
t.Errorf("score with the rule = %d, want 5 (РОТ alone; the repeated cross-word earns nothing)", restricted.Score)
}
// The word is still formed and still reported — it simply earns nothing.
if len(restricted.Words) != len(unrestricted.Words) {
t.Errorf("words with the rule = %v, want the same words as without it (%v)", restricted.Words, unrestricted.Words)
want := []string{"рот", "кот"}
if len(restricted.Words) != len(want) || restricted.Words[0] != want[0] || restricted.Words[1] != want[1] {
t.Errorf("words with the rule = %v, want %v", restricted.Words, want)
}
}
@@ -173,3 +177,12 @@ func TestNoRepeatOffLeavesTheGameUnchanged(t *testing.T) {
t.Errorf("generated %d moves, want the solver's %d untouched", got, want)
}
}
// applyScaffold stands КО down column 5 above row 12, so a play across row 12 completes КОТ.
func applyScaffold(t *testing.T, g *Game, idx func(string) byte) {
t.Helper()
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
{Row: 10, Col: 5, Letter: idx("к")},
{Row: 11, Col: 5, Letter: idx("о")},
}})
}
+4 -1
View File
@@ -200,7 +200,10 @@ the choice joins the pairing key, so a player only meets opponents who picked th
**Erudite forbids repeating a word.** A word laid on the board belongs to the game: it cannot be laid
again, and the AI never plays one either. A play whose main word is already on the board is rejected
like any illegal move; a play that only forms an already-played word **across** its main word stands —
like any illegal move — the tiles read invalid on the board and the move cannot be sent, and because
the word itself is a perfectly good one the caption above the scores names it, reading
"WORD: already used" rather than the usual score. That verdict is reached on the device, before the
move is offered to the server, in an online game exactly as in an offline one; a play that only forms an already-played word **across** its main word stands —
that word is incidental to the move — but scores nothing. The rule is not a choice: every Erudite
game takes it, and both Scrabble variants (where the official rules place no such restriction) never
do, so the variant's one-line description on **New Game** reads "no repeated words". Games started
+4 -1
View File
@@ -206,7 +206,10 @@ e-mail) либо ввод фразы. Активные игры форфейтя
**В «Эрудите» слово нельзя повторять.** Выставленное на доску слово принадлежит партии: выставить
его ещё раз нельзя, и робот такого хода тоже не делает. Ход, чьё основное слово уже стоит на доске,
отклоняется как любой недопустимый; ход, который лишь образует уже выставленное слово **поперёк**
отклоняется как любой недопустимый: фишки на доске подсвечиваются как недопустимые и ход не отправить,
а поскольку само слово вполне настоящее, подпись над счётом называет его — «СЛОВО: уже использовано»
вместо обычных очков. Это решение принимается на устройстве, до того как ход предложен серверу,
одинаково в онлайн- и в оффлайн-партии; ход, который лишь образует уже выставленное слово **поперёк**
основного, засчитывается — такое слово побочно для хода, — но очков не приносит. Правило не
выбирается: его берёт каждая партия «Эрудита», и никогда — оба варианта скрэббла (в официальных
правилах такого ограничения нет), поэтому однострочное описание варианта на экране **новой игры**
+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 {