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:
@@ -0,0 +1,97 @@
|
||||
// `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();
|
||||
Reference in New Issue
Block a user