Merge pull request 'feat(offline): local pass-and-play (hotseat) — 2-4 players, seat/host PINs' (#211) from feature/offline-hotseat into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped

This commit was merged in pull request #211.
This commit is contained in:
2026-07-07 14:27:57 +00:00
30 changed files with 1934 additions and 111 deletions
+25 -6
View File
@@ -1242,17 +1242,36 @@ browser-language detection — crawlers render with arbitrary languages). The fa
built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
**web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It **web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
**precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with **precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
no network, and falls every in-scope navigation back to the precached shell (the hash router no network. Shell **navigations are network-first** the fresh `no-cache` shell is fetched from the
resolves the route client-side); the landing page and the conditional polyfill bundle are excluded, server (so a new deploy is picked up on the very next launch, not the one after), falling back to the
and the Connect stream and runtime API POSTs are never precached nor intercepted, so the live app is precached shell only when the network is unreachable or too slow; the immutable hashed assets stay
never served stale. This satisfies Chromium's installability requirement (a registered SW, needed cache-first, and the hash router resolves the route client-side. This navigation route is registered
**before** the precache route on purpose: Workbox matches in registration order, and the precache
route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve the shell
cache-first — the old build's version until a second load. The landing page and the conditional
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those 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 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 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 locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
or hidden. A `vs_ai` hint (online and offline alike) is unlimited and wallet-free but idle-gated 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 (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 **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), 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** 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 (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 *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). disabled or hidden (the Stats tab; the *random opponent* option in New Game).
Only **vs_ai** games are playable — New Game creates a device-local game against the robot, which 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 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, 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 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 (the download keeps running in the background, so a later switch is instant). The mode is
device-scoped and sticky across launches. 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 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 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 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 со входом по подтверждённой почте может переключиться в осознанный Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с **офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
отключены или скрыты (вкладка Статистика; варианты «с другом» и «случайный соперник» в Новой игре). отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
Играть можно только в **vs_ai** — Новая игра создаёт локальную игру против робота, который ходит Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки) заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
@@ -285,6 +285,21 @@ e-mail) либо ввод фразы. Активные игры форфейтя
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
сохраняется между запусками. сохраняется между запусками.
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 24 человек
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
дать любому месту **свой необязательный PIN**. Во время игры доска видна всегда, но место с PIN
вместо стойки показывает кнопку **«Разблокировать»**, пока владелец не введёт PIN, — так устройство
передаётся из рук в руки, и только защищённое место прячет свои буквы; на каждом ходу замок
возвращается. **Ведущий** (кнопка ведущего в игре) может по паролю ведущего **пропустить** ход
текущего игрока, **исключить** игрока или **завершить игру досрочно** — досрочное завершение стирает
партию без победителей и проигравших. Подсказки и чат в игре по очереди отключены. Нормально
завершённая партия сохраняется в офлайн-лобби как любая другая; удаление любой игры по очереди —
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
своего хода.
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить** несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
+126
View File
@@ -0,0 +1,126 @@
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).
// It first waits for the dots to be empty, so a call made right after a previous entry does not lose
// digits during the 250 ms verdict pause (the 4th digit → pause → clear/advance).
async function typePin(page: Page, digits: string): Promise<void> {
await expect(page.locator('.pad .dot.on')).toHaveCount(0);
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 seed: the bag deals a full rack deterministically, AND the seeded seating shuffle keeps
// the roster order (Ann first) so the locked-seat assertions below are stable. (Seat order is
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
// 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 (the plaques mirror Quick Match; "Scrabble" is the Latin English name).
await page.locator('.variant', { hasText: 'Scrabble' }).click();
// Two players: Ann (PIN-locked) and Bob (open).
await page.locator('.pname').nth(0).fill('Ann');
await page.locator('.pname').nth(1).fill('Bob');
// Row-delete: a 3rd player's kebab asks the host PIN, which ARMS a ❌ (not a silent delete);
// tapping the ❌ removes the row.
await page.getByRole('button', { name: /Add player|Добавить/i }).click();
await expect(page.locator('.prow')).toHaveCount(3);
await page.locator('.prow').nth(2).locator('.pkebab').click();
await typePin(page, '9999');
const rowDel = page.locator('.prow').nth(2).locator('.prow-del');
await expect(rowDel).toBeVisible();
await rowDel.click();
await expect(page.locator('.prow')).toHaveCount(2);
// Lock Ann's seat with a PIN.
await page.locator('.prow').nth(0).locator('.plink').click();
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);
// Opening the history reveals NO social controls on the seat plaques (a local game is
// account-less), and the Dictionary entry (the comms button) is kept for the active game.
await page.locator('.scoreboard').click();
await expect(page.locator('.fico')).toHaveCount(0);
await expect(page.locator('.chat-ico')).toBeVisible();
await page.locator('.scoreboard').click();
// 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);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { test, expect, type Page } from './fixtures';
// Playwright's WebKit has no real soft keyboard, so emulate iOS's visual-viewport behaviour: replace
// window.visualViewport with a controllable fake BEFORE the app boots. The app's syncViewport
// (app.svelte.ts) reads visualViewport.height/offsetTop and mirrors them into --vvh / --vv-top, which
// position the pinned app-shell (app.css html.app-shell body). Driving the fake exercises the exact
// code path the real keyboard triggers on iOS — where the layout viewport does NOT shrink and the
// visual viewport instead offsets down toward the focused field.
async function installFakeViewport(page: Page): Promise<void> {
await page.addInitScript(() => {
const fake = new EventTarget() as EventTarget & { height: number; offsetTop: number; width: number };
fake.height = window.innerHeight;
fake.offsetTop = 0;
fake.width = window.innerWidth;
Object.defineProperty(window, 'visualViewport', { configurable: true, value: fake });
(window as unknown as { __vv: typeof fake }).__vv = fake;
});
}
async function setViewport(page: Page, height: number, offsetTop: number): Promise<void> {
await page.evaluate(
([h, t]) => {
const vv = (window as unknown as { __vv: { height: number; offsetTop: number; dispatchEvent(e: Event): boolean } }).__vv;
vv.height = h;
vv.offsetTop = t;
vv.dispatchEvent(new Event('resize'));
vv.dispatchEvent(new Event('scroll'));
},
[height, offsetTop],
);
}
async function shell(page: Page): Promise<{ vvh: string; top: string; bodyTop: string }> {
return page.evaluate(() => ({
vvh: getComputedStyle(document.documentElement).getPropertyValue('--vvh').trim(),
top: getComputedStyle(document.documentElement).getPropertyValue('--vv-top').trim(),
bodyTop: getComputedStyle(document.body).top,
}));
}
test.describe('visual-viewport shell (soft-keyboard alignment)', () => {
test('the pinned shell follows the visual viewport height AND offset', async ({ page }) => {
await installFakeViewport(page);
await page.goto('/');
await expect(page.locator('html.app-shell')).toBeAttached();
const innerH = await page.evaluate(() => window.innerHeight);
// Keyboard closed: full height, no offset.
await setViewport(page, innerH, 0);
let s = await shell(page);
expect(s.top).toBe('0px');
expect(s.bodyTop).toBe('0px');
// Keyboard open + iOS offset: the visual viewport shrinks AND offsets down. The pinned shell must
// follow BOTH — the body's top must equal the offset (not stay at 0), else the top-anchored
// content shows empty space below (the iOS bug this fixes).
await setViewport(page, innerH - 300, 180);
s = await shell(page);
expect(s.vvh).toBe(`${innerH - 300}px`);
expect(s.top).toBe('180px');
expect(s.bodyTop).toBe('180px');
// Keyboard closed again: the offset reverts to 0 — no stale shift left behind.
await setViewport(page, innerH, 0);
s = await shell(page);
expect(s.top).toBe('0px');
expect(s.bodyTop).toBe('0px');
});
});
+7 -4
View File
@@ -23,10 +23,13 @@ const DIST = 'dist';
// wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, to 114 for // wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, to 114 for
// the offline auto-detect (the cold-start reachability check and the "no connection" dialog live in // the offline auto-detect (the cold-start reachability check and the "no connection" dialog live in
// the boot path), then to 115 for the vs_ai idle-hint gate — its monotonic clock, lock badge and // the boot path), then to 115 for the vs_ai idle-hint gate — its monotonic clock, lock badge and
// countdown toast live in the always-loaded game screen (Game.svelte). The heavy parts — the dict // countdown toast live in the always-loaded game screen (Game.svelte) — and to 120 for the offline
// loader, the move generator and the preload orchestration — still stay in lazy chunks. Scoped CSS // pass-and-play (hotseat) mode: the on-screen PIN pad, the creation roster and the in-game host menu
// lands in the CSS chunk, not this JS budget. // live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
const BUDGET = { app: 115, shared: 30, landing: 5 }; // hashing stay in lazy chunks / are negligible). The heavy parts — the dict loader, the move
// generator and the preload orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS
// chunk, not this JS budget.
const BUDGET = { app: 120, shared: 30, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing. // local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+9 -1
View File
@@ -162,7 +162,15 @@ html.app-shell {
} }
html.app-shell body { html.app-shell body {
position: fixed; position: fixed;
inset: 0; /* Follow the visual viewport (top + height), not merely fill the layout viewport: iOS Safari /
WKWebView does not shrink the layout viewport for the soft keyboard — it offsets the visual
viewport down toward the focused field — so a top-anchored (inset:0) shell would misalign, showing
empty space below the content (worst on a repeat focus). --vv-top / --vvh are kept in sync from
app.svelte.ts (syncViewport). The fallbacks equal inset:0 before the first sync / on the landing. */
top: var(--vv-top, 0px);
left: 0;
right: 0;
height: var(--vvh, 100%);
overflow: hidden; overflow: hidden;
} }
+268
View File
@@ -0,0 +1,268 @@
<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 {
// Let the 4th dot register before the verdict: a beat so the fill (and, on failure, the shake)
// reads as a deliberate response rather than an instant flicker.
await new Promise((r) => setTimeout(r, 250));
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>
+12 -9
View File
@@ -16,18 +16,21 @@
// The game is rendered (and cached) before its comms open, so the cache tells us whether // The game is rendered (and cached) before its comms open, so the cache tells us whether
// it is still active without another fetch; an unknown game keeps the Dictionary offered. // it is still active without another fetch; an unknown game keeps the Dictionary offered.
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished'); const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
// An honest-AI game has no chat at all, so its hub is Dictionary-only. // A local game has NO chat: an honest-AI game (vs_ai) and an offline pass-and-play (hotseat) game.
const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false); // Its hub is Dictionary-only.
const chatless = $derived(
(getCachedGame(id)?.view.game.vsAi ?? false) || (getCachedGame(id)?.view.game.hotseat ?? false),
);
// Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid: // Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid:
// an AI game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary // a chatless game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary
// deep-link falls back to Chat). // deep-link falls back to Chat).
// An honest-AI game has only the Dictionary, so start there regardless of the entry route — this // A chatless game starts on the Dictionary regardless of the entry route — this keeps ChatScreen
// keeps ChatScreen (which fetches chat over the network) from mounting even for a beat, which would // (which fetches chat over the network) from mounting even for a beat, which would otherwise raise
// otherwise raise an error toast in offline mode. // an error toast in offline mode.
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let tab = $state<CommsTab>(vsAi ? 'dictionary' : initialTab); let tab = $state<CommsTab>(chatless ? 'dictionary' : initialTab);
$effect(() => { $effect(() => {
if (vsAi) tab = 'dictionary'; if (chatless) tab = 'dictionary';
else if (tab === 'dictionary' && !active) tab = 'chat'; else if (tab === 'dictionary' && !active) tab = 'chat';
}); });
</script> </script>
@@ -46,7 +49,7 @@
{#snippet tabbar()} {#snippet tabbar()}
<TabBar> <TabBar>
{#if !vsAi} {#if !chatless}
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}> <button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span> <span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
</button> </button>
+267 -15
View File
@@ -4,6 +4,7 @@
import TabBar from '../components/TabBar.svelte'; import TabBar from '../components/TabBar.svelte';
import TapConfirm from '../components/TapConfirm.svelte'; import TapConfirm from '../components/TapConfirm.svelte';
import Modal from '../components/Modal.svelte'; import Modal from '../components/Modal.svelte';
import PinPad from '../components/PinPad.svelte';
import DictWarmup from '../components/DictWarmup.svelte'; import DictWarmup from '../components/DictWarmup.svelte';
import Board from './Board.svelte'; import Board from './Board.svelte';
import Rack from './Rack.svelte'; import Rack from './Rack.svelte';
@@ -19,6 +20,7 @@
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
import { lastMoveCells, replay } from '../lib/board'; import { lastMoveCells, replay } from '../lib/board';
import { badgeKind } from '../lib/unread'; import { badgeKind } from '../lib/unread';
import { seatMedal } from '../lib/result';
import { historyGrid } from '../lib/history'; import { historyGrid } from '../lib/history';
import { centre, premiumGrid } from '../lib/premiums'; import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants'; import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
@@ -163,6 +165,11 @@
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open')); const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat); const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status === 'finished'); 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 // 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 // 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). // hint was spent in another game (see lib/hints).
@@ -670,6 +677,10 @@
void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
} }
localUnsub?.(); localUnsub?.();
// Leaving a hotseat game re-locks the current seat, so returning from the lobby re-prompts its
// PIN. Read the id from the loaded view, NOT the `id` prop: a $props() value reads back undefined
// during Svelte teardown, and isLocalGameId(undefined) would throw and abort this cleanup.
if (view?.game.hotseat) localSource.relock(view.game.id);
}); });
function onCell(row: number, col: number) { function onCell(row: number, col: number) {
@@ -774,8 +785,8 @@
// rapid placements do not pile up requests on a slow link. // rapid placements do not pile up requests on a slow link.
evalCtrl?.abort(); evalCtrl?.abort();
evalCtrl = null; evalCtrl = null;
// Off-turn the composition is position-only: no score preview or evaluate. // Off-turn (or a locked hotseat seat) the composition is position-only: no score preview.
if (!isMyTurn) return; if (!canMove) return;
const sub = toSubmit(placement); const sub = toSubmit(placement);
if (!sub) return; if (!sub) return;
// Instant on-device preview when the game's dictionary is warm; the network otherwise. // Instant on-device preview when the game's dictionary is warm; the network otherwise.
@@ -833,12 +844,106 @@
refreshRecent(); 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() { async function commit() {
const sub = toSubmit(placement); const sub = toSubmit(placement);
if (!sub) return; if (!sub) return;
busy = true; busy = true;
try { try {
applyMoveResult(await source.submitPlay(id, sub.tiles, variant)); applyMoveResult(await source.submitPlay(id, sub.tiles, variant));
if (view?.game.hotseat) await advanceHotseat();
haptic('success'); haptic('success');
zoomed = false; zoomed = false;
} catch (e) { } catch (e) {
@@ -858,6 +963,7 @@
busy = true; busy = true;
try { try {
applyMoveResult(await source.pass(id)); applyMoveResult(await source.pass(id));
if (view?.game.hotseat) await advanceHotseat();
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} finally { } finally {
@@ -979,6 +1085,7 @@
busy = true; busy = true;
try { try {
applyMoveResult(await source.exchange(id, tiles, variant)); applyMoveResult(await source.exchange(id, tiles, variant));
if (view?.game.hotseat) await advanceHotseat();
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} finally { } finally {
@@ -1000,7 +1107,15 @@
if (!view) return ''; if (!view) return '';
const me = view.game.seats[view.seat]; const me = view.game.seats[view.seat];
if (me?.isWinner) return t('game.won'); if (me?.isWinner) return t('game.won');
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied'); if (view.game.endReason === 'aborted') return t('game.tied'); // an abort is a draw for everyone
const myScore = me?.score ?? 0;
// A declared winner (incl. by resignation) that is not me, or anyone who scored higher → a loss.
if (view.game.seats.some((s) => s.isWinner) || view.game.seats.some((s) => !s.resigned && s.score > myScore)) {
return t('game.lost');
}
// No one above me and no declared winner: a tie for the lead is a (shared) win; only an
// all-level finish is a draw.
return view.game.seats.filter((s) => !s.resigned).every((s) => s.score === myScore) ? t('game.tied') : t('game.won');
} }
// The finished-game export offers two formats — the GCG file and the server-rendered PNG // The finished-game export offers two formats — the GCG file and the server-rendered PNG
@@ -1316,8 +1431,9 @@
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested // (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled). // opponent still shows it, but disabled).
function canAddFriend(s: { accountId: string; seat: number }): boolean { function canAddFriend(s: { accountId: string; seat: number }): boolean {
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player. // Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in
if (view?.game.vsAi) return false; // a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids.
if (view?.game.vsAi || isLocalGameId(id)) return false;
return ( return (
!!s.accountId && !!s.accountId &&
!app.profile?.isGuest && !app.profile?.isGuest &&
@@ -1331,7 +1447,7 @@
// may still be blocked (the block overrides the friendship), so it omits the friend exclusion. // may still be blocked (the block overrides the friendship), so it omits the friend exclusion.
// An already-blocked opponent hides it (both controls go, and the name is struck). // An already-blocked opponent hides it (both controls go, and the name is struck).
function canBlock(s: { accountId: string; seat: number }): boolean { function canBlock(s: { accountId: string; seat: number }): boolean {
if (view?.game.vsAi) return false; if (view?.game.vsAi || isLocalGameId(id)) return false;
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s); return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
} }
</script> </script>
@@ -1389,7 +1505,7 @@
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if} {#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
{#each view.game.seats as s (s.seat)} {#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}> <div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div> <div class="nm" class:struck={seatBlocked(s)}>{#if gameOver && view.game.hotseat}<span class="medal" aria-hidden="true">{seatMedal(view.game, s.seat)}</span>{/if}{seatName(s)}</div>
<div class="sc" class:blockprompt={blockConfirm[s.seat]}> <div class="sc" class:blockprompt={blockConfirm[s.seat]}>
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if} {#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
</div> </div>
@@ -1433,13 +1549,18 @@
{:else} {:else}
<button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button> <button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button>
{/if} {/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} {:else}
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button> <button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if} {/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/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 <!-- The comms entry opens Chat + the word Dictionary. A local game (vs_ai or hotseat) has no
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). --> chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
{#if !(gameOver && view.game.vsAi)} — so drop the entry only when a local game is finished; an active local game keeps it for
the Dictionary. An online game always keeps it (chat outlives the game). -->
{#if !(gameOver && (view.game.vsAi || view.game.hotseat))}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}> <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} {#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
</button> </button>
@@ -1510,9 +1631,12 @@
<div class="status"> <div class="status">
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span> <span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
{#if gameOver} {#if gameOver}
<strong class="over">{resultText()}</strong> <!-- A hotseat game shows its result as per-seat medals on the plaques (below), not a
viewer-centric "you won/lost" — with 2-4 local players there is no single "you". A vs_ai
game keeps the "you won/lost" text (one human). -->
{#if !view.game.hotseat}<strong class="over">{resultText()}</strong>{/if}
{:else if placement.pending.length === 0} {: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} {/if}
<span class="scores"> <span class="scores">
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if} {#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
@@ -1526,6 +1650,11 @@
a finished game shows the final rack greyed out and the controls disabled. --> 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-row" class:inert={gameOver} data-coach="game-rack">
<div class="rack-wrap"> <div class="rack-wrap">
{#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 <Rack
slots={rackSlots} slots={rackSlots}
{variant} {variant}
@@ -1536,18 +1665,25 @@
dropIndex={reorderTo} dropIndex={reorderTo}
ondown={onRackDown} ondown={onRackDown}
/> />
{/if}
</div> </div>
{#if !gameOver && placement.pending.length > 0 && !recallOverRack} {#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} {/if}
</div> </div>
{/snippet} {/snippet}
{#snippet controlButtons()} {#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> <span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
</button> </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 <!-- 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 idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
lifts live at the unlock moment. --> lifts live at the unlock moment. -->
@@ -1623,6 +1759,55 @@
</Modal> </Modal>
{/if} {/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} {#if exportOpen}
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}> <Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
<div class="export-opts"> <div class="export-opts">
@@ -1692,6 +1877,10 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* The finished-game place medal on a local (offline) seat plaque, left of the name. */
.medal {
margin-right: 3px;
}
.sc { .sc {
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -2122,4 +2311,67 @@
font-size: 0.62rem; font-size: 0.62rem;
line-height: 1; 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> </style>
+43 -11
View File
@@ -725,17 +725,37 @@ function syncTelegramSafeArea(): void {
root.classList.toggle('tg-fullscreen', top > 0); root.classList.toggle('tg-fullscreen', top > 0);
} }
// The soft-keyboard height threshold (px) that distinguishes an open keyboard from browser chrome
// (an address bar shrinks the visual viewport by far less), used to gate the focus-into-view scroll.
const KEYBOARD_MIN_PX = 120;
/** /**
* syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can * syncViewport mirrors the visual viewport into CSS vars so the pinned app-shell (app.css
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard). * `html.app-shell body`) both FITS (`--vvh`) and FOLLOWS (`--vv-top`) the visible area above the soft
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible * keyboard. iOS Safari / WKWebView does not shrink the layout viewport for the keyboard (Android
* without the page scrolling, so the layout no longer jumps when the keyboard appears. * Chrome does); instead the visual viewport shrinks AND offsets down to reveal the focused field, and
* those values do not always cleanly revert. Tracking only the height left the top-anchored shell
* misaligned on iOS — empty space below the content, worst on a repeat focus; tracking offsetTop too
* keeps the shell on the visible area on every platform. On the keyboard-OPEN transition it also
* scrolls the focused field into view, since iOS does not reliably scroll a pinned document to it.
*/ */
function syncViewportHeight(): void { let keyboardOpen = false;
function syncViewport(): void {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
const vv = typeof window !== 'undefined' ? window.visualViewport : null; const win = typeof window !== 'undefined' ? window : null;
const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0; const vv = win ? win.visualViewport : null;
const h = vv ? vv.height : win ? win.innerHeight : 0;
const top = vv ? vv.offsetTop : 0;
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`); if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
document.documentElement.style.setProperty('--vv-top', `${top}px`);
const open = !!vv && !!win && win.innerHeight - h > KEYBOARD_MIN_PX;
if (open && !keyboardOpen) {
const el = document.activeElement;
if (el instanceof HTMLElement && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
requestAnimationFrame(() => el.scrollIntoView({ block: 'center' }));
}
}
keyboardOpen = open;
} }
/** /**
@@ -795,11 +815,23 @@ export async function bootstrap(): Promise<void> {
setLocale(guess); setLocale(guess);
} }
// Track the visual-viewport height so screens fit above an open soft keyboard (--vvh). // Track the visual viewport (height + offset) so screens fit and follow the visible area above an
syncViewportHeight(); // open soft keyboard (--vvh / --vv-top). Both resize AND scroll fire on iOS when the keyboard moves
// the visual viewport.
syncViewport();
if (typeof window !== 'undefined' && window.visualViewport) { if (typeof window !== 'undefined' && window.visualViewport) {
window.visualViewport.addEventListener('resize', syncViewportHeight); window.visualViewport.addEventListener('resize', syncViewport);
window.visualViewport.addEventListener('scroll', syncViewportHeight); window.visualViewport.addEventListener('scroll', syncViewport);
}
// Enter on a single-line <input> dismisses the soft keyboard (blur): the keyboard's default
// return/"Go" key otherwise does nothing on a bare input, which reads as stuck. A <textarea> (the
// feedback form) is excluded so Enter still inserts a newline; a form's own Enter→submit still
// fires first (this listener is on the document, so it runs after the field's own handler).
if (typeof document !== 'undefined') {
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target instanceof HTMLInputElement) e.target.blur();
});
} }
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no // Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
+10 -1
View File
@@ -41,6 +41,14 @@ export const localSource = {
create: (opts) => load().then((s) => s.create(opts)), create: (opts) => load().then((s) => s.create(opts)),
list: () => load().then((s) => s.list()), list: () => load().then((s) => s.list()),
delete: (id) => load().then((s) => s.delete(id)), 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)),
// relock is fire-and-forget: the engine is already loaded (the game was open), and re-locking a
// cached game before the screen is left needs no result.
relock: (id) => void load().then((s) => s.relock(id)),
// events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires // 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. // the real subscription once the engine loads and, until then, cancels a pending subscribe.
events: (id, onEvent) => { events: (id, onEvent) => {
@@ -54,7 +62,8 @@ export const localSource = {
unsub(); unsub();
}; };
}, },
} satisfies GameLoopSource & Pick<LocalSource, 'events' | 'create' | 'list' | 'delete'>; } satisfies GameLoopSource &
Pick<LocalSource, 'events' | 'create' | 'list' | 'delete' | 'unlockSeat' | 'verifyHostPin' | 'hostAction' | 'relock'>;
/** /**
* gameSource returns the source that runs the game with the given id: the local engine for a local * 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.game_active': 'Available only after the game is finished.',
'error.invalid_profile': 'Some profile fields are invalid.', 'error.invalid_profile': 'Some profile fields are invalid.',
'error.already_confirmed': 'This email is already confirmed.', '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; } as const;
export type MessageKey = keyof typeof en; export type MessageKey = keyof typeof en;
+29
View File
@@ -389,4 +389,33 @@ export const ru: Record<MessageKey, string> = {
'error.game_active': 'Доступно только после завершения игры.', 'error.game_active': 'Доступно только после завершения игры.',
'error.invalid_profile': 'Некоторые поля профиля некорректны.', 'error.invalid_profile': 'Некоторые поля профиля некорректны.',
'error.already_confirmed': 'Эта почта уже подтверждена.', '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': 'Отменить партию без результата?',
}; };
+4
View File
@@ -240,6 +240,10 @@ export class LocalGame {
handOf(player: number): number[] { handOf(player: number): number[] {
return [...this.hands[player]]; return [...this.hands[player]];
} }
/** resignedOf reports whether a seat has resigned or been excluded by the host. */
resignedOf(player: number): boolean {
return this.resigned[player] ?? false;
}
/** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */ /** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */
get winnerIndex(): number { get winnerIndex(): number {
return winner(this.over, this.reason, this.scores, this.resigned); return winner(this.over, this.reason, this.scores, this.resigned);
+16
View File
@@ -83,4 +83,20 @@ describe('local game serialize + replay', () => {
expect(rec.journal.length).toBe(g.moveCount); expect(rec.journal.length).toBe(g.moveCount);
expect(replayGame(rec, dawg).seed).toBe(9999999999n); 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 { LocalGame, type LocalMove, type DropoutTiles, type LocalGameOptions } from './engine';
import type { Dawg } from '../dict/dawg'; import type { Dawg } from '../dict/dawg';
import type { Variant } from '../model'; 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). */ /** Seat describes one player of a local game (forward-compatible with a 2-4 hotseat). */
export interface Seat { 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 /** 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). */ * turn exactly as in an online game; absent for the robot (a synthetic id is derived). */
accountId?: string; 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. */ /** 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. */ /** The dictionary-independent, alphabet-index-space move journal. */
journal: LocalMove[]; journal: LocalMove[];
status: 'active' | 'finished'; 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; createdAtUnix: number;
updatedAtUnix: number; updatedAtUnix: number;
/** Wall-clock ms at which the vs_ai idle hint unlocks (the robot's last move + the window), or 0 /** 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 { export interface RecordMeta {
id: string; id: string;
seats: Seat[]; seats: Seat[];
hotseat?: boolean;
hostPin?: PinLock;
createdAtUnix: number; createdAtUnix: number;
updatedAtUnix: number; updatedAtUnix: number;
hintUnlockAtMs: number; hintUnlockAtMs: number;
@@ -71,6 +83,8 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
seats: meta.seats, seats: meta.seats,
journal: game.history, journal: game.history,
status: game.isOver ? 'finished' : 'active', status: game.isOver ? 'finished' : 'active',
hotseat: meta.hotseat,
hostPin: meta.hostPin,
createdAtUnix: meta.createdAtUnix, createdAtUnix: meta.createdAtUnix,
updatedAtUnix: meta.updatedAtUnix, updatedAtUnix: meta.updatedAtUnix,
hintUnlockAtMs: meta.hintUnlockAtMs, hintUnlockAtMs: meta.hintUnlockAtMs,
+159
View File
@@ -0,0 +1,159 @@
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('exposes a host-excluded seat as resigned in the view (for the medal ranking)', async () => {
const src = await hotseat('local:hR', [
{ kind: 'human', name: 'A' },
{ kind: 'human', name: 'B' },
{ kind: 'human', name: 'C' },
], '4321');
await src.hostAction('local:hR', '4321', 'resign', 1); // exclude seat 1 (game continues: 2 active)
const st = await src.gameState('local:hR');
expect(st.game.seats[1].resigned).toBe(true);
expect(st.game.seats[0].resigned).toBe(false);
expect(st.game.seats[2].resigned).toBe(false);
});
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('re-locks an unlocked seat on relock (returning to the lobby)', async () => {
const src = await hotseat('local:h9', [
{ kind: 'human', name: 'Ann', pin: await newLock('1234') },
{ kind: 'human', name: 'Bob' },
]);
const opened = await src.unlockSeat('local:h9', '1234');
expect(opened.locked).toBe(false);
src.relock('local:h9');
const st = await src.gameState('local:h9');
expect(st.locked).toBe(true); // returning to the game re-prompts the seat PIN
expect(st.rack).toEqual([]);
});
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, and unlocked', async () => {
const src = await hotseat('local:h8', [
{ kind: 'human', name: 'A', pin: await newLock('1111') },
{ kind: 'human', name: 'B', pin: await newLock('2222') },
]);
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');
expect(st.locked).toBeFalsy(); // a finished game shows its final rack, never an unlock button
});
});
+85 -7
View File
@@ -17,6 +17,7 @@ import { setAlphabet, hasAlphabet } from '../alphabet';
import { HINT_GATE_MS } from '../hints'; import { HINT_GATE_MS } from '../hints';
import { LOCAL_ID_PREFIX, isLocalGameId } from './id'; import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
import { decide } from '../robot/strategy'; import { decide } from '../robot/strategy';
import { verifyPin, type PinLock } from '../pin';
import { GatewayError, type PlacedTile } from '../client'; import { GatewayError, type PlacedTile } from '../client';
import type { import type {
EvalResult, EvalResult,
@@ -87,6 +88,9 @@ function encodePlacements(variant: Variant, tiles: PlacedTile[]): { row: number;
interface Live { interface Live {
game: LocalGame; game: LocalGame;
record: LocalGameRecord; 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 live = new Map<string, Live>();
private readonly listeners = new Map<string, Set<(e: PushEvent) => void>>(); 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: { async create(opts: {
id: string; id: string;
variant: Variant; variant: Variant;
@@ -105,6 +110,8 @@ export class LocalSource implements GameLoopSource {
seed: bigint; seed: bigint;
multipleWords: boolean; multipleWords: boolean;
seats: LocalSeat[]; seats: LocalSeat[];
hotseat?: boolean;
hostPin?: PinLock;
}): Promise<StateView> { }): Promise<StateView> {
const dawg = await getDawg(opts.variant, opts.dictVersion); const dawg = await getDawg(opts.variant, opts.dictVersion);
if (!dawg) throw new GatewayError('dict_unavailable'); if (!dawg) throw new GatewayError('dict_unavailable');
@@ -119,12 +126,14 @@ export class LocalSource implements GameLoopSource {
const now = nowUnix(); const now = nowUnix();
const record = serializeGame(game, { const record = serializeGame(game, {
id: opts.id, 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, createdAtUnix: now,
updatedAtUnix: now, updatedAtUnix: now,
hintUnlockAtMs: 0, // no robot move yet: a human-first opening's hint is open at once 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); this.live.set(opts.id, entry);
await saveLocalGame(record); await saveLocalGame(record);
return this.stateView(entry); return this.stateView(entry);
@@ -185,10 +194,67 @@ export class LocalSource implements GameLoopSource {
const entry = await this.load(gameId); const entry = await this.load(gameId);
const seat = entry.game.currentPlayer; const seat = entry.game.currentPlayer;
const move = entry.game.resign(); const move = entry.game.resign();
entry.unlockedSeat = null;
await this.persist(entry); await this.persist(entry);
return this.moveResult(entry, move, seat); 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);
}
/** relock drops any per-turn seat unlock, so re-opening a hotseat game re-prompts the current
* seat's PIN. Called when the game screen is left (returning to the lobby). No-op when the game is
* not cached, or a seat with no PIN (which is never withheld anyway). */
relock(gameId: string): void {
const entry = this.live.get(gameId);
if (entry) entry.unlockedSeat = null;
}
/** 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> { async hint(gameId: string): Promise<HintResult> {
// The idle gate is enforced client-side with a monotonic clock (see Game.svelte / lib/hints): // 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 // a wall-clock timestamp here could be defeated by changing the device clock. This just serves
@@ -246,7 +312,7 @@ export class LocalSource implements GameLoopSource {
if (!record) throw new GatewayError('game_not_found'); if (!record) throw new GatewayError('game_not_found');
const dawg = await getDawg(record.variant, record.dictVersion); const dawg = await getDawg(record.variant, record.dictVersion);
if (!dawg) throw new GatewayError('dict_unavailable'); 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); this.live.set(gameId, entry);
return entry; return entry;
} }
@@ -258,6 +324,7 @@ export class LocalSource implements GameLoopSource {
const entry = await this.load(gameId); const entry = await this.load(gameId);
const seat = entry.game.currentPlayer; const seat = entry.game.currentPlayer;
const move = act(entry.game); 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); const robotMoves = this.runRobots(entry);
await this.persist(entry); await this.persist(entry);
const result = this.moveResult(entry, move, seat); const result = this.moveResult(entry, move, seat);
@@ -306,6 +373,8 @@ export class LocalSource implements GameLoopSource {
entry.record = serializeGame(entry.game, { entry.record = serializeGame(entry.game, {
id: entry.record.id, id: entry.record.id,
seats: entry.record.seats, seats: entry.record.seats,
hotseat: entry.record.hotseat,
hostPin: entry.record.hostPin,
createdAtUnix: entry.record.createdAtUnix, createdAtUnix: entry.record.createdAtUnix,
updatedAtUnix: nowUnix(), updatedAtUnix: nowUnix(),
hintUnlockAtMs: entry.record.hintUnlockAtMs, hintUnlockAtMs: entry.record.hintUnlockAtMs,
@@ -351,9 +420,15 @@ export class LocalSource implements GameLoopSource {
return leftMs > 0 ? Math.ceil(leftMs / 1000) : 0; 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 { private stateView(entry: Live): StateView {
const seat = this.humanSeat(entry); const hotseat = !!entry.record.hotseat;
const rack = entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)); const seat = hotseat ? entry.game.currentPlayer : this.humanSeat(entry);
// A finished game is never locked — its final rack is shown for review, not an unlock button.
const locked = !entry.game.isOver && hotseat && !!entry.record.seats[seat]?.pin && entry.unlockedSeat !== seat;
const rack = locked ? [] : entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t));
return { return {
game: this.gameView(entry), game: this.gameView(entry),
seat, seat,
@@ -362,6 +437,7 @@ export class LocalSource implements GameLoopSource {
hintsRemaining: 1, hintsRemaining: 1,
walletBalance: 0, walletBalance: 0,
hintUnlockLeftSeconds: this.hintUnlockLeft(entry), hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
locked,
}; };
} }
@@ -388,6 +464,7 @@ export class LocalSource implements GameLoopSource {
score: game.scoreOf(i), score: game.scoreOf(i),
hintsUsed: 0, hintsUsed: 0,
isWinner: game.isOver && i === game.winnerIndex, isWinner: game.isOver && i === game.winnerIndex,
resigned: game.resignedOf(i),
})); }));
return { return {
id: record.id, id: record.id,
@@ -401,7 +478,8 @@ export class LocalSource implements GameLoopSource {
moveCount: game.moveCount, moveCount: game.moveCount,
endReason: game.isOver ? game.endReason : '', endReason: game.isOver ? game.endReason : '',
lastActivityUnix: record.updatedAtUnix, lastActivityUnix: record.updatedAtUnix,
vsAi: true, vsAi: !record.hotseat,
hotseat: !!record.hotseat,
unreadChat: false, unreadChat: false,
unreadMessages: false, unreadMessages: false,
seats, seats,
+10
View File
@@ -29,6 +29,9 @@ export interface Seat {
score: number; score: number;
hintsUsed: number; hintsUsed: number;
isWinner: boolean; isWinner: boolean;
/** Offline hotseat only: the seat resigned or was excluded by the host. Set by the local source so
* the finished-game medal ranking can place it last (no medal); undefined for online seats. */
resigned?: boolean;
} }
export interface GameView { export interface GameView {
@@ -47,6 +50,10 @@ export interface GameView {
lastActivityUnix: number; lastActivityUnix: number;
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */ /** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
vsAi: boolean; 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 /** 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, * 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). */ * move result); the live-event GameView leaves it false (events bump unread instead). */
@@ -87,6 +94,9 @@ export interface StateView {
* non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt, * 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. */ * so a client clock change cannot skew it; a relaunch re-fetches a fresh value. See lib/hints. */
hintUnlockLeftSeconds?: number; 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 { 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;
}
+45 -2
View File
@@ -1,14 +1,15 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resultBadge } from './result'; import { resultBadge, seatMedal } from './result';
import type { GameView, Seat } from './model'; import type { GameView, Seat } from './model';
const seat = (s: number, accountId: string, score: number, isWinner = false): Seat => ({ const seat = (s: number, accountId: string, score: number, isWinner = false, resigned = false): Seat => ({
seat: s, seat: s,
accountId, accountId,
displayName: accountId, displayName: accountId,
score, score,
hintsUsed: 0, hintsUsed: 0,
isWinner, isWinner,
resigned,
}); });
function game(seats: Seat[], status = 'finished', toMove = 0): GameView { function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
@@ -74,4 +75,46 @@ describe('resultBadge', () => {
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]); const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' }); expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
}); });
it('tie for the lead (3-4p): a shared victory for the leaders, a placed finish for those below', () => {
// The reported case, viewer-side: two seats tie for the lead (-8), one is last (-14) — no single
// winner(), so this must NOT read as a full draw for everyone.
expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.victory', emoji: '🏆' });
expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.place3', emoji: '🥉' });
});
it('an aborted game reads as a draw for everyone, whatever the scores', () => {
const g = { ...game([seat(0, 'me', 50), seat(1, 'a', 30)]), endReason: 'aborted' };
expect(resultBadge(g, 'me')).toEqual({ key: 'result.draw', emoji: '🏅' });
});
});
describe('seatMedal', () => {
it('is empty for a game still in progress', () => {
expect(seatMedal(game([seat(0, 'a', 5), seat(1, 'b', 3)], 'active'), 0)).toBe('');
});
it('finished: places each seat by final score', () => {
const g = game([seat(0, 'a', 100), seat(1, 'b', 400, true), seat(2, 'c', 300), seat(3, 'd', 200)]);
expect(seatMedal(g, 1)).toBe('🏆'); // 400 → 1st
expect(seatMedal(g, 2)).toBe('🥈'); // 300 → 2nd
expect(seatMedal(g, 3)).toBe('🥉'); // 200 → 3rd
expect(seatMedal(g, 0)).toBe('🏅'); // 100 → 4th
});
it('a tie for the lead shares first place; the rest place below (competition ranking)', () => {
// The owner's case: a scoreless end deducted racks to -8 / -14 / -8, tying seats 0 and 2 for the
// lead — they must SHARE the trophy, not all show the last-place medal (the reported bug).
const g = game([seat(0, 'a', -8), seat(1, 'b', -14), seat(2, 'c', -8)]);
expect(seatMedal(g, 0)).toBe('🏆');
expect(seatMedal(g, 2)).toBe('🏆');
expect(seatMedal(g, 1)).toBe('🥉'); // two seats rank above it → 3rd
});
it('an excluded / resigned seat gets no medal and does not push the others down', () => {
const g = game([seat(0, 'a', 50), seat(1, 'b', 900, false, true), seat(2, 'c', 30)]);
expect(seatMedal(g, 1)).toBe(''); // resigned: no medal, though its frozen score is the highest
expect(seatMedal(g, 0)).toBe('🏆'); // ranked among the rest: highest → 1st
expect(seatMedal(g, 2)).toBe('🥈');
});
}); });
+41 -9
View File
@@ -9,6 +9,13 @@ export interface ResultBadge {
emoji: string; emoji: string;
} }
/** placeBadge maps a 1-based finishing rank to its label + medal (2nd is a defeat in a duel). */
function placeBadge(rank: number, players: number): ResultBadge {
if (rank === 2) return players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' };
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
return { key: 'result.place4', emoji: '🏅' };
}
export function resultBadge(game: GameView, myId: string): ResultBadge { export function resultBadge(game: GameView, myId: string): ResultBadge {
const me = game.seats.find((s) => s.accountId === myId); const me = game.seats.find((s) => s.accountId === myId);
@@ -19,14 +26,39 @@ export function resultBadge(game: GameView, myId: string): ResultBadge {
} }
if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' }; if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' };
if (!game.seats.some((s) => s.isWinner)) return { key: 'result.draw', emoji: '🏅' };
// Someone else won and it is not me, so I did not win — even when scores are level (a // No single winner: a tie for the lead, a full draw, or an aborted game. An abort or an all-level
// win by resignation or timeout can leave the winner at or below my score). The winner // finish is a draw; a tie for the lead is a SHARED victory for the top scorers, and a placed finish
// takes rank 1; place me among the remaining seats by score, starting at rank 2. // for those below. (winner() is -1 on ANY tie for the lead, so isWinner alone cannot tell a
const ahead = game.seats.filter((s) => !s.isWinner && s.accountId !== myId && s.score > (me?.score ?? 0)).length; // three-way "two share first, one is last" apart from a genuine draw — the reported bug.)
const rank = 2 + ahead; if (!game.seats.some((s) => s.isWinner)) {
if (rank === 2) return game.players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' }; const myScore = me?.score ?? 0;
if (rank === 3) return { key: 'result.place3', emoji: '🥉' }; const higher = game.seats.filter((s) => !s.resigned && s.score > myScore).length;
return { key: 'result.place4', emoji: '🏅' }; const allLevel = game.seats.filter((s) => !s.resigned).every((s) => s.score === myScore);
if (game.endReason === 'aborted' || (higher === 0 && allLevel)) return { key: 'result.draw', emoji: '🏅' };
return higher === 0 ? { key: 'result.victory', emoji: '🏆' } : placeBadge(1 + higher, game.players);
}
// A winner exists but it is not me — even when scores are level (a win by resignation or timeout
// can leave the winner at or below my score). The winner takes rank 1; place me among the remaining
// (non-resigned) seats by score, starting at rank 2.
const ahead = game.seats.filter((s) => !s.isWinner && !s.resigned && s.accountId !== myId && s.score > (me?.score ?? 0)).length;
return placeBadge(2 + ahead, game.players);
}
/**
* seatMedal returns a per-SEAT place emoji for a finished game (empty while it is still in progress),
* so a hotseat game can show each player's medal on their plaque instead of a single viewer-centric
* "you won/lost" (which is meaningless with 2-4 local players). It ranks by the FINAL score with
* competition ranking (equal scores share a place), so a tie for the lead SHARES the trophy rather
* than reading as a full draw. A resigned / host-excluded seat places last, with no medal, and does
* not push the remaining players down. Ranked by score, not by the engine's single-winner flag
* (which is -1 on any tie for the lead — the reported all-last-place bug).
*/
export function seatMedal(game: GameView, seat: number): string {
if (game.status !== 'finished') return '';
const s = game.seats.find((x) => x.seat === seat);
if (!s || s.resigned) return '';
const rank = 1 + game.seats.filter((x) => !x.resigned && x.score > s.score).length;
return rank === 1 ? '🏆' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '🏅';
} }
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { buildSeats, commitName, rosterReady, shuffleSeeded, 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);
});
});
describe('shuffleSeeded', () => {
it('is deterministic for the same seed', () => {
expect(shuffleSeeded([0, 1, 2, 3], 42n)).toEqual(shuffleSeeded([0, 1, 2, 3], 42n));
});
it('keeps every element (a permutation) and does not mutate the input', () => {
const input = [0, 1, 2, 3];
const out = shuffleSeeded(input, 7n);
expect([...out].sort((a, b) => a - b)).toEqual([0, 1, 2, 3]);
expect(input).toEqual([0, 1, 2, 3]); // input untouched
});
it('varies the order across seeds (not always the identity)', () => {
const orders = new Set([1n, 2n, 3n, 4n, 5n, 6n, 7n, 8n].map((s) => shuffleSeeded([0, 1, 2, 3], s).join('')));
expect(orders.size).toBeGreaterThan(1);
});
});
+54
View File
@@ -0,0 +1,54 @@
// 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 }));
}
/**
* shuffleSeeded returns a deterministic shuffle of items driven by seed — a Fisher-Yates over a small
* in-house PRNG (a distinct seed derivation from the tile bag, so the seating is not correlated with
* the tile draws). Used to randomise the hotseat seating at game start, so the first mover is random;
* being seed-driven keeps it reproducible for replay and tests.
*/
export function shuffleSeeded<T>(items: readonly T[], seed: bigint): T[] {
const out = [...items];
let a = (Number(BigInt.asUintN(32, seed ^ 0x9e3779b9n)) >>> 0) || 1;
const rand = (): number => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
+42 -11
View File
@@ -4,6 +4,7 @@
import Screen from '../components/Screen.svelte'; import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte'; import TabBar from '../components/TabBar.svelte';
import Modal from '../components/Modal.svelte'; import Modal from '../components/Modal.svelte';
import PinPad from '../components/PinPad.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte'; import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; 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 // 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. // per-account and irreversible. Only one row is revealed at a time.
let revealedId = $state<string | null>(null); 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; 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, // 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. // 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 if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe
drag = { id, x0: e.clientX, y0: e.clientY }; 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; if (!drag) return;
const dx = e.clientX - drag.x0; const dx = e.clientX - drag.x0;
const dy = e.clientY - drag.y0; const dy = e.clientY - drag.y0;
// Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it // Only a revealable row (a finished game, or any hotseat game) reveals its delete on a horizontal
// does not also open the game. Active rows ignore swipes and stay plain tap-to-open. // swipe; that swipe then suppresses the tap so it does not also open the game. Other active rows
if (finished && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) { // ignore swipes and stay plain tap-to-open.
if (revealable && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
swiped = true; swiped = true;
revealedId = dx < 0 ? drag.id : null; 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) { async function acceptInvite(inv: Invitation) {
try { try {
const r = await gateway.invitationAccept(inv.id); const r = await gateway.invitationAccept(inv.id);
@@ -315,15 +328,15 @@
<div class="list"> <div class="list">
{#each group.list as g (g.id)} {#each group.list as g (g.id)}
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)} {@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}> <div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id}>
{#if group.finished} {#if group.finished || g.hotseat}
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button> <button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button>
{/if} {/if}
<div class="row"> <div class="row">
<button <button
class="open" class="open"
onpointerdown={(e) => onRowDown(e, g.id)} onpointerdown={(e) => onRowDown(e, g.id)}
onpointerup={(e) => onRowUp(e, group.finished)} onpointerup={(e) => onRowUp(e, group.finished || !!g.hotseat)}
onclick={() => openGame(g)} onclick={() => openGame(g)}
> >
<span class="info"> <span class="info">
@@ -340,12 +353,15 @@
>{/each}</span >{/each}</span
> >
</span> </span>
<!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. --> <!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. A
hotseat game shows no lobby medal — its result lives on the in-game seat
plaques, and resultBadge is viewer-centric (no seat matches the account). A
vs_ai game keeps its medal (one human, a straightforward win/loss). -->
{#key blinkNonce.get(g.id) ?? 0} {#key blinkNonce.get(g.id) ?? 0}
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{resultBadge(g, myId).emoji}</span> <span class="emoji" class:blink={blinkingIds.has(g.id)}>{g.hotseat ? '' : resultBadge(g, myId).emoji}</span>
{/key} {/key}
</button> </button>
{#if group.finished} {#if group.finished || g.hotseat}
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}></button> <button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}></button>
{:else} {:else}
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out <!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
@@ -378,6 +394,21 @@
</Modal> </Modal>
{/if} {/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()} {#snippet tabbar()}
<TabBar> <TabBar>
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}> <button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
+19 -1
View File
@@ -17,10 +17,17 @@
} }
async function signIn() { async function signIn() {
if (busy || code.trim().length === 0) return;
busy = true; busy = true;
await loginEmail(email.trim(), code.trim()); await loginEmail(email.trim(), code.trim());
busy = false; busy = false;
} }
// Track the code and auto-submit once the 6-digit code is complete (Enter submits too).
function onCode(v: string): void {
code = v;
if (code.trim().length === 6) void signIn();
}
</script> </script>
<main class="login"> <main class="login">
@@ -46,10 +53,16 @@
{:else} {:else}
<p class="muted">{t('login.codeSent', { email })}</p> <p class="muted">{t('login.codeSent', { email })}</p>
<input <input
class="codein"
inputmode="numeric" inputmode="numeric"
autocomplete="one-time-code" autocomplete="one-time-code"
maxlength="6"
placeholder={t('login.codePlaceholder')} placeholder={t('login.codePlaceholder')}
bind:value={code} value={code}
oninput={(e) => onCode(e.currentTarget.value)}
onkeydown={(e) => {
if (e.key === 'Enter') void signIn();
}}
/> />
<button class="secondary" disabled={busy || !code.trim()} onclick={signIn}> <button class="secondary" disabled={busy || !code.trim()} onclick={signIn}>
{t('login.signIn')} {t('login.signIn')}
@@ -98,6 +111,11 @@
color: var(--text); color: var(--text);
font-size: 1rem; font-size: 1rem;
} }
/* The email code: the same spread-digit look as the friend-code input elsewhere. */
.codein {
letter-spacing: 0.3em;
font-size: 1.1rem;
}
button { button {
padding: 12px; padding: 12px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
+333 -15
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Screen from '../components/Screen.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 { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte'; import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
@@ -9,6 +11,9 @@
import { newLocalGameId, randomSeed } from '../lib/localgame/id'; import { newLocalGameId, randomSeed } from '../lib/localgame/id';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.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, shuffleSeeded, type RosterRow } from '../lib/roster';
import type { AccountRef, Variant } from '../lib/model'; import type { AccountRef, Variant } from '../lib/model';
import { import {
availableVariants, availableVariants,
@@ -144,13 +149,143 @@
} }
} }
// --- 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);
// The row whose delete cross is armed: tapping a row's kebab asks the host PIN, and a correct PIN
// arms its ❌ (mirroring the lobby delete) rather than removing it silently. Tapping the kebab again
// (or arming another row) clears the authorisation.
let deleteRow = $state<number | 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') deleteRow = target.index; // arm the ❌; the actual delete is a tap on it
break;
}
}
// onKebab toggles a row's delete authorisation: a second tap (already armed) closes it and resets
// the host authorisation; otherwise it asks the host PIN (which, on success, arms the ❌).
function onKebab(i: number): void {
if (deleteRow === i) {
deleteRow = null;
return;
}
deleteRow = null; // only one row armed at a time
pad = { kind: 'row-delete', index: i };
}
function removeRow(i: number): void {
rows = rows.filter((_, j) => j !== i);
deleteRow = null;
}
function addRow(): void {
rows = [...rows, { name: '', committed: '' }];
deleteRow = null;
}
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();
const seed = randomSeed();
// Randomise the seating so the FIRST mover is random (the engine starts at seat 0, so shuffling
// the seats picks a random starter and turn order). Seed-driven, so replay is consistent.
// 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, best-effort store)
// — so the game would vanish on reload. See lib/localgame/store.
await localSource.create({
id,
variant: inviteVariant,
dictVersion: version,
seed,
multipleWords: multipleWordsForRequest(inviteVariant, multipleWords),
seats: $state.snapshot(shuffleSeeded(buildSeats(rows), seed)),
hotseat: true,
hostPin: hostPin ? $state.snapshot(hostPin) : undefined,
});
navigate(`/game/${id}`);
} catch (e) {
handleError(e);
} finally {
starting = false;
}
}
</script> </script>
<Screen title={t('new.title')} back="/"> <Screen title={t('new.title')} back="/">
<div class="page"> <div class="page">
<!-- Offline mode offers only a local vs_ai game: the friends flow needs the network, so the <!-- The auto/friends selector. Online it is hidden for guests (no friends to invite). Offline
auto/friends selector is hidden and the auto (vs_ai) flow shows on its own. --> it is always shown: "quick" is the local vs_ai flow, "with friends" is the local
{#if !guest && !offlineMode.active} pass-and-play (hotseat) flow. -->
{#if !guest || offlineMode.active}
<div class="seg modes"> <div class="seg modes">
<button class="opt" class:active={mode === 'auto'} onclick={() => (mode = 'auto')}>{t('new.auto')}</button> <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> <button class="opt" class:active={mode === 'friends'} onclick={() => (mode = 'friends')}>{t('new.withFriends')}</button>
@@ -190,7 +325,6 @@
<input type="checkbox" bind:checked={multipleWords} /> <input type="checkbox" bind:checked={multipleWords} />
</label> </label>
{/if} {/if}
<div class="grow"></div>
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p> <p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if} {#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
<button <button
@@ -198,6 +332,78 @@
disabled={!selectedAuto || (!connection.online && !offlineMode.active) || starting} disabled={!selectedAuto || (!connection.online && !offlineMode.active) || starting}
onclick={() => selectedAuto && find(selectedAuto)} onclick={() => selectedAuto && find(selectedAuto)}
>{t('new.start')}</button> >{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="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>
<!-- Variant plaques + the multiple-words toggle, identical to Quick Match. -->
<div class="variants">
{#each variants as v (v.id)}
<button class="variant" class:selected={inviteVariant === v.id} onclick={() => (inviteVariant = v.id)}>
<span class="vmain">
<span class="vname">{t(v.label)}</span>
{#if VARIANT_FLAG[v.id]}
<span class="vflag">{VARIANT_FLAG[v.id]}</span>
{:else}
<img class="vflag-img" src="flag-ussr.svg" alt="" />
{/if}
</span>
<span class="vrules">{t(VARIANT_RULES[v.id])}</span>
</button>
{/each}
</div>
{#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
<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}
{#if deleteRow === i}
<button class="prow-del" aria-label={t('hotseat.removePlayer')} onclick={() => removeRow(i)}></button>
{/if}
<button
class="pkebab"
class:armed={deleteRow === i}
disabled={!hostPin}
aria-label={t('hotseat.removePlayer')}
onclick={() => onKebab(i)}
></button>
{/if}
</div>
{/each}
</div>
{#if rows.length < 4}
<button class="addrow" disabled={!hostPin} onclick={addRow}>
+ {t('hotseat.addPlayer')}
</button>
{/if}
<button
class="invite"
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
onclick={startHotseat}
>{t('new.start')}</button>
{:else if friends.length === 0} {:else if friends.length === 0}
<p class="subtitle">{t('new.noFriends')}</p> <p class="subtitle">{t('new.noFriends')}</p>
{:else} {:else}
@@ -245,16 +451,37 @@
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button> <button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
</div> </div>
{/if} {/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> </div>
</Screen> </Screen>
<style> <style>
/* Natural-flow content (matching the Profile sub-view): no forced height and no pinned CTA, so the
screen's own scroll (Screen .content + --vvh) handles overflow and a focused input scrolls into
view above the soft keyboard with a single, clean scroll container. */
.page { .page {
padding: var(--pad); padding: var(--pad);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
height: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.subtitle { .subtitle {
@@ -339,8 +566,6 @@
border-color: var(--accent); border-color: var(--accent);
} }
.fg { .fg {
flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
@@ -363,9 +588,6 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.friends-scroll { .friends-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
@@ -426,11 +648,6 @@
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;
} }
/* Pushes the auto-match start cluster (move-limit hint + Start button) to the bottom,
mirroring the friend-game invite button pinned by the .fg scroll area. */
.grow {
flex: 1;
}
.invite { .invite {
flex: 0 0 auto; flex: 0 0 auto;
padding: 14px; padding: 14px;
@@ -450,4 +667,105 @@
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.4; 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;
}
.pkebab.armed {
color: var(--accent);
}
/* The delete cross a correct host PIN arms next to a player row (mirrors the lobby game delete). */
.prow-del {
flex: 0 0 auto;
padding: 6px 8px;
border: none;
background: none;
font-size: 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> </style>
+13 -5
View File
@@ -24,17 +24,19 @@ clientsClaim();
// Drop precaches left by a previous SW revision (including the old install-only shell cache). // Drop precaches left by a previous SW revision (including the old install-only shell cache).
cleanupOutdatedCaches(); cleanupOutdatedCaches();
// Precache the shell + hashed assets injected at build time — exactly what a cold offline launch
// needs. The landing page and the old-engine polyfill bundle are excluded via the build globs.
precacheAndRoute(self.__WB_MANIFEST);
// Navigations are network-first so a new deploy loads immediately when online: fetch the fresh // Navigations are network-first so a new deploy loads immediately when online: fetch the fresh
// (no-cache) shell from the server — it references the new hashed assets, which are then fetched // (no-cache) shell from the server — it references the new hashed assets, which are then fetched
// fresh — and only fall back to the precached shell when the network is unreachable or too slow (a // fresh — and only fall back to the precached shell when the network is unreachable or too slow (a
// short timeout). This keeps the app up to date online while still cold-launching offline. Only the // short timeout). This keeps the app up to date online while still cold-launching offline. Only the
// tiny HTML is re-fetched; the immutable hashed assets stay cache-first (precacheAndRoute above) and // tiny HTML is re-fetched; the immutable hashed assets stay cache-first (precacheAndRoute below) and
// re-download only when their hash — i.e. the version — changes. Deny-list the RPC path and the // re-download only when their hash — i.e. the version — changes. Deny-list the RPC path and the
// admin console so those are never resolved to the app shell. // admin console so those are never resolved to the app shell.
//
// This route MUST be registered BEFORE precacheAndRoute: Workbox matches routes in registration
// order, and the precache route matches shell navigations too (its directoryIndex is index.html), so
// registering it first would shadow this handler and serve the shell CACHE-FIRST — i.e. the old
// build's version until the next load. NavigationRoute matches only request.mode === 'navigate', so
// hashed-asset requests still fall through to the cache-first precache route below.
const NAV_TIMEOUT_MS = 3000; const NAV_TIMEOUT_MS = 3000;
async function freshShell({ request }: { request: Request }): Promise<Response> { async function freshShell({ request }: { request: Request }): Promise<Response> {
const ctrl = new AbortController(); const ctrl = new AbortController();
@@ -50,3 +52,9 @@ async function freshShell({ request }: { request: Request }): Promise<Response>
return (await matchPrecache('index.html')) ?? Response.error(); return (await matchPrecache('index.html')) ?? Response.error();
} }
registerRoute(new NavigationRoute(freshShell, { denylist: [/^\/scrabble\.edge\.v1\.Gateway/, /^\/_gm\//] })); registerRoute(new NavigationRoute(freshShell, { denylist: [/^\/scrabble\.edge\.v1\.Gateway/, /^\/_gm\//] }));
// Precache the shell + hashed assets injected at build time — exactly what a cold offline launch
// needs (the precached index.html is also the offline fallback matchPrecache serves above). The
// immutable hashed assets stay cache-first here; the landing page and the old-engine polyfill bundle
// are excluded via the build globs.
precacheAndRoute(self.__WB_MANIFEST);