release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0 #212
@@ -1259,7 +1259,11 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
|
||||
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
||||
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
|
||||
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
|
||||
the ad-banner slot. A **cold launch already in offline mode** boots from the persisted session and
|
||||
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
|
||||
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
||||
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
||||
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
|
||||
mode** boots from the persisted session and
|
||||
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
||||
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
||||
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
||||
|
||||
+5
-2
@@ -265,8 +265,11 @@ Only **vs_ai** games are playable — New Game creates a device-local game again
|
||||
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.
|
||||
(once installed) launches from a precached shell even with no network. Switching **to** offline first
|
||||
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
|
||||
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
|
||||
(the download keeps running in the background, so a later switch is instant). The mode is
|
||||
device-scoped and sticky across launches.
|
||||
|
||||
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
|
||||
with no connection it switches to offline for that session; when the device is online but the gateway
|
||||
|
||||
@@ -270,8 +270,11 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||
запускается из прекешированного шелла даже без сети. Режим привязан к устройству и сохраняется между
|
||||
запусками.
|
||||
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
||||
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
||||
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
||||
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
||||
сохраняется между запусками.
|
||||
|
||||
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
||||
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Browser orchestration for the offline-toggle readiness wait. Lazily imported by
|
||||
// offline.svelte.ts's requestOffline so the dict loader/generator it pulls in stays out of the main
|
||||
// bundle. It runs the same cache-first preload as the background warmup (preload.ts), but bounded by
|
||||
// a UI wait: it tells the toggle whether flipping to offline can succeed right now, and leaves the
|
||||
// fetch running on a timeout so a later flip is instant.
|
||||
|
||||
import type { Profile } from '../model';
|
||||
import { preloadDicts } from './preload';
|
||||
import { getDawg, dictLoadingDisabled } from '../dict';
|
||||
import { raceOfflineReady } from '../offline';
|
||||
|
||||
/**
|
||||
* ensureOfflineDicts fetches the profile's enabled variants' dictionaries cache-first (instant when
|
||||
* already warm, a network fetch otherwise) and reports, within budgetMs, whether every one is
|
||||
* available — the offline toggle's readiness gate. With no enabled variant there is nothing to play
|
||||
* offline, so it is never ready. The fetch is not aborted on a timeout; it keeps warming the
|
||||
* on-device cache in the background so a later flip to offline succeeds immediately.
|
||||
*/
|
||||
export async function ensureOfflineDicts(prof: Profile, budgetMs: number): Promise<boolean> {
|
||||
if (prof.variantPreferences.length === 0) return false;
|
||||
const run = preloadDicts(prof.dictVersions, prof.variantPreferences, {
|
||||
getDawg,
|
||||
disabled: dictLoadingDisabled,
|
||||
retries: 1,
|
||||
});
|
||||
return raceOfflineReady(run, budgetMs);
|
||||
}
|
||||
@@ -217,6 +217,8 @@ export const en = {
|
||||
'settings.offlineMode': 'Play mode',
|
||||
'settings.online': 'Online',
|
||||
'settings.offline': 'Offline',
|
||||
'settings.offlineChecking': 'Loading dictionaries…',
|
||||
'settings.offlineNeedsData': 'Not enough data on the device. Internet access is needed to download it.',
|
||||
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
|
||||
'offline.promptTitle': 'No connection. Enable offline mode?',
|
||||
'offline.promptYes': 'Enable',
|
||||
|
||||
@@ -217,6 +217,8 @@ export const ru: Record<MessageKey, string> = {
|
||||
'settings.offlineMode': 'Режим игры',
|
||||
'settings.online': 'Онлайн',
|
||||
'settings.offline': 'Оффлайн',
|
||||
'settings.offlineChecking': 'Загрузка словарей…',
|
||||
'settings.offlineNeedsData': 'Недостаточно данных. Необходим доступ в интернет для загрузки.',
|
||||
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
|
||||
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
|
||||
'offline.promptYes': 'Включить',
|
||||
|
||||
@@ -40,6 +40,26 @@ export function setOfflineMode(on: boolean, persist = true): void {
|
||||
if (persist) saveOfflinePref(on);
|
||||
}
|
||||
|
||||
/** The toggle-flip readiness wait: entering offline waits at most this long for the enabled
|
||||
* variants' dictionaries before reverting to online (the fetch then continues in the background). */
|
||||
export const TOGGLE_READY_BUDGET_MS = 5000;
|
||||
|
||||
/**
|
||||
* requestOffline attempts to enter offline mode from the Settings toggle. It fetches the enabled
|
||||
* variants' dictionaries cache-first and, if every one is available within the readiness budget,
|
||||
* switches to a deliberate (persisted) offline mode and returns true. Otherwise it stays online and
|
||||
* returns false — the caller shows the "needs internet" note — while the fetch keeps warming the
|
||||
* cache so a later flip is instant. The readiness glue and the dict loader are imported dynamically,
|
||||
* so neither is pulled into the main bundle. Returns false with no profile.
|
||||
*/
|
||||
export async function requestOffline(prof: Profile | null, budgetMs = TOGGLE_READY_BUDGET_MS): Promise<boolean> {
|
||||
if (!prof) return false;
|
||||
const m = await import('./dict/offlineready');
|
||||
const ready = await m.ensureOfflineDicts(prof, budgetMs);
|
||||
if (ready) setOfflineMode(true);
|
||||
return ready;
|
||||
}
|
||||
|
||||
// The dict-preload warning: true when a first-lobby background preload could not fetch every
|
||||
// enabled variant's dictionary (typically a poor connection), so offline mode may be incomplete.
|
||||
// The lobby shows a notice in place of the ad banner while it holds.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline } from './offline';
|
||||
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline, raceOfflineReady } from './offline';
|
||||
import type { Variant } from './model';
|
||||
|
||||
// A minimal in-memory localStorage for the persistence tests (node has none).
|
||||
@@ -53,3 +53,25 @@ describe('offline mode helpers', () => {
|
||||
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('raceOfflineReady (toggle-flip readiness wait)', () => {
|
||||
// A sleep that never elapses: the fetch always wins the race, exercising its resolution.
|
||||
const never = (): Promise<void> => new Promise<void>(() => {});
|
||||
// A sleep that elapses immediately: the budget always wins the race, exercising the timeout.
|
||||
const now = (): Promise<void> => Promise.resolve();
|
||||
|
||||
it('is ready when the fetch resolves with nothing failed before the budget', async () => {
|
||||
const run = Promise.resolve({ failed: [] as Variant[] });
|
||||
expect(await raceOfflineReady(run, 5000, never)).toBe(true);
|
||||
});
|
||||
|
||||
it('is not ready when a variant is still missing after the fetch', async () => {
|
||||
const run = Promise.resolve({ failed: ['erudit_ru'] as Variant[] });
|
||||
expect(await raceOfflineReady(run, 5000, never)).toBe(false);
|
||||
});
|
||||
|
||||
it('is not ready when the budget elapses before the fetch resolves', async () => {
|
||||
const run = new Promise<{ failed: Variant[] }>(() => {}); // never resolves
|
||||
expect(await raceOfflineReady(run, 5000, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,23 @@ export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant)
|
||||
return enabled.filter((v) => !hasDict(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* raceOfflineReady runs the dictionary fetch `run` against a `budgetMs` wait and reports whether
|
||||
* offline mode can be entered now: ready only when the fetch resolves with nothing still failed
|
||||
* before the budget elapses. On a timeout the caller stops waiting but does NOT abort `run` — it
|
||||
* keeps warming the on-device cache so a later flip to offline is instant. The sleep is injected so
|
||||
* the logic stays pure and unit-tests in the node env.
|
||||
*/
|
||||
export async function raceOfflineReady(
|
||||
run: Promise<{ failed: readonly unknown[] }>,
|
||||
budgetMs: number,
|
||||
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||
): Promise<boolean> {
|
||||
const elapsed = sleep(budgetMs).then(() => null);
|
||||
const res = await Promise.race([run, elapsed]);
|
||||
return res !== null && res.failed.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* offlinePreloadEligible reports whether a background dictionary preload should run in this
|
||||
* context: an installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import type { BoardLabelMode } from '../lib/boardlabels';
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
import { insideVK } from '../lib/vk';
|
||||
import { offlineMode, setOfflineMode } from '../lib/offline.svelte';
|
||||
import { offlineMode, setOfflineMode, requestOffline } from '../lib/offline.svelte';
|
||||
import { isStandalone } from '../lib/pwa';
|
||||
import InstallApp from '../components/InstallApp.svelte';
|
||||
|
||||
@@ -35,6 +35,25 @@
|
||||
classic: 'settings.labelsClassic',
|
||||
none: 'settings.labelsNone',
|
||||
};
|
||||
|
||||
// The offline toggle gates entry on dictionary readiness: flipping to offline fetches the enabled
|
||||
// variants' dictionaries (cache-first) and waits at most a few seconds; if they cannot be made
|
||||
// available it stays online and shows the "needs internet" note, while the fetch keeps warming the
|
||||
// cache in the background so a later flip is instant. Leaving offline (-> online) is never gated.
|
||||
let checking = $state(false);
|
||||
let needsData = $state(false);
|
||||
|
||||
async function goOffline(): Promise<void> {
|
||||
if (offlineMode.active || checking) return;
|
||||
needsData = false;
|
||||
checking = true;
|
||||
try {
|
||||
const ready = await requestOffline(app.profile);
|
||||
if (!ready) needsData = true;
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
@@ -89,13 +108,26 @@
|
||||
<section>
|
||||
<h3>{t('settings.offlineMode')}</h3>
|
||||
<div class="seg">
|
||||
<button class="opt" class:active={!offlineMode.active} onclick={() => setOfflineMode(false)}>
|
||||
<button
|
||||
class="opt"
|
||||
class:active={!offlineMode.active}
|
||||
disabled={checking}
|
||||
onclick={() => {
|
||||
needsData = false;
|
||||
setOfflineMode(false);
|
||||
}}
|
||||
>
|
||||
{t('settings.online')}
|
||||
</button>
|
||||
<button class="opt" class:active={offlineMode.active} onclick={() => setOfflineMode(true)}>
|
||||
<button class="opt" class:active={offlineMode.active} disabled={checking} onclick={goOffline}>
|
||||
{t('settings.offline')}
|
||||
</button>
|
||||
</div>
|
||||
{#if checking}
|
||||
<p class="onote">{t('settings.offlineChecking')}</p>
|
||||
{:else if needsData}
|
||||
<p class="onote onote--warn" role="alert">{t('settings.offlineNeedsData')}</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -139,6 +171,18 @@
|
||||
color: var(--accent-text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.opt:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
.onote {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.onote--warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user