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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user