feat(ui): explicit offline state inside an online game (+ offline word check) #260
@@ -124,8 +124,8 @@ dropped). Horizontal scaling is explicit future work.
|
||||
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
|
||||
*connection lost* banner, **freezes** the tray and move controls, and hides the resign, add-friend/block
|
||||
and chat/dictionary controls (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.
|
||||
|
||||
+3
-2
@@ -328,8 +328,9 @@ 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
|
||||
board and the play area **freezes**: the rack and the move controls disable, and the resign, the
|
||||
add-friend/block and the chat/dictionary controls are hidden, 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
|
||||
|
||||
@@ -332,8 +332,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
**Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения
|
||||
Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, —
|
||||
потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь
|
||||
потеряна*, и игровая область **замораживается**: рэк, ходовые кнопки, кнопки «в друзья»/«заблокировать»
|
||||
и вход в чат/словарь отключаются, а начатый ход остаётся на доске черновиком. Ничего не отправляется;
|
||||
потеряна*, и игровая область **замораживается**: рэк и ходовые кнопки отключаются, а «сдаться»,
|
||||
«в друзья»/«заблокировать» и вход в чат/словарь скрываются; начатый ход остаётся на доске черновиком.
|
||||
Ничего не отправляется;
|
||||
когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала,
|
||||
когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка
|
||||
и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве.
|
||||
|
||||
@@ -64,13 +64,52 @@ test('online game offline: banner shows, the tray and comms entry freeze, and it
|
||||
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.
|
||||
// The comms entry (💬) and the resign control are HIDDEN offline (like the frozen social controls);
|
||||
// both live in the history drawer, so open it and assert no action icons remain in its header.
|
||||
await page.locator('.scoreboard').click();
|
||||
await expect(page.locator('button:has(.chat-ico)')).toBeDisabled();
|
||||
await expect(page.locator('.hhead button.hicon')).toHaveCount(0);
|
||||
|
||||
// Recovery: the network returns, the banner clears and the tray is interactive again.
|
||||
// Recovery: the network returns, the banner clears, the tray is interactive and the comms entry returns.
|
||||
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();
|
||||
await expect(page.locator('button:has(.chat-ico)')).toBeVisible();
|
||||
});
|
||||
|
||||
test('online game offline: the dictionary still accepts input and checks (variant seeded from cache)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
// A Russian (erudit_ru — first in the mock's variant preferences) game, so the offline dictionary is
|
||||
// exercised with a non-Latin alphabet that differs from the panel's default 'scrabble_en'. The panel
|
||||
// seeds its variant + pinned version from the cached game; the mock resolves gameState even offline
|
||||
// (no kill switch), so on the real backend — where that fetch fails offline — the seed is what keeps
|
||||
// the input and the dawg fallback working. This guards the offline dictionary flow end-to-end.
|
||||
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();
|
||||
|
||||
// Go offline, then open the dictionary directly (its in-game entry is hidden offline) so CheckScreen
|
||||
// mounts with no network and must seed the variant/version from the cached game, not a failed fetch.
|
||||
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||
await expect(page.locator('.offline-banner')).toBeVisible({ timeout: 15000 });
|
||||
await page.evaluate(() => {
|
||||
const route = location.hash.slice(1); // /game/<id>
|
||||
(window as unknown as { __router: { navigate(p: string): void } }).__router.navigate(route + '/check');
|
||||
});
|
||||
|
||||
// The check input keeps the Cyrillic word (the variant is known) and the Check button is usable —
|
||||
// the outcome the cache seed guarantees on the real backend where the offline gameState fetch fails.
|
||||
const input = page.locator('.check input');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill('СЛОВО');
|
||||
await expect(input).toHaveValue('СЛОВО');
|
||||
await expect(page.locator('.check button')).toBeEnabled();
|
||||
|
||||
// Running the check resolves to a verdict (or the neutral offline note), proving the fallback path
|
||||
// executes rather than hanging.
|
||||
await page.locator('.check button').click();
|
||||
await expect(page.locator('.verdict')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { alphabetLetters } from '../lib/alphabet';
|
||||
import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword';
|
||||
import { onExternalLinkClick } from '../lib/links';
|
||||
import { getCachedGame } from '../lib/gamecache';
|
||||
import type { Variant } from '../lib/model';
|
||||
|
||||
// Word-check on its own screen: unlimited dictionary lookups, each with a
|
||||
@@ -25,15 +26,24 @@
|
||||
const checked = new Map<string, boolean>();
|
||||
|
||||
onMount(async () => {
|
||||
// Seed the variant + pinned version from the cached game first (present once the board has opened,
|
||||
// and preloaded for ongoing games): an online game's dictionary must keep working while offline,
|
||||
// where the gameState call below cannot run. The variant's alphabet is already cached from opening
|
||||
// the board, so input sanitising and the offline dawg fallback work off the seed alone.
|
||||
const cached = getCachedGame(id)?.view.game;
|
||||
if (cached) {
|
||||
variant = cached.variant;
|
||||
dictVersion = cached.dictVersion;
|
||||
}
|
||||
try {
|
||||
// Include the alphabet so input sanitising + the check accept the variant's letters. Routed
|
||||
// through gameSource so an offline (local) game resolves its state from the device, not the
|
||||
// network.
|
||||
// Refresh over the network (and cache the alphabet on a cold deep-link that skipped the board).
|
||||
const st = await gameSource(id).gameState(id, true);
|
||||
variant = st.game.variant;
|
||||
dictVersion = st.game.dictVersion;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
// Offline or a transient failure: the cache seed already set variant + version, so only surface
|
||||
// an error when there was nothing cached to fall back to.
|
||||
if (!cached) handleError(e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1607,6 +1607,10 @@
|
||||
{:else if view.game.hotseat}
|
||||
<!-- Hotseat resign is a host (referee) action, not self-serve; keep the slot empty. -->
|
||||
<span aria-hidden="true"></span>
|
||||
{:else if !netReady}
|
||||
<!-- Offline: resigning needs the server, so hide the control (like the frozen social controls);
|
||||
an empty slot keeps the header's space-between layout. -->
|
||||
<span aria-hidden="true"></span>
|
||||
{:else}
|
||||
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||
{/if}
|
||||
@@ -1615,8 +1619,8 @@
|
||||
chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
|
||||
— 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" disabled={!netReady} onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
||||
{#if netReady && !(gameOver && (view.game.vsAi || view.game.hotseat))}
|
||||
<button class="hicon" 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}
|
||||
|
||||
Reference in New Issue
Block a user