471fadc6a4
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m5s
Two fixes from contour testing of the offline-in-game UX: - The dictionary word check was unusable offline (Check button disabled) when the panel was reached while already offline: CheckScreen fetched the variant + pinned version over the network in onMount, which fails offline, leaving the default variant so input sanitising stripped every letter. Seed both from the cached game (present once the board has opened) so the input and the on-device dawg fallback work without a round-trip; the network refresh still runs when online and on a cold deep-link. - The resign and chat controls were only functionally disabled (chat) or not gated at all (resign) offline, so they still looked active. Hide both while offline, matching the frozen social controls. Tests: the in-game offline e2e now asserts the drawer action icons are hidden (not disabled); a new e2e guards the offline dictionary flow. Docs updated.
196 lines
6.5 KiB
Svelte
196 lines
6.5 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { gateway } from '../lib/gateway';
|
|
import { gameSource, isLocalGameId } from '../lib/gamesource';
|
|
import { connection } from '../lib/connection.svelte';
|
|
import { handleError, showToast } from '../lib/app.svelte';
|
|
import { t } from '../lib/i18n/index.svelte';
|
|
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
|
|
// complaint, off the board so the soft keyboard never relayouts the play area.
|
|
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>();
|
|
|
|
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 {
|
|
// 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) {
|
|
// 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);
|
|
}
|
|
});
|
|
|
|
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 {
|
|
return canCheckWord(word, checked.has(word.trim().toUpperCase()), cooling);
|
|
}
|
|
async function runCheck() {
|
|
if (!canCheck()) return;
|
|
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) {
|
|
// Dynamically imported so the dawg reader/loader stays out of the app entry bundle (the dict
|
|
// subsystem is lazy everywhere else too); it loads only when an offline check actually runs.
|
|
const { localWordCheck } = await import('../lib/dict/check');
|
|
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 };
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
}
|
|
async function complain() {
|
|
if (!result) return;
|
|
try {
|
|
await gateway.complaint(id, result.word, '');
|
|
showToast(t('game.complaintSent'));
|
|
result = null;
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="wrap">
|
|
<div class="check">
|
|
<input
|
|
value={word}
|
|
oninput={onInput}
|
|
onkeydown={(e) => e.key === 'Enter' && runCheck()}
|
|
placeholder={t('game.checkWordPrompt')}
|
|
/>
|
|
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
|
|
</div>
|
|
{#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 })
|
|
: t('game.wordIllegal', { word: result.word })}
|
|
</p>
|
|
<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) && connection.online}
|
|
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
|
{/if}
|
|
{#if result.legal && connection.online}
|
|
<a
|
|
class="lookup"
|
|
href={dictionaryLookupUrl(result.word, variant)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onclick={onExternalLinkClick}>{t('game.lookup')}</a>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.wrap {
|
|
padding: 16px var(--pad);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
}
|
|
.check {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.check input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
padding: 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
text-transform: uppercase;
|
|
}
|
|
.check button {
|
|
padding: 10px 16px;
|
|
border: 1px solid var(--accent);
|
|
background: var(--accent);
|
|
color: var(--accent-text);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.check button:disabled {
|
|
opacity: 0.5;
|
|
}
|
|
.verdict {
|
|
margin: 0;
|
|
font-weight: 600;
|
|
}
|
|
.verdict.ok {
|
|
color: var(--ok, #2e7d32);
|
|
}
|
|
.verdict.bad {
|
|
color: var(--danger, #c0392b);
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.complain {
|
|
padding: 8px 14px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.lookup {
|
|
color: var(--accent);
|
|
text-decoration: underline;
|
|
font-size: 0.95em;
|
|
}
|
|
</style>
|