From 4f4768b0922ae7fa998a1c7acd8c7ddf5fd10b95 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 27 Jul 2026 19:12:37 +0200 Subject: [PATCH] fix(rules): name a repeated word in the move preview instead of scoring it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composing an already-played word read as a perfectly legal move: the tiles drew as legal, the caption showed "БРА = 6" and the ✅ was enabled, and only the server refused the play with the bare "illegal move" toast. The on-device evaluator already applied the rule, but it reported the refusal as a plain illegal play, so nothing distinguished a repeated word from a misspelling and the caption fell back to the turn label. It now names the word it rejected, and the game screen reads "БРА: уже использовано" above the scores while the tiles stay marked invalid — the composition is wrong, but for a reason the player cannot read off the board, since the word itself is in the dictionary. The verdict is reached on the device before the play is ever offered to the server, in an online game exactly as in an offline one; the offline source reports the same reason through the same field. Only the main word is ever refused: a cross-word the game has already used still stands (it is incidental to laying the main word) and merely scores nothing, so the rule cannot falsely block a play in a game with several words per turn. The scoring of such a play is now pinned to the point on both sides: the engine test and the client test evaluate the same position — РОТ laid across a standing КО, completing an already-played КОТ — and assert the same totals (10 unrestricted, 5 with the rule), so a divergence between the two engines breaks one of them. The client test also replays the exact test-contour position this was reported from. --- backend/internal/engine/norepeat_test.go | 29 +++++-- docs/FUNCTIONAL.md | 5 +- docs/FUNCTIONAL_ru.md | 5 +- ui/src/game/Game.svelte | 5 ++ ui/src/lib/dict/eval.norepeat.test.ts | 105 +++++++++++++++++++++++ ui/src/lib/dict/eval.ts | 4 +- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/localgame/engine.ts | 12 ++- ui/src/lib/localgame/source.ts | 4 +- ui/src/lib/model.ts | 5 ++ 11 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 ui/src/lib/dict/eval.norepeat.test.ts diff --git a/backend/internal/engine/norepeat_test.go b/backend/internal/engine/norepeat_test.go index e4d50f6..7e4f7ae 100644 --- a/backend/internal/engine/norepeat_test.go +++ b/backend/internal/engine/norepeat_test.go @@ -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("о")}, + }}) +} diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 298e9ac..a95831d 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index aa526e1..3e9d291 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -206,7 +206,10 @@ e-mail) либо ввод фразы. Активные игры форфейтя **В «Эрудите» слово нельзя повторять.** Выставленное на доску слово принадлежит партии: выставить его ещё раз нельзя, и робот такого хода тоже не делает. Ход, чьё основное слово уже стоит на доске, -отклоняется как любой недопустимый; ход, который лишь образует уже выставленное слово **поперёк** +отклоняется как любой недопустимый: фишки на доске подсвечиваются как недопустимые и ход не отправить, +а поскольку само слово вполне настоящее, подпись над счётом называет его — «СЛОВО: уже использовано» +вместо обычных очков. Это решение принимается на устройстве, до того как ход предложен серверу, +одинаково в онлайн- и в оффлайн-партии; ход, который лишь образует уже выставленное слово **поперёк** основного, засчитывается — такое слово побочно для хода, — но очков не приносит. Правило не выбирается: его берёт каждая партия «Эрудита», и никогда — оба варианта скрэббла (в официальных правилах такого ограничения нет), поэтому однострочное описание варианта на экране **новой игры** diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 9c631cf..aecec1e 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1580,6 +1580,11 @@ {resultText()} {:else if preview?.legal && !recallOverRack} {preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score} + {:else if preview?.repeatedWord && !recallOverRack} + + {preview.repeatedWord.toUpperCase()}{': '}{t('game.wordAlreadyUsed')} {:else} {view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} diff --git a/ui/src/lib/dict/eval.norepeat.test.ts b/ui/src/lib/dict/eval.norepeat.test.ts new file mode 100644 index 0000000..b6f0cd5 --- /dev/null +++ b/ui/src/lib/dict/eval.norepeat.test.ts @@ -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('рот'); + }); +}); diff --git a/ui/src/lib/dict/eval.ts b/ui/src/lib/dict/eval.ts index 72a762b..5f2938e 100644 --- a/ui/src/lib/dict/eval.ts +++ b/ui/src/lib/dict/eval.ts @@ -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' }; } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index d431b15..8725405 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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", diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index f10e86e..ad75f7f 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -99,6 +99,7 @@ export const ru: Record = { 'game.bagCount': 'В мешке: {n}', 'game.bagEmpty': 'Мешок пуст', 'game.hints': 'Подсказки {n}', + 'game.wordAlreadyUsed': 'уже использовано', 'game.yourTurn': 'Ваш ход', 'game.yourTurnBy': '{name}: Ваш ход!', 'game.opponentsTurn': 'Ход соперника', diff --git a/ui/src/lib/localgame/engine.ts b/ui/src/lib/localgame/engine.ts index eab5d1c..edc907c 100644 --- a/ui/src/lib/localgame/engine.ts +++ b/ui/src/lib/localgame/engine.ts @@ -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 }; } diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 45696b9..e0fb509 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -272,11 +272,13 @@ export class LocalSource implements GameLoopSource { async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise { 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, }; } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index e948266..15837be 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -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 {