diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5fbd441..1e6ae33 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 **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 -no network, and falls every in-scope navigation back to the precached shell (the hash router -resolves the route client-side); 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 +no network. Shell **navigations are network-first** — the fresh `no-cache` shell is fetched from the +server (so a new deploy is picked up on the very next launch, not the one after), falling back to the +precached shell only when the network is unreachable or too slow; the immutable hashed assets stay +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 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 device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry creates one through the in-browser engine — the same game screen then drives it, the robot replying -locally; online-only affordances (the Stats tab, the friend/random options in New Game) are disabled -or hidden. A `vs_ai` hint (online and offline alike) is unlimited and wallet-free but idle-gated +locally; online-only affordances (the Stats tab, the random-opponent option) are disabled +or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**. +A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` + +per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host +(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the +current turn, **exclude** a player (a forced seat resign, `resignSeat`) or **terminate** the game +(deleted, no winner/loser); each seat may also carry its own **optional PIN** that withholds its rack +(the board stays visible — only the tray is gated) until it is entered, so players pass the device +around freely and lock a seat only against a distrusted tablemate. The unlock is **per turn** +(re-locks on advance). PINs are a **social lock, not cryptography** — a salted SHA-256 kept only in +the device record (`lib/pin`); a 4-digit PIN is trivially brute-forceable and the racks live in +client memory regardless, so they defeat a casual glance, not a determined peek. A hotseat game is +**not `vs_ai`**: hints (the freed control becomes the host button) and chat/self-resign are gone, and +its lobby card — active *and* finished — is master-PIN-gated to delete (so the last mover cannot +instantly wipe it); a naturally finished game is saved with its result like any local game. A `vs_ai` +hint (online and offline alike) is unlimited and wallet-free but idle-gated (unlocked ~30 min into a stuck turn), and counts toward no hint statistic. The **source** reports the **seconds left** (`hint_unlock_left_seconds` on the game view) — computed by the **backend from the server clock** online (which also enforces the gate, returning `hint_locked` for an early request), diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index b8e60ea..bda5ac7 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -269,8 +269,8 @@ statistics. An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode** (Settings → Play mode). Offline, the app never touches the network: the header turns blue with an *Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are -disabled or hidden (the Stats tab; the *with friends* and *random opponent* options in New Game). -Only **vs_ai** games are playable — New Game creates a device-local game against the robot, which +disabled or hidden (the Stats tab; the *random opponent* option in New Game). +A New Game against the robot creates a device-local **vs_ai** game — it plays entirely in the browser with no backend. Local games are kept on the device, visible only in offline mode, and never sync to the account. So a game can be created and played with no connection, the app quietly preloads the dictionaries for the player's enabled variants while still online, and @@ -280,6 +280,20 @@ and waits briefly, and if they cannot be readied it stays online with a short *n (the download keeps running in the background, so a later switch is instant). The mode is device-scoped and sticky across launches. +Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one +device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style +keypad; until it is set the player rows stay locked. The app then asks whether you are playing too — +if so you take the first seat with your profile name. You name each player (2 to 4; add or remove +rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**. +During the game the board is always visible, but a PIN-locked seat shows an **Unlock** button in place +of its rack until its owner enters the PIN — so the device passes hand to hand and only a protected +seat hides its letters; the lock returns each turn. The **leader** (the host button in the game) can, +with the leader password, **skip** the current player's turn, **remove** a player, or **end the game +early** — an early end discards the game with no winner or loser. Hints and chat are off in a +pass-and-play game. A game that finishes normally is saved to the offline lobby like any other; +deleting any pass-and-play game — running or finished — from the lobby asks for the leader password, +so no one can wipe a game the moment they have moved. + The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch with no connection it switches to offline for that session; when the device is online but the gateway stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index b91b12e..a2ca461 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -274,8 +274,8 @@ e-mail) либо ввод фразы. Активные игры форфейтя Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный **офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности -отключены или скрыты (вкладка Статистика; варианты «с другом» и «случайный соперник» в Новой игре). -Играть можно только в **vs_ai** — Новая игра создаёт локальную игру против робота, который ходит +отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре). +Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки) @@ -285,6 +285,21 @@ e-mail) либо ввод фразы. Активные игры форфейтя продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и сохраняется между запусками. +В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек +за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле +экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает, +играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого +игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете +дать любому месту **свой необязательный PIN**. Во время игры доска видна всегда, но место с PIN +вместо стойки показывает кнопку **«Разблокировать»**, пока владелец не введёт PIN, — так устройство +передаётся из рук в руки, и только защищённое место прячет свои буквы; на каждом ходу замок +возвращается. **Ведущий** (кнопка ведущего в игре) может по паролю ведущего **пропустить** ход +текущего игрока, **исключить** игрока или **завершить игру досрочно** — досрочное завершение стирает +партию без победителей и проигравших. Подсказки и чат в игре по очереди отключены. Нормально +завершённая партия сохраняется в офлайн-лобби как любая другая; удаление любой игры по очереди — +идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после +своего хода. + Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить** diff --git a/ui/e2e/hotseat.spec.ts b/ui/e2e/hotseat.spec.ts new file mode 100644 index 0000000..2e26dab --- /dev/null +++ b/ui/e2e/hotseat.spec.ts @@ -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 { + 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 { + await page.getByRole('button', { name: /guest|гост/i }).first().click(); + await expect(page.locator('button.tab').nth(2)).toBeVisible(); +} + +async function goOffline(page: Page): Promise { + 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 { + 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); + }); +}); diff --git a/ui/e2e/viewport.spec.ts b/ui/e2e/viewport.spec.ts new file mode 100644 index 0000000..46e02b8 --- /dev/null +++ b/ui/e2e/viewport.spec.ts @@ -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 { + 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 { + 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'); + }); +}); diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 43c4ba9..ab27cb6 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -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 // 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 -// countdown toast live in the always-loaded game screen (Game.svelte). 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: 115, shared: 30, landing: 5 }; +// countdown toast live in the always-loaded game screen (Game.svelte) — and to 120 for the offline +// pass-and-play (hotseat) mode: the on-screen PIN pad, the creation roster and the in-game host menu +// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN +// 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 // local file (e.g. the Telegram SDK loaded from a CDN) or is missing. diff --git a/ui/src/app.css b/ui/src/app.css index 193cabe..2e573a7 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -162,7 +162,15 @@ html.app-shell { } html.app-shell body { 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; } diff --git a/ui/src/components/PinPad.svelte b/ui/src/components/PinPad.svelte new file mode 100644 index 0000000..9514e61 --- /dev/null +++ b/ui/src/components/PinPad.svelte @@ -0,0 +1,268 @@ + + + + + +
+ {#if phase === 'menu'} + + {:else} +

{promptText()}

+ {#key shakeSeq} + + {/key} +
+ {#each keys as k} + + {/each} + + + +
+ {/if} +
+
+ + diff --git a/ui/src/game/CommsHub.svelte b/ui/src/game/CommsHub.svelte index b2f311f..a3b442a 100644 --- a/ui/src/game/CommsHub.svelte +++ b/ui/src/game/CommsHub.svelte @@ -16,18 +16,21 @@ // 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. const active = $derived(getCachedGame(id)?.view.game.status !== 'finished'); - // An honest-AI game has no chat at all, so its hub is Dictionary-only. - const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false); + // A local game has NO chat: an honest-AI game (vs_ai) and an offline pass-and-play (hotseat) game. + // 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: - // 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). - // An honest-AI game has only the Dictionary, so start there regardless of the entry route — this - // keeps ChatScreen (which fetches chat over the network) from mounting even for a beat, which would - // otherwise raise an error toast in offline mode. + // A chatless game starts on the Dictionary regardless of the entry route — this keeps ChatScreen + // (which fetches chat over the network) from mounting even for a beat, which would otherwise raise + // an error toast in offline mode. // svelte-ignore state_referenced_locally - let tab = $state(vsAi ? 'dictionary' : initialTab); + let tab = $state(chatless ? 'dictionary' : initialTab); $effect(() => { - if (vsAi) tab = 'dictionary'; + if (chatless) tab = 'dictionary'; else if (tab === 'dictionary' && !active) tab = 'chat'; }); @@ -46,7 +49,7 @@ {#snippet tabbar()} - {#if !vsAi} + {#if !chatless} diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 09e884d..e76709f 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -4,6 +4,7 @@ import TabBar from '../components/TabBar.svelte'; import TapConfirm from '../components/TapConfirm.svelte'; import Modal from '../components/Modal.svelte'; + import PinPad from '../components/PinPad.svelte'; import DictWarmup from '../components/DictWarmup.svelte'; import Board from './Board.svelte'; import Rack from './Rack.svelte'; @@ -19,6 +20,7 @@ import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; import { lastMoveCells, replay } from '../lib/board'; import { badgeKind } from '../lib/unread'; + import { seatMedal } from '../lib/result'; import { historyGrid } from '../lib/history'; import { centre, premiumGrid } from '../lib/premiums'; 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 isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat); const gameOver = $derived(!!view && view.game.status === 'finished'); + // Offline hotseat: the seat to move is PIN-locked until its owner enters the seat PIN this turn. + // While locked the board stays visible but the rack is withheld and the move controls disabled; + // canMove folds the lock into isMyTurn (locked is always false for a network / vs_ai game). + const locked = $derived(!!view?.locked); + const canMove = $derived(isMyTurn && !locked); // The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the // wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet // hint was spent in another game (see lib/hints). @@ -670,6 +677,10 @@ void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); } 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) { @@ -774,8 +785,8 @@ // rapid placements do not pile up requests on a slow link. evalCtrl?.abort(); evalCtrl = null; - // Off-turn the composition is position-only: no score preview or evaluate. - if (!isMyTurn) return; + // Off-turn (or a locked hotseat seat) the composition is position-only: no score preview. + if (!canMove) return; const sub = toSubmit(placement); if (!sub) return; // Instant on-device preview when the game's dictionary is warm; the network otherwise. @@ -833,12 +844,106 @@ refreshRecent(); } + // --- offline hotseat: seat unlock + host (referee) overrides ------------------- + let unlockOpen = $state(false); // the current locked seat's owner enters their PIN to reveal the rack + // The host menu: 'pin' collects the master PIN, then 'menu' lists the overrides. hostPinEntered is + // the verified master PIN, reused to authorise the chosen action (the source re-checks it). + let hostMenuStep = $state<'closed' | 'pin' | 'menu'>('closed'); + let hostPinEntered = $state(''); + // The pending host action awaiting its confirm ("Skip X's turn?" etc.); null shows the menu list. + let hostConfirm = $state<{ action: 'skip' | 'resign' | 'terminate'; seat?: number; name?: string } | null>(null); + let hostDone = $state(false); // the transient success ✅ shown after an override + + // hotseatName is the plain display name of a seat by index (hotseat seats are account-less local + // players, so no "you"/🤖 resolution is needed — unlike the seat-object seatName used elsewhere). + function hotseatName(i: number): string { + return view?.game.seats[i]?.displayName ?? ''; + } + + function startSkip(): void { + if (!view) return; + hostConfirm = { action: 'skip', seat: view.game.toMove, name: hotseatName(view.game.toMove) }; + } + + function flashDone(): void { + hostDone = true; + setTimeout(() => (hostDone = false), 1100); + } + + // advanceHotseat re-points the screen at the next seat after a hotseat move: the source has advanced + // the turn and re-locked, so a fresh state gives the next seat's rack (empty while it is locked). + async function advanceHotseat(): Promise { + const st = await source.gameState(id, false); + view = st; + rackIds = st.rack.map((_, i) => i); + placement = newPlacement(st.rack); + selected = null; + preview = null; + setCachedGame(id, st, moves, ''); + } + + // onUnlock verifies the current seat's PIN through the source (which reveals the rack on success) + // and applies the unlocked state; handed to the PIN pad as its verifier. + async function onUnlock(pin: string): Promise { + try { + const st = await localSource.unlockSeat(id, pin); + view = st; + rackIds = st.rack.map((_, i) => i); + placement = newPlacement(st.rack); + selected = null; + return true; + } catch { + return false; + } + } + + // verifyHost captures the entered master PIN (reused to authorise the chosen action) and checks it. + async function verifyHost(pin: string): Promise { + hostPinEntered = pin; + return localSource.verifyHostPin(id, pin); + } + + function hostConfirmText(c: { action: 'skip' | 'resign' | 'terminate'; name?: string }): string { + if (c.action === 'skip') return t('hotseat.askSkip', { name: c.name ?? '' }); + if (c.action === 'resign') return t('hotseat.askExclude', { name: c.name ?? '' }); + return t('hotseat.askTerminate'); + } + + async function runHostAction(): Promise { + if (!hostConfirm) return; + const { action, seat } = hostConfirm; + hostConfirm = null; + hostMenuStep = 'closed'; + busy = true; + try { + const st = await localSource.hostAction(id, hostPinEntered, action, seat); + if (st === null) { + navigate('/'); // terminated: the game is deleted, so return to the lobby + return; + } + view = st; + rackIds = st.rack.map((_, i) => i); + placement = newPlacement(st.rack); + selected = null; + preview = null; + moves = (await source.gameHistory(id)).moves; // a skip / resign added a journal move + setCachedGame(id, st, moves, ''); + flashDone(); + } catch (e) { + handleError(e); + } finally { + busy = false; + hostPinEntered = ''; + } + } + async function commit() { const sub = toSubmit(placement); if (!sub) return; busy = true; try { applyMoveResult(await source.submitPlay(id, sub.tiles, variant)); + if (view?.game.hotseat) await advanceHotseat(); haptic('success'); zoomed = false; } catch (e) { @@ -858,6 +963,7 @@ busy = true; try { applyMoveResult(await source.pass(id)); + if (view?.game.hotseat) await advanceHotseat(); } catch (e) { handleError(e); } finally { @@ -979,6 +1085,7 @@ busy = true; try { applyMoveResult(await source.exchange(id, tiles, variant)); + if (view?.game.hotseat) await advanceHotseat(); } catch (e) { handleError(e); } finally { @@ -1000,7 +1107,15 @@ if (!view) return ''; const me = view.game.seats[view.seat]; 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 @@ -1316,8 +1431,9 @@ // (not the still-empty seat of an open game) who is not yet a friend (an already-requested // opponent still shows it, but disabled). function canAddFriend(s: { accountId: string; seat: number }): boolean { - // Never offer add-friend against an AI opponent, an existing friend, or a blocked player. - if (view?.game.vsAi) return false; + // Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in + // 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 ( !!s.accountId && !app.profile?.isGuest && @@ -1331,7 +1447,7 @@ // 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). 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); } @@ -1389,7 +1505,7 @@ {#if badge}{/if} {#each view.game.seats as s (s.seat)}
-
{seatName(s)}
+
{#if gameOver && view.game.hotseat}{/if}{seatName(s)}
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
@@ -1433,13 +1549,18 @@ {:else} {/if} + {:else if view.game.hotseat} + + {:else} {/if} {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} - - {#if !(gameOver && view.game.vsAi)} + + {#if !(gameOver && (view.game.vsAi || view.game.hotseat))} @@ -1510,9 +1631,12 @@
{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })} {#if gameOver} - {resultText()} + + {#if !view.game.hotseat}{resultText()}{/if} {:else if placement.pending.length === 0} - {isMyTurn ? t('game.yourTurn') : turnLabel()} + {view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} {#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if} @@ -1526,28 +1650,40 @@ a finished game shows the final rack greyed out and the controls disabled. -->
- + {#if locked} + + + {:else} + + {/if}
{#if !gameOver && placement.pending.length > 0 && !recallOverRack} - + {/if}
{/snippet} {#snippet controlButtons()} - - {#if view?.game.vsAi} + {#if view?.game.hotseat} + + + {:else if view?.game.vsAi} @@ -1623,6 +1759,55 @@ {/if} + +{#if unlockOpen && view} + (unlockOpen = false)} + onresult={() => (unlockOpen = false)} + /> +{/if} + + +{#if hostMenuStep === 'pin'} + (hostMenuStep = 'closed')} + onresult={() => (hostMenuStep = 'menu')} + /> +{/if} +{#if hostMenuStep === 'menu' && view} + { hostMenuStep = 'closed'; hostConfirm = null; }}> + {#if hostConfirm} +

{hostConfirmText(hostConfirm)}

+
+ + +
+ {:else} +
+ +
{t('hotseat.exclude')}
+ {#each view.game.seats as s (s.seat)} + + {/each} + +
+ {/if} +
+{/if} +{#if hostDone} + +{/if} + {#if exportOpen} (exportOpen = false)}>
@@ -1692,6 +1877,10 @@ overflow: hidden; text-overflow: ellipsis; } + /* The finished-game place medal on a local (offline) seat plaque, left of the name. */ + .medal { + margin-right: 3px; + } .sc { font-weight: 700; font-variant-numeric: tabular-nums; @@ -2122,4 +2311,67 @@ font-size: 0.62rem; line-height: 1; } + /* --- offline hotseat --- */ + /* The locked-seat rack overlay: fills the rack tray with an "unlock" button, the board above + stays visible. */ + .unlock { + width: 100%; + height: 100%; + min-height: 44px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + .host-menu { + display: flex; + flex-direction: column; + gap: 8px; + } + .host-act, + .host-seat { + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-weight: 600; + text-align: left; + } + .host-act.danger { + border-color: var(--danger); + color: var(--danger); + } + .host-sub { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 4px; + } + .host-q { + margin: 0 0 12px; + font-weight: 600; + } + /* The transient success tick after a host override — a centred ✅ that fades out on a timer. */ + .host-done { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 4rem; + z-index: 50; + pointer-events: none; + animation: host-done-fade 1.1s ease forwards; + } + @keyframes host-done-fade { + 0% { opacity: 0; transform: scale(0.7); } + 25% { opacity: 1; transform: scale(1); } + 75% { opacity: 1; transform: scale(1); } + 100% { opacity: 0; transform: scale(1); } + } diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 7e33aaf..173f59d 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -725,17 +725,37 @@ function syncTelegramSafeArea(): void { 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 - * fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard). - * On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible - * without the page scrolling, so the layout no longer jumps when the keyboard appears. + * syncViewport mirrors the visual viewport into CSS vars so the pinned app-shell (app.css + * `html.app-shell body`) both FITS (`--vvh`) and FOLLOWS (`--vv-top`) the visible area above the soft + * keyboard. iOS Safari / WKWebView does not shrink the layout viewport for the keyboard (Android + * 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; - const vv = typeof window !== 'undefined' ? window.visualViewport : null; - const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0; + const win = typeof window !== 'undefined' ? window : null; + 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`); + 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 { setLocale(guess); } - // Track the visual-viewport height so screens fit above an open soft keyboard (--vvh). - syncViewportHeight(); + // Track the visual viewport (height + offset) so screens fit and follow the visible area above an + // 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) { - window.visualViewport.addEventListener('resize', syncViewportHeight); - window.visualViewport.addEventListener('scroll', syncViewportHeight); + window.visualViewport.addEventListener('resize', syncViewport); + window.visualViewport.addEventListener('scroll', syncViewport); + } + + // Enter on a single-line dismisses the soft keyboard (blur): the keyboard's default + // return/"Go" key otherwise does nothing on a bare input, which reads as stuck. A