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 {