Compare commits

...

7 Commits

Author SHA1 Message Date
Ilia Denisov baa9961c3e docs(offline): document hotseat pass-and-play
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) Failing after 12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
ARCHITECTURE (offline section) + FUNCTIONAL (+_ru mirror): the local
pass-and-play mode — host/referee master PIN, per-seat optional PIN
locks (social lock, salted SHA-256, client-only), the board-visible seat
unlock, host overrides (skip/exclude/terminate), no hints/chat, and the
master-PIN-gated lobby deletion.
2026-07-07 11:56:40 +02:00
Ilia Denisov 66278e34b7 test(offline): e2e hotseat flow + fix $state-proxy persist
Add the Playwright mock e2e for offline pass-and-play (Chromium+WebKit):
create a 2-seat hotseat with a PIN-locked seat, verify the rack is
withheld, unlock (wrong then right PIN), host-skip the current turn, and
terminate from the lobby behind the master PIN.

The e2e surfaced a persistence bug: the roster PINs are $state proxies,
which fail structured-clone on the IndexedDB put (silently — best-effort
store), so a created hotseat game vanished on reload. Snapshot the seats
+ host PIN to plain objects at the create() call site.
2026-07-07 11:53:50 +02:00
Ilia Denisov 452a686e07 feat(offline): hotseat lobby delete behind the master PIN
Lobby.svelte: hotseat games show the kebab / swipe delete in BOTH the
active and finished groups (not finished-only); deleting one opens a
master-PIN pad first — active = terminate (no result), finished = remove
— so the last mover cannot instantly wipe a game. Local games stay
deletable offline. Non-hotseat games are unchanged (finished-only, free).
2026-07-07 11:43:02 +02:00
Ilia Denisov 4ddc690c38 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).
2026-07-07 11:39:43 +02:00
Ilia Denisov 6216359c6f feat(offline): hotseat creation roster + host-participate flow
NewGame offline 'with friends' now builds a local pass-and-play game:
- lib/roster.ts: keep-last-valid name + roster->seats (unit-tested).
- NewGame.svelte: master host-PIN gate (rows disabled until set),
  'are you playing too?' prompt seating the host at row 0, 2-4 player
  rows with inline name validation + optional per-seat PIN, add/remove
  (remove behind the master PIN), variant + multiple-words, Start.
- PinPad: verification via a verify(pin) callback (UI-held lock at
  creation, source-held in-game) instead of a lock prop.
- i18n: hotseat.* (en + ru); the offline mode selector is now shown offline.
2026-07-07 11:28:17 +02:00
Ilia Denisov 8c67d679d9 feat(offline): hotseat record schema + source (locks, host actions)
Extend the local game to offline pass-and-play (hotseat):
- serialize.ts: Seat.pin, record hotseat + hostPin (all optional; old
  vs_ai records read hotseat as false).
- source.ts: create() takes hotseat/hostPin + per-seat pins; stateView
  reveals the seat-to-move's rack and withholds it (locked) when that
  seat is PIN-locked, until unlockSeat; ephemeral per-turn unlock
  re-locks on advance; new unlockSeat / verifyHostPin / hostAction
  (skip/resign/terminate); gameView sets vsAi=!hotseat + the hotseat flag.
- model.ts: GameView.hotseat, StateView.locked (offline-only, optional).

Tests: source.hotseat (lock/unlock/re-lock, host skip/resign/terminate,
master-PIN gate, natural-end persistence) + serialize hotseat shape.
2026-07-07 11:20:39 +02:00
Ilia Denisov 69673e7727 feat(offline): PIN lock primitives + Apple-style keypad
Groundwork for offline pass-and-play (hotseat) seat/host PIN locks:
- lib/pin.ts: salted SHA-256 PinLock (newSalt/hashPin/newLock/verifyPin);
  a social lock for a shared device (documented, not cryptography).
- components/PinPad.svelte: 4-digit keypad (set/verify/change), auto-submit
  on the 4th digit, shake on mismatch, hardware-keyboard support.
- i18n: pin.* keys (en + ru).

