From a4e6727c70a71ccc6ec686b04f58a8fdd2af51b6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 13 Jun 2026 16:41:55 +0200 Subject: [PATCH] feat(ui): merge Exchange/Pass into one action; drop dead Tournaments tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lobby: remove the inert 🏆 Tournaments tab (it only raised a 'coming soon' toast); the lobby is back to three tabs, matching docs/FUNCTIONAL.md. Game: fold the separate 🥺 Skip (pass) tab into the 🔄 tab, now Exchange/Pass. Its dialog passes when no tile is selected (button 'Pass without exchanging') and exchanges when tiles are ('Exchange N'). The tab is no longer gated on an empty bag (pass must stay reachable in the endgame); inside the dialog tile selection is disabled while the bag is below a full rack (bagLen >= RACK_SIZE). The merge is UI-only. A pass is NOT an exchange of zero tiles: the rules allow an exchange only with a full rack left in the bag and forbid a zero-tile swap, while a pass is always legal; GCG (Poslfit) writes a pass as a bare '-' and an exchange as '-TILES'. Pass and exchange stay distinct end-to-end (wire, engine, history/GCG); the dialog dispatches the existing gateway.pass / gateway.exchange. No backend/wire/history/GCG change. Docs: docs/UI_DESIGN.md, docs/FUNCTIONAL.md (+_ru), PRERELEASE.md. Tests: ui/e2e/game.spec.ts. --- PRERELEASE.md | 20 ++++++++++++++++++++ docs/FUNCTIONAL.md | 6 ++++-- docs/FUNCTIONAL_ru.md | 6 ++++-- docs/UI_DESIGN.md | 11 +++++++---- ui/e2e/game.spec.ts | 30 +++++++++++++++++++----------- ui/src/game/Game.svelte | 29 +++++++++++++++++++++-------- ui/src/lib/i18n/en.ts | 8 +++----- ui/src/lib/i18n/ru.ts | 8 +++----- ui/src/screens/Lobby.svelte | 5 +---- 9 files changed, 82 insertions(+), 41 deletions(-) diff --git a/PRERELEASE.md b/PRERELEASE.md index a18868f..5d7f542 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -442,3 +442,23 @@ Then Stage 18. - **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md` (+`_ru`). Regression gate: UI `check` + unit (`tapconfirm`) + build + bundle budget + e2e (Chromium & WebKit), all green. + +- **UI — Merge Exchange/Pass; drop the dead Tournaments tab** (owner ad-hoc, not on the raw TODO + list): the lobby's 🏆 *Tournaments* tab was an inert `lobby.soon` toast — removed (the lobby is back + to three tabs, matching `docs/FUNCTIONAL.md`). In-game the separate 🥺 *Skip* (pass) tab folds into + the 🔄 tab, now **Exchange/Pass**, whose dialog passes when no tile is selected and exchanges when + tiles are. + - **Decision — a pass is NOT an exchange of zero (verified against the rules + GCG):** the merge is + **UI-only**. Pass and exchange stay distinct game actions end-to-end — wire (`GameActionRequest` vs + `ExchangeRequest`), engine (`ActionPass` vs `ActionExchange`), and the GCG Poslfit dialect (a pass is + a bare `-`, an exchange is `-TILES`). The engine forbids a zero-tile exchange (`ErrNothingToExchange`) + and allows an exchange only with a full rack left in the bag (`ErrNotEnoughTilesToExchange`), while a + pass is always legal — collapsing them would lose a real distinction. The dialog dispatches the + existing `gateway.pass` / `gateway.exchange`. + - **What shipped:** `Lobby.svelte` (tab removed); `Game.svelte` (one 🔄 Exchange/Pass tab no longer + gated on an empty bag; the dialog disables tile selection while the bag is below a full rack + (`bagLen >= RACK_SIZE`), its confirm button reading **Pass without exchanging** / **Exchange N**); + i18n (`game.draw` → Exchange/Pass, new `game.passNoExchange`, dropped `game.skip` / + `lobby.tournaments` / `lobby.soon`). No backend/wire/history/GCG change. + - **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md` + (+`_ru`). Regression gate: UI `check` + unit + build + bundle budget + e2e (Chromium & WebKit). diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 6457aa1..dd60d06 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -102,8 +102,10 @@ settings and the game starts once every invitee has accepted — any decline can expires after seven days. ### Playing a game -Place tiles, pass, exchange, or resign. Tiles are laid without choosing a -direction — the game infers the play's orientation, so a single tile that extends +Place tiles, pass, exchange, or resign. Pass and exchange share one control — +choosing no tiles passes the turn, choosing tiles exchanges them. Tiles are laid +without choosing a direction — the game infers the play's orientation, so a single +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 reports the word(s) a tentative move would form and its score, or that it is not diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 8bda84f..afed035 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -106,8 +106,10 @@ nudge) приходят от бота **этой партии** — по язы ответа приглашение протухает через семь дней. ### Игровой процесс -Выкладывание фишек, пас, обмен или сдача. Фишки кладутся без выбора направления — -игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая уже лежащее +Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления: +без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора +направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая +уже лежащее слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index fb47aa6..ee92f94 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -142,15 +142,18 @@ Login uses `Screen`. - **TapConfirm** (`components/TapConfirm.svelte`, logic in `lib/tapconfirm.ts`): the shared tap-to-confirm control. A first tap arms a ~2 s window showing a **fading ✅** (no fade under reduce-motion, but the window still holds); a tap on the ✅ within it confirms, - otherwise it reverts. Used by the **Skip** and **Hint** tabs (the icon morphs to ✅, no - label — replacing the old press-and-hold popover) and the in-game **add-friend 🤝**. The + otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing + the old press-and-hold popover) and the in-game **add-friend 🤝**. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action). - **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent button) beside the rack commits the move — no popover, and disabled while the pending word is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab. -- **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a - remaining-count badge, disabled at zero); 🔀 Shuffle (no label, no confirm), which +- **Game tab bar**: 🔄 Exchange/Pass (opens a dialog — pick tiles to **Exchange N**, or pick + none to **Pass without exchanging**; pass is always available on your turn, exchange only + while the bag still holds a full rack, below which the tiles disable and only the pass + 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. The under-board slot shows the **Scores: N** preview. The screen **title** is the variant's diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 7f464e3..35c62a1 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -17,7 +17,8 @@ async function openGame(page: Page): Promise { test('offline shows the Connecting indicator and softly disables server actions', async ({ page }) => { await openGame(page); - // The exchange/draw tab is a server action; on my turn with tiles in the bag it is live. + // The Exchange/Pass tab is a server action; on my turn it is live (pass is always available, + // so unlike before it no longer goes inert when the bag empties). const draw = page.locator('.tab').first(); await expect(draw).toBeEnabled(); await expect(page.getByText('Connecting…')).toHaveCount(0); @@ -179,25 +180,32 @@ test('history: a swipe-up close does not make a follow-up score tap jump', async await expect(page.locator('.boardwrap.slid')).toBeVisible(); }); -test('Draw opens the exchange dialog and confirms a selection', async ({ page }) => { +test('Exchange/Pass dialog: a selection exchanges, no selection offers a pass', async ({ page }) => { await openGame(page); - await page.locator('button:has-text("🔄")').click(); // Draw tab + await page.locator('button:has-text("🔄")').click(); // the merged Exchange/Pass tab await expect(page.locator('.exch')).toBeVisible(); + // With nothing selected the confirm button offers a pass; selecting tiles flips it to an + // exchange of that many (the two are distinct game actions behind one dialog). + const confirm = page.locator('button.confirm'); + await expect(confirm).toHaveText('Pass without exchanging'); await page.locator('.etile').first().click(); await expect(page.locator('.etile.sel')).toHaveCount(1); - await page.locator('button.confirm').click(); + await expect(confirm).toHaveText('Exchange 1'); + await confirm.click(); await expect(page.locator('.exch')).toBeHidden(); }); -test('pass confirms with a tap on the fading ✅ instead of a popup', async ({ page }) => { +test('passing goes through the Exchange/Pass dialog with no tiles selected', async ({ page }) => { await openGame(page); - const pass = page.getByRole('button', { name: 'Skip' }); // the 🥺 tab (aria-label) - await expect(pass).toBeEnabled(); - await pass.click(); // arm: 🥺 -> a fading ✅ - await pass.click(); // tap the ✅ to confirm within the window - // The pass hands the turn over, so the control goes inert. - await expect(pass).toBeDisabled(); + const draw = page.locator('button:has-text("🔄")'); // the merged Exchange/Pass tab + await expect(draw).toBeEnabled(); + await draw.click(); + // No tiles selected -> the confirm button passes the turn (a pass, not an exchange of zero). + await page.locator('button.confirm').click(); + await expect(page.locator('.exch')).toBeHidden(); + // The pass hands the turn over, so the tab goes inert. + await expect(draw).toBeDisabled(); }); test('check-word sanitises input and shows a verdict', async ({ page }) => { diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 943a906..80018c4 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -104,7 +104,13 @@ const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open')); const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat); const gameOver = $derived(!!view && view.game.status === 'finished'); - const bagEmpty = $derived((view?.bagLen ?? 0) === 0); + // RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange + // gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied + // exchange regardless (engine rejects when bag.Len() < rules.RackSize). + const RACK_SIZE = 7; + // Exchange is legal only while the bag still holds a full rack; below that the player may pass + // but not swap, so the merged Exchange/Pass dialog offers pass alone there. + const canExchange = $derived((view?.bagLen ?? 0) >= RACK_SIZE); // The seat whose move the history grid awaits with a "thinking…" placeholder: the player to // move while the game is active, but never the viewer themselves (their own pending cell // stays empty) and never on a finished game. @@ -634,6 +640,16 @@ busy = false; } } + // confirmExchangeOrPass routes the merged dialog's confirm button: no tiles selected means a + // pass (a distinct game action), any selection means a tile exchange. + async function confirmExchangeOrPass() { + if (exchangeSel.length === 0) { + exchangeOpen = false; + await doPass(); + return; + } + await doExchange(); + } function resultText(): string { if (!view) return ''; @@ -917,12 +933,9 @@ {#snippet tabbar()} {#if view} - - - 🥺{t('game.skip')} - (exchangeOpen = false)}>
{#each view.rack as letter, i (i)} - {/each}
- {/if} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index c62bf5a..38e2241 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -30,7 +30,6 @@ export const en = { 'lobby.noFinished': 'No finished games yet.', 'lobby.new': 'New', 'lobby.stats': 'Stats', - 'lobby.tournaments': 'Tourn.', 'lobby.profile': 'Profile', 'lobby.settings': 'Settings', 'lobby.about': 'About', @@ -38,7 +37,6 @@ export const en = { 'lobby.theirTurn': 'Their turn', 'lobby.hideGame': 'Remove from list', 'lobby.vs': 'vs {opponents}', - 'lobby.soon': 'Coming soon', 'new.title': 'New game', 'new.subtitle': 'Auto-match with another player', @@ -63,8 +61,7 @@ export const en = { 'game.waiting': "Waiting for {name}", 'game.makeMove': 'Make move', 'game.reset': 'Reset', - 'game.draw': 'Draw', - 'game.skip': 'Skip', + 'game.draw': 'Exchange/Pass', 'game.shuffle': 'Shuffle', 'game.hint': 'Hint', 'game.chat': 'Chat', @@ -75,8 +72,9 @@ export const en = { 'game.previewIllegal': 'Not a legal move', 'game.oneWordRule': 'One word per turn', 'game.chooseBlank': 'Choose a letter for the blank', - 'game.exchangeTitle': 'Select tiles to exchange', + 'game.exchangeTitle': 'Exchange or pass', 'game.exchangeConfirm': 'Exchange {n}', + 'game.passNoExchange': 'Pass without exchanging', 'game.confirmResign': 'Resign this game?', 'game.hintShown': 'Best move: {word} for {n}', 'game.over': 'Game over', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 6e01f54..260eb9e 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -31,7 +31,6 @@ export const ru: Record = { 'lobby.noFinished': 'Пока нет завершённых игр.', 'lobby.new': 'Новая', 'lobby.stats': 'Статы', - 'lobby.tournaments': 'Турниры', 'lobby.profile': 'Профиль', 'lobby.settings': 'Настройки', 'lobby.about': 'О программе', @@ -39,7 +38,6 @@ export const ru: Record = { 'lobby.theirTurn': 'Ход соперника', 'lobby.hideGame': 'Убрать из списка', 'lobby.vs': 'против {opponents}', - 'lobby.soon': 'Скоро', 'new.title': 'Новая игра', 'new.subtitle': 'Автоподбор соперника', @@ -64,8 +62,7 @@ export const ru: Record = { 'game.waiting': 'Ожидаем {name}', 'game.makeMove': 'Сделать ход', 'game.reset': 'Сброс', - 'game.draw': 'Обмен', - 'game.skip': 'Пас', + 'game.draw': 'Обмен/Пас', 'game.shuffle': 'Перемешать', 'game.hint': 'Подсказка', 'game.chat': 'Чат', @@ -76,8 +73,9 @@ export const ru: Record = { 'game.previewIllegal': 'Недопустимый ход', 'game.oneWordRule': 'Одно слово за ход', 'game.chooseBlank': 'Выберите букву для бланка', - 'game.exchangeTitle': 'Выберите фишки для обмена', + 'game.exchangeTitle': 'Обмен или пас', 'game.exchangeConfirm': 'Обменять {n}', + 'game.passNoExchange': 'Пас без обмена', 'game.confirmResign': 'Сдаться в этой игре?', 'game.hintShown': 'Лучший ход: {word} на {n}', 'game.over': 'Игра окончена', diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 075edb0..f005bbc 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import Screen from '../components/Screen.svelte'; import TabBar from '../components/TabBar.svelte'; - import { app, handleError, showToast } from '../lib/app.svelte'; + import { app, handleError } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; import { gateway } from '../lib/gateway'; import { navigate } from '../lib/router.svelte'; @@ -228,9 +228,6 @@ -