fix(rules): name a repeated word in the move preview instead of scoring it
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
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.
This commit is contained in:
@@ -109,10 +109,7 @@ func TestNoRepeatDropsTheScoreOfAReplayedCrossWord(t *testing.T) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
g, idx := newEruditNoRepeat(t, noRepeat)
|
g, idx := newEruditNoRepeat(t, noRepeat)
|
||||||
playKotAtCentre(t, g, idx)
|
playKotAtCentre(t, g, idx)
|
||||||
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
|
applyScaffold(t, g, idx)
|
||||||
{Row: 10, Col: 5, Letter: idx("к")},
|
|
||||||
{Row: 11, Col: 5, Letter: idx("о")},
|
|
||||||
}})
|
|
||||||
g.hands[g.toMove] = []byte{idx("р"), idx("о"), idx("т")}
|
g.hands[g.toMove] = []byte{idx("р"), idx("о"), idx("т")}
|
||||||
rec, err := g.EvaluatePlay([]TileRecord{
|
rec, err := g.EvaluatePlay([]TileRecord{
|
||||||
{Row: 12, Col: 3, Letter: "р"},
|
{Row: 12, Col: 3, Letter: "р"},
|
||||||
@@ -126,12 +123,19 @@ func TestNoRepeatDropsTheScoreOfAReplayedCrossWord(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unrestricted, restricted := evaluate(t, false), evaluate(t, true)
|
unrestricted, restricted := evaluate(t, false), evaluate(t, true)
|
||||||
if restricted.Score >= unrestricted.Score {
|
// Exact totals, not merely "less": the client port scores this very position in
|
||||||
t.Errorf("score with the rule = %d, want less than %d without it", restricted.Score, unrestricted.Score)
|
// 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.
|
// The word is still formed and still reported — it simply earns nothing.
|
||||||
if len(restricted.Words) != len(unrestricted.Words) {
|
want := []string{"рот", "кот"}
|
||||||
t.Errorf("words with the rule = %v, want the same words as without it (%v)", restricted.Words, unrestricted.Words)
|
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)
|
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
|
**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
|
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
|
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
|
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
|
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()}
|
{resultText()}
|
||||||
{:else if preview?.legal && !recallOverRack}
|
{:else if preview?.legal && !recallOverRack}
|
||||||
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score}
|
{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}
|
{:else}
|
||||||
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
|
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
|
||||||
{/if}
|
{/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 words = [m.main, ...m.cross].map((w) => decodeWord(variant, w.letters));
|
||||||
const score = playedWords ? noRepeatScore(m, playedWords, (l) => decodeWord(variant, l)) : m.score;
|
const score = playedWords ? noRepeatScore(m, playedWords, (l) => decodeWord(variant, l)) : m.score;
|
||||||
if (score === null) {
|
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' };
|
return { legal: true, score, words, dir: dir === Horizontal ? 'H' : 'V' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const en = {
|
|||||||
'game.bagCount': 'In the bag: {n}',
|
'game.bagCount': 'In the bag: {n}',
|
||||||
'game.bagEmpty': 'Bag is empty',
|
'game.bagEmpty': 'Bag is empty',
|
||||||
'game.hints': 'Hints {n}',
|
'game.hints': 'Hints {n}',
|
||||||
|
'game.wordAlreadyUsed': 'already used',
|
||||||
'game.yourTurn': 'Your turn',
|
'game.yourTurn': 'Your turn',
|
||||||
'game.yourTurnBy': '{name}: Your turn!',
|
'game.yourTurnBy': '{name}: Your turn!',
|
||||||
'game.opponentsTurn': "Opponent's turn",
|
'game.opponentsTurn': "Opponent's turn",
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.bagCount': 'В мешке: {n}',
|
'game.bagCount': 'В мешке: {n}',
|
||||||
'game.bagEmpty': 'Мешок пуст',
|
'game.bagEmpty': 'Мешок пуст',
|
||||||
'game.hints': 'Подсказки {n}',
|
'game.hints': 'Подсказки {n}',
|
||||||
|
'game.wordAlreadyUsed': 'уже использовано',
|
||||||
'game.yourTurn': 'Ваш ход',
|
'game.yourTurn': 'Ваш ход',
|
||||||
'game.yourTurnBy': '{name}: Ваш ход!',
|
'game.yourTurnBy': '{name}: Ваш ход!',
|
||||||
'game.opponentsTurn': 'Ход соперника',
|
'game.opponentsTurn': 'Ход соперника',
|
||||||
|
|||||||
@@ -295,13 +295,21 @@ export class LocalGame {
|
|||||||
/** evaluatePlay scores a candidate placement for the current position without committing it,
|
/** 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
|
* 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. */
|
* 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 dir = playDirection(this.board, this.vrs, this.dawg, tiles);
|
||||||
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
|
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
|
||||||
if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir };
|
if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir };
|
||||||
const m = res.move;
|
const m = res.move;
|
||||||
const score = this.noRepeatWords ? noRepeatScore(m, this.playedKeys()) : m.score;
|
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 };
|
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> {
|
async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult> {
|
||||||
const { game } = await this.load(gameId);
|
const { game } = await this.load(gameId);
|
||||||
const r = game.evaluatePlay(encodePlacements(variant, tiles));
|
const r = game.evaluatePlay(encodePlacements(variant, tiles));
|
||||||
|
const decode = (w: readonly number[]) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase();
|
||||||
return {
|
return {
|
||||||
legal: r.legal,
|
legal: r.legal,
|
||||||
score: r.score,
|
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') : '',
|
dir: r.legal ? (r.dir === 0 ? 'H' : 'V') : '',
|
||||||
|
repeatedWord: r.repeated ? decode(r.repeated) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ export interface EvalResult {
|
|||||||
words: string[];
|
words: string[];
|
||||||
/** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */
|
/** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */
|
||||||
dir: string;
|
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 {
|
export interface WordCheckResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user