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:
Ilia Denisov
2026-05-11 16:16:31 +02:00
parent 5a2a977dc6
commit 5b07bb4e14
26 changed files with 2181 additions and 209 deletions
@@ -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();