feat(ui): merge Exchange/Pass into one action; drop dead Tournaments tab #52

Merged
developer merged 1 commits from feature/ui-merge-exchange-pass into development 2026-06-13 19:58:37 +00:00
9 changed files with 82 additions and 41 deletions
Showing only changes of commit a4e6727c70 - Show all commits
+20
View File
@@ -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).
+4 -2
View File
@@ -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
+4 -2
View File
@@ -106,8 +106,10 @@ nudge) приходят от бота **этой партии** — по язы
ответа приглашение протухает через семь дней.
### Игровой процесс
Выкладывание фишек, пас, обмен или сдача. Фишки кладутся без выбора направления
игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая уже лежащее
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора
направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая
уже лежащее
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
+7 -4
View File
@@ -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
+19 -11
View File
@@ -17,7 +17,8 @@ async function openGame(page: Page): Promise<void> {
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 }) => {
+21 -8
View File
@@ -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}
<TabBar>
<button class="tab" disabled={busy || !isMyTurn || !connection.online || bagEmpty} onclick={openExchange}>
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
</button>
<TapConfirm triggerClass="tab" label={t('game.skip')} disabled={busy || !isMyTurn || !connection.online} onconfirm={doPass}>
<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>
</TapConfirm>
<TapConfirm
triggerClass="tab"
label={t('game.hint')}
@@ -966,13 +979,13 @@
<Modal title={t('game.exchangeTitle')} onclose={() => (exchangeOpen = false)}>
<div class="exch">
{#each view.rack as letter, i (i)}
<button class="etile" class:sel={exchangeSel.includes(i)} onclick={() => toggleExch(i)}>
<button class="etile" class:sel={exchangeSel.includes(i)} disabled={!canExchange} onclick={() => toggleExch(i)}>
{letter === BLANK ? '?' : letter}
</button>
{/each}
</div>
<button class="confirm" disabled={exchangeSel.length === 0} onclick={doExchange}>
{t('game.exchangeConfirm', { n: exchangeSel.length })}
<button class="confirm" onclick={confirmExchangeOrPass}>
{exchangeSel.length === 0 ? t('game.passNoExchange') : t('game.exchangeConfirm', { n: exchangeSel.length })}
</button>
</Modal>
{/if}
+3 -5
View File
@@ -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',
+3 -5
View File
@@ -31,7 +31,6 @@ export const ru: Record<MessageKey, string> = {
'lobby.noFinished': 'Пока нет завершённых игр.',
'lobby.new': 'Новая',
'lobby.stats': 'Статы',
'lobby.tournaments': 'Турниры',
'lobby.profile': 'Профиль',
'lobby.settings': 'Настройки',
'lobby.about': 'О программе',
@@ -39,7 +38,6 @@ export const ru: Record<MessageKey, string> = {
'lobby.theirTurn': 'Ход соперника',
'lobby.hideGame': 'Убрать из списка',
'lobby.vs': 'против {opponents}',
'lobby.soon': 'Скоро',
'new.title': 'Новая игра',
'new.subtitle': 'Автоподбор соперника',
@@ -64,8 +62,7 @@ export const ru: Record<MessageKey, string> = {
'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<MessageKey, string> = {
'game.previewIllegal': 'Недопустимый ход',
'game.oneWordRule': 'Одно слово за ход',
'game.chooseBlank': 'Выберите букву для бланка',
'game.exchangeTitle': 'Выберите фишки для обмена',
'game.exchangeTitle': 'Обмен или пас',
'game.exchangeConfirm': 'Обменять {n}',
'game.passNoExchange': 'Пас без обмена',
'game.confirmResign': 'Сдаться в этой игре?',
'game.hintShown': 'Лучший ход: {word} на {n}',
'game.over': 'Игра окончена',
+1 -4
View File
@@ -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 @@
<button class="tab" onclick={() => navigate('/stats')}>
<span class="sq">📊</span><span class="lbl">{t('lobby.stats')}</span>
</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
<span class="sq">🏆</span><span class="lbl">{t('lobby.tournaments')}</span>
</button>
<button class="tab" onclick={() => navigate('/settings')}>
<span class="sq">⚙️{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span>
<span class="lbl">{t('lobby.settings')}</span>