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
+104
View File
@@ -24,6 +24,8 @@ import type { WrapMode } from "../map/world";
const PREF_NAMESPACE = "game-prefs";
const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`;
const PREF_KEY_LAST_VIEWED_TURN = (gameId: string) =>
`${gameId}/last-viewed-turn`;
/**
* GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell
@@ -66,6 +68,17 @@ export class GameStateStore {
* this flag is enough to keep the network silent.
*/
synthetic = $state(false);
/**
* pendingTurn carries the latest server-side turn the user has not
* yet opened: it is `> currentTurn` whenever the server reports a
* new turn (either through a `game.turn.ready` push event after
* boot, or through the boot-time discovery that the persisted
* `lastViewedTurn` is behind the lobby's `current_turn`). The
* layout's `$effect` renders a toast/banner when it is non-null;
* `advanceToPending()` refreshes the store onto the new turn and
* clears the rune.
*/
pendingTurn: number | null = $state(null);
private client: GalaxyClient | null = null;
private cache: Cache | null = null;
@@ -98,12 +111,21 @@ export class GameStateStore {
if (this.client === null || this.cache === null) {
throw new Error("game-state: setGame called before init");
}
// Only forget the pending indicator when the consumer is
// actually switching games. On the initial `setGame` after
// `init` the previous `gameId` is the empty string, and a
// concurrent `markPendingTurn` from a push event arriving
// while we were still bootstrapping must not be erased.
if (this.gameId !== "" && this.gameId !== gameId) {
this.pendingTurn = null;
}
this.gameId = gameId;
this.status = "loading";
this.error = null;
this.report = null;
this.wrapMode = await readWrapMode(this.cache, gameId);
const lastViewed = await readLastViewedTurn(this.cache, gameId);
try {
const summary = await this.findGame(gameId);
@@ -114,7 +136,68 @@ export class GameStateStore {
}
this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn;
// If the persisted last-viewed turn is older than the
// server-side current turn, open the user on their last-seen
// snapshot and surface the gap through `pendingTurn` so the
// shell can render a "new turn available" affordance instead
// of silently auto-advancing.
if (
lastViewed !== null &&
lastViewed >= 0 &&
lastViewed < summary.currentTurn
) {
this.pendingTurn = summary.currentTurn;
await this.loadTurn(lastViewed);
} else {
await this.loadTurn(summary.currentTurn);
}
} catch (err) {
if (this.destroyed) return;
this.status = "error";
this.error = describe(err);
}
}
/**
* markPendingTurn records a server-reported new turn (typically
* delivered through `game.turn.ready`). Values that are not
* strictly ahead of the latest known turn (current or already
* pending) are ignored so a replayed event cannot regress the
* indicator.
*/
markPendingTurn(turn: number): void {
const latest = this.pendingTurn ?? this.currentTurn;
if (turn > latest) {
this.pendingTurn = turn;
}
}
/**
* advanceToPending re-queries the lobby record and loads the
* report at the server's latest `current_turn`, then clears the
* pending indicator. Unlike `setGame`, this skips the
* `lastViewedTurn` lookup — the user has explicitly asked to
* jump to the new turn, so any persisted bookmark from the
* previous session is irrelevant. Failures keep the indicator
* set so the user can retry from the same affordance.
*/
async advanceToPending(): Promise<void> {
if (this.pendingTurn === null || this.client === null) {
return;
}
this.status = "loading";
this.error = null;
try {
const summary = await this.findGame(this.gameId);
if (summary === null) {
this.status = "error";
this.error = `game ${this.gameId} is not in your list`;
return;
}
this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn;
await this.loadTurn(summary.currentTurn);
this.pendingTurn = null;
} catch (err) {
if (this.destroyed) return;
this.status = "error";
@@ -219,6 +302,13 @@ export class GameStateStore {
this.report = report;
this.currentTurn = turn;
this.status = "ready";
if (this.cache !== null) {
await this.cache.put(
PREF_NAMESPACE,
PREF_KEY_LAST_VIEWED_TURN(this.gameId),
turn,
);
}
}
private installVisibilityListener(): void {
@@ -239,6 +329,20 @@ async function readWrapMode(cache: Cache, gameId: string): Promise<WrapMode> {
return "torus";
}
async function readLastViewedTurn(
cache: Cache,
gameId: string,
): Promise<number | null> {
const stored = await cache.get<number>(
PREF_NAMESPACE,
PREF_KEY_LAST_VIEWED_TURN(gameId),
);
if (typeof stored !== "number" || !Number.isFinite(stored)) {
return null;
}
return stored;
}
function describe(err: unknown): string {
if (err instanceof GameStateError) {
return err.message;