Merge pull request 'feat(offline): offline lobby — list + create + play local vs_ai games [C6]' (#198) from feature/offline-lobby 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 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m43s

This commit was merged in pull request #198.
This commit is contained in:
2026-07-06 12:43:23 +00:00
12 changed files with 230 additions and 18 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
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
+11 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { gameSource, isLocalGameId } from '../lib/gamesource';
import { handleError, showToast } from '../lib/app.svelte'; import { handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { alphabetLetters } from '../lib/alphabet'; import { alphabetLetters } from '../lib/alphabet';
@@ -20,8 +21,10 @@
onMount(async () => { onMount(async () => {
try { try {
// Include the alphabet so input sanitising + the check accept the variant's letters. // Include the alphabet so input sanitising + the check accept the variant's letters. Routed
const st = await gateway.gameState(id, true); // through gameSource so an offline (local) game resolves its state from the device, not the
// network.
const st = await gameSource(id).gameState(id, true);
variant = st.game.variant; variant = st.game.variant;
} catch (e) { } catch (e) {
handleError(e); handleError(e);
@@ -41,7 +44,7 @@
cooling = true; cooling = true;
setTimeout(() => (cooling = false), 5000); setTimeout(() => (cooling = false), 5000);
try { try {
const r = await gateway.checkWord(id, w, variant); const r = await gameSource(id).checkWord(id, w, variant);
checked.set(w, r.legal); checked.set(w, r.legal);
result = { word: w, legal: r.legal }; result = { word: w, legal: r.legal };
} catch (e) { } catch (e) {
@@ -77,7 +80,11 @@
: t('game.wordIllegal', { word: result.word })} : t('game.wordIllegal', { word: result.word })}
</p> </p>
<div class="actions"> <div class="actions">
<button class="complain" onclick={complain}>{t('game.complain')}</button> <!-- Complaints go to the admin over the network; a local (offline) game has no backend to
receive them, so the control is dropped there. -->
{#if !isLocalGameId(id)}
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if}
{#if result.legal} {#if result.legal}
<a <a
class="lookup" class="lookup"
+4 -1
View File
@@ -21,8 +21,11 @@
// 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 // an AI 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
// 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 // svelte-ignore state_referenced_locally
let tab = $state<CommsTab>(initialTab); let tab = $state<CommsTab>(vsAi ? 'dictionary' : initialTab);
$effect(() => { $effect(() => {
if (vsAi) tab = 'dictionary'; if (vsAi) tab = 'dictionary';
else if (tab === 'dictionary' && !active) tab = 'chat'; else if (tab === 'dictionary' && !active) tab = 'chat';
+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));
} }
+28 -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;
@@ -87,6 +101,18 @@
// over the slower live stream; gating it keeps the card, its blink and the toast on one event. // over the slower live stream; gating it keeps the card, its blink and the toast on one event.
if (app.lastEvent && app.lastEvent.kind !== 'heartbeat') void load(); if (app.lastEvent && app.lastEvent.kind !== 'heartbeat') void load();
}); });
// Reload when the deliberate offline mode flips (the toggle lives in Settings, so the lobby can
// stay mounted across the change): entering offline must immediately drop the online games and
// show only the device-local ones, and leaving it must refetch the online lobby. Guarded so the
// initial run — already covered by onMount — does not double-load.
let wasOffline = offlineMode.active;
$effect(() => {
const now = offlineMode.active;
if (now !== wasOffline) {
wasOffline = now;
void load();
}
});
const myId = $derived(app.session?.userId ?? ''); const myId = $derived(app.session?.userId ?? '');
const groups = $derived(groupGames(games, myId, app.chatUnread)); const groups = $derived(groupGames(games, myId, app.chatUnread));
@@ -341,7 +367,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}