ui/phase-24: push events, turn-ready toast, single SubscribeEvents consumer
Wires the gateway's signed SubscribeEvents stream end-to-end:
- backend: emit game.turn.ready from lobby.OnRuntimeSnapshot on every
current_turn advance, addressed to every active membership, push-only
channel, idempotency key turn-ready:<game_id>:<turn>;
- ui: single EventStream singleton replaces revocation-watcher.ts and
carries both per-event dispatch and revocation detection; toast
primitive (store + host) lives in lib/; GameStateStore gains
pendingTurn/markPendingTurn/advanceToPending and a persisted
lastViewedTurn so a return after multiple turns surfaces the same
"view now" affordance as a live push event;
- mandatory event-signature verification through ui/core
(verifyPayloadHash + verifyEvent), full-jitter exponential backoff
1s -> 30s on transient failure, signOut("revoked") on
Unauthenticated or clean end-of-stream;
- catalog and migration accept the new kind; tests cover producer
(testcontainers + capturing publisher), consumer (Vitest event
stream, toast, game-state extensions), and a Playwright e2e
delivering a signed frame to the live UI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -4,28 +4,66 @@
|
||||
import { page } from "$app/state";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
import { startRevocationWatcher } from "$lib/revocation-watcher";
|
||||
import { eventStream } from "../api/events.svelte";
|
||||
import { loadCore } from "../platform/core/index";
|
||||
import { GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||
import ToastHost from "$lib/toast-host.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let stopWatcher: (() => void) | null = null;
|
||||
// `streamSessionId` records the device session id the event stream
|
||||
// is currently bound to. The `$effect` below uses it to detect a
|
||||
// re-login (different session id while still authenticated) and
|
||||
// restart the stream against the fresh credentials.
|
||||
let streamSessionId: string | null = null;
|
||||
|
||||
onMount(() => {
|
||||
void session.init();
|
||||
return () => {
|
||||
if (stopWatcher !== null) {
|
||||
stopWatcher();
|
||||
stopWatcher = null;
|
||||
}
|
||||
eventStream.stop();
|
||||
streamSessionId = null;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (session.status === "authenticated" && stopWatcher === null) {
|
||||
stopWatcher = startRevocationWatcher();
|
||||
} else if (session.status !== "authenticated" && stopWatcher !== null) {
|
||||
stopWatcher();
|
||||
stopWatcher = null;
|
||||
if (
|
||||
session.status === "authenticated" &&
|
||||
session.keypair !== null &&
|
||||
session.deviceSessionId !== null &&
|
||||
GATEWAY_RESPONSE_PUBLIC_KEY.length > 0
|
||||
) {
|
||||
const keypair = session.keypair;
|
||||
const deviceSessionId = session.deviceSessionId;
|
||||
if (streamSessionId !== deviceSessionId) {
|
||||
if (streamSessionId !== null) {
|
||||
eventStream.stop();
|
||||
}
|
||||
streamSessionId = deviceSessionId;
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
const core = await loadCore();
|
||||
// Bail out if the session flipped away from this id
|
||||
// while we were loading core (logout, re-login).
|
||||
if (
|
||||
session.deviceSessionId !== deviceSessionId ||
|
||||
session.status !== "authenticated"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
eventStream.start({
|
||||
core,
|
||||
keypair,
|
||||
deviceSessionId,
|
||||
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
|
||||
});
|
||||
} catch (err) {
|
||||
console.info("layout: failed to start event stream", err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
} else if (streamSessionId !== null) {
|
||||
eventStream.stop();
|
||||
streamSessionId = null;
|
||||
}
|
||||
|
||||
const pathname = page.url.pathname;
|
||||
@@ -57,6 +95,8 @@
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
<ToastHost />
|
||||
|
||||
<style>
|
||||
.status {
|
||||
padding: 2rem;
|
||||
|
||||
@@ -90,6 +90,11 @@ fresh.
|
||||
getSyntheticReport,
|
||||
isSyntheticGameId,
|
||||
} from "../../../api/synthetic-report";
|
||||
import {
|
||||
eventStream,
|
||||
type VerifiedEvent,
|
||||
} from "../../../api/events.svelte";
|
||||
import { toast } from "$lib/toast.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -224,6 +229,60 @@ fresh.
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
// `unsubTurnReady` carries the `eventStream.on(...)` disposer for
|
||||
// the game-scoped turn-ready handler. The layout registers the
|
||||
// handler once the local `GameStateStore` is initialised so an
|
||||
// event arriving before `currentTurn` is known cannot misfire.
|
||||
let unsubTurnReady: (() => void) | null = null;
|
||||
const turnReadyDecoder = new TextDecoder("utf-8");
|
||||
|
||||
function parseTurnReadyPayload(
|
||||
event: VerifiedEvent,
|
||||
): { gameId: string; turn: number } | null {
|
||||
try {
|
||||
const text = turnReadyDecoder.decode(event.payloadBytes);
|
||||
const json: unknown = JSON.parse(text);
|
||||
if (typeof json !== "object" || json === null) {
|
||||
return null;
|
||||
}
|
||||
const record = json as Record<string, unknown>;
|
||||
const eventGameId = record.game_id;
|
||||
const eventTurn = record.turn;
|
||||
if (
|
||||
typeof eventGameId !== "string" ||
|
||||
typeof eventTurn !== "number" ||
|
||||
!Number.isFinite(eventTurn)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { gameId: eventGameId, turn: eventTurn };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let activeTurnReadyToastId: string | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const pending = gameState.pendingTurn;
|
||||
if (pending === null) {
|
||||
if (activeTurnReadyToastId !== null) {
|
||||
toast.dismiss(activeTurnReadyToastId);
|
||||
activeTurnReadyToastId = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
activeTurnReadyToastId = toast.show({
|
||||
messageKey: "game.events.turn_ready.message",
|
||||
messageParams: { turn: String(pending) },
|
||||
actionLabelKey: "game.events.turn_ready.action",
|
||||
onAction: () => {
|
||||
void gameState.advanceToPending();
|
||||
},
|
||||
durationMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
(async (): Promise<void> => {
|
||||
// DEV-only synthetic-report path. The lobby's "Load
|
||||
@@ -276,6 +335,19 @@ fresh.
|
||||
deviceSessionId,
|
||||
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
|
||||
});
|
||||
// Register the `game.turn.ready` dispatch before the
|
||||
// network roundtrips below so an event delivered
|
||||
// while `gameState.init` is still in flight is not
|
||||
// dropped by the singleton stream. `markPendingTurn`
|
||||
// already protects against turns that do not advance
|
||||
// past the current snapshot.
|
||||
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
|
||||
const parsed = parseTurnReadyPayload(event);
|
||||
if (parsed === null || parsed.gameId !== gameId) {
|
||||
return;
|
||||
}
|
||||
gameState.markPendingTurn(parsed.turn);
|
||||
});
|
||||
await Promise.all([
|
||||
gameState.init({ client, cache, gameId }),
|
||||
orderDraft.init({ cache, gameId }),
|
||||
@@ -299,6 +371,10 @@ fresh.
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubTurnReady !== null) {
|
||||
unsubTurnReady();
|
||||
unsubTurnReady = null;
|
||||
}
|
||||
gameState.dispose();
|
||||
orderDraft.dispose();
|
||||
selection.dispose();
|
||||
|
||||
Reference in New Issue
Block a user