5b07bb4e14
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>
98 lines
2.8 KiB
TypeScript
98 lines
2.8 KiB
TypeScript
// `ToastStore` is the single transient-notification primitive for the
|
|
// SvelteKit shell. Phase 24 ships it together with the push-event
|
|
// dispatch: the per-game layout shows one `Turn N is ready. View now.`
|
|
// toast on a verified `game.turn.ready` event. Later phases reuse the
|
|
// same store for mail / battle / lobby toasts (PLAN.md §"cross-cutting
|
|
// shell").
|
|
//
|
|
// The store keeps **one** active toast at a time: a fresh `show()`
|
|
// replaces the previous descriptor. This matches the UX intent of
|
|
// "one loud notification at a time" — the rare cases where several
|
|
// events arrive in quick succession are still observable, because
|
|
// each replacement re-arms the timer and the user sees every payload
|
|
// in turn.
|
|
|
|
import type { TranslationKey } from "./i18n/index.svelte";
|
|
|
|
/**
|
|
* ToastDescriptor describes one toast in flight. `messageKey` and
|
|
* `actionLabelKey` are typed against the i18n catalog so a missing
|
|
* translation key fails at compile time. `durationMs === null` (or
|
|
* `undefined`) makes the toast sticky — the user must dismiss it
|
|
* through the action button or another `show()` call.
|
|
*/
|
|
export interface ToastDescriptor {
|
|
id: string;
|
|
messageKey: TranslationKey;
|
|
messageParams?: Record<string, string>;
|
|
actionLabelKey?: TranslationKey;
|
|
onAction?: () => void;
|
|
durationMs?: number | null;
|
|
}
|
|
|
|
class ToastStore {
|
|
current: ToastDescriptor | null = $state(null);
|
|
|
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
private counter = 0;
|
|
|
|
/**
|
|
* show replaces the active toast with descriptor and returns its
|
|
* fresh id. Pass that id to `dismiss(id)` from a delayed callback
|
|
* to avoid dismissing a newer toast that already took its slot.
|
|
*/
|
|
show(descriptor: Omit<ToastDescriptor, "id">): string {
|
|
this.clearTimer();
|
|
this.counter += 1;
|
|
const id = String(this.counter);
|
|
const full: ToastDescriptor = { ...descriptor, id };
|
|
this.current = full;
|
|
if (
|
|
full.durationMs !== null &&
|
|
full.durationMs !== undefined &&
|
|
full.durationMs > 0
|
|
) {
|
|
const duration = full.durationMs;
|
|
this.timer = setTimeout(() => {
|
|
this.dismiss(id);
|
|
}, duration);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* dismiss clears the active toast. With an id, the call is a
|
|
* no-op when the active toast has a different id — this guards
|
|
* the auto-dismiss timer from clobbering a newer descriptor.
|
|
*/
|
|
dismiss(id?: string): void {
|
|
if (
|
|
id !== undefined &&
|
|
(this.current === null || this.current.id !== id)
|
|
) {
|
|
return;
|
|
}
|
|
this.clearTimer();
|
|
this.current = null;
|
|
}
|
|
|
|
/**
|
|
* resetForTests forgets every in-flight descriptor and the id
|
|
* counter. Production code never calls this.
|
|
*/
|
|
resetForTests(): void {
|
|
this.clearTimer();
|
|
this.current = null;
|
|
this.counter = 0;
|
|
}
|
|
|
|
private clearTimer(): void {
|
|
if (this.timer !== null) {
|
|
clearTimeout(this.timer);
|
|
this.timer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const toast = new ToastStore();
|