From bf46b9492d18c3c4b7b3b7be4bbebb8cc06e9c8f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 15:58:05 +0200 Subject: [PATCH] fix(ui): one-word games must not highlight phantom cross words; review polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../templates/pages/game_detail.gohtml | 1 + backend/internal/adminconsole/views.go | 8 +++- .../internal/server/handlers_admin_console.go | 1 + docs/FUNCTIONAL.md | 5 ++- docs/FUNCTIONAL_ru.md | 5 ++- docs/UI_DESIGN.md | 10 +++-- ui/src/components/Modal.svelte | 24 +++++++++++- ui/src/components/TabBar.svelte | 4 +- ui/src/game/Game.svelte | 26 ++++++++++--- ui/src/lib/formed.test.ts | 23 ++++++++--- ui/src/lib/formed.ts | 38 ++++++++++++------- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + 13 files changed, 111 insertions(+), 36 deletions(-) diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml index 0c33a48..8afe55d 100644 --- a/backend/internal/adminconsole/templates/pages/game_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml @@ -8,6 +8,7 @@
  • Dictionary {{.DictVersion}}
  • Status {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}
  • AI game {{if .VsAI}}🤖 yes{{else}}no{{end}}
  • +
  • Word rule {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}
  • Players {{.Players}}
  • To move seat {{.ToMove}}
  • Moves {{.MoveCount}}
  • diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 5f354b0..cdc8676 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -348,8 +348,12 @@ type GameDetailView struct { UpdatedAt string FinishedAt string // VsAI marks an honest-AI game (shown as a 🤖 flag in the summary). - VsAI bool - Seats []SeatRow + 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. HasRobot bool diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index bc39dcf..cfb72f9 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -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. diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 29caaeb..e8b7f0b 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index b01c083..40efc4c 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -219,8 +219,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово -(и чуть темнее — фишки доски, через которые проходит слово), или спокойно-розовыми, -когда слова нет, — а **оранжевый бейдж** на основном слове несёт очки хода. Кнопка +(и чуть темнее — фишки доски, через которые проходит слово; в партии «одно слово за ход» +подсвечивается только основное слово), или спокойно-розовыми, когда слова нет, — а +**оранжевый бейдж** на основном слове несёт очки хода. Кнопка подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве** и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 7681013..0d2c510 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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". diff --git a/ui/src/components/Modal.svelte b/ui/src/components/Modal.svelte index c0adfea..a3200c6 100644 --- a/ui/src/components/Modal.svelte +++ b/ui/src/components/Modal.svelte @@ -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}

    {title}

    {/if} + {#if title} +
    +

    {title}

    + {#if titleAside}{titleAside}{/if} +
    + {/if} {@render children?.()} @@ -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; + } diff --git a/ui/src/components/TabBar.svelte b/ui/src/components/TabBar.svelte index 8f6d512..e518098 100644 --- a/ui/src/components/TabBar.svelte +++ b/ui/src/components/TabBar.svelte @@ -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; } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 324cf30..f2ab090 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -490,7 +490,9 @@ // rack (recallOverRack), so the highlight and badge clear the instant a recall begins. const EMPTY_FORMED = new Set(); 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 @@ {#snippet turnStrip()} - {#if view && !(gameOver && view.game.hotseat)}
    {#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} - (exchangeOpen = false)}> + (exchangeOpen = false)} + >
    {#each view.rack as letter, i (i)}