diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 23c8f9c..610c987 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -118,10 +118,17 @@ dropped). Horizontal scaling is explicit future work. one whose response was lost — its button is disabled while offline and re-issued on reconnect). A reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's - drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an - offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in - those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no - local lobby, no transport kill switch and no device-local create paths. + drop/recovery feeds the same machine. **Telegram/VK are exempt from the offline-*play* model** — + `offlineMode.active` is hard-gated on `offlineCapable()` (false in those mini-apps), so nothing — not + even a version lock — puts them in offline mode: no blue chrome, no local lobby, no transport kill + switch and no device-local create paths. The machine still tracks reachability there, though: a + connection lost **inside an online game** is surfaced on **every platform**, driven off the machine's + confirmed-offline state (`netState.offline`, not `offlineMode.active`) — the game screen shows a + *connection lost* banner and **freezes** the tray, the move controls, the add-friend/block controls and + the chat/dictionary entry (a started move stays a draft, re-committed by the player on reconnect), and + the word-check tool falls back to the game's pinned on-device dictionary (`ui/src/lib/dict/check.ts`, + exact when that dawg is cached, else *unavailable*) with its network-only complaint and external + look-up hidden. **Edge hardening:** every request body on the public listener is capped at `GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 2f1a744..f5a7951 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -229,7 +229,10 @@ brief warm-up shows on the first open otherwise — and falls back to the server local dictionary is unavailable. The dictionary check tool is unlimited and offers a complaint on any result; for a word it finds, it also links out to an external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for -English) to look it up. Hints are governed per game — +English) to look it up. While offline in an online game the check runs against the game's own +dictionary on the device — the same verdict as the server when that dictionary is available, +otherwise it reads *unavailable offline* — and the complaint and the external look-up, which both +need the network, are hidden. Hints are governed per game — whether they are allowed and how many each player starts with — and draw on a personal hint wallet once the per-game allowance is spent. Against the robot (**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an @@ -322,6 +325,16 @@ offline on its own, and restoring the network returns it online by itself. There switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome offline, so you are never interrupted for a hiccup. +**Losing the connection while inside an online game** is surfaced on **every platform, including the +Telegram/VK mini-apps** — unlike the offline-play mode above, which is web/native only — because an +online game needs the server for every move. A slim *connection lost* banner appears at the top of the +board and the play area **freezes**: the rack, the move controls, the add-friend/block controls and the +chat/dictionary entry all disable, while a move you had started stays on the board as a draft. Nothing +is sent; when the connection returns the banner clears, the controls come back and you commit the move +yourself. If you were already in the chat or the dictionary when the drop happened you stay there — chat +becomes read-only (send and nudge disable) and the dictionary keeps checking words against the game's +on-device dictionary. + ### Staying up to date When a new client version is required, the app does not lock you out. On the **native** app a too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index c4d7f4d..1d8cf7a 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -236,7 +236,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских), -чтобы его посмотреть. Подсказки управляются настройками +чтобы его посмотреть. В офлайне в онлайн-партии проверка идёт по собственному словарю партии на +устройстве — тот же вердикт, что и на сервере, если этот словарь доступен, иначе показывается +*недоступно офлайн*, — а жалоба и внешняя ссылка, которым нужна сеть, скрываются. Подсказки управляются настройками партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота (**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как @@ -327,6 +329,15 @@ e-mail) либо ввод фразы. Активные игры форфейтя кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что вас не прерывают из-за короткой икоты. +**Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения +Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, — +потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь +потеряна*, и игровая область **замораживается**: рэк, ходовые кнопки, кнопки «в друзья»/«заблокировать» +и вход в чат/словарь отключаются, а начатый ход остаётся на доске черновиком. Ничего не отправляется; +когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала, +когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка +и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве. + ### Актуальная версия Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн** diff --git a/ui/e2e/ingame-ux.spec.ts b/ui/e2e/ingame-ux.spec.ts index d91c073..85f005c 100644 --- a/ui/e2e/ingame-ux.spec.ts +++ b/ui/e2e/ingame-ux.spec.ts @@ -39,3 +39,38 @@ test('in-game UX: turn strip, bag badge + table footer, staged-play highlight an await expect(page.locator('.scorebadge')).toBeVisible(); await expect(page.locator('.make')).toBeVisible(); }); + +test('online game offline: banner shows, the tray and comms entry freeze, and it thaws on recovery', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /🎲/ }).click(); + await page.getByRole('button', { name: 'Random player' }).click(); + await page.locator('.variant').first().click(); + await page.getByRole('button', { name: /Start game/i }).click(); + await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent()); + await expect(page.getByText('Robo')).toBeVisible(); + + // Baseline online: no banner, the tray is interactive, the comms (chat/dictionary) entry is enabled. + await expect(page.locator('.offline-banner')).toHaveCount(0); + await expect(page.locator('.rack .tile').first()).toBeEnabled(); + + // The connection drops (the net hook rides past the hysteresis straight to offline). + await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline()); + + // The explicit "connection lost" banner appears and every rack tile freezes (cannot compose a move). + await expect(page.locator('.offline-banner')).toBeVisible({ timeout: 15000 }); + const tiles = page.locator('.rack .tile'); + const n = await tiles.count(); + expect(n).toBeGreaterThan(0); + for (let i = 0; i < n; i++) await expect(tiles.nth(i)).toBeDisabled(); + + // The comms entry (💬 — chat + dictionary) is disabled; it lives in the history drawer, so open it. + await page.locator('.scoreboard').click(); + await expect(page.locator('button:has(.chat-ico)')).toBeDisabled(); + + // Recovery: the network returns, the banner clears and the tray is interactive again. + await page.evaluate(() => (window as unknown as { __net: { online(): void } }).__net.online()); + await expect(page.locator('.offline-banner')).toHaveCount(0, { timeout: 15000 }); + await expect(page.locator('.rack .tile').first()).toBeEnabled(); + await expect(page.locator('button:has(.chat-ico)')).toBeEnabled(); +}); diff --git a/ui/src/game/CheckScreen.svelte b/ui/src/game/CheckScreen.svelte index b16d471..c07aff5 100644 --- a/ui/src/game/CheckScreen.svelte +++ b/ui/src/game/CheckScreen.svelte @@ -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('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(); @@ -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 @@ /> - {#if result} + {#if unavailable} +

