feat(ui): explicit offline state inside an online game (+ offline word check)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped

When an online game loses the connection the game screen now says so and freezes
instead of silently greying out: a "connection lost" banner appears and the rack,
the move controls, the add-friend/block controls and the chat/dictionary entry all
disable (a started move stays a draft, committed by the player on reconnect). It is
driven off the net-state machine (netState.offline), so it also covers the
Telegram/VK mini-apps, where a lost connection was previously mute in-game.

If the player is already in the dictionary when the drop happens, the word check
falls back to the game's pinned on-device dictionary (exact when that dawg is
cached; "unavailable offline" otherwise) and the network-only complaint + external
look-up hide. Chat, when already open, keeps its existing read-only degrade
(send/nudge disable). New pure helper localWordCheck is unit-tested; the in-game
gating gets an e2e.
This commit is contained in:
Ilia Denisov
2026-07-14 08:53:18 +02:00
parent c34e2a8dbf
commit 28afcff551
11 changed files with 212 additions and 11 deletions
+28 -3
View File
@@ -2,6 +2,8 @@
import { onMount } from 'svelte';
import { gateway } from '../lib/gateway';
import { gameSource, isLocalGameId } from '../lib/gamesource';
import { connection } from '../lib/connection.svelte';
import { localWordCheck } from '../lib/dict/check';
import { handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import { alphabetLetters } from '../lib/alphabet';
@@ -14,8 +16,12 @@
let { id }: { id: string } = $props();
let variant = $state<Variant>('scrabble_en');
let dictVersion = $state('');
let word = $state('');
let result = $state<{ word: string; legal: boolean } | null>(null);
// unavailable is set when an offline check cannot run because the game's pinned dictionary is not
// on the device (not cached/bundled); the panel then shows a neutral "offline" note.
let unavailable = $state(false);
let cooling = $state(false);
const checked = new Map<string, boolean>();
@@ -26,6 +32,7 @@
// network.
const st = await gameSource(id).gameState(id, true);
variant = st.game.variant;
dictVersion = st.game.dictVersion;
} catch (e) {
handleError(e);
}
@@ -33,6 +40,7 @@
function onInput(e: Event) {
word = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
unavailable = false;
}
// Disabled while cooling, for an already-checked word, or an out-of-range length.
function canCheck(): boolean {
@@ -43,7 +51,22 @@
const w = word.trim().toUpperCase();
cooling = true;
setTimeout(() => (cooling = false), 5000);
unavailable = false;
try {
// Offline in an online game the network check_word would fail, so fall back to the game's own
// pinned dictionary read on-device: exact when that dawg is cached/bundled, otherwise the check
// is unavailable. A local game already resolves checkWord on-device, so it keeps that path.
if (!isLocalGameId(id) && !connection.online) {
const legal = await localWordCheck(variant, dictVersion, w);
if (legal === null) {
result = null;
unavailable = true;
return;
}
checked.set(w, legal);
result = { word: w, legal };
return;
}
const r = await gameSource(id).checkWord(id, w, variant);
checked.set(w, r.legal);
result = { word: w, legal: r.legal };
@@ -73,7 +96,9 @@
/>
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div>
{#if result}
{#if unavailable}
<p class="verdict">{t('game.checkOffline')}</p>
{:else if result}
<p class="verdict" class:ok={result.legal} class:bad={!result.legal}>
{result.legal
? t('game.wordLegal', { word: result.word })
@@ -82,10 +107,10 @@
<div class="actions">
<!-- 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)}
{#if !isLocalGameId(id) && connection.online}
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if}
{#if result.legal}
{#if result.legal && connection.online}
<a
class="lookup"
href={dictionaryLookupUrl(result.word, variant)}
+23 -2
View File
@@ -15,6 +15,7 @@
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { netState } from '../lib/netstate.svelte';
import { maybeShowInterstitial } from '../lib/ads';
import { GatewayError } from '../lib/client';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
@@ -63,6 +64,10 @@
// disabled while disconnected or in offline mode (which also stops the "something went wrong"
// toasts a blocked call would otherwise raise).
const netReady = $derived(isLocalGameId(id) || (connection.online && !offlineMode.active));
// offlineInGame marks an online game that has lost the connection (confirmed offline, on any
// platform including Telegram/VK): the play area shows an explicit "connection lost" banner and
// the tray/actions freeze. It follows netState (not offlineMode, which is inert in the mini-apps).
const offlineInGame = $derived(!isLocalGameId(id) && netState.offline);
// Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
let localUnsub: (() => void) | null = null;
@@ -1463,6 +1468,7 @@
// a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids.
if (view?.game.vsAi || isLocalGameId(id)) return false;
return (
netReady &&
!!s.accountId &&
!app.profile?.isGuest &&
s.accountId !== app.session?.userId &&
@@ -1476,7 +1482,7 @@
// An already-blocked opponent hides it (both controls go, and the name is struck).
function canBlock(s: { accountId: string; seat: number }): boolean {
if (view?.game.vsAi || isLocalGameId(id)) return false;
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
return netReady && !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
}
</script>
@@ -1490,6 +1496,9 @@
selfInset={landscape}
>
{#if view}
{#if offlineInGame}
<div class="offline-banner" role="status">{t('game.offlineBanner')}</div>
{/if}
{#if landscape}
<div class="game-land">
<div class="leftpane">
@@ -1607,7 +1616,7 @@
— so drop the entry only when a local game is finished; an active local game keeps it for
the Dictionary. An online game always keeps it (chat outlives the game). -->
{#if !(gameOver && (view.game.vsAi || view.game.hotseat))}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
<button class="hicon" disabled={!netReady} onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
</button>
{/if}
@@ -1694,6 +1703,7 @@
draggingId={reorderDragId}
dropIndex={reorderTo}
confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined}
frozen={!netReady}
ondown={onRackDown}
/>
{/if}
@@ -2178,6 +2188,17 @@
color: var(--text-muted);
padding: 40px;
}
/* An online game that lost the connection: a slim, non-blocking strip at the top of the play area.
A solid token background (not color-mix) keeps it visible on the old-WebView floor. */
.offline-banner {
padding: 7px var(--pad);
text-align: center;
font-size: 0.85rem;
line-height: 1.25;
color: var(--text);
background: var(--surface-2);
border-bottom: 1px solid var(--border);
}
.ghost {
position: fixed;
width: 40px;
+10
View File
@@ -13,6 +13,7 @@
shuffling = false,
draggingId = null,
dropIndex = null,
frozen = false,
confirm,
ondown,
}: {
@@ -26,6 +27,10 @@
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
draggingId?: number | null;
dropIndex?: number | null;
/** frozen disables tile interaction (offline in an online game): tiles cannot be picked up to
* compose or reorder a move until the connection returns. The confirm control is gated
* separately by the parent. */
frozen?: boolean;
/** The confirm-move control, rendered as the fixed 7th slot of the rack while a play is staged
* (the parent owns the button and its enablement; the rack only positions it). */
confirm?: Snippet;
@@ -65,6 +70,7 @@
class:selected={selected === slot.index}
class:shift={dropIndex != null && i >= dropIndex}
data-rack-index={slot.index}
disabled={frozen}
animate:hop={shuffling}
onpointerdown={(e) => ondown(e, slot.index)}
>
@@ -114,6 +120,10 @@
outline: 3px solid var(--accent);
outline-offset: -3px;
}
/* Frozen (offline in an online game): the tray is inert until the connection returns. */
.tile:disabled {
opacity: 0.5;
}
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
tile width plus the rack gap), so the drop position is visible. */
.rack.reordering .tile {
+49
View File
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { setAlphabet } from '../alphabet';
// getDawg is the only side-effecting dependency; stub it so the test exercises localWordCheck's
// own logic (load -> encode -> membership -> null handling) against a fake reader, not a real DAWG.
vi.mock('./loader', () => ({ getDawg: vi.fn() }));
import { getDawg } from './loader';
import { localWordCheck } from './check';
const mockedGetDawg = vi.mocked(getDawg);
// A tiny cached alphabet so indexForLetter can encode the test words.
setAlphabet('scrabble_en', [
{ index: 0, letter: 'A', value: 1 },
{ index: 1, letter: 'B', value: 3 },
{ index: 2, letter: 'C', value: 3 },
]);
describe('localWordCheck', () => {
afterEach(() => vi.clearAllMocks());
it('returns true when the dawg contains the word', async () => {
mockedGetDawg.mockResolvedValue({ indexOf: () => 5 } as never);
expect(await localWordCheck('scrabble_en', 'v1', 'AB')).toBe(true);
});
it('returns false when the dawg does not contain the word', async () => {
mockedGetDawg.mockResolvedValue({ indexOf: () => -1 } as never);
expect(await localWordCheck('scrabble_en', 'v1', 'AB')).toBe(false);
});
it('returns null when the dawg is unavailable (offline and uncached)', async () => {
mockedGetDawg.mockResolvedValue(null);
expect(await localWordCheck('scrabble_en', 'v1', 'AB')).toBe(null);
});
it('encodes the word to alphabet indices for the membership test', async () => {
const indexOf = vi.fn(() => 0);
mockedGetDawg.mockResolvedValue({ indexOf } as never);
await localWordCheck('scrabble_en', 'v1', 'CAB');
expect(indexOf).toHaveBeenCalledWith([2, 0, 1]);
});
it('loads the dawg for the pinned (variant, version)', async () => {
mockedGetDawg.mockResolvedValue({ indexOf: () => 1 } as never);
await localWordCheck('scrabble_en', 'v1.3.0', 'AB');
expect(mockedGetDawg).toHaveBeenCalledWith('scrabble_en', 'v1.3.0');
});
});
+26
View File
@@ -0,0 +1,26 @@
// Offline word-check fallback for an online game. An online game's dictionary check normally goes to
// the gateway (game.check_word); while disconnected that call fails, so the check falls back to the
// game's own pinned dictionary read locally. Using the game's pinned (variant, version) means the
// answer matches the server's — no divergence — as long as that dawg is available on-device; if it
// is not cached/bundled offline, the check is simply unavailable (null). The membership test mirrors
// the local engine's dictionaryHas (encode to alphabet indices, then Dawg.indexOf).
import { getDawg } from './loader';
import { indexForLetter } from '../alphabet';
import type { Variant } from '../model';
/**
* localWordCheck reports whether word is in the (variant, version) dictionary using the on-device
* dawg, or null when that dawg cannot be obtained (offline and neither cached nor bundled). word
* must already be sanitised to the variant's alphabet (as the check panel does).
*/
export async function localWordCheck(
variant: Variant,
version: string,
word: string,
): Promise<boolean | null> {
const dawg = await getDawg(variant, version);
if (!dawg) return null;
const idx = Array.from(word, (ch) => indexForLetter(variant, ch));
return dawg.indexOf(idx) >= 0;
}
+2
View File
@@ -133,6 +133,8 @@ export const en = {
'game.complaintSent': 'Thanks, sent for review.',
'game.check': 'Check',
'game.checkWait': 'Please wait a moment.',
'game.checkOffline': 'Word check is unavailable offline.',
'game.offlineBanner': 'Connection lost — your move will send once youre back online.',
'game.noHintOptions': 'No options with your letters.',
'game.thinking': 'thinking…',
+2
View File
@@ -133,6 +133,8 @@ export const ru: Record<MessageKey, string> = {
'game.complaintSent': 'Спасибо, отправлено на проверку.',
'game.check': 'Проверить',
'game.checkWait': 'Секунду, пожалуйста.',
'game.checkOffline': 'Проверка недоступна офлайн.',
'game.offlineBanner': 'Связь потеряна — ход отправится, когда вернётся сеть.',
'game.noHintOptions': 'Нет вариантов с вашим набором.',
'game.thinking': 'думает…',