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 @@ -