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)}