Engine/source/UI wiring follows.
2026-07-07 11:11:13 +02:00
20 changed files with 1466 additions and 44 deletions
+16 -2
View File
@@ -1251,8 +1251,22 @@ toggle — distinct from the transient gateway-reachability signal — that tint
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
creates one through the in-browser engine — the same game screen then drives it, the robot replying
locally; online-only affordances (the Stats tab, the friend/random options in New Game) are disabled
or hidden. A `vs_ai` hint (online and offline alike) is unlimited and wallet-free but idle-gated
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
current turn, **exclude** a player (a forced seat resign, `resignSeat`) or **terminate** the game
(deleted, no winner/loser); each seat may also carry its own **optional PIN** that withholds its rack
(the board stays visible — only the tray is gated) until it is entered, so players pass the device
around freely and lock a seat only against a distrusted tablemate. The unlock is **per turn**
(re-locks on advance). PINs are a **social lock, not cryptography** — a salted SHA-256 kept only in
the device record (`lib/pin`); a 4-digit PIN is trivially brute-forceable and the racks live in
client memory regardless, so they defeat a casual glance, not a determined peek. A hotseat game is
**not `vs_ai`**: hints (the freed control becomes the host button) and chat/self-resign are gone, and
its lobby card — active *and* finished — is master-PIN-gated to delete (so the last mover cannot
instantly wipe it); a naturally finished game is saved with its result like any local game. A `vs_ai`
hint (online and offline alike) is unlimited and wallet-free but idle-gated
(unlocked ~30 min into a stuck turn), and counts toward no hint statistic. The **source** reports the
**seconds left** (`hint_unlock_left_seconds` on the game view) — computed by the **backend from the
server clock** online (which also enforces the gate, returning `hint_locked` for an early request),
+16 -2
View File
@@ -269,8 +269,8 @@ statistics.
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
disabled or hidden (the Stats tab; the *with friends* and *random opponent* options in New Game).
Only **vs_ai** games are playable — New Game creates a device-local game against the robot, which
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
A New Game against the robot creates a device-local **vs_ai** game — it
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
offline mode, and never sync to the account. So a game can be created and played with no connection,
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
@@ -280,6 +280,20 @@ and waits briefly, and if they cannot be readied it stays online with a short *n
(the download keeps running in the background, so a later switch is instant). The mode is
device-scoped and sticky across launches.
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
During the game the board is always visible, but a PIN-locked seat shows an **Unlock** button in place
of its rack until its owner enters the PIN — so the device passes hand to hand and only a protected
seat hides its letters; the lock returns each turn. The **leader** (the host button in the game) can,
with the leader password, **skip** the current player's turn, **remove** a player, or **end the game
early** — an early end discards the game with no winner or loser. Hints and chat are off in a
pass-and-play game. A game that finishes normally is saved to the offline lobby like any other;
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
so no one can wipe a game the moment they have moved.
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
with no connection it switches to offline for that session; when the device is online but the gateway
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
+17 -2
View File
@@ -274,8 +274,8 @@ e-mail) либо ввод фразы. Активные игры форфейтя
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
отключены или скрыты (вкладка Статистика; варианты «с другом» и «случайный соперник» в Новой игре).
Играть можно только в **vs_ai** — Новая игра создаёт локальную игру против робота, который ходит
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
@@ -285,6 +285,21 @@ e-mail) либо ввод фразы. Активные игры форфейтя
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
сохраняется между запусками.
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 24 человек
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
дать любому месту **свой необязательный PIN**. Во время игры доска видна всегда, но место с PIN
вместо стойки показывает кнопку **«Разблокировать»**, пока владелец не введёт PIN, — так устройство
передаётся из рук в руки, и только защищённое место прячет свои буквы; на каждом ходу замок
возвращается. **Ведущий** (кнопка ведущего в игре) может по паролю ведущего **пропустить** ход
текущего игрока, **исключить** игрока или **завершить игру досрочно** — досрочное завершение стирает
партию без победителей и проигравших. Подсказки и чат в игре по очереди отключены. Нормально
завершённая партия сохраняется в офлайн-лобби как любая другая; удаление любой игры по очереди —
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
своего хода.
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
+102
View File
@@ -0,0 +1,102 @@
import { test, expect, type Page } from './fixtures';
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the
// Settings offline toggle shows.
async function forceStandalone(page: Page): Promise<void> {
await page.addInitScript(() => {
const orig = window.matchMedia.bind(window);
window.matchMedia = (q: string) =>
(q.includes('display-mode: standalone')
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
: orig(q)) as MediaQueryList;
});
}
async function enterLobby(page: Page): Promise<void> {
await page.getByRole('button', { name: /guest|гост/i }).first().click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
}
async function goOffline(page: Page): Promise<void> {
await page.locator('button.tab').nth(2).click(); // Settings
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
}
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
async function typePin(page: Page, digits: string): Promise<void> {
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
}
test.describe('offline hotseat (pass-and-play)', () => {
test('create with a locked seat, unlock, host-skip, terminate from the lobby', async ({ page }) => {
await forceStandalone(page);
await page.goto('/');
await enterLobby(page);
await goOffline(page);
// A known bag seed so the locked seat deals a full rack deterministically.
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
// New game -> the offline mode selector now offers "with friends" (hotseat).
await page.locator('button.tab').nth(0).click();
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
// The player rows are disabled until the mandatory host (master) PIN is set.
await expect(page.locator('.pname').first()).toBeDisabled();
// Set the host PIN (enter + confirm), then decline taking a seat.
await page.locator('.hostpin .plink').click();
await typePin(page, '9999');
await typePin(page, '9999');
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
// Pick the English variant if the picker offers a choice.
const variant = page.locator('.fg select').first();
if (await variant.isEnabled()) await variant.selectOption('scrabble_en');
// Two players: Ann (PIN-locked) and Bob (open).
await page.locator('.pname').nth(0).fill('Ann');
await page.locator('.pname').nth(1).fill('Bob');
await page.locator('.prow').nth(0).locator('.plink').click(); // Ann's seat PIN
await typePin(page, '1234');
await typePin(page, '1234');
await page.locator('button.invite').click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
// Ann is to move and PIN-locked: the board is visible but her rack is withheld behind Unlock.
await expect(page.locator('.unlock')).toBeVisible();
await expect(page.locator('.rack .tile')).toHaveCount(0);
// A wrong PIN keeps it locked; the right PIN reveals the full rack.
await page.locator('.unlock').click();
await typePin(page, '0000');
await expect(page.locator('.pad')).toBeVisible(); // still open after a wrong PIN
await typePin(page, '1234');
await expect(page.locator('.rack .tile')).toHaveCount(7);
// Host override: 🔐 -> master PIN -> skip the current turn -> confirm. The turn passes to Bob,
// whose seat is open, so his rack shows without a lock.
await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click();
await typePin(page, '9999');
await page.getByRole('button', { name: /Skip|Пропустить/ }).click();
await page.getByRole('button', { name: /^(OK|ОК)$/ }).click();
await expect(page.locator('.unlock')).toHaveCount(0);
await expect(page.locator('.rack .tile')).toHaveCount(7);
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
await expect(page.locator('.rowwrap')).toHaveCount(1);
await page.locator('.kebab').first().click();
const del = page.locator('.rowwrap.revealed .del');
await expect(del).toBeVisible();
await del.click();
await typePin(page, '9999');
await expect(page.locator('.rowwrap')).toHaveCount(0);
});
});
+265
View File
@@ -0,0 +1,265 @@
<script lang="ts">
// Apple-lock-screen-style 4-digit PIN pad for offline pass-and-play (hotseat) games.
// Modes:
// set — enter a PIN, then repeat it to confirm; emits { kind: 'set', lock }.
// verify — enter a PIN; runs `verify(pin)` on the 4th digit; emits { kind: 'verified' }
// or shakes + clears on a wrong PIN.
// change — run `verify` on the current PIN, then offer "set a new PIN" (the set flow)
// or, when allowRemove, "remove PIN" ({ kind: 'removed' }).
// Verification is delegated to the caller's `verify` callback: at creation time it checks a
// UI-held lock (lib/pin verifyPin); in-game it delegates to the local source (which holds the
// stored lock). The pad never shows an OK button: entering the 4th digit is the action. PIN
// storage is a social lock, not cryptography — see lib/pin.ts.
import Modal from './Modal.svelte';
import { t } from '../lib/i18n/index.svelte';
import { newLock, type PinLock } from '../lib/pin';
/** PinResult is the outcome handed to onresult when the pad completes. */
export type PinResult =
| { kind: 'set'; lock: PinLock }
| { kind: 'verified' }
| { kind: 'removed' };
let {
mode,
title = '',
verify,
allowRemove = false,
onclose,
onresult,
}: {
mode: 'set' | 'verify' | 'change';
title?: string;
/** Verifies an entered PIN (required for 'verify' and 'change'). */
verify?: (pin: string) => Promise<boolean>;
allowRemove?: boolean;
onclose?: () => void;
onresult: (r: PinResult) => void;
} = $props();
const LEN = 4;
// 'old' verifies the existing PIN (change); 'menu' offers set-new / remove (change);
// 'enter' collects a PIN; 'confirm' re-enters it (set / change→new). `mode` is fixed
// for the pad's lifetime (callers mount a fresh pad per operation), so capturing its
// initial value here is intentional.
// svelte-ignore state_referenced_locally
let phase = $state<'old' | 'menu' | 'enter' | 'confirm'>(mode === 'change' ? 'old' : 'enter');
let buffer = $state('');
let first = $state(''); // the first entry, awaiting confirmation
let error = $state(false); // drives the error prompt + shake
let shakeSeq = $state(0); // bumped on each error to replay the shake animation
let busy = $state(false); // guards the async check between the 4th digit and the outcome
function promptText(): string {
if (error) return phase === 'confirm' ? t('pin.mismatch') : t('pin.wrong');
switch (phase) {
case 'old':
return t('pin.enterCurrent');
case 'confirm':
return t('pin.repeat');
default:
return mode === 'verify' ? t('pin.enter') : t('pin.create');
}
}
function fail(): void {
buffer = '';
error = true;
shakeSeq += 1;
}
async function commit(): Promise<void> {
busy = true;
try {
if (phase === 'old') {
if (verify && (await verify(buffer))) {
buffer = '';
error = false;
phase = 'menu';
} else fail();
} else if (phase === 'enter' && mode === 'verify') {
if (verify && (await verify(buffer))) onresult({ kind: 'verified' });
else fail();
} else if (phase === 'enter') {
first = buffer;
buffer = '';
error = false;
phase = 'confirm';
} else if (phase === 'confirm') {
if (buffer === first) onresult({ kind: 'set', lock: await newLock(buffer) });
else {
first = '';
phase = 'enter';
fail();
}
}
} finally {
busy = false;
}
}
function press(d: string): void {
if (busy || phase === 'menu' || buffer.length >= LEN) return;
error = false;
buffer += d;
if (buffer.length === LEN) void commit();
}
function back(): void {
if (busy || phase === 'menu') return;
buffer = buffer.slice(0, -1);
}
function startNew(): void {
error = false;
first = '';
buffer = '';
phase = 'enter';
}
function onkey(e: KeyboardEvent): void {
if (e.key >= '0' && e.key <= '9') {
press(e.key);
e.preventDefault();
} else if (e.key === 'Backspace') {
back();
e.preventDefault();
} else if (e.key === 'Escape') {
onclose?.();
}
}
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
const slots = Array.from({ length: LEN }, (_, i) => i);
</script>
<svelte:window onkeydown={onkey} />
<Modal {title} {onclose} overlayKeyboard>
<div class="pad">
{#if phase === 'menu'}
<div class="menu">
<button class="opt" onclick={startNew}>{t('pin.setNew')}</button>
{#if allowRemove}
<button class="opt danger" onclick={() => onresult({ kind: 'removed' })}>
{t('pin.remove')}
</button>
{/if}
</div>
{:else}
<p class="prompt" class:err={error}>{promptText()}</p>
{#key shakeSeq}
<div class="dots" class:shake={error} aria-hidden="true">
{#each slots as i}
<span class="dot" class:on={i < buffer.length}></span>
{/each}
</div>
{/key}
<div class="grid">
{#each keys as k}
<button class="key" onclick={() => press(k)} aria-label={k}>{k}</button>
{/each}
<span class="key spacer"></span>
<button class="key" onclick={() => press('0')} aria-label="0">0</button>
<button class="key back" onclick={back} aria-label="⌫" disabled={buffer.length === 0}>⌫</button>
</div>
{/if}
</div>
</Modal>
<style>
.pad {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding-top: 4px;
}
.prompt {
margin: 0;
font-size: 0.95rem;
color: var(--muted, var(--text));
min-height: 1.2em;
}
.prompt.err {
color: var(--danger);
}
.dots {
display: flex;
gap: 16px;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1.5px solid var(--text);
box-sizing: border-box;
}
.dot.on {
background: var(--text);
}
.shake {
animation: shake 0.32s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-7px); }
40% { transform: translateX(7px); }
60% { transform: translateX(-5px); }
80% { transform: translateX(5px); }
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.key {
width: 62px;
height: 62px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 1.4rem;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
}
.key:active {
background: var(--accent);
color: var(--accent-text);
}
.key.back {
border-color: transparent;
background: transparent;
font-size: 1.2rem;
}
.key.back:disabled {
opacity: 0.3;
}
.key.spacer {
border: none;
background: none;
pointer-events: none;
}
.menu {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.opt {
padding: 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
font-weight: 600;
}
.opt.danger {
border-color: var(--danger);
color: var(--danger);
}
</style>
+248 -18
View File
@@ -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>
+7 -1
View File
@@ -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<LocalSource, 'events' | 'create' | 'list' | 'delete'>;
} satisfies GameLoopSource &
Pick<LocalSource, 'events' | 'create' | 'list' | 'delete' | 'unlockSeat' | 'verifyHostPin' | 'hostAction'>;
/**
* gameSource returns the source that runs the game with the given id: the local engine for a local
+29
View File
@@ -389,6 +389,35 @@ export const en = {
'error.game_active': 'Available only after the game is finished.',
'error.invalid_profile': 'Some profile fields are invalid.',
'error.already_confirmed': 'This email is already confirmed.',
'pin.enter': 'Enter PIN',
'pin.create': 'Create a PIN',
'pin.repeat': 'Repeat the PIN',
'pin.enterCurrent': 'Enter current PIN',
'pin.mismatch': "PINs don't match",
'pin.wrong': 'Wrong PIN',
'pin.setNew': 'Set a new PIN',
'pin.remove': 'Remove PIN',
'hotseat.hostPin': 'Host password',
'hotseat.setPin': 'Password',
'hotseat.changePin': 'Change',
'hotseat.playerName': 'Player name',
'hotseat.addPlayer': 'Add player',
'hotseat.removePlayer': 'Remove player',
'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;
+29
View File
@@ -389,4 +389,33 @@ export const ru: Record<MessageKey, string> = {
'error.game_active': 'Доступно только после завершения игры.',
'error.invalid_profile': 'Некоторые поля профиля некорректны.',
'error.already_confirmed': 'Эта почта уже подтверждена.',
'pin.enter': 'Введите PIN',
'pin.create': 'Придумайте PIN',
'pin.repeat': 'Повторите PIN',
'pin.enterCurrent': 'Введите текущий PIN',
'pin.mismatch': 'PIN не совпадает',
'pin.wrong': 'Неверный PIN',
'pin.setNew': 'Задать новый PIN',
'pin.remove': 'Удалить пароль',
'hotseat.hostPin': 'Пароль ведущего',
'hotseat.setPin': 'Пароль',
'hotseat.changePin': 'Изменить',
'hotseat.playerName': 'Имя игрока',
'hotseat.addPlayer': 'Добавить игрока',
'hotseat.removePlayer': 'Удалить игрока',
'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': 'Отменить партию без результата?',
};
+16
View File
@@ -83,4 +83,20 @@ describe('local game serialize + replay', () => {
expect(rec.journal.length).toBe(g.moveCount);
expect(replayGame(rec, dawg).seed).toBe(9999999999n);
});
it('serialises hotseat metadata: the mode, master PIN and per-seat PINs', async () => {
const { newLock } = await import('../pin');
const hostPin = await newLock('9999');
const seatPin = await newLock('1234');
const hotseatSeats: Seat[] = [
{ kind: 'human', name: 'Ann', pin: seatPin },
{ kind: 'human', name: 'Bob' },
];
const g = makeGame(5n);
const rec = serializeGame(g, { id: 'h1', seats: hotseatSeats, hotseat: true, hostPin, createdAtUnix: 1, updatedAtUnix: 2, hintUnlockAtMs: 0 });
expect(rec.hotseat).toBe(true);
expect(rec.hostPin).toEqual(hostPin);
expect(rec.seats[0].pin).toEqual(seatPin);
expect(rec.seats[1].pin).toBeUndefined();
});
});
+14
View File
@@ -12,6 +12,7 @@
import { LocalGame, type LocalMove, type DropoutTiles, type LocalGameOptions } from './engine';
import type { Dawg } from '../dict/dawg';
import type { Variant } from '../model';
import type { PinLock } from '../pin';
/** Seat describes one player of a local game (forward-compatible with a 2-4 hotseat). */
export interface Seat {
@@ -20,6 +21,9 @@ export interface Seat {
/** The player's real account id for a human seat, so the client identifies "you" and the correct
* turn exactly as in an online game; absent for the robot (a synthetic id is derived). */
accountId?: string;
/** Offline hotseat only: the seat's optional PIN lock. When set, the seat's rack stays hidden
* until the PIN is entered (a social lock for a shared device — see lib/pin). */
pin?: PinLock;
}
/** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */
@@ -36,6 +40,12 @@ export interface LocalGameRecord {
/** The dictionary-independent, alphabet-index-space move journal. */
journal: LocalMove[];
status: 'active' | 'finished';
/** Offline pass-and-play (hotseat): 2-4 humans share one device. Absent on vs_ai records (and on
* older records predating the feature), which the source reads as false. */
hotseat?: boolean;
/** Offline hotseat only: the mandatory master (host/referee) PIN lock, needed to skip/resign a
* seat, exclude a player or terminate the game (see lib/pin). Absent on vs_ai records. */
hostPin?: PinLock;
createdAtUnix: number;
updatedAtUnix: number;
/** Wall-clock ms at which the vs_ai idle hint unlocks (the robot's last move + the window), or 0
@@ -49,6 +59,8 @@ export interface LocalGameRecord {
export interface RecordMeta {
id: string;
seats: Seat[];
hotseat?: boolean;
hostPin?: PinLock;
createdAtUnix: number;
updatedAtUnix: number;
hintUnlockAtMs: number;
@@ -71,6 +83,8 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
seats: meta.seats,
journal: game.history,
status: game.isOver ? 'finished' : 'active',
hotseat: meta.hotseat,
hostPin: meta.hostPin,
createdAtUnix: meta.createdAtUnix,
updatedAtUnix: meta.updatedAtUnix,
hintUnlockAtMs: meta.hintUnlockAtMs,
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect, vi } from 'vitest';
import type { StateView } from '../model';
// getDawg is mocked to the committed sample dictionary (the store's IndexedDB is absent under node,
// so games live in the source's in-memory cache — created once, then driven without reload).
vi.mock('../dict', () => ({
getDawg: async () => {
const { Dawg } = await import('../dict/dawg');
const { readFileSync } = await import('node:fs');
return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
},
}));
import { LocalSource } from './source';
import type { Seat } from './serialize';
import { newLock } from '../pin';
async function hotseat(id: string, seats: Seat[], hostDigits = '9999'): Promise<LocalSource> {
const src = new LocalSource();
await src.create({
id,
variant: 'scrabble_en',
dictVersion: 'sample',
seed: 3n,
multipleWords: true,
seats,
hotseat: true,
hostPin: await newLock(hostDigits),
});
return src;
}
describe('LocalSource hotseat', () => {
it('creates a pass-and-play game: not vs_ai, board visible, first seat to move', async () => {
const src = await hotseat('local:h1', [
{ kind: 'human', name: 'Ann' },
{ kind: 'human', name: 'Bob' },
]);
const st = await src.gameState('local:h1');
expect(st.game.vsAi).toBe(false);
expect(st.game.hotseat).toBe(true);
expect(st.game.toMove).toBe(0);
expect(st.locked).toBeFalsy(); // seat 0 has no PIN
expect(st.rack.length).toBe(7);
expect(st.game.seats.map((s) => s.displayName)).toEqual(['Ann', 'Bob']);
});
it('hides the rack of a PIN-locked seat until the right PIN unlocks it', async () => {
const src = await hotseat('local:h2', [
{ kind: 'human', name: 'Ann', pin: await newLock('1234') },
{ kind: 'human', name: 'Bob' },
]);
const st = await src.gameState('local:h2');
expect(st.locked).toBe(true);
expect(st.rack).toEqual([]);
await expect(src.unlockSeat('local:h2', '0000')).rejects.toMatchObject({ code: 'wrong_pin' });
const opened = await src.unlockSeat('local:h2', '1234');
expect(opened.locked).toBe(false);
expect(opened.rack.length).toBe(7);
});
it('re-locks the seat when the turn advances and again when it comes back', async () => {
const src = await hotseat('local:h3', [
{ kind: 'human', name: 'A', pin: await newLock('1111') },
{ kind: 'human', name: 'B' },
{ kind: 'human', name: 'C' },
]);
await src.unlockSeat('local:h3', '1111');
await src.pass('local:h3'); // seat 0 -> 1
let st = await src.gameState('local:h3');
expect(st.game.toMove).toBe(1);
expect(st.locked).toBeFalsy(); // seat 1 has no PIN
await src.pass('local:h3'); // 1 -> 2
await src.pass('local:h3'); // 2 -> 0
st = await src.gameState('local:h3');
expect(st.game.toMove).toBe(0);
expect(st.locked).toBe(true); // seat 0 needs re-unlocking
});
it('lets the host skip the current turn, gated by the master PIN', async () => {
const src = await hotseat('local:h4', [
{ kind: 'human', name: 'A' },
{ kind: 'human', name: 'B' },
], '4321');
expect((await src.gameState('local:h4')).game.toMove).toBe(0);
await expect(src.hostAction('local:h4', '0000', 'skip')).rejects.toMatchObject({ code: 'wrong_pin' });
const st = await src.hostAction('local:h4', '4321', 'skip');
expect(st).not.toBeNull();
expect((st as StateView).game.toMove).toBe(1);
});
it('lets the host resign a seat (ending a two-player game)', async () => {
const src = await hotseat('local:h5', [
{ kind: 'human', name: 'A' },
{ kind: 'human', name: 'B' },
], '4321');
const st = await src.hostAction('local:h5', '4321', 'resign', 1);
expect((st as StateView).game.status).toBe('finished');
});
it('lets the host terminate the game, deleting it', async () => {
const src = await hotseat('local:h6', [
{ kind: 'human', name: 'A' },
{ kind: 'human', name: 'B' },
], '4321');
const r = await src.hostAction('local:h6', '4321', 'terminate');
expect(r).toBeNull();
await expect(src.gameState('local:h6')).rejects.toMatchObject({ code: 'game_not_found' });
});
it('verifies the master PIN', async () => {
const src = await hotseat('local:h7', [
{ kind: 'human', name: 'A' },
{ kind: 'human', name: 'B' },
], '4321');
expect(await src.verifyHostPin('local:h7', '4321')).toBe(true);
expect(await src.verifyHostPin('local:h7', '0000')).toBe(false);
});
it('persists a naturally finished game (scoreless run) as finished', async () => {
const src = await hotseat('local:h8', [
{ kind: 'human', name: 'A' },
{ kind: 'human', name: 'B' },
]);
for (let i = 0; i < 6; i++) await src.pass('local:h8');
const st = await src.gameState('local:h8');
expect(st.game.status).toBe('finished');
});
});
+75 -7
View File
@@ -17,6 +17,7 @@ import { setAlphabet, hasAlphabet } from '../alphabet';
import { HINT_GATE_MS } from '../hints';
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
import { decide } from '../robot/strategy';
import { verifyPin, type PinLock } from '../pin';
import { GatewayError, type PlacedTile } from '../client';
import type {
EvalResult,
@@ -87,6 +88,9 @@ function encodePlacements(variant: Variant, tiles: PlacedTile[]): { row: number;
interface Live {
game: LocalGame;
record: LocalGameRecord;
/** Offline hotseat only: which seat is currently unlocked (its PIN was entered this turn), or null.
* Ephemeral — never persisted; cleared on every turn advance so each turn re-locks. */
unlockedSeat: number | null;
}
/**
@@ -97,7 +101,8 @@ export class LocalSource implements GameLoopSource {
private readonly live = new Map<string, Live>();
private readonly listeners = new Map<string, Set<(e: PushEvent) => void>>();
/** create starts a new local vs_ai game, persists it and returns its state. */
/** create starts a new local game — a vs_ai game or an offline pass-and-play (hotseat) game when
* hotseat is set — persists it and returns its state. hostPin is the hotseat master PIN. */
async create(opts: {
id: string;
variant: Variant;
@@ -105,6 +110,8 @@ export class LocalSource implements GameLoopSource {
seed: bigint;
multipleWords: boolean;
seats: LocalSeat[];
hotseat?: boolean;
hostPin?: PinLock;
}): Promise<StateView> {
const dawg = await getDawg(opts.variant, opts.dictVersion);
if (!dawg) throw new GatewayError('dict_unavailable');
@@ -119,12 +126,14 @@ export class LocalSource implements GameLoopSource {
const now = nowUnix();
const record = serializeGame(game, {
id: opts.id,
seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId })),
seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId, pin: s.pin })),
hotseat: opts.hotseat,
hostPin: opts.hostPin,
createdAtUnix: now,
updatedAtUnix: now,
hintUnlockAtMs: 0, // no robot move yet: a human-first opening's hint is open at once
});
const entry: Live = { game, record };
const entry: Live = { game, record, unlockedSeat: null };
this.live.set(opts.id, entry);
await saveLocalGame(record);
return this.stateView(entry);
@@ -185,10 +194,59 @@ export class LocalSource implements GameLoopSource {
const entry = await this.load(gameId);
const seat = entry.game.currentPlayer;
const move = entry.game.resign();
entry.unlockedSeat = null;
await this.persist(entry);
return this.moveResult(entry, move, seat);
}
/**
* unlockSeat reveals the current hotseat seat's rack once its PIN is entered. It verifies pin
* against the seat's stored lock (a seat with no lock unlocks with any pin) and, on success, marks
* the seat unlocked for this turn only — a subsequent move re-locks it. Throws 'wrong_pin' on a
* mismatch. Returns the freshly unlocked state (rack revealed).
*/
async unlockSeat(gameId: string, pin: string): Promise<StateView> {
const entry = await this.load(gameId);
const seat = entry.game.currentPlayer;
const lock = entry.record.seats[seat]?.pin;
if (lock && !(await verifyPin(pin, lock))) throw new GatewayError('wrong_pin');
entry.unlockedSeat = seat;
return this.stateView(entry);
}
/** verifyHostPin reports whether pin matches the hotseat master (host/referee) PIN. */
async verifyHostPin(gameId: string, pin: string): Promise<boolean> {
const entry = await this.load(gameId);
return !!entry.record.hostPin && (await verifyPin(pin, entry.record.hostPin));
}
/**
* hostAction runs a host (referee) override on a hotseat game, gated by the master PIN:
* 'skip' — pass the current seat's turn;
* 'resign' — resign seat targetSeat (exclude a player / concede on their behalf);
* 'terminate' — end the game with no result and delete it from the store.
* Returns the updated StateView, or null when the game was terminated. Throws 'wrong_pin' on a bad
* PIN.
*/
async hostAction(
gameId: string,
pin: string,
action: 'skip' | 'resign' | 'terminate',
targetSeat?: number,
): Promise<StateView | null> {
const entry = await this.load(gameId);
if (!entry.record.hostPin || !(await verifyPin(pin, entry.record.hostPin))) throw new GatewayError('wrong_pin');
if (action === 'terminate') {
await this.delete(gameId);
return null;
}
if (action === 'skip') entry.game.pass();
else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer);
entry.unlockedSeat = null;
await this.persist(entry);
return this.stateView(entry);
}
async hint(gameId: string): Promise<HintResult> {
// The idle gate is enforced client-side with a monotonic clock (see Game.svelte / lib/hints):
// a wall-clock timestamp here could be defeated by changing the device clock. This just serves
@@ -246,7 +304,7 @@ export class LocalSource implements GameLoopSource {
if (!record) throw new GatewayError('game_not_found');
const dawg = await getDawg(record.variant, record.dictVersion);
if (!dawg) throw new GatewayError('dict_unavailable');
const entry: Live = { game: replayGame(record, dawg), record };
const entry: Live = { game: replayGame(record, dawg), record, unlockedSeat: null };
this.live.set(gameId, entry);
return entry;
}
@@ -258,6 +316,7 @@ export class LocalSource implements GameLoopSource {
const entry = await this.load(gameId);
const seat = entry.game.currentPlayer;
const move = act(entry.game);
entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai)
const robotMoves = this.runRobots(entry);
await this.persist(entry);
const result = this.moveResult(entry, move, seat);
@@ -306,6 +365,8 @@ export class LocalSource implements GameLoopSource {
entry.record = serializeGame(entry.game, {
id: entry.record.id,
seats: entry.record.seats,
hotseat: entry.record.hotseat,
hostPin: entry.record.hostPin,
createdAtUnix: entry.record.createdAtUnix,
updatedAtUnix: nowUnix(),
hintUnlockAtMs: entry.record.hintUnlockAtMs,
@@ -351,9 +412,14 @@ export class LocalSource implements GameLoopSource {
return leftMs > 0 ? Math.ceil(leftMs / 1000) : 0;
}
// stateView is the seated player's private view. For a vs_ai game that is the single human seat; for
// a hotseat game it is the seat to move — and when that seat carries a PIN, the rack is withheld
// (empty, locked=true) until unlockSeat is called this turn.
private stateView(entry: Live): StateView {
const seat = this.humanSeat(entry);
const rack = entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t));
const hotseat = !!entry.record.hotseat;
const seat = hotseat ? entry.game.currentPlayer : this.humanSeat(entry);
const locked = hotseat && !!entry.record.seats[seat]?.pin && entry.unlockedSeat !== seat;
const rack = locked ? [] : entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t));
return {
game: this.gameView(entry),
seat,
@@ -362,6 +428,7 @@ export class LocalSource implements GameLoopSource {
hintsRemaining: 1,
walletBalance: 0,
hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
locked,
};
}
@@ -401,7 +468,8 @@ export class LocalSource implements GameLoopSource {
moveCount: game.moveCount,
endReason: game.isOver ? game.endReason : '',
lastActivityUnix: record.updatedAtUnix,
vsAi: true,
vsAi: !record.hotseat,
hotseat: !!record.hotseat,
unreadChat: false,
unreadMessages: false,
seats,
+7
View File
@@ -47,6 +47,10 @@ export interface GameView {
lastActivityUnix: number;
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
vsAi: boolean;
/** true = a local offline pass-and-play (hotseat) game: 2-4 humans share one device. Set only by
* the local source; the online gateway never sets it. Drives the per-seat PIN-lock UI and the host
* menu (which replaces the hint control). A hotseat game is never vsAi. */
hotseat?: boolean;
/** Per-viewer flag: the requesting player has at least one unread chat entry (message or
* nudge) in this game. Set on the authoritative REST views (lobby list, game state,
* move result); the live-event GameView leaves it false (events bump unread instead). */
@@ -87,6 +91,9 @@ export interface StateView {
* non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt,
* so a client clock change cannot skew it; a relaunch re-fetches a fresh value. See lib/hints. */
hintUnlockLeftSeconds?: number;
/** Offline hotseat only: the seat to move is PIN-locked, so its rack is withheld (empty) until the
* seat's PIN is entered (see LocalSource.unlockSeat). Absent/false everywhere else. */
locked?: boolean;
}
export interface MoveResult {
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { hashPin, newLock, newSalt, verifyPin } from './pin';
describe('hashPin', () => {
it('is deterministic for the same pin and salt', async () => {
const salt = 'fixedsalt';
expect(await hashPin('1234', salt)).toBe(await hashPin('1234', salt));
});
it('differs when the salt differs', async () => {
expect(await hashPin('1234', 'saltA')).not.toBe(await hashPin('1234', 'saltB'));
});
it('differs when the pin differs', async () => {
const salt = 'fixedsalt';
expect(await hashPin('1234', salt)).not.toBe(await hashPin('4321', salt));
});
});
describe('newSalt', () => {
it('returns a non-empty value', () => {
expect(newSalt().length).toBeGreaterThan(0);
});
it('returns a fresh value each call', () => {
expect(newSalt()).not.toBe(newSalt());
});
});
describe('newLock / verifyPin', () => {
it('produces a lock that verifies its own pin', async () => {
const lock = await newLock('1234');
expect(lock.hash.length).toBeGreaterThan(0);
expect(lock.salt.length).toBeGreaterThan(0);
expect(await verifyPin('1234', lock)).toBe(true);
});
it('rejects a wrong pin', async () => {
const lock = await newLock('1234');
expect(await verifyPin('0000', lock)).toBe(false);
});
it('salts each lock independently (same pin, different hashes)', async () => {
const a = await newLock('1234');
const b = await newLock('1234');
expect(a.salt).not.toBe(b.salt);
expect(a.hash).not.toBe(b.hash);
});
});
+45
View File
@@ -0,0 +1,45 @@
// Seat / host PIN locks for offline pass-and-play (hotseat) games.
//
// A PinLock is a salted SHA-256 digest of a 4-digit PIN. This is a SOCIAL lock for a
// shared device — not cryptographic protection. A 4-digit PIN has only 10^4
// combinations and the whole game runs on the client, so a determined peek reads the
// racks straight from memory regardless. The salt only keeps the PIN itself out of a
// casual devtools glance and defeats precomputed tables; it does not make the PIN
// brute-force resistant.
/** PinLock is a stored, salted SHA-256 digest of a PIN (see the file header caveat). */
export interface PinLock {
hash: string;
salt: string;
}
// b64url encodes bytes as base64url without padding (matches the idiom in vkid.ts).
function b64url(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** newSalt returns a fresh random base64url salt for a PinLock. */
export function newSalt(): string {
const a = new Uint8Array(16);
crypto.getRandomValues(a);
return b64url(a);
}
/** hashPin returns the base64url SHA-256 digest binding salt to pin. */
export async function hashPin(pin: string, salt: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${salt}:${pin}`));
return b64url(new Uint8Array(digest));
}
/** newLock builds a PinLock for pin with a fresh salt. */
export async function newLock(pin: string): Promise<PinLock> {
const salt = newSalt();
return { salt, hash: await hashPin(pin, salt) };
}
/** verifyPin reports whether pin matches the digest stored in lock. */
export async function verifyPin(pin: string, lock: PinLock): Promise<boolean> {
return (await hashPin(pin, lock.salt)) === lock.hash;
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { buildSeats, commitName, rosterReady, type RosterRow } from './roster';
describe('commitName (keep-last-valid)', () => {
it('commits a valid, trimmed name', () => {
expect(commitName('Ann', '')).toBe('Ann');
expect(commitName(' Ann ', '')).toBe('Ann');
});
it('keeps the previous valid name when the input goes invalid', () => {
expect(commitName('', 'Ann')).toBe('Ann');
expect(commitName(' ', 'Ann')).toBe('Ann');
expect(commitName('a'.repeat(33), 'Ann')).toBe('Ann'); // over the 32-rune cap
expect(commitName('_bad', 'Ann')).toBe('Ann'); // leading separator
});
it('replaces the committed name with a newer valid one', () => {
expect(commitName('Bob', 'Ann')).toBe('Bob');
});
});
describe('rosterReady', () => {
const row = (committed: string): RosterRow => ({ name: committed, committed });
it('needs 2-4 rows, each with a committed name', () => {
expect(rosterReady([row('A'), row('B')])).toBe(true);
expect(rosterReady([row('A'), row('B'), row('C'), row('D')])).toBe(true);
});
it('rejects fewer than 2, more than 4, or an empty row', () => {
expect(rosterReady([row('A')])).toBe(false);
expect(rosterReady([row('A'), row('B'), row('C'), row('D'), row('E')])).toBe(false);
expect(rosterReady([row('A'), row('')])).toBe(false);
});
});
describe('buildSeats', () => {
it('maps committed rows to account-less human seats, carrying the PIN', () => {
const lock = { hash: 'h', salt: 's' };
const seats = buildSeats([
{ name: 'Ann', committed: 'Ann', pin: lock },
{ name: 'x', committed: 'Bob' },
]);
expect(seats).toEqual([
{ kind: 'human', name: 'Ann', pin: lock },
{ kind: 'human', name: 'Bob', pin: undefined },
]);
expect(seats.every((s) => !('accountId' in s) || s.accountId === undefined)).toBe(true);
});
});
+32
View File
@@ -0,0 +1,32 @@
// Pure helpers for the offline hotseat (pass-and-play) creation roster: keeping a last-valid name as
// a row is edited, and turning the finished roster into engine seats. Kept out of the component so it
// is unit-testable; the form itself is exercised by the e2e layer.
import { validDisplayName } from './profileValidation';
import type { PinLock } from './pin';
import type { Seat } from './localgame/serialize';
/** RosterRow is one editable player row: the live input, the last valid name kept when the input
* goes invalid, and the optional per-seat PIN lock. */
export interface RosterRow {
name: string;
committed: string;
pin?: PinLock;
}
/** commitName returns the new last-valid name: the trimmed input when it is a valid display name,
* otherwise prevCommitted unchanged — so editing a set name into an invalid one keeps the old one. */
export function commitName(input: string, prevCommitted: string): string {
return validDisplayName(input) ? input.trim() : prevCommitted;
}
/** rosterReady reports whether the roster can start a game: 2-4 rows, each with a committed name. */
export function rosterReady(rows: RosterRow[]): boolean {
return rows.length >= 2 && rows.length <= 4 && rows.every((r) => r.committed !== '');
}
/** buildSeats turns committed roster rows into engine seats — all human, account-less (local
* players sharing a device), carrying each row's optional PIN. */
export function buildSeats(rows: RosterRow[]): Seat[] {
return rows.map((r): Seat => ({ kind: 'human', name: r.committed, pin: r.pin }));
}
+37 -9
View File
@@ -4,6 +4,7 @@
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import Modal from '../components/Modal.svelte';
import PinPad from '../components/PinPad.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
@@ -186,6 +187,9 @@
// revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
// per-account and irreversible. Only one row is revealed at a time.
let revealedId = $state<string | null>(null);
// A hotseat game's deletion is gated by the host (master) PIN — both active (terminate the game)
// and finished; pinTarget holds the game id while its PIN pad is open.
let pinTarget = $state<string | null>(null);
let drag: { id: string; x0: number; y0: number } | null = null;
// A horizontal swipe must not also count as a tap that opens the game; armed on swipe,
// consumed by the next tap, and reset on the next pointerdown so a later tap is never eaten.
@@ -196,13 +200,14 @@
if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe
drag = { id, x0: e.clientX, y0: e.clientY };
}
function onRowUp(e: PointerEvent, finished: boolean): void {
function onRowUp(e: PointerEvent, revealable: boolean): void {
if (!drag) return;
const dx = e.clientX - drag.x0;
const dy = e.clientY - drag.y0;
// Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it
// does not also open the game. Active rows ignore swipes and stay plain tap-to-open.
if (finished && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
// Only a revealable row (a finished game, or any hotseat game) reveals its delete on a horizontal
// swipe; that swipe then suppresses the tap so it does not also open the game. Other active rows
// ignore swipes and stay plain tap-to-open.
if (revealable && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
swiped = true;
revealedId = dx < 0 ? drag.id : null;
}
@@ -240,6 +245,14 @@
}
}
// startDelete routes the row's delete: a hotseat game (active or finished) is gated behind the host
// master PIN (so the last mover cannot instantly wipe the game); any other finished game deletes at
// once.
function startDelete(g: GameView): void {
if (g.hotseat) pinTarget = g.id;
else void hide(g.id);
}
async function acceptInvite(inv: Invitation) {
try {
const r = await gateway.invitationAccept(inv.id);
@@ -315,15 +328,15 @@
<div class="list">
{#each group.list as g (g.id)}
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
{#if group.finished}
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id}>
{#if group.finished || g.hotseat}
<button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button>
{/if}
<div class="row">
<button
class="open"
onpointerdown={(e) => onRowDown(e, g.id)}
onpointerup={(e) => onRowUp(e, group.finished)}
onpointerup={(e) => onRowUp(e, group.finished || !!g.hotseat)}
onclick={() => openGame(g)}
>
<span class="info">
@@ -345,7 +358,7 @@
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{resultBadge(g, myId).emoji}</span>
{/key}
</button>
{#if group.finished}
{#if group.finished || g.hotseat}
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}></button>
{:else}
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
@@ -378,6 +391,21 @@
</Modal>
{/if}
<!-- Deleting a hotseat game (active or finished) is gated by the host master PIN. -->
{#if pinTarget}
<PinPad
mode="verify"
title={t('hotseat.hostPin')}
verify={(p) => localSource.verifyHostPin(pinTarget ?? '', p)}
onclose={() => (pinTarget = null)}
onresult={() => {
const id = pinTarget;
pinTarget = null;
if (id) void hide(id);
}}
/>
{/if}
{#snippet tabbar()}
<TabBar>
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
+280 -3
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import Modal from '../components/Modal.svelte';
import PinPad, { type PinResult } from '../components/PinPad.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
@@ -9,6 +11,9 @@
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { validDisplayName } from '../lib/profileValidation';
import { verifyPin, type PinLock } from '../lib/pin';
import { commitName, rosterReady, buildSeats, type RosterRow } from '../lib/roster';
import type { AccountRef, Variant } from '../lib/model';
import {
availableVariants,
@@ -144,13 +149,115 @@
}
}
// --- offline hotseat (pass-and-play) ---
// A device-local 2-4 human game. The host (referee) sets a mandatory master PIN first — it gates
// the roster and, in-game, the host overrides; each player row optionally locks its seat with a PIN.
let hostPin = $state<PinLock | undefined>(undefined);
let rows = $state<RosterRow[]>([
{ name: '', committed: '' },
{ name: '', committed: '' },
]);
let askHostPlays = $state(false); // the "do you take a seat?" prompt shown once the host PIN is set
// The PIN-pad overlay target: what a completed PIN applies to.
type PadTarget =
| { kind: 'host-set' }
| { kind: 'host-change' }
| { kind: 'seat-set'; index: number }
| { kind: 'seat-change'; index: number }
| { kind: 'row-delete'; index: number };
let pad = $state<PadTarget | null>(null);
const padMode = (p: PadTarget): 'set' | 'verify' | 'change' =>
p.kind === 'host-set' || p.kind === 'seat-set' ? 'set' : p.kind === 'row-delete' ? 'verify' : 'change';
function padVerify(p: PadTarget): ((pin: string) => Promise<boolean>) | undefined {
if (p.kind === 'host-change' || p.kind === 'row-delete') return (pin) => verifyPin(pin, hostPin!);
if (p.kind === 'seat-change') return (pin) => verifyPin(pin, rows[p.index].pin!);
return undefined; // set flows need no verifier
}
const padTitle = (p: PadTarget): string =>
p.kind === 'host-set' || p.kind === 'host-change' || p.kind === 'row-delete'
? t('hotseat.hostPin')
: t('hotseat.setPin');
function onPad(r: PinResult): void {
const target = pad;
pad = null;
if (!target) return;
switch (target.kind) {
case 'host-set':
if (r.kind === 'set') {
hostPin = r.lock;
askHostPlays = true;
}
break;
case 'host-change':
if (r.kind === 'set') hostPin = r.lock; // the host PIN is mandatory — no remove option
break;
case 'seat-set':
if (r.kind === 'set') rows[target.index].pin = r.lock;
break;
case 'seat-change':
if (r.kind === 'set') rows[target.index].pin = r.lock;
else if (r.kind === 'removed') rows[target.index].pin = undefined;
break;
case 'row-delete':
if (r.kind === 'verified') rows = rows.filter((_, j) => j !== target.index);
break;
}
}
function setHostPlays(yes: boolean): void {
askHostPlays = false;
if (!yes) return;
// Seat the host at row 0, its name reused from the profile (a normal seat — its optional seat PIN
// is separate from the master PIN).
const committed = commitName(app.profile?.displayName ?? '', '');
rows[0] = { name: committed, committed };
}
function openSeatPin(i: number): void {
pad = rows[i].pin ? { kind: 'seat-change', index: i } : { kind: 'seat-set', index: i };
}
async function startHotseat(): Promise<void> {
if (starting || !hostPin || !inviteVariant || !rosterReady(rows)) return;
starting = true;
try {
const version = app.profile?.dictVersions?.[inviteVariant];
if (!version) {
handleError(new Error('dict_unavailable'));
return;
}
const id = newLocalGameId();
// Snapshot the roster + PINs to plain objects: the rows/pins are $state proxies, and a proxy
// fails structured-clone when the record is persisted to IndexedDB (silently, since the store
// is best-effort) — so the game would vanish on reload. See lib/localgame/store.
await localSource.create({
id,
variant: inviteVariant,
dictVersion: version,
seed: randomSeed(),
multipleWords: multipleWordsForRequest(inviteVariant, multipleWords),
seats: $state.snapshot(buildSeats(rows)),
hotseat: true,
hostPin: hostPin ? $state.snapshot(hostPin) : undefined,
});
navigate(`/game/${id}`);
} catch (e) {
handleError(e);
} finally {
starting = false;
}
}
</script>
<Screen title={t('new.title')} back="/">
<div class="page">
<!-- Offline mode offers only a local vs_ai game: the friends flow needs the network, so the
auto/friends selector is hidden and the auto (vs_ai) flow shows on its own. -->
{#if !guest && !offlineMode.active}
<!-- The auto/friends selector. Online it is hidden for guests (no friends to invite). Offline
it is always shown: "quick" is the local vs_ai flow, "with friends" is the local
pass-and-play (hotseat) flow. -->
{#if !guest || offlineMode.active}
<div class="seg modes">
<button class="opt" class:active={mode === 'auto'} onclick={() => (mode = 'auto')}>{t('new.auto')}</button>
<button class="opt" class:active={mode === 'friends'} onclick={() => (mode = 'friends')}>{t('new.withFriends')}</button>
@@ -198,6 +305,68 @@
disabled={!selectedAuto || (!connection.online && !offlineMode.active) || starting}
onclick={() => selectedAuto && find(selectedAuto)}
>{t('new.start')}</button>
{:else if offlineMode.active}
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
PIN first (it gates the roster); each seat may add its own PIN. -->
<div class="fg">
<div class="hostpin">
<span class="ftitle">{t('hotseat.hostPin')}</span>
<button class="plink" onclick={() => (pad = hostPin ? { kind: 'host-change' } : { kind: 'host-set' })}>
{hostPin ? t('hotseat.changePin') : t('hotseat.setPin')}
</button>
</div>
<label class="field">
<span>{t('new.gameType')}</span>
<select bind:value={inviteVariant} class:placeholder={!inviteVariant} disabled={variants.length === 1}>
<option value="" disabled></option>
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
</select>
</label>
{#if inviteVariant && supportsMultipleWordsToggle(inviteVariant)}
<label class="toggle">
<span>{t('new.multipleWordsPerTurn')}</span>
<input type="checkbox" bind:checked={multipleWords} />
</label>
{/if}
<div class="roster">
{#each rows as row, i (i)}
<div class="prow">
<input
class="pname"
class:invalid={row.name.trim() !== '' && !validDisplayName(row.name)}
bind:value={row.name}
disabled={!hostPin}
placeholder={t('hotseat.playerName')}
maxlength="40"
oninput={() => (row.committed = commitName(row.name, row.committed))}
onblur={() => (row.name = row.committed)}
/>
<button class="plink" disabled={!hostPin || row.committed === ''} onclick={() => openSeatPin(i)}>
{row.pin ? t('hotseat.changePin') : t('hotseat.setPin')}
</button>
{#if rows.length > 2}
<button
class="pkebab"
disabled={!hostPin}
aria-label={t('hotseat.removePlayer')}
onclick={() => (pad = { kind: 'row-delete', index: i })}
>⋮</button>
{/if}
</div>
{/each}
</div>
{#if rows.length < 4}
<button class="addrow" disabled={!hostPin} onclick={() => (rows = [...rows, { name: '', committed: '' }])}>
+ {t('hotseat.addPlayer')}
</button>
{/if}
<div class="grow"></div>
<button
class="invite"
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
onclick={startHotseat}
>{t('new.start')}</button>
</div>
{:else if friends.length === 0}
<p class="subtitle">{t('new.noFriends')}</p>
{:else}
@@ -245,6 +414,25 @@
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
</div>
{/if}
{#if pad}
<PinPad
mode={padMode(pad)}
title={padTitle(pad)}
verify={padVerify(pad)}
allowRemove={pad.kind === 'seat-change'}
onclose={() => (pad = null)}
onresult={onPad}
/>
{/if}
{#if askHostPlays}
<Modal title={t('hotseat.hostPlaysTitle')} onclose={() => (askHostPlays = false)}>
<div class="hostplays">
<button class="ghost" onclick={() => setHostPlays(false)}>{t('hotseat.hostPlaysNo')}</button>
<button class="primary" onclick={() => setHostPlays(true)}>{t('hotseat.hostPlaysYes')}</button>
</div>
</Modal>
{/if}
</div>
</Screen>
@@ -450,4 +638,93 @@
font-size: 0.85rem;
line-height: 1.4;
}
/* --- offline hotseat roster --- */
.hostpin {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 11px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-sm);
}
.roster {
display: flex;
flex-direction: column;
gap: 8px;
}
.prow {
display: flex;
align-items: center;
gap: 8px;
}
.pname {
flex: 1;
min-width: 0;
padding: 10px 11px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.pname:disabled {
opacity: 0.5;
}
.pname.invalid {
border-color: var(--danger);
}
.plink {
flex: 0 0 auto;
padding: 8px 10px;
border: none;
background: none;
color: var(--accent);
font-weight: 600;
white-space: nowrap;
}
.plink:disabled {
color: var(--text-muted);
opacity: 0.6;
}
.pkebab {
flex: 0 0 auto;
padding: 6px 8px;
border: none;
background: none;
color: var(--text-muted);
font-size: 1.1rem;
line-height: 1;
}
.addrow {
align-self: flex-start;
padding: 8px 12px;
border: 1px dashed var(--border);
background: none;
color: var(--accent);
border-radius: var(--radius-sm);
font-weight: 600;
}
.addrow:disabled {
opacity: 0.5;
color: var(--text-muted);
}
.hostplays {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.hostplays button {
padding: 10px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.hostplays .primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
</style>