{t('game.checkOffline')}

+ {:else if result}

{result.legal ? t('game.wordLegal', { word: result.word }) @@ -82,10 +107,10 @@

- {#if !isLocalGameId(id)} + {#if !isLocalGameId(id) && connection.online} {/if} - {#if result.legal} + {#if result.legal && connection.online} 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); } @@ -1490,6 +1496,9 @@ selfInset={landscape} > {#if view} + {#if offlineInGame} +
{t('game.offlineBanner')}
+ {/if} {#if landscape}
@@ -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))} - {/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; diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index ebca3d6..28d6c13 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -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 { diff --git a/ui/src/lib/dict/check.test.ts b/ui/src/lib/dict/check.test.ts new file mode 100644 index 0000000..e51758f --- /dev/null +++ b/ui/src/lib/dict/check.test.ts @@ -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'); + }); +}); diff --git a/ui/src/lib/dict/check.ts b/ui/src/lib/dict/check.ts new file mode 100644 index 0000000..3d0430c --- /dev/null +++ b/ui/src/lib/dict/check.ts @@ -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 { + const dawg = await getDawg(variant, version); + if (!dawg) return null; + const idx = Array.from(word, (ch) => indexForLetter(variant, ch)); + return dawg.indexOf(idx) >= 0; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 6590022..81786f2 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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 you’re back online.', 'game.noHintOptions': 'No options with your letters.', 'game.thinking': 'thinking…', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 0660455..75871c2 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -133,6 +133,8 @@ export const ru: Record = { 'game.complaintSent': 'Спасибо, отправлено на проверку.', 'game.check': 'Проверить', 'game.checkWait': 'Секунду, пожалуйста.', + 'game.checkOffline': 'Проверка недоступна офлайн.', + 'game.offlineBanner': 'Связь потеряна — ход отправится, когда вернётся сеть.', 'game.noHintOptions': 'Нет вариантов с вашим набором.', 'game.thinking': 'думает…',