feat(ui): merge Exchange/Pass into one action; drop dead Tournaments tab
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s

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.
This commit is contained in:
Ilia Denisov
2026-06-13 16:41:55 +02:00
parent 1d41cf8222
commit a4e6727c70
9 changed files with 82 additions and 41 deletions
+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}