From ef832b823de95f6d48da830c2c3c0ef2226a2073 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 11:35:23 +0200 Subject: [PATCH] feat(offline): offline lobby lists + creates local vs_ai games MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In offline mode the lobby now shows only the device-local games and its New-vs-AI entry creates one through the in-browser engine — the visible payoff of the offline mode. - LocalSource.list() reconstructs a lobby GameView per stored local game by replay, exposed through the lazy gamesource proxy; unit-tested via an in-memory store. - Lobby.load() branches on offlineMode: lists local games and skips every gateway call (no online games/invitations/incoming); the Stats tab is disabled offline. - NewGame offline: find() creates a device-local vs_ai game via LocalSource.create using the profile's advertised dict version + a local seed; the friends flow and the random-opponent option are hidden, and the variant picker / Start are enabled offline (were gated on connection). - id.ts: newLocalGameId + randomSeed (tested). Docs: ARCHITECTURE + FUNCTIONAL(+ru) offline-mode section. Deferred to fast-follow: the Settings Friends/Profile/Feedback affordance gating, the flip-to-offline readiness wait, the offline mock e2e (needs mock-dawg support), and the local-hint UI. The offline flow is verified on the test contour — the mock e2e cannot enter offline mode (the toggle is gated to an installed PWA). --- docs/ARCHITECTURE.md | 8 +++- docs/FUNCTIONAL.md | 12 +++++ docs/FUNCTIONAL_ru.md | 12 +++++ ui/src/lib/gamesource.ts | 3 +- ui/src/lib/localgame/id.test.ts | 24 ++++++++++ ui/src/lib/localgame/id.ts | 11 +++++ ui/src/lib/localgame/source.list.test.ts | 61 ++++++++++++++++++++++++ ui/src/lib/localgame/source.ts | 22 ++++++++- ui/src/screens/Lobby.svelte | 18 ++++++- ui/src/screens/NewGame.svelte | 45 ++++++++++++++--- 10 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 ui/src/lib/localgame/id.test.ts create mode 100644 ui/src/lib/localgame/source.list.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1a19ac6..18ca0e9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1248,8 +1248,12 @@ and the Connect stream and runtime API POSTs are never precached nor intercepted 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. To have data ready before the -switch, the **Profile advertises the current dictionary version per variant** (`dict_versions`, +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. To have data ready before the switch, the **Profile advertises the current dictionary +version per variant** (`dict_versions`, filled from the registry on the existing cold-start profile request — no extra round-trip), and an eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries — on lobby entry and on a variant-preference change — through the same three-tier loader, retried diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 39ffc2b..ec05603 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -256,6 +256,18 @@ the same occasional move against its plan that fades out by the endgame), and ch add-friend are off. AI games are **practice** — they never count toward a player's statistics. +### 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 +*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 +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 +(once installed) launches from a precached shell even with no network. The mode is device-scoped and +sticky across launches. + ### Social: friends, block, chat, nudge Become friends in two ways: redeem a **one-time code** the other player issues (six digits, valid for twelve hours), or send a **request to someone you have played diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 1c6700e..32d198a 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -261,6 +261,18 @@ e-mail) либо ввод фразы. Активные игры форфейтя паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока. +### Офлайн-режим +Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный +**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с +меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности +отключены или скрыты (вкладка Статистика; варианты «с другом» и «случайный соперник» в Новой игре). +Играть можно только в **vs_ai** — Новая игра создаёт локальную игру против робота, который ходит +целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и +никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение +заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки) +запускается из прекешированного шелла даже без сети. Режим привязан к устройству и сохраняется между +запусками. + ### Социальное: друзья, блок, чат, nudge Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку diff --git a/ui/src/lib/gamesource.ts b/ui/src/lib/gamesource.ts index a8ecd1a..e35bb38 100644 --- a/ui/src/lib/gamesource.ts +++ b/ui/src/lib/gamesource.ts @@ -39,6 +39,7 @@ export const localSource = { draftGet: (_id) => load().then((s) => s.draftGet()), draftSave: (_id, _json) => load().then((s) => s.draftSave()), create: (opts) => load().then((s) => s.create(opts)), + list: () => load().then((s) => s.list()), // events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires // the real subscription once the engine loads and, until then, cancels a pending subscribe. events: (id, onEvent) => { @@ -52,7 +53,7 @@ export const localSource = { unsub(); }; }, -} satisfies GameLoopSource & Pick; +} satisfies GameLoopSource & Pick; /** * gameSource returns the source that runs the game with the given id: the local engine for a local diff --git a/ui/src/lib/localgame/id.test.ts b/ui/src/lib/localgame/id.test.ts new file mode 100644 index 0000000..c47d67e --- /dev/null +++ b/ui/src/lib/localgame/id.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { LOCAL_ID_PREFIX, isLocalGameId, newLocalGameId, randomSeed } from './id'; + +describe('local game id helpers', () => { + it('recognises local ids by prefix', () => { + expect(isLocalGameId(LOCAL_ID_PREFIX + 'x')).toBe(true); + expect(isLocalGameId('deadbeef')).toBe(false); + }); + + it('mints unique local ids', () => { + const a = newLocalGameId(); + const b = newLocalGameId(); + expect(isLocalGameId(a)).toBe(true); + expect(a).not.toBe(b); + }); + + it('produces a bigint seed in the 32-bit range', () => { + for (let i = 0; i < 50; i++) { + const s = randomSeed(); + expect(typeof s).toBe('bigint'); + expect(s >= 0n && s <= 0xffffffffn).toBe(true); + } + }); +}); diff --git a/ui/src/lib/localgame/id.ts b/ui/src/lib/localgame/id.ts index cbeda4b..bba13ef 100644 --- a/ui/src/lib/localgame/id.ts +++ b/ui/src/lib/localgame/id.ts @@ -10,3 +10,14 @@ export const LOCAL_ID_PREFIX = 'local:'; export function isLocalGameId(id: string): boolean { return id.startsWith(LOCAL_ID_PREFIX); } + +/** newLocalGameId mints a fresh, device-unique local game id (time + random suffix — no crypto API, + * so it also works on the older engines the SPA still supports). */ +export function newLocalGameId(): string { + return `${LOCAL_ID_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** randomSeed returns a random 32-bit seed for a new local game's deterministic bag. */ +export function randomSeed(): bigint { + return BigInt(Math.floor(Math.random() * 0xffffffff)); +} diff --git a/ui/src/lib/localgame/source.list.test.ts b/ui/src/lib/localgame/source.list.test.ts new file mode 100644 index 0000000..6d8fd46 --- /dev/null +++ b/ui/src/lib/localgame/source.list.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { LocalGameRecord } from './serialize'; + +// An in-memory stand-in for the IndexedDB store, so list() — which reads the *persisted* records, +// not the source's in-memory cache — can be exercised in the node env (the real store is a +// best-effort no-op without IndexedDB). Declared before the vi.mock factory that closes over it. +const store = new Map(); +vi.mock('./store', () => ({ + saveLocalGame: async (r: LocalGameRecord) => void store.set(r.id, r), + getLocalGame: async (id: string) => store.get(id) ?? null, + listLocalGames: async () => [...store.values()], + deleteLocalGame: async (id: string) => void store.delete(id), +})); +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'; + +const seats: Seat[] = [ + { kind: 'human', name: 'You' }, + { kind: 'robot', name: 'Robot' }, +]; +const opts = (id: string, seed: bigint) => ({ + id, + variant: 'scrabble_en' as const, + dictVersion: 'sample', + seed, + multipleWords: true, + seats, +}); + +beforeEach(() => store.clear()); + +describe('LocalSource.list', () => { + it('is empty when no games are stored', async () => { + expect(await new LocalSource().list()).toEqual([]); + }); + + it('returns a lobby GameView for every stored local game', async () => { + const src = new LocalSource(); + await src.create(opts('local:a', 1n)); + await src.create(opts('local:b', 2n)); + const list = await src.list(); + expect(new Set(list.map((g) => g.id))).toEqual(new Set(['local:a', 'local:b'])); + expect(list.every((g) => g.vsAi && g.status === 'active')).toBe(true); + }); + + it('reconstructs a game persisted by a previous session (not in the source cache)', async () => { + // Seed the store via one source, then list from a fresh source whose in-memory cache is empty. + await new LocalSource().create(opts('local:old', 7n)); + const list = await new LocalSource().list(); + expect(list.map((g) => g.id)).toEqual(['local:old']); + expect(list[0].variant).toBe('scrabble_en'); + }); +}); diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 213b12f..35ef2e0 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -10,7 +10,7 @@ import { LocalGame, type LocalMove } from './engine'; import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat } from './serialize'; -import { getLocalGame, saveLocalGame } from './store'; +import { getLocalGame, saveLocalGame, listLocalGames } from './store'; import { RULESETS } from './ruleset'; import { getDawg } from '../dict'; import { LOCAL_ID_PREFIX, isLocalGameId } from './id'; @@ -131,6 +131,26 @@ export class LocalSource implements GameLoopSource { return this.stateView(entry); } + /** + * list returns a lobby GameView for every stored local game, most-recently-updated first, so the + * offline lobby renders them with the same card machinery as the online games. Each is + * reconstructed by replay; a record whose dictionary is unavailable (e.g. offline without its + * dawg) is skipped, since it cannot be rendered — never throws. + */ + async list(): Promise { + const records = await listLocalGames(); + records.sort((a, b) => b.updatedAtUnix - a.updatedAtUnix); + const views: GameView[] = []; + for (const record of records) { + try { + views.push(this.gameView(await this.load(record.id))); + } catch { + /* a record that cannot be replayed (its dawg is not cached offline) is left out */ + } + } + return views; + } + async gameState(gameId: string): Promise { return this.stateView(await this.load(gameId)); } diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 3a45642..c0ce6df 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -13,7 +13,8 @@ import { badgeKind } from '../lib/unread'; import { getLobby, setLobby } from '../lib/lobbycache'; import { preloadGames } from '../lib/preload'; - import { kickDictPreload } from '../lib/offline.svelte'; + import { kickDictPreload, offlineMode } from '../lib/offline.svelte'; + import { localSource } from '../lib/gamesource'; import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort'; import type { AccountRef, GameView, Invitation } from '../lib/model'; import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants'; @@ -34,6 +35,19 @@ const settingsBadge = $derived(app.notifications + (app.feedbackReplyUnread ? 1 : 0)); async function load() { + // Deliberate offline mode: never touch the network. The lobby shows only the device-local + // vs_ai games (reconstructed from the store) plus the New-vs-AI entry; there are no online + // games, invitations or incoming requests to fetch. + if (offlineMode.active) { + games = await localSource.list(); + invitations = []; + incoming = []; + atGameLimit = false; + app.notifications = 0; + setLobby({ games, invitations, incoming, atGameLimit }); + app.lobbyReady = true; + return; + } try { const list = await gateway.gamesList(); games = list.games; @@ -341,7 +355,7 @@ - @@ -125,17 +154,19 @@ {/if} {#if mode === 'auto'} -
- - -
+ {#if !offlineMode.active} +
+ + +
+ {/if}
{#each variants as v (v.id)} {:else if friends.length === 0}