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
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:
+11
-4
@@ -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
|
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
|
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
|
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
|
drop/recovery feeds the same machine. **Telegram/VK are exempt from the offline-*play* model** —
|
||||||
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
|
`offlineMode.active` is hard-gated on `offlineCapable()` (false in those mini-apps), so nothing — not
|
||||||
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
|
even a version lock — puts them in offline mode: no blue chrome, no local lobby, no transport kill
|
||||||
local lobby, no transport kill switch and no device-local create paths.
|
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
|
**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
|
`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
|
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||||
|
|||||||
+14
-1
@@ -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
|
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
|
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
|
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
|
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
|
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
|
(**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
|
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.
|
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
|
### Staying up to date
|
||||||
When a new client version is required, the app does not lock you out. On the **native** app a
|
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
|
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
|
||||||
|
|||||||
+12
-1
@@ -236,7 +236,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||||
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||||
чтобы его посмотреть. Подсказки управляются настройками
|
чтобы его посмотреть. В офлайне в онлайн-партии проверка идёт по собственному словарю партии на
|
||||||
|
устройстве — тот же вердикт, что и на сервере, если этот словарь доступен, иначе показывается
|
||||||
|
*недоступно офлайн*, — а жалоба и внешняя ссылка, которым нужна сеть, скрываются. Подсказки управляются настройками
|
||||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
||||||
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
||||||
@@ -327,6 +329,15 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
||||||
вас не прерывают из-за короткой икоты.
|
вас не прерывают из-за короткой икоты.
|
||||||
|
|
||||||
|
**Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения
|
||||||
|
Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, —
|
||||||
|
потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь
|
||||||
|
потеряна*, и игровая область **замораживается**: рэк, ходовые кнопки, кнопки «в друзья»/«заблокировать»
|
||||||
|
и вход в чат/словарь отключаются, а начатый ход остаётся на доске черновиком. Ничего не отправляется;
|
||||||
|
когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала,
|
||||||
|
когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка
|
||||||
|
и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве.
|
||||||
|
|
||||||
### Актуальная версия
|
### Актуальная версия
|
||||||
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
||||||
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
||||||
|
|||||||
@@ -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('.scorebadge')).toBeVisible();
|
||||||
await expect(page.locator('.make')).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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { gameSource, isLocalGameId } from '../lib/gamesource';
|
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 { handleError, showToast } from '../lib/app.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { alphabetLetters } from '../lib/alphabet';
|
import { alphabetLetters } from '../lib/alphabet';
|
||||||
@@ -14,8 +16,12 @@
|
|||||||
let { id }: { id: string } = $props();
|
let { id }: { id: string } = $props();
|
||||||
|
|
||||||
let variant = $state<Variant>('scrabble_en');
|
let variant = $state<Variant>('scrabble_en');
|
||||||
|
let dictVersion = $state('');
|
||||||
let word = $state('');
|
let word = $state('');
|
||||||
let result = $state<{ word: string; legal: boolean } | null>(null);
|
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);
|
let cooling = $state(false);
|
||||||
const checked = new Map<string, boolean>();
|
const checked = new Map<string, boolean>();
|
||||||
|
|
||||||
@@ -26,6 +32,7 @@
|
|||||||
// network.
|
// network.
|
||||||
const st = await gameSource(id).gameState(id, true);
|
const st = await gameSource(id).gameState(id, true);
|
||||||
variant = st.game.variant;
|
variant = st.game.variant;
|
||||||
|
dictVersion = st.game.dictVersion;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
@@ -33,6 +40,7 @@
|
|||||||
|
|
||||||
function onInput(e: Event) {
|
function onInput(e: Event) {
|
||||||
word = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
|
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.
|
// Disabled while cooling, for an already-checked word, or an out-of-range length.
|
||||||
function canCheck(): boolean {
|
function canCheck(): boolean {
|
||||||
@@ -43,7 +51,22 @@
|
|||||||
const w = word.trim().toUpperCase();
|
const w = word.trim().toUpperCase();
|
||||||
cooling = true;
|
cooling = true;
|
||||||
setTimeout(() => (cooling = false), 5000);
|
setTimeout(() => (cooling = false), 5000);
|
||||||
|
unavailable = false;
|
||||||
try {
|
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);
|
const r = await gameSource(id).checkWord(id, w, variant);
|
||||||
checked.set(w, r.legal);
|
checked.set(w, r.legal);
|
||||||
result = { word: w, legal: r.legal };
|
result = { word: w, legal: r.legal };
|
||||||
@@ -73,7 +96,9 @@
|
|||||||
/>
|
/>
|
||||||
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
|
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
|
||||||
</div>
|
</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}>
|
<p class="verdict" class:ok={result.legal} class:bad={!result.legal}>
|
||||||
{result.legal
|
{result.legal
|
||||||
? t('game.wordLegal', { word: result.word })
|
? t('game.wordLegal', { word: result.word })
|
||||||
@@ -82,10 +107,10 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<!-- Complaints go to the admin over the network; a local (offline) game has no backend to
|
<!-- Complaints go to the admin over the network; a local (offline) game has no backend to
|
||||||
receive them, so the control is dropped there. -->
|
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>
|
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if result.legal}
|
{#if result.legal && connection.online}
|
||||||
<a
|
<a
|
||||||
class="lookup"
|
class="lookup"
|
||||||
href={dictionaryLookupUrl(result.word, variant)}
|
href={dictionaryLookupUrl(result.word, variant)}
|
||||||
|
|||||||
+23
-2
@@ -15,6 +15,7 @@
|
|||||||
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
|
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { offlineMode } from '../lib/offline.svelte';
|
import { offlineMode } from '../lib/offline.svelte';
|
||||||
|
import { netState } from '../lib/netstate.svelte';
|
||||||
import { maybeShowInterstitial } from '../lib/ads';
|
import { maybeShowInterstitial } from '../lib/ads';
|
||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
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"
|
// disabled while disconnected or in offline mode (which also stops the "something went wrong"
|
||||||
// toasts a blocked call would otherwise raise).
|
// toasts a blocked call would otherwise raise).
|
||||||
const netReady = $derived(isLocalGameId(id) || (connection.online && !offlineMode.active));
|
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).
|
// Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
|
||||||
let localUnsub: (() => void) | null = null;
|
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.
|
// 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;
|
if (view?.game.vsAi || isLocalGameId(id)) return false;
|
||||||
return (
|
return (
|
||||||
|
netReady &&
|
||||||
!!s.accountId &&
|
!!s.accountId &&
|
||||||
!app.profile?.isGuest &&
|
!app.profile?.isGuest &&
|
||||||
s.accountId !== app.session?.userId &&
|
s.accountId !== app.session?.userId &&
|
||||||
@@ -1476,7 +1482,7 @@
|
|||||||
// An already-blocked opponent hides it (both controls go, and the name is struck).
|
// An already-blocked opponent hides it (both controls go, and the name is struck).
|
||||||
function canBlock(s: { accountId: string; seat: number }): boolean {
|
function canBlock(s: { accountId: string; seat: number }): boolean {
|
||||||
if (view?.game.vsAi || isLocalGameId(id)) return false;
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -1490,6 +1496,9 @@
|
|||||||
selfInset={landscape}
|
selfInset={landscape}
|
||||||
>
|
>
|
||||||
{#if view}
|
{#if view}
|
||||||
|
{#if offlineInGame}
|
||||||
|
<div class="offline-banner" role="status">{t('game.offlineBanner')}</div>
|
||||||
|
{/if}
|
||||||
{#if landscape}
|
{#if landscape}
|
||||||
<div class="game-land">
|
<div class="game-land">
|
||||||
<div class="leftpane">
|
<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
|
— 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). -->
|
the Dictionary. An online game always keeps it (chat outlives the game). -->
|
||||||
{#if !(gameOver && (view.game.vsAi || view.game.hotseat))}
|
{#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}
|
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1694,6 +1703,7 @@
|
|||||||
draggingId={reorderDragId}
|
draggingId={reorderDragId}
|
||||||
dropIndex={reorderTo}
|
dropIndex={reorderTo}
|
||||||
confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined}
|
confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined}
|
||||||
|
frozen={!netReady}
|
||||||
ondown={onRackDown}
|
ondown={onRackDown}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -2178,6 +2188,17 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 40px;
|
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 {
|
.ghost {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
shuffling = false,
|
shuffling = false,
|
||||||
draggingId = null,
|
draggingId = null,
|
||||||
dropIndex = null,
|
dropIndex = null,
|
||||||
|
frozen = false,
|
||||||
confirm,
|
confirm,
|
||||||
ondown,
|
ondown,
|
||||||
}: {
|
}: {
|
||||||
@@ -26,6 +27,10 @@
|
|||||||
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
|
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
|
||||||
draggingId?: number | null;
|
draggingId?: number | null;
|
||||||
dropIndex?: 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 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). */
|
* (the parent owns the button and its enablement; the rack only positions it). */
|
||||||
confirm?: Snippet;
|
confirm?: Snippet;
|
||||||
@@ -65,6 +70,7 @@
|
|||||||
class:selected={selected === slot.index}
|
class:selected={selected === slot.index}
|
||||||
class:shift={dropIndex != null && i >= dropIndex}
|
class:shift={dropIndex != null && i >= dropIndex}
|
||||||
data-rack-index={slot.index}
|
data-rack-index={slot.index}
|
||||||
|
disabled={frozen}
|
||||||
animate:hop={shuffling}
|
animate:hop={shuffling}
|
||||||
onpointerdown={(e) => ondown(e, slot.index)}
|
onpointerdown={(e) => ondown(e, slot.index)}
|
||||||
>
|
>
|
||||||
@@ -114,6 +120,10 @@
|
|||||||
outline: 3px solid var(--accent);
|
outline: 3px solid var(--accent);
|
||||||
outline-offset: -3px;
|
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
|
/* 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. */
|
tile width plus the rack gap), so the drop position is visible. */
|
||||||
.rack.reordering .tile {
|
.rack.reordering .tile {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -133,6 +133,8 @@ export const en = {
|
|||||||
'game.complaintSent': 'Thanks, sent for review.',
|
'game.complaintSent': 'Thanks, sent for review.',
|
||||||
'game.check': 'Check',
|
'game.check': 'Check',
|
||||||
'game.checkWait': 'Please wait a moment.',
|
'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.noHintOptions': 'No options with your letters.',
|
||||||
'game.thinking': 'thinking…',
|
'game.thinking': 'thinking…',
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.complaintSent': 'Спасибо, отправлено на проверку.',
|
'game.complaintSent': 'Спасибо, отправлено на проверку.',
|
||||||
'game.check': 'Проверить',
|
'game.check': 'Проверить',
|
||||||
'game.checkWait': 'Секунду, пожалуйста.',
|
'game.checkWait': 'Секунду, пожалуйста.',
|
||||||
|
'game.checkOffline': 'Проверка недоступна офлайн.',
|
||||||
|
'game.offlineBanner': 'Связь потеряна — ход отправится, когда вернётся сеть.',
|
||||||
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
||||||
'game.thinking': 'думает…',
|
'game.thinking': 'думает…',
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user