feat(offline): offline lobby — list + create + play local vs_ai games [C6] #198

Merged
developer merged 3 commits from feature/offline-lobby into development 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
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
+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
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
+12
View File
@@ -261,6 +261,18 @@ e-mail) либо ввод фразы. Активные игры форфейтя
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
отключены или скрыты (вкладка Статистика; варианты «с другом» и «случайный соперник» в Новой игре).
Играть можно только в **vs_ai** — Новая игра создаёт локальную игру против робота, который ходит
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
запускается из прекешированного шелла даже без сети. Режим привязан к устройству и сохраняется между
запусками.
### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
+11 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { gateway } from '../lib/gateway';
import { gameSource, isLocalGameId } from '../lib/gamesource';
import { handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import { alphabetLetters } from '../lib/alphabet';
@@ -20,8 +21,10 @@
onMount(async () => {
try {
// Include the alphabet so input sanitising + the check accept the variant's letters.
const st = await gateway.gameState(id, true);
// Include the alphabet so input sanitising + the check accept the variant's letters. Routed
// 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;
} catch (e) {
handleError(e);
@@ -41,7 +44,7 @@
cooling = true;
setTimeout(() => (cooling = false), 5000);
try {
const r = await gateway.checkWord(id, w, variant);
const r = await gameSource(id).checkWord(id, w, variant);
checked.set(w, r.legal);
result = { word: w, legal: r.legal };
} catch (e) {
@@ -77,7 +80,11 @@
: t('game.wordIllegal', { word: result.word })}
</p>
<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}
<a
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:
// an AI 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.
// svelte-ignore state_referenced_locally
let tab = $state<CommsTab>(initialTab);
let tab = $state<CommsTab>(vsAi ? 'dictionary' : initialTab);
$effect(() => {
if (vsAi) tab = 'dictionary';
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()),
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<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
+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 {
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 { 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<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> {
return this.stateView(await this.load(gameId));
}
+28 -2
View File
@@ -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;
@@ -87,6 +101,18 @@
// 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();
});
// 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 groups = $derived(groupGames(games, myId, app.chatUnread));
@@ -341,7 +367,7 @@
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
</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>
</button>
<button class="tab" onclick={() => navigate('/settings')}>
+38 -7
View File
@@ -4,6 +4,9 @@
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.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 { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { AccountRef, Variant } from '../lib/model';
@@ -54,6 +57,30 @@
if (starting) return;
starting = true;
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');
if (r.game) navigate(`/game/${r.game.id}`);
} catch (e) {
@@ -117,7 +144,9 @@
<Screen title={t('new.title')} back="/">
<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">
<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>
@@ -125,17 +154,19 @@
{/if}
{#if mode === 'auto'}
<div class="seg modes">
<button class="opt" class:active={opponent === 'ai'} onclick={() => (opponent = 'ai')}>🤖 {t('new.opponentAI')}</button>
<button class="opt" class:active={opponent === 'random'} onclick={() => (opponent = 'random')}>👤 {t('new.opponentRandom')}</button>
</div>
{#if !offlineMode.active}
<div class="seg modes">
<button class="opt" class:active={opponent === 'ai'} onclick={() => (opponent = 'ai')}>🤖 {t('new.opponentAI')}</button>
<button class="opt" class:active={opponent === 'random'} onclick={() => (opponent = 'random')}>👤 {t('new.opponentRandom')}</button>
</div>
{/if}
<div class="variants">
{#each variants as v (v.id)}
<button
class="variant"
class:selected={selectedAuto === v.id}
onclick={() => (selectedAuto = v.id)}
disabled={!connection.online}
disabled={!connection.online && !offlineMode.active}
>
<span class="vmain">
<span class="vname">{t(v.label)}</span>
@@ -160,7 +191,7 @@
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
<button
class="invite"
disabled={!selectedAuto || !connection.online || starting}
disabled={!selectedAuto || (!connection.online && !offlineMode.active) || starting}
onclick={() => selectedAuto && find(selectedAuto)}
>{t('new.start')}</button>
{:else if friends.length === 0}