feat(offline): offline lobby lists + creates local vs_ai games
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s

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).
This commit is contained in:
Ilia Denisov
2026-07-06 11:35:23 +02:00
parent 99f0a545be
commit ef832b823d
10 changed files with 203 additions and 13 deletions
+6 -2
View File
@@ -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 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. To have data ready before the an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
switch, the **Profile advertises the current dictionary version per variant** (`dict_versions`, 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 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 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 — on lobby entry and on a variant-preference change — through the same three-tier loader, retried
+12
View File
@@ -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 add-friend are off. AI games are **practice** — they never count toward a player's
statistics. 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 ### Social: friends, block, chat, nudge
Become friends in two ways: redeem a **one-time code** the other player issues (six 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 digits, valid for twelve hours), or send a **request to someone you have played
+12
View File
@@ -261,6 +261,18 @@ e-mail) либо ввод фразы. Активные игры форфейтя
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока. редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
отключены или скрыты (вкладка Статистика; варианты «с другом» и «случайный соперник» в Новой игре).
Играть можно только в **vs_ai** — Новая игра создаёт локальную игру против робота, который ходит
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
запускается из прекешированного шелла даже без сети. Режим привязан к устройству и сохраняется между
запусками.
### Социальное: друзья, блок, чат, nudge ### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
+2 -1
View File
@@ -39,6 +39,7 @@ export const localSource = {
draftGet: (_id) => load().then((s) => s.draftGet()), draftGet: (_id) => load().then((s) => s.draftGet()),
draftSave: (_id, _json) => load().then((s) => s.draftSave()), draftSave: (_id, _json) => load().then((s) => s.draftSave()),
create: (opts) => load().then((s) => s.create(opts)), 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 // 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) => {
@@ -52,7 +53,7 @@ export const localSource = {
unsub(); unsub();
}; };
}, },
} satisfies GameLoopSource & Pick<LocalSource, 'events' | 'create'>; } satisfies GameLoopSource & Pick<LocalSource, 'events' | 'create' | 'list'>;
/** /**
* 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
+24
View File
@@ -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);
}
});
});
+11
View File
@@ -10,3 +10,14 @@ export const LOCAL_ID_PREFIX = 'local:';
export function isLocalGameId(id: string): boolean { export function isLocalGameId(id: string): boolean {
return id.startsWith(LOCAL_ID_PREFIX); 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));
}
+61
View File
@@ -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<string, LocalGameRecord>();
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');
});
});
+21 -1
View File
@@ -10,7 +10,7 @@
import { LocalGame, type LocalMove } from './engine'; import { LocalGame, type LocalMove } from './engine';
import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat } from './serialize'; 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 { RULESETS } from './ruleset';
import { getDawg } from '../dict'; import { getDawg } from '../dict';
import { LOCAL_ID_PREFIX, isLocalGameId } from './id'; import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
@@ -131,6 +131,26 @@ export class LocalSource implements GameLoopSource {
return this.stateView(entry); 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<GameView[]> {
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<StateView> { async gameState(gameId: string): Promise<StateView> {
return this.stateView(await this.load(gameId)); return this.stateView(await this.load(gameId));
} }
+16 -2
View File
@@ -13,7 +13,8 @@
import { badgeKind } from '../lib/unread'; import { badgeKind } from '../lib/unread';
import { getLobby, setLobby } from '../lib/lobbycache'; import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload'; 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 { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model'; import type { AccountRef, GameView, Invitation } from '../lib/model';
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants'; import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
@@ -34,6 +35,19 @@
const settingsBadge = $derived(app.notifications + (app.feedbackReplyUnread ? 1 : 0)); const settingsBadge = $derived(app.notifications + (app.feedbackReplyUnread ? 1 : 0));
async function load() { 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 { try {
const list = await gateway.gamesList(); const list = await gateway.gamesList();
games = list.games; games = list.games;
@@ -341,7 +355,7 @@
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}> <button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span> <span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
</button> </button>
<button class="tab" onclick={() => navigate('/stats')}> <button class="tab" disabled={offlineMode.active} onclick={() => navigate('/stats')}>
<span class="sq" data-coach="lobby-stats">✏️</span><span class="lbl">{t('lobby.stats')}</span> <span class="sq" data-coach="lobby-stats">✏️</span><span class="lbl">{t('lobby.stats')}</span>
</button> </button>
<button class="tab" onclick={() => navigate('/settings')}> <button class="tab" onclick={() => navigate('/settings')}>
+38 -7
View File
@@ -4,6 +4,9 @@
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';
import { offlineMode } from '../lib/offline.svelte';
import { localSource } from '../lib/gamesource';
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 type { AccountRef, Variant } from '../lib/model'; import type { AccountRef, Variant } from '../lib/model';
@@ -54,6 +57,30 @@
if (starting) return; if (starting) return;
starting = true; starting = true;
try { try {
// Offline mode: create a device-local vs_ai game instead of enqueuing on the backend. The
// dictionary version comes from the profile (advertised for exactly this), and the bag is
// seeded locally; the same game screen then drives it through the local source.
if (offlineMode.active) {
const version = app.profile?.dictVersions?.[v];
if (!version) {
handleError(new Error('dict_unavailable'));
return;
}
const id = newLocalGameId();
await localSource.create({
id,
variant: v,
dictVersion: version,
seed: randomSeed(),
multipleWords: multipleWordsForRequest(v, multipleWords),
seats: [
{ kind: 'human', name: app.profile?.displayName ?? 'You' },
{ kind: 'robot', name: 'Robot' },
],
});
navigate(`/game/${id}`);
return;
}
const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords), opponent === 'ai'); const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords), opponent === 'ai');
if (r.game) navigate(`/game/${r.game.id}`); if (r.game) navigate(`/game/${r.game.id}`);
} catch (e) { } catch (e) {
@@ -117,7 +144,9 @@
<Screen title={t('new.title')} back="/"> <Screen title={t('new.title')} back="/">
<div class="page"> <div class="page">
{#if !guest} <!-- Offline mode offers only a local vs_ai game: the friends flow needs the network, so the
auto/friends selector is hidden and the auto (vs_ai) flow shows on its own. -->
{#if !guest && !offlineMode.active}
<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>
@@ -125,17 +154,19 @@
{/if} {/if}
{#if mode === 'auto'} {#if mode === 'auto'}
<div class="seg modes"> {#if !offlineMode.active}
<button class="opt" class:active={opponent === 'ai'} onclick={() => (opponent = 'ai')}>🤖 {t('new.opponentAI')}</button> <div class="seg modes">
<button class="opt" class:active={opponent === 'random'} onclick={() => (opponent = 'random')}>👤 {t('new.opponentRandom')}</button> <button class="opt" class:active={opponent === 'ai'} onclick={() => (opponent = 'ai')}>🤖 {t('new.opponentAI')}</button>
</div> <button class="opt" class:active={opponent === 'random'} onclick={() => (opponent = 'random')}>👤 {t('new.opponentRandom')}</button>
</div>
{/if}
<div class="variants"> <div class="variants">
{#each variants as v (v.id)} {#each variants as v (v.id)}
<button <button
class="variant" class="variant"
class:selected={selectedAuto === v.id} class:selected={selectedAuto === v.id}
onclick={() => (selectedAuto = v.id)} onclick={() => (selectedAuto = v.id)}
disabled={!connection.online} disabled={!connection.online && !offlineMode.active}
> >
<span class="vmain"> <span class="vmain">
<span class="vname">{t(v.label)}</span> <span class="vname">{t(v.label)}</span>
@@ -160,7 +191,7 @@
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if} {#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
<button <button
class="invite" class="invite"
disabled={!selectedAuto || !connection.online || 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 friends.length === 0} {:else if friends.length === 0}