beda6ccd3d
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
iOS Safari/WKWebView does not shrink the layout viewport for the soft keyboard (Android Chrome does): the visual viewport shrinks AND offsets down toward the focused field, and the values do not cleanly revert. The pinned shell tracked only the height (--vvh), not the offset, so its top-anchored content misaligned on iOS — empty space below the form, worst on a repeat focus. Fix (one place; covers NewGame, Chat, Feedback): - app.svelte.ts syncViewport: also mirror visualViewport.offsetTop into --vv-top, and scroll the focused field into view on the keyboard-open transition (iOS does not reliably scroll a pinned document to it). - app.css: the pinned app-shell body follows top=--vv-top + height=--vvh (was inset:0), so it stays on the visible area. - e2e/viewport.spec.ts emulates the visual-viewport resize+offset (a fake window.visualViewport) to verify the shell follows, on Chromium+WebKit. Hotseat: returning to the lobby now re-locks the current seat (its PIN is re-prompted) — LocalSource.relock, cleared in Game.svelte onDestroy. Root-caused a crash it exposed: a $props() value (id) reads back undefined during Svelte teardown, so isLocalGameId(id) threw and aborted onDestroy (breaking navigation) — read the id from the loaded view instead.
77 lines
4.1 KiB
TypeScript
77 lines
4.1 KiB
TypeScript
// Dispatch a game id to its source: the local engine (an offline vs_ai game) or the network
|
|
// gateway. The same game screen (game/Game.svelte) drives both through the shared game-loop subset
|
|
// (GameLoopSource); a game id decides which. A network game's calls are unchanged — gameSource
|
|
// returns the gateway itself for it — so the seam adds the local path without touching online play.
|
|
//
|
|
// The offline engine is kept OUT of the app entry bundle: this module statically imports only the
|
|
// gateway, the tiny id helper and types; the LocalSource is dynamically imported on first use of a
|
|
// local game, so it loads as a separate chunk only when someone plays offline.
|
|
|
|
import { gateway } from './gateway';
|
|
import { isLocalGameId } from './localgame/id';
|
|
import type { GameLoopSource, LocalSource } from './localgame/source';
|
|
|
|
let loaded: Promise<LocalSource> | null = null;
|
|
function load(): Promise<LocalSource> {
|
|
if (!loaded) loaded = import('./localgame/source').then((m) => new m.LocalSource());
|
|
return loaded;
|
|
}
|
|
|
|
/**
|
|
* localSource is a lazy handle on the single offline LocalSource. It is obtainable synchronously,
|
|
* but each method dynamically imports the engine on first use, so the offline code stays out of the
|
|
* app entry bundle until a local game is actually played. It exposes the game-loop methods plus the
|
|
* local-only create() and the robot-reply event subscription events().
|
|
*/
|
|
export const localSource = {
|
|
// Offline scoring is self-contained, so the local source ignores includeAlphabet, the evaluate
|
|
// abort signal, and the draft (drafts are not persisted offline); the proxy accepts the full
|
|
// gateway signatures but forwards only what the local source uses.
|
|
gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)),
|
|
gameHistory: (id) => load().then((s) => s.gameHistory(id)),
|
|
submitPlay: (id, tiles, variant) => load().then((s) => s.submitPlay(id, tiles, variant)),
|
|
pass: (id) => load().then((s) => s.pass(id)),
|
|
exchange: (id, tiles, variant) => load().then((s) => s.exchange(id, tiles, variant)),
|
|
resign: (id) => load().then((s) => s.resign(id)),
|
|
hint: (id) => load().then((s) => s.hint(id)),
|
|
evaluate: (id, tiles, variant, _signal) => load().then((s) => s.evaluate(id, tiles, variant)),
|
|
checkWord: (id, word, variant) => load().then((s) => s.checkWord(id, word, variant)),
|
|
draftGet: (_id) => load().then((s) => s.draftGet()),
|
|
draftSave: (_id, _json) => load().then((s) => s.draftSave()),
|
|
create: (opts) => load().then((s) => s.create(opts)),
|
|
list: () => load().then((s) => s.list()),
|
|
delete: (id) => load().then((s) => s.delete(id)),
|
|
// Offline hotseat controls (no network equivalent): reveal a PIN-locked seat, and the host
|
|
// (referee) overrides gated by the master PIN.
|
|
unlockSeat: (id, pin) => load().then((s) => s.unlockSeat(id, pin)),
|
|
verifyHostPin: (id, pin) => load().then((s) => s.verifyHostPin(id, pin)),
|
|
hostAction: (id, pin, action, targetSeat) => load().then((s) => s.hostAction(id, pin, action, targetSeat)),
|
|
// relock is fire-and-forget: the engine is already loaded (the game was open), and re-locking a
|
|
// cached game before the screen is left needs no result.
|
|
relock: (id) => void load().then((s) => s.relock(id)),
|
|
// events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires
|
|
// the real subscription once the engine loads and, until then, cancels a pending subscribe.
|
|
events: (id, onEvent) => {
|
|
let unsub = (): void => {};
|
|
let cancelled = false;
|
|
void load().then((s) => {
|
|
if (!cancelled) unsub = s.events(id, onEvent);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
unsub();
|
|
};
|
|
},
|
|
} satisfies GameLoopSource &
|
|
Pick<LocalSource, 'events' | 'create' | 'list' | 'delete' | 'unlockSeat' | 'verifyHostPin' | 'hostAction' | 'relock'>;
|
|
|
|
/**
|
|
* gameSource returns the source that runs the game with the given id: the local engine for a local
|
|
* id, otherwise the network gateway (which satisfies the same game-loop interface).
|
|
*/
|
|
export function gameSource(id: string): GameLoopSource {
|
|
return isLocalGameId(id) ? localSource : gateway;
|
|
}
|
|
|
|
export { isLocalGameId } from './localgame/id';
|