fix(ui): one-word games must not highlight phantom cross words; review polish
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m25s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s

Board-highlight bug (reported on the contour): formedGeometry walked cross words
unconditionally, so in a single-word (one-word-per-turn) game a staged tile sitting
next to a committed tile lit up a green "cross word" the engine ignores — and which
need not even be a real word (the reported "БО lights up green in a one-word ПОПА
play"). Gate the cross-word walk on the game's multipleWordsPerTurn flag. The score
(8) was already correct — a premium square under the main word.

Also, from review:
- the turn strip reads the staged play's "WORD+WORD = N" while composing a legal move,
  reverting to the turn / result text otherwise;
- the Exchange/Pass dialog shows the bag count ("In the bag: N" / "Bag is empty")
  right-aligned in the title row, via a new optional Modal `titleAside`;
- cosmetics: half the turn strip's bottom padding (the plaques below carry their own
  top pad); a top gap above the landscape rack (it sat flush under the docked history);
  more horizontal padding on tab count badges so a 2-3 digit bag count clears the pill
  ends;
- admin console: the game Summary now shows the single-word / multiple-words rule.

Tests: formed single-word case added; full unit (584) + e2e (chromium + webkit, 113
each) green; backend build + adminconsole templates parse. Docs (FUNCTIONAL +_ru,
UI_DESIGN) updated.
This commit is contained in:
Ilia Denisov
2026-07-10 15:58:05 +02:00
parent 1e1117c28e
commit bf46b9492d
13 changed files with 111 additions and 36 deletions
@@ -8,6 +8,7 @@
<li><b>Dictionary</b> {{.DictVersion}}</li> <li><b>Dictionary</b> {{.DictVersion}}</li>
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li> <li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li> <li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
<li><b>Word rule</b> {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}</li>
<li><b>Players</b> {{.Players}}</li> <li><b>Players</b> {{.Players}}</li>
<li><b>To move</b> seat {{.ToMove}}</li> <li><b>To move</b> seat {{.ToMove}}</li>
<li><b>Moves</b> {{.MoveCount}}</li> <li><b>Moves</b> {{.MoveCount}}</li>
+6 -2
View File
@@ -348,8 +348,12 @@ type GameDetailView struct {
UpdatedAt string UpdatedAt string
FinishedAt string FinishedAt string
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary). // VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
VsAI bool VsAI bool
Seats []SeatRow // MultipleWordsPerTurn is the game's cross-word rule: true = standard Scrabble (every cross-word
// is validated and scored), false = the single-word rule (only the main word along the play
// direction counts). Shown in the summary so an operator can tell the rule at a glance.
MultipleWordsPerTurn bool
Seats []SeatRow
// HasRobot is true when any seat is a robot, gating the robot-target caption; // HasRobot is true when any seat is a robot, gating the robot-target caption;
// RobotTargetPct is the configured global play-to-win rate, in percent. // RobotTargetPct is the configured global play-to-win rate, in percent.
HasRobot bool HasRobot bool
@@ -566,6 +566,7 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason, Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt), MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI, FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
} }
// Resolve seats and detect robot seats; capture the human opponent's timezone, which // Resolve seats and detect robot seats; capture the human opponent's timezone, which
// anchors the robot's sleep window for the next-move ETA. // anchors the robot's sleep window for the next-move ETA.
+3 -2
View File
@@ -211,8 +211,9 @@ tile that extends
an existing word (down a column or across a row) is accepted. A play is validated an existing word (down a column or across a row) is accepted. A play is validated
against the game's dictionary at submit time and scored; an unlimited preview shows against the game's dictionary at submit time and scored; an unlimited preview shows
**on the board itself** whether the tentative move is legal — the staged tiles turn a light **on the board itself** whether the tentative move is legal — the staged tiles turn a light
green once they form a word, a shade darker on the board tiles the word runs through, or a green once they form a word, a shade darker on the board tiles the word runs through (in a
calm pink when they form none — and an **orange badge** on the main word carries the move's one-word-per-turn game only the main word), or a calm pink when they form none — and an
**orange badge** on the main word carries the move's
score. The confirm control is offered only once the move is legal. The preview is score. The confirm control is offered only once the move is legal. The preview is
computed **on-device** for an instant response once the game's dictionary has loaded — a computed **on-device** for an instant response once the game's dictionary has loaded — a
brief warm-up shows on the first open otherwise — and falls back to the server whenever the brief warm-up shows on the first open otherwise — and falls back to the server whenever the
+3 -2
View File
@@ -219,8 +219,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли
предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово
(и чуть темнее — фишки доски, через которые проходит слово), или спокойно-розовыми, (и чуть темнее — фишки доски, через которые проходит слово; в партии «одно слово за ход»
когда слова нет, — а **оранжевый бейдж** на основном слове несёт очки хода. Кнопка подсвечивается только основное слово), или спокойно-розовыми, когда слова нет, — а
**оранжевый бейдж** на основном слове несёт очки хода. Кнопка
подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве** подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве**
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
+6 -4
View File
@@ -249,7 +249,8 @@ e2e and the screenshots.
width in both layouts, so the tiles are square and as large as the row allows (the confirm ✅ takes width in both layouts, so the tiles are square and as large as the row allows (the confirm ✅ takes
the fixed 7th slot while a play is staged). the fixed 7th slot while a play is staged).
- **Highlights**: while composing a play the staged tiles are **tinted by legality** — a light - **Highlights**: while composing a play the staged tiles are **tinted by legality** — a light
green once they form a word (a shade darker on the committed board tiles the word runs through), green once they form a word (a shade darker on the committed board tiles the word runs through
in a **single-word game** only the main word lights up, since the engine ignores cross words),
a calm pink when they form none — and an **orange score badge** sits on a corner of the main a calm pink when they form none — and an **orange score badge** sits on a corner of the main
word's tile, its digit sized like a tile's point value (so it scales with the zoom) and clamped word's tile, its digit sized like a tile's point value (so it scales with the zoom) and clamped
to stay on the board. Off-turn or with no preview the staged tiles keep the plain pending fill. to stay on the board. Off-turn or with no preview the staged tiles keep the plain pending fill.
@@ -286,9 +287,10 @@ e2e and the screenshots.
remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label, remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
no confirm), which no confirm), which
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the **animates** — tiles hop along a low parabola to their new slots (duration scaled by the
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. Whose turn it distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
is — or the viewer's result once the game ends — shows in a **thin strip above the score above the score plaques** reads, while composing a legal play, the formed word(s) and the move
plaques**; the tentative move's score rides the board badge (above), not an under-board caption. score ("WORD+WORD = N"); otherwise whose turn it is, or the viewer's result once the game ends. The
move score also rides the board badge (above), never an under-board caption.
The screen **title** is the variant's The screen **title** is the variant's
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble". display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
+23 -1
View File
@@ -3,12 +3,16 @@
let { let {
title = '', title = '',
titleAside = '',
onclose, onclose,
overlayKeyboard = false, overlayKeyboard = false,
bottomSheet = false, bottomSheet = false,
children, children,
}: { }: {
title?: string; title?: string;
/** Optional secondary text pinned to the right of the title row, in a lighter (non-bold)
* muted style — e.g. the bag count beside the "Exchange or pass" title. */
titleAside?: string;
onclose?: () => void; onclose?: () => void;
overlayKeyboard?: boolean; overlayKeyboard?: boolean;
bottomSheet?: boolean; bottomSheet?: boolean;
@@ -65,7 +69,12 @@
style:--kb={bottomSheet ? `${kb}px` : null} style:--kb={bottomSheet ? `${kb}px` : null}
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
> >
{#if title}<h2>{title}</h2>{/if} {#if title}
<div class="titlerow">
<h2>{title}</h2>
{#if titleAside}<span class="aside">{titleAside}</span>{/if}
</div>
{/if}
{@render children?.()} {@render children?.()}
</div> </div>
</div> </div>
@@ -121,8 +130,21 @@
max-height: 86dvh; max-height: 86dvh;
overflow: auto; overflow: auto;
} }
.titlerow {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
h2 { h2 {
margin: 0 0 10px; margin: 0 0 10px;
font-size: 1.05rem; font-size: 1.05rem;
} }
/* Secondary title-row text (e.g. the bag count): lighter and muted so the title still leads. */
.aside {
font-weight: 400;
font-size: 0.9rem;
color: var(--text-muted);
white-space: nowrap;
}
</style> </style>
+3 -1
View File
@@ -85,7 +85,9 @@
color: var(--accent-text); color: var(--accent-text);
border-radius: 999px; border-radius: 999px;
min-width: 15px; min-width: 15px;
padding: 0 3px; /* Enough horizontal room that a two/three-digit count (e.g. the bag count) does not touch the
pill's rounded ends. */
padding: 0 5px;
line-height: 1.4; line-height: 1.4;
text-align: center; text-align: center;
} }
+21 -5
View File
@@ -490,7 +490,9 @@
// rack (recallOverRack), so the highlight and badge clear the instant a recall begins. // rack (recallOverRack), so the highlight and badge clear the instant a recall begins.
const EMPTY_FORMED = new Set<string>(); const EMPTY_FORMED = new Set<string>();
const formedGeom = $derived( const formedGeom = $derived(
preview?.legal && !recallOverRack ? formedGeometry(board, placement.pending) : null, preview?.legal && !recallOverRack
? formedGeometry(board, placement.pending, !!view?.game.multipleWordsPerTurn)
: null,
); );
const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED); const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED);
const boardBadge = $derived( const boardBadge = $derived(
@@ -1522,13 +1524,16 @@
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches <!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
above so the markup and logic stay single-sourced (see the {#if landscape} split). --> above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
{#snippet turnStrip()} {#snippet turnStrip()}
<!-- A thin line above the score plaques: whose turn it is during play, or the viewer's <!-- A thin line above the score plaques: while composing a legal play it reads the formed
result once the game ends. A hotseat game shows its result as per-seat medals on the word(s) and the move score ("WORD+WORD = N"); otherwise whose turn it is during play, or the
viewer's result once the game ends. A hotseat game shows its result as per-seat medals on the
plaques (2-4 local players have no single "you"), so the strip is hidden when it is over. --> plaques (2-4 local players have no single "you"), so the strip is hidden when it is over. -->
{#if view && !(gameOver && view.game.hotseat)} {#if view && !(gameOver && view.game.hotseat)}
<div class="turnstrip" class:result={gameOver}> <div class="turnstrip" class:result={gameOver}>
{#if gameOver} {#if gameOver}
{resultText()} {resultText()}
{:else if preview?.legal && !recallOverRack}
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score}
{: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}
@@ -1764,7 +1769,11 @@
{/if} {/if}
{#if exchangeOpen && view} {#if exchangeOpen && view}
<Modal title={t('game.exchangeTitle')} onclose={() => (exchangeOpen = false)}> <Modal
title={t('game.exchangeTitle')}
titleAside={view.bagLen === 0 ? t('game.bagEmpty') : t('game.bagCount', { n: view.bagLen })}
onclose={() => (exchangeOpen = false)}
>
<div class="exch"> <div class="exch">
{#each view.rack as letter, i (i)} {#each view.rack as letter, i (i)}
<button class="etile" class:sel={exchangeSel.includes(i)} disabled={!canExchange} onclick={() => toggleExch(i)}> <button class="etile" class:sel={exchangeSel.includes(i)} disabled={!canExchange} onclick={() => toggleExch(i)}>
@@ -1917,7 +1926,9 @@
play, accented for the viewer's win/lose result once the game ends. */ play, accented for the viewer's win/lose result once the game ends. */
.turnstrip { .turnstrip {
flex: none; flex: none;
padding: 4px var(--pad); /* A smaller bottom pad than top: the score plaques below carry their own top padding, so an
even strip read as too tall with the text pushed up. */
padding: 4px var(--pad) 2px;
text-align: center; text-align: center;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
@@ -2049,6 +2060,11 @@
pointer-events: none; pointer-events: none;
opacity: 0.55; opacity: 0.55;
} }
/* Landscape: the rack sits directly under the docked history (no board between them), so give it a
small gap above; portrait gets its spacing from the board's own padding. */
.game-land .rack-row {
padding-top: 6px;
}
.rack-wrap { .rack-wrap {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
+18 -5
View File
@@ -20,14 +20,14 @@ const cells = (g: ReturnType<typeof formedGeometry>) => [...(g?.cells ?? new Set
describe('formedGeometry', () => { describe('formedGeometry', () => {
it('returns null with nothing staged', () => { it('returns null with nothing staged', () => {
expect(formedGeometry(board(), [])).toBeNull(); expect(formedGeometry(board(), [], true)).toBeNull();
}); });
it('finds a fresh horizontal word with no cross words', () => { it('finds a fresh horizontal word with no cross words', () => {
const g = formedGeometry(board(), [ const g = formedGeometry(board(), [
{ row: 7, col: 7 }, { row: 7, col: 7 },
{ row: 7, col: 8 }, { row: 7, col: 8 },
]); ], true);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 }); expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['7,7', '7,8']); expect(cells(g)).toEqual(['7,7', '7,8']);
}); });
@@ -36,14 +36,14 @@ describe('formedGeometry', () => {
const g = formedGeometry(board(), [ const g = formedGeometry(board(), [
{ row: 7, col: 7 }, { row: 7, col: 7 },
{ row: 8, col: 7 }, { row: 8, col: 7 },
]); ], true);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'V', len: 2 }); expect(g?.main).toEqual({ row: 7, col: 7, dir: 'V', len: 2 });
expect(cells(g)).toEqual(['7,7', '8,7']); expect(cells(g)).toEqual(['7,7', '8,7']);
}); });
it('extends a committed tile into the main word (committed cell in the set)', () => { it('extends a committed tile into the main word (committed cell in the set)', () => {
// A committed A at (7,7); a lone tile to its right forms the two-letter main word. // A committed A at (7,7); a lone tile to its right forms the two-letter main word.
const g = formedGeometry(board([[7, 7, 'A']]), [{ row: 7, col: 8 }]); const g = formedGeometry(board([[7, 7, 'A']]), [{ row: 7, col: 8 }], true);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 }); expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['7,7', '7,8']); expect(cells(g)).toEqual(['7,7', '7,8']);
}); });
@@ -54,11 +54,23 @@ describe('formedGeometry', () => {
const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [ const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
{ row: 7, col: 7 }, { row: 7, col: 7 },
{ row: 7, col: 8 }, { row: 7, col: 8 },
]); ], true);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 }); expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['6,8', '7,7', '7,8', '8,8']); expect(cells(g)).toEqual(['6,8', '7,7', '7,8', '8,8']);
}); });
it('omits cross words under the single-word rule (multipleWords = false)', () => {
// The same board as the cross-word case, but a single-word game: the engine ignores the vertical
// X-_-Y run (and it need not be a real word), so the geometry must highlight only the main
// (horizontal) word — the reported "БО lights up green in a one-word game" bug.
const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
{ row: 7, col: 7 },
{ row: 7, col: 8 },
], false);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['7,7', '7,8']);
});
it('takes the longer axis as the main word for a lone connecting tile', () => { it('takes the longer axis as the main word for a lone connecting tile', () => {
// (7,8) joins a length-3 horizontal (cols 6-8) and a length-4 vertical (rows 5-8): the vertical // (7,8) joins a length-3 horizontal (cols 6-8) and a length-4 vertical (rows 5-8): the vertical
// is longer, so it is the main word and the horizontal becomes a cross word. // is longer, so it is the main word and the horizontal becomes a cross word.
@@ -71,6 +83,7 @@ describe('formedGeometry', () => {
[8, 8, 'E'], [8, 8, 'E'],
]), ]),
[{ row: 7, col: 8 }], [{ row: 7, col: 8 }],
true,
); );
expect(g?.main).toEqual({ row: 5, col: 8, dir: 'V', len: 4 }); expect(g?.main).toEqual({ row: 5, col: 8, dir: 'V', len: 4 });
expect(cells(g)).toEqual(['5,8', '6,8', '7,6', '7,7', '7,8', '8,8']); expect(cells(g)).toEqual(['5,8', '6,8', '7,6', '7,7', '7,8', '8,8']);
+24 -14
View File
@@ -77,12 +77,19 @@ function span(
* formedGeometry derives the composed play's word geometry from the board and the staged tiles. * formedGeometry derives the composed play's word geometry from the board and the staged tiles.
* It returns the main word (the maximal run along the play axis through the staged tiles) and the * It returns the main word (the maximal run along the play axis through the staged tiles) and the
* set of every cell the main word and each cross word (a perpendicular run of two or more cells * set of every cell the main word and each cross word (a perpendicular run of two or more cells
* through a staged tile) cover. It returns null when nothing is staged, or when the staged tiles * through a staged tile) cover. Under the **single-word rule** (multipleWords = false) cross words
* are not on a single line — a shape only an illegal play produces, which the caller never asks * are omitted, matching the engine, which ignores them entirely — a perpendicular run there is
* about (it is invoked only for a legal preview). The play axis is fixed by the staged tiles when * neither validated nor scored (and need not even be a real word), so highlighting it would be
* two or more are colinear; a lone tile takes whichever axis forms the longer word. * misleading. It returns null when nothing is staged, or when the staged tiles are not on a single
* line — a shape only an illegal play produces, which the caller never asks about (it is invoked
* only for a legal preview). The play axis is fixed by the staged tiles when two or more are
* colinear; a lone tile takes whichever axis forms the longer word.
*/ */
export function formedGeometry(board: Board, pending: readonly Cell[]): FormedGeometry | null { export function formedGeometry(
board: Board,
pending: readonly Cell[],
multipleWords: boolean,
): FormedGeometry | null {
if (pending.length === 0) return null; if (pending.length === 0) return null;
const filled = filledPredicate(board, pending); const filled = filledPredicate(board, pending);
const first = pending[0]; const first = pending[0];
@@ -115,15 +122,18 @@ export function formedGeometry(board: Board, pending: readonly Cell[]): FormedGe
} }
// Cross words: the perpendicular run through each staged tile, kept only when it is a real word // Cross words: the perpendicular run through each staged tile, kept only when it is a real word
// (two or more cells). // (two or more cells). Skipped under the single-word rule, where the engine ignores cross words,
const cross: Dir = dir === 'H' ? 'V' : 'H'; // so a perpendicular run must not be highlighted as if it were a formed word.
for (const p of pending) { if (multipleWords) {
const cs = span(filled, cross, p.row, p.col); const cross: Dir = dir === 'H' ? 'V' : 'H';
if (cs.len < 2) continue; for (const p of pending) {
if (cross === 'H') { const cs = span(filled, cross, p.row, p.col);
for (let c = cs.start; c < cs.start + cs.len; c++) cells.add(key(p.row, c)); if (cs.len < 2) continue;
} else { if (cross === 'H') {
for (let r = cs.start; r < cs.start + cs.len; r++) cells.add(key(r, p.col)); for (let c = cs.start; c < cs.start + cs.len; c++) cells.add(key(p.row, c));
} else {
for (let r = cs.start; r < cs.start + cs.len; r++) cells.add(key(r, p.col));
}
} }
} }
+1
View File
@@ -87,6 +87,7 @@ export const en = {
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack 'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
'game.bag': '{n} in the bag', 'game.bag': '{n} in the bag',
'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.yourTurn': 'Your turn', 'game.yourTurn': 'Your turn',
+1
View File
@@ -87,6 +87,7 @@ export const ru: Record<MessageKey, string> = {
'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack 'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack
'game.bag': '{n} в мешке', 'game.bag': '{n} в мешке',
'game.bagCount': 'В мешке: {n}',
'game.bagEmpty': 'Мешок пуст', 'game.bagEmpty': 'Мешок пуст',
'game.hints': 'Подсказки {n}', 'game.hints': 'Подсказки {n}',
'game.yourTurn': 'Ваш ход', 'game.yourTurn': 'Ваш ход',