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
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:
@@ -8,6 +8,7 @@
|
||||
<li><b>Dictionary</b> {{.DictVersion}}</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>Word rule</b> {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}</li>
|
||||
<li><b>Players</b> {{.Players}}</li>
|
||||
<li><b>To move</b> seat {{.ToMove}}</li>
|
||||
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||
|
||||
@@ -349,6 +349,10 @@ type GameDetailView struct {
|
||||
FinishedAt string
|
||||
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
||||
VsAI bool
|
||||
// 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;
|
||||
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||
|
||||
@@ -566,6 +566,7 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
|
||||
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
|
||||
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
|
||||
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
|
||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||
}
|
||||
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
|
||||
// anchors the robot's sleep window for the next-move ETA.
|
||||
|
||||
+3
-2
@@ -211,8 +211,9 @@ tile that extends
|
||||
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
|
||||
**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
|
||||
calm pink when they form none — and an **orange badge** on the main word carries the move's
|
||||
green once they form a word, a shade darker on the board tiles the word runs through (in a
|
||||
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
|
||||
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
|
||||
|
||||
@@ -219,8 +219,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
|
||||
сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли
|
||||
предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово
|
||||
(и чуть темнее — фишки доски, через которые проходит слово), или спокойно-розовыми,
|
||||
когда слова нет, — а **оранжевый бейдж** на основном слове несёт очки хода. Кнопка
|
||||
(и чуть темнее — фишки доски, через которые проходит слово; в партии «одно слово за ход»
|
||||
подсвечивается только основное слово), или спокойно-розовыми, когда слова нет, — а
|
||||
**оранжевый бейдж** на основном слове несёт очки хода. Кнопка
|
||||
подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве**
|
||||
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
|
||||
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||
|
||||
+6
-4
@@ -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
|
||||
the fixed 7th slot while a play is staged).
|
||||
- **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
|
||||
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.
|
||||
@@ -286,9 +287,10 @@ e2e and the screenshots.
|
||||
remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
|
||||
no confirm), which
|
||||
**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
|
||||
is — or the viewer's result once the game ends — shows in a **thin strip above the score
|
||||
plaques**; the tentative move's score rides the board badge (above), not an under-board caption.
|
||||
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
|
||||
above the score plaques** reads, while composing a legal play, the formed word(s) and the move
|
||||
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
|
||||
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
|
||||
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
|
||||
let {
|
||||
title = '',
|
||||
titleAside = '',
|
||||
onclose,
|
||||
overlayKeyboard = false,
|
||||
bottomSheet = false,
|
||||
children,
|
||||
}: {
|
||||
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;
|
||||
overlayKeyboard?: boolean;
|
||||
bottomSheet?: boolean;
|
||||
@@ -65,7 +69,12 @@
|
||||
style:--kb={bottomSheet ? `${kb}px` : null}
|
||||
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?.()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,8 +130,21 @@
|
||||
max-height: 86dvh;
|
||||
overflow: auto;
|
||||
}
|
||||
.titlerow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 10px;
|
||||
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>
|
||||
|
||||
@@ -85,7 +85,9 @@
|
||||
color: var(--accent-text);
|
||||
border-radius: 999px;
|
||||
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;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
+21
-5
@@ -490,7 +490,9 @@
|
||||
// rack (recallOverRack), so the highlight and badge clear the instant a recall begins.
|
||||
const EMPTY_FORMED = new Set<string>();
|
||||
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 boardBadge = $derived(
|
||||
@@ -1522,13 +1524,16 @@
|
||||
<!-- 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). -->
|
||||
{#snippet turnStrip()}
|
||||
<!-- A thin line above the score plaques: 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
|
||||
<!-- A thin line above the score plaques: while composing a legal play it reads the formed
|
||||
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. -->
|
||||
{#if view && !(gameOver && view.game.hotseat)}
|
||||
<div class="turnstrip" class:result={gameOver}>
|
||||
{#if gameOver}
|
||||
{resultText()}
|
||||
{:else if preview?.legal && !recallOverRack}
|
||||
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score}
|
||||
{:else}
|
||||
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
|
||||
{/if}
|
||||
@@ -1764,7 +1769,11 @@
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
{#each view.rack as letter, i (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. */
|
||||
.turnstrip {
|
||||
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;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
@@ -2049,6 +2060,11 @@
|
||||
pointer-events: none;
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -20,14 +20,14 @@ const cells = (g: ReturnType<typeof formedGeometry>) => [...(g?.cells ?? new Set
|
||||
|
||||
describe('formedGeometry', () => {
|
||||
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', () => {
|
||||
const g = formedGeometry(board(), [
|
||||
{ row: 7, col: 7 },
|
||||
{ row: 7, col: 8 },
|
||||
]);
|
||||
], true);
|
||||
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
|
||||
expect(cells(g)).toEqual(['7,7', '7,8']);
|
||||
});
|
||||
@@ -36,14 +36,14 @@ describe('formedGeometry', () => {
|
||||
const g = formedGeometry(board(), [
|
||||
{ row: 7, col: 7 },
|
||||
{ row: 8, col: 7 },
|
||||
]);
|
||||
], true);
|
||||
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'V', len: 2 });
|
||||
expect(cells(g)).toEqual(['7,7', '8,7']);
|
||||
});
|
||||
|
||||
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.
|
||||
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(cells(g)).toEqual(['7,7', '7,8']);
|
||||
});
|
||||
@@ -54,11 +54,23 @@ describe('formedGeometry', () => {
|
||||
const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
|
||||
{ row: 7, col: 7 },
|
||||
{ row: 7, col: 8 },
|
||||
]);
|
||||
], true);
|
||||
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
|
||||
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', () => {
|
||||
// (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.
|
||||
@@ -71,6 +83,7 @@ describe('formedGeometry', () => {
|
||||
[8, 8, 'E'],
|
||||
]),
|
||||
[{ row: 7, col: 8 }],
|
||||
true,
|
||||
);
|
||||
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']);
|
||||
|
||||
+16
-6
@@ -77,12 +77,19 @@ function span(
|
||||
* 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
|
||||
* 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
|
||||
* 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.
|
||||
* through a staged tile) cover. Under the **single-word rule** (multipleWords = false) cross words
|
||||
* are omitted, matching the engine, which ignores them entirely — a perpendicular run there is
|
||||
* neither validated nor scored (and need not even be a real word), so highlighting it would be
|
||||
* 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;
|
||||
const filled = filledPredicate(board, pending);
|
||||
const first = pending[0];
|
||||
@@ -115,7 +122,9 @@ 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
|
||||
// (two or more cells).
|
||||
// (two or more cells). Skipped under the single-word rule, where the engine ignores cross words,
|
||||
// so a perpendicular run must not be highlighted as if it were a formed word.
|
||||
if (multipleWords) {
|
||||
const cross: Dir = dir === 'H' ? 'V' : 'H';
|
||||
for (const p of pending) {
|
||||
const cs = span(filled, cross, p.row, p.col);
|
||||
@@ -126,6 +135,7 @@ export function formedGeometry(board: Board, pending: readonly Cell[]): FormedGe
|
||||
for (let r = cs.start; r < cs.start + cs.len; r++) cells.add(key(r, p.col));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { main, cells };
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export const en = {
|
||||
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
|
||||
|
||||
'game.bag': '{n} in the bag',
|
||||
'game.bagCount': 'In the bag: {n}',
|
||||
'game.bagEmpty': 'Bag is empty',
|
||||
'game.hints': 'Hints {n}',
|
||||
'game.yourTurn': 'Your turn',
|
||||
|
||||
@@ -87,6 +87,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack
|
||||
|
||||
'game.bag': '{n} в мешке',
|
||||
'game.bagCount': 'В мешке: {n}',
|
||||
'game.bagEmpty': 'Мешок пуст',
|
||||
'game.hints': 'Подсказки {n}',
|
||||
'game.yourTurn': 'Ваш ход',
|
||||
|
||||
Reference in New Issue
Block a user