feat(offline): hotseat in-game seat lock + host menu
Game.svelte drives a hotseat game: - the seat-to-move's rack is replaced by an Unlock button when the seat is PIN-locked (board stays visible); canMove gates the move controls on the unlock; PinPad(verify) -> source.unlockSeat reveals it. - the hint slot becomes a host button (hints off in hotseat), always enabled while the game runs (acts on a locked seat too). Master-PIN -> action sheet: skip current / exclude a player / end the game, each with a confirm + a fading check; terminate deletes and returns to the lobby. - per-turn advance re-fetches the next seat's (locked) state; self-resign and chat are hidden (hotseat resign is a host action, no comms). - gamesource: expose unlockSeat / verifyHostPin / hostAction on the local proxy. i18n hotseat.* (en + ru).
This commit is contained in:
+248
-18
@@ -4,6 +4,7 @@
|
||||
import TabBar from '../components/TabBar.svelte';
|
||||
import TapConfirm from '../components/TapConfirm.svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import PinPad from '../components/PinPad.svelte';
|
||||
import DictWarmup from '../components/DictWarmup.svelte';
|
||||
import Board from './Board.svelte';
|
||||
import Rack from './Rack.svelte';
|
||||
@@ -163,6 +164,11 @@
|
||||
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');
|
||||
// Offline hotseat: the seat to move is PIN-locked until its owner enters the seat PIN this turn.
|
||||
// While locked the board stays visible but the rack is withheld and the move controls disabled;
|
||||
// canMove folds the lock into isMyTurn (locked is always false for a network / vs_ai game).
|
||||
const locked = $derived(!!view?.locked);
|
||||
const canMove = $derived(isMyTurn && !locked);
|
||||
// The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the
|
||||
// wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet
|
||||
// hint was spent in another game (see lib/hints).
|
||||
@@ -774,8 +780,8 @@
|
||||
// rapid placements do not pile up requests on a slow link.
|
||||
evalCtrl?.abort();
|
||||
evalCtrl = null;
|
||||
// Off-turn the composition is position-only: no score preview or evaluate.
|
||||
if (!isMyTurn) return;
|
||||
// Off-turn (or a locked hotseat seat) the composition is position-only: no score preview.
|
||||
if (!canMove) return;
|
||||
const sub = toSubmit(placement);
|
||||
if (!sub) return;
|
||||
// Instant on-device preview when the game's dictionary is warm; the network otherwise.
|
||||
@@ -833,12 +839,106 @@
|
||||
refreshRecent();
|
||||
}
|
||||
|
||||
// --- offline hotseat: seat unlock + host (referee) overrides -------------------
|
||||
let unlockOpen = $state(false); // the current locked seat's owner enters their PIN to reveal the rack
|
||||
// The host menu: 'pin' collects the master PIN, then 'menu' lists the overrides. hostPinEntered is
|
||||
// the verified master PIN, reused to authorise the chosen action (the source re-checks it).
|
||||
let hostMenuStep = $state<'closed' | 'pin' | 'menu'>('closed');
|
||||
let hostPinEntered = $state('');
|
||||
// The pending host action awaiting its confirm ("Skip X's turn?" etc.); null shows the menu list.
|
||||
let hostConfirm = $state<{ action: 'skip' | 'resign' | 'terminate'; seat?: number; name?: string } | null>(null);
|
||||
let hostDone = $state(false); // the transient success ✅ shown after an override
|
||||
|
||||
// hotseatName is the plain display name of a seat by index (hotseat seats are account-less local
|
||||
// players, so no "you"/🤖 resolution is needed — unlike the seat-object seatName used elsewhere).
|
||||
function hotseatName(i: number): string {
|
||||
return view?.game.seats[i]?.displayName ?? '';
|
||||
}
|
||||
|
||||
function startSkip(): void {
|
||||
if (!view) return;
|
||||
hostConfirm = { action: 'skip', seat: view.game.toMove, name: hotseatName(view.game.toMove) };
|
||||
}
|
||||
|
||||
function flashDone(): void {
|
||||
hostDone = true;
|
||||
setTimeout(() => (hostDone = false), 1100);
|
||||
}
|
||||
|
||||
// advanceHotseat re-points the screen at the next seat after a hotseat move: the source has advanced
|
||||
// the turn and re-locked, so a fresh state gives the next seat's rack (empty while it is locked).
|
||||
async function advanceHotseat(): Promise<void> {
|
||||
const st = await source.gameState(id, false);
|
||||
view = st;
|
||||
rackIds = st.rack.map((_, i) => i);
|
||||
placement = newPlacement(st.rack);
|
||||
selected = null;
|
||||
preview = null;
|
||||
setCachedGame(id, st, moves, '');
|
||||
}
|
||||
|
||||
// onUnlock verifies the current seat's PIN through the source (which reveals the rack on success)
|
||||
// and applies the unlocked state; handed to the PIN pad as its verifier.
|
||||
async function onUnlock(pin: string): Promise<boolean> {
|
||||
try {
|
||||
const st = await localSource.unlockSeat(id, pin);
|
||||
view = st;
|
||||
rackIds = st.rack.map((_, i) => i);
|
||||
placement = newPlacement(st.rack);
|
||||
selected = null;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// verifyHost captures the entered master PIN (reused to authorise the chosen action) and checks it.
|
||||
async function verifyHost(pin: string): Promise<boolean> {
|
||||
hostPinEntered = pin;
|
||||
return localSource.verifyHostPin(id, pin);
|
||||
}
|
||||
|
||||
function hostConfirmText(c: { action: 'skip' | 'resign' | 'terminate'; name?: string }): string {
|
||||
if (c.action === 'skip') return t('hotseat.askSkip', { name: c.name ?? '' });
|
||||
if (c.action === 'resign') return t('hotseat.askExclude', { name: c.name ?? '' });
|
||||
return t('hotseat.askTerminate');
|
||||
}
|
||||
|
||||
async function runHostAction(): Promise<void> {
|
||||
if (!hostConfirm) return;
|
||||
const { action, seat } = hostConfirm;
|
||||
hostConfirm = null;
|
||||
hostMenuStep = 'closed';
|
||||
busy = true;
|
||||
try {
|
||||
const st = await localSource.hostAction(id, hostPinEntered, action, seat);
|
||||
if (st === null) {
|
||||
navigate('/'); // terminated: the game is deleted, so return to the lobby
|
||||
return;
|
||||
}
|
||||
view = st;
|
||||
rackIds = st.rack.map((_, i) => i);
|
||||
placement = newPlacement(st.rack);
|
||||
selected = null;
|
||||
preview = null;
|
||||
moves = (await source.gameHistory(id)).moves; // a skip / resign added a journal move
|
||||
setCachedGame(id, st, moves, '');
|
||||
flashDone();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
hostPinEntered = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
const sub = toSubmit(placement);
|
||||
if (!sub) return;
|
||||
busy = true;
|
||||
try {
|
||||
applyMoveResult(await source.submitPlay(id, sub.tiles, variant));
|
||||
if (view?.game.hotseat) await advanceHotseat();
|
||||
haptic('success');
|
||||
zoomed = false;
|
||||
} catch (e) {
|
||||
@@ -858,6 +958,7 @@
|
||||
busy = true;
|
||||
try {
|
||||
applyMoveResult(await source.pass(id));
|
||||
if (view?.game.hotseat) await advanceHotseat();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
@@ -979,6 +1080,7 @@
|
||||
busy = true;
|
||||
try {
|
||||
applyMoveResult(await source.exchange(id, tiles, variant));
|
||||
if (view?.game.hotseat) await advanceHotseat();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
@@ -1433,13 +1535,17 @@
|
||||
{:else}
|
||||
<button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button>
|
||||
{/if}
|
||||
{:else if view.game.hotseat}
|
||||
<!-- Hotseat resign is a host (referee) action, not self-serve; keep the slot empty. -->
|
||||
<span aria-hidden="true"></span>
|
||||
{:else}
|
||||
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||
{/if}
|
||||
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
||||
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
|
||||
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). -->
|
||||
{#if !(gameOver && view.game.vsAi)}
|
||||
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary).
|
||||
A hotseat game has no chat either (local players, no accounts). -->
|
||||
{#if !(gameOver && view.game.vsAi) && !view.game.hotseat}
|
||||
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
||||
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
||||
</button>
|
||||
@@ -1512,7 +1618,7 @@
|
||||
{#if gameOver}
|
||||
<strong class="over">{resultText()}</strong>
|
||||
{:else if placement.pending.length === 0}
|
||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||
<span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||
{/if}
|
||||
<span class="scores">
|
||||
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
|
||||
@@ -1526,28 +1632,40 @@
|
||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||
<div class="rack-row" class:inert={gameOver} data-coach="game-rack">
|
||||
<div class="rack-wrap">
|
||||
<Rack
|
||||
slots={rackSlots}
|
||||
{variant}
|
||||
{selected}
|
||||
{landscape}
|
||||
shuffling={shuffling && !app.reduceMotion}
|
||||
draggingId={reorderDragId}
|
||||
dropIndex={reorderTo}
|
||||
ondown={onRackDown}
|
||||
/>
|
||||
{#if locked}
|
||||
<!-- A PIN-locked hotseat seat: the rack is withheld until its owner unlocks it. The board
|
||||
above stays visible; only this tray is gated. -->
|
||||
<button class="unlock" onclick={() => (unlockOpen = true)}>🔒 {t('hotseat.unlock')}</button>
|
||||
{:else}
|
||||
<Rack
|
||||
slots={rackSlots}
|
||||
{variant}
|
||||
{selected}
|
||||
{landscape}
|
||||
shuffling={shuffling && !app.reduceMotion}
|
||||
draggingId={reorderDragId}
|
||||
dropIndex={reorderTo}
|
||||
ondown={onRackDown}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet controlButtons()}
|
||||
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={openExchange}>
|
||||
<button class="tab" disabled={busy || !canMove || !netReady} onclick={openExchange}>
|
||||
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||
</button>
|
||||
{#if view?.game.vsAi}
|
||||
{#if view?.game.hotseat}
|
||||
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
|
||||
(even on a locked seat: the host may skip a player who forgot their PIN). -->
|
||||
<button class="tab" disabled={busy || gameOver} onclick={() => (hostMenuStep = 'pin')}>
|
||||
<span class="sq">🔐</span><span class="lbl">{t('hotseat.host')}</span>
|
||||
</button>
|
||||
{:else if view?.game.vsAi}
|
||||
<!-- vs_ai hints are unlimited and wallet-free, so no confirm and no count. A 🔒 badge marks the
|
||||
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
|
||||
lifts live at the unlock moment. -->
|
||||
@@ -1623,6 +1741,55 @@
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<!-- Offline hotseat: unlock the current seat's rack. -->
|
||||
{#if unlockOpen && view}
|
||||
<PinPad
|
||||
mode="verify"
|
||||
title={t('hotseat.unlockTitle', { name: hotseatName(view.game.toMove) })}
|
||||
verify={onUnlock}
|
||||
onclose={() => (unlockOpen = false)}
|
||||
onresult={() => (unlockOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Offline hotseat: the host (referee) enters the master PIN, then picks an override. -->
|
||||
{#if hostMenuStep === 'pin'}
|
||||
<PinPad
|
||||
mode="verify"
|
||||
title={t('hotseat.host')}
|
||||
verify={verifyHost}
|
||||
onclose={() => (hostMenuStep = 'closed')}
|
||||
onresult={() => (hostMenuStep = 'menu')}
|
||||
/>
|
||||
{/if}
|
||||
{#if hostMenuStep === 'menu' && view}
|
||||
<Modal title={t('hotseat.host')} onclose={() => { hostMenuStep = 'closed'; hostConfirm = null; }}>
|
||||
{#if hostConfirm}
|
||||
<p class="host-q">{hostConfirmText(hostConfirm)}</p>
|
||||
<div class="confirm-row">
|
||||
<button class="cancel" onclick={() => (hostConfirm = null)}>{t('common.cancel')}</button>
|
||||
<button class="danger" onclick={runHostAction}>{t('common.ok')}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="host-menu">
|
||||
<button class="host-act" onclick={startSkip}>
|
||||
{t('hotseat.skip')}
|
||||
</button>
|
||||
<div class="host-sub">{t('hotseat.exclude')}</div>
|
||||
{#each view.game.seats as s (s.seat)}
|
||||
<button class="host-seat" onclick={() => (hostConfirm = { action: 'resign', seat: s.seat, name: s.displayName })}>
|
||||
{s.displayName}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="host-act danger" onclick={() => (hostConfirm = { action: 'terminate' })}>{t('hotseat.terminate')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
{/if}
|
||||
{#if hostDone}
|
||||
<div class="host-done" aria-hidden="true">✅</div>
|
||||
{/if}
|
||||
|
||||
{#if exportOpen}
|
||||
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
|
||||
<div class="export-opts">
|
||||
@@ -2122,4 +2289,67 @@
|
||||
font-size: 0.62rem;
|
||||
line-height: 1;
|
||||
}
|
||||
/* --- offline hotseat --- */
|
||||
/* The locked-seat rack overlay: fills the rack tray with an "unlock" button, the board above
|
||||
stays visible. */
|
||||
.unlock {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 44px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.host-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.host-act,
|
||||
.host-seat {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
.host-act.danger {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
.host-sub {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.host-q {
|
||||
margin: 0 0 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* The transient success tick after a host override — a centred ✅ that fades out on a timer. */
|
||||
.host-done {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 4rem;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
animation: host-done-fade 1.1s ease forwards;
|
||||
}
|
||||
@keyframes host-done-fade {
|
||||
0% { opacity: 0; transform: scale(0.7); }
|
||||
25% { opacity: 1; transform: scale(1); }
|
||||
75% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user