2f70ef1b85
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Two offline bugs found on the contour:
- The word-check screen (CheckScreen) called gateway.gameState/checkWord
directly, so in an offline (local) game it hit the network and errored
("something went wrong"). Route both through gameSource(id) — the local
source answers from the device dawg. The complaint control (online-only,
no offline backend) is hidden for a local game.
- The lobby did not react to the offline-mode toggle (which lives in Settings,
so the lobby can stay mounted): an online game lingered until the next
reload. Reload on an offlineMode flip so entering offline immediately shows
only device-local games.
Cold offline launch hanging on the splash (boot still fetches the profile over
the network) is the separate C2 offline-boot follow-up (needs a persisted
profile + a boot short-circuit).
159 lines
4.4 KiB
Svelte
159 lines
4.4 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { gateway } from '../lib/gateway';
|
|
import { gameSource, isLocalGameId } from '../lib/gamesource';
|
|
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 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 word = $state('');
|
|
let result = $state<{ word: string; legal: boolean } | null>(null);
|
|
let cooling = $state(false);
|
|
const checked = new Map<string, boolean>();
|
|
|
|
onMount(async () => {
|
|
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.
|
|
const st = await gameSource(id).gameState(id, true);
|
|
variant = st.game.variant;
|
|
} catch (e) {
|
|
handleError(e);
|
|
}
|
|
});
|
|
|
|
function onInput(e: Event) {
|
|
word = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
|
|
}
|
|
// 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);
|
|
try {
|
|
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 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)}
|
|
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
|
{/if}
|
|
{#if result.legal}
|
|
<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>
|