release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0 #212
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -261,6 +261,18 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
|
||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||
|
||||
### Офлайн-режим
|
||||
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
||||
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
||||
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
||||
отключены или скрыты (вкладка Статистика; варианты «с другом» и «случайный соперник» в Новой игре).
|
||||
Играть можно только в **vs_ai** — Новая игра создаёт локальную игру против робота, который ходит
|
||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||
запускается из прекешированного шелла даже без сети. Режим привязан к устройству и сохраняется между
|
||||
запусками.
|
||||
|
||||
### Социальное: друзья, блок, чат, nudge
|
||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user