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
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:
@@ -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;
|
||||
@@ -341,7 +355,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