diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 09e884d..a9bf0e1 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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 { + 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 { + 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 { + 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 { + 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} {/if} + {:else if view.game.hotseat} + + {:else} {/if} {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} - {#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} @@ -1512,7 +1618,7 @@ {#if gameOver} {resultText()} {:else if placement.pending.length === 0} - {isMyTurn ? t('game.yourTurn') : turnLabel()} + {view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} {#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. -->
- + {#if locked} + + + {:else} + + {/if}
{#if !gameOver && placement.pending.length > 0 && !recallOverRack} - + {/if}
{/snippet} {#snippet controlButtons()} - - {#if view?.game.vsAi} + {#if view?.game.hotseat} + + + {:else if view?.game.vsAi} @@ -1623,6 +1741,55 @@ {/if} + +{#if unlockOpen && view} + (unlockOpen = false)} + onresult={() => (unlockOpen = false)} + /> +{/if} + + +{#if hostMenuStep === 'pin'} + (hostMenuStep = 'closed')} + onresult={() => (hostMenuStep = 'menu')} + /> +{/if} +{#if hostMenuStep === 'menu' && view} + { hostMenuStep = 'closed'; hostConfirm = null; }}> + {#if hostConfirm} +

{hostConfirmText(hostConfirm)}

+
+ + +
+ {:else} +
+ +
{t('hotseat.exclude')}
+ {#each view.game.seats as s (s.seat)} + + {/each} + +
+ {/if} +
+{/if} +{#if hostDone} + +{/if} + {#if exportOpen} (exportOpen = false)}>
@@ -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); } + } diff --git a/ui/src/lib/gamesource.ts b/ui/src/lib/gamesource.ts index 201ffbe..9031aac 100644 --- a/ui/src/lib/gamesource.ts +++ b/ui/src/lib/gamesource.ts @@ -41,6 +41,11 @@ export const localSource = { create: (opts) => load().then((s) => s.create(opts)), list: () => load().then((s) => s.list()), delete: (id) => load().then((s) => s.delete(id)), + // Offline hotseat controls (no network equivalent): reveal a PIN-locked seat, and the host + // (referee) overrides gated by the master PIN. + unlockSeat: (id, pin) => load().then((s) => s.unlockSeat(id, pin)), + verifyHostPin: (id, pin) => load().then((s) => s.verifyHostPin(id, pin)), + hostAction: (id, pin, action, targetSeat) => load().then((s) => s.hostAction(id, pin, action, targetSeat)), // events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires // the real subscription once the engine loads and, until then, cancels a pending subscribe. events: (id, onEvent) => { @@ -54,7 +59,8 @@ export const localSource = { unsub(); }; }, -} satisfies GameLoopSource & Pick; +} satisfies GameLoopSource & + Pick; /** * gameSource returns the source that runs the game with the given id: the local engine for a local diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index f3bd967..d665346 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -408,6 +408,16 @@ export const en = { 'hotseat.hostPlaysTitle': 'Are you playing too?', 'hotseat.hostPlaysYes': 'Yes, I play', 'hotseat.hostPlaysNo': 'No', + 'hotseat.unlock': 'Unlock', + 'hotseat.unlockTitle': 'Unlock: {name}', + 'hotseat.turnOf': 'Turn: {name}', + 'hotseat.host': 'Host', + 'hotseat.skip': 'Skip the current turn', + 'hotseat.exclude': 'Exclude a player:', + 'hotseat.terminate': 'End the game', + 'hotseat.askSkip': "Skip {name}'s turn?", + 'hotseat.askExclude': 'Remove {name} from the game?', + 'hotseat.askTerminate': 'End the game with no result?', } as const; export type MessageKey = keyof typeof en; diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index ea0310c..05ecef9 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -408,4 +408,14 @@ export const ru: Record = { 'hotseat.hostPlaysTitle': 'Принимаете участие в игре?', 'hotseat.hostPlaysYes': 'Да, играю', 'hotseat.hostPlaysNo': 'Нет', + 'hotseat.unlock': 'Разблокировать', + 'hotseat.unlockTitle': 'Разблокировать: {name}', + 'hotseat.turnOf': 'Ход: {name}', + 'hotseat.host': 'Ведущий', + 'hotseat.skip': 'Пропустить ход', + 'hotseat.exclude': 'Исключить игрока:', + 'hotseat.terminate': 'Отменить партию', + 'hotseat.askSkip': 'Пропустить ход игрока {name}?', + 'hotseat.askExclude': 'Удалить {name} из игры?', + 'hotseat.askTerminate': 'Отменить партию без результата?', };