fix(offline): keep the local composition; feat(rules): no repeated words in Erudit #287
@@ -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
@@ -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
|
||||
|
||||
@@ -206,7 +206,10 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
|
||||
**В «Эрудите» слово нельзя повторять.** Выставленное на доску слово принадлежит партии: выставить
|
||||
его ещё раз нельзя, и робот такого хода тоже не делает. Ход, чьё основное слово уже стоит на доске,
|
||||
отклоняется как любой недопустимый; ход, который лишь образует уже выставленное слово **поперёк**
|
||||
отклоняется как любой недопустимый: фишки на доске подсвечиваются как недопустимые и ход не отправить,
|
||||
а поскольку само слово вполне настоящее, подпись над счётом называет его — «СЛОВО: уже использовано»
|
||||
вместо обычных очков. Это решение принимается на устройстве, до того как ход предложен серверу,
|
||||
одинаково в онлайн- и в оффлайн-партии; ход, который лишь образует уже выставленное слово **поперёк**
|
||||
основного, засчитывается — такое слово побочно для хода, — но очков не приносит. Правило не
|
||||
выбирается: его берёт каждая партия «Эрудита», и никогда — оба варианта скрэббла (в официальных
|
||||
правилах такого ограничения нет), поэтому однострочное описание варианта на экране **новой игры**
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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('рот');
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': 'Ход соперника',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user