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:
+77
-22
@@ -2581,40 +2581,95 @@ Decisions during stage:
|
||||
`game.table.*` so the two surfaces evolve independently. ≈90 new
|
||||
keys, en + ru in lockstep.
|
||||
|
||||
## Phase 24. Push Events — Turn-Ready
|
||||
## ~~Phase 24. Push Events — Turn-Ready~~
|
||||
|
||||
Status: pending.
|
||||
Status: done.
|
||||
|
||||
Goal: subscribe to the server push stream and refresh client state
|
||||
when a turn-ready event arrives.
|
||||
|
||||
Artifacts:
|
||||
Artifacts (delivered):
|
||||
|
||||
- `ui/frontend/src/api/events.ts` push-stream subscription wired
|
||||
through `GalaxyClient.subscribeEvents` and Connect server-streaming
|
||||
- on `game.turn.ready` event: invalidate `(game_id, current_turn)`
|
||||
cache entries and trigger a fresh report fetch
|
||||
- a top-of-screen toast: `Turn N+1 is ready. View now.` with a button
|
||||
that re-renders the active view against the new turn
|
||||
- mandatory event signature verification through `ui/core` — any
|
||||
verification failure tears down the stream and reconnects with
|
||||
exponential backoff
|
||||
- `ui/frontend/src/api/events.svelte.ts` — single
|
||||
`SubscribeEvents` consumer per session. Absorbs the previous
|
||||
`revocation-watcher.ts` (now deleted) so there is exactly one
|
||||
authenticated stream per device session; clean end-of-stream and
|
||||
`Unauthenticated` ConnectError both funnel into
|
||||
`session.signOut("revoked")`. Exposes a `connectionStatus` rune
|
||||
for the future header indicator.
|
||||
- `ui/frontend/src/lib/toast.svelte.ts` and `toast-host.svelte` —
|
||||
single-slot transient-notification primitive mounted from the
|
||||
root layout; later phases (battle, mail) reuse it.
|
||||
- `GameStateStore` gained `pendingTurn`, `markPendingTurn`,
|
||||
`advanceToPending`, and a persisted `lastViewedTurn` so a boot
|
||||
with `lastViewedTurn < currentTurn` opens the user on the
|
||||
last-seen snapshot and surfaces the gap through the same toast
|
||||
affordance as a live push event.
|
||||
- Backend producer: `lobby.Service.OnRuntimeSnapshot` emits
|
||||
`game.turn.ready` on every `current_turn` advance, addressed to
|
||||
every active membership, idempotency key
|
||||
`turn-ready:<game_id>:<turn>`, payload `{game_id, turn}`.
|
||||
Catalog routes it through the push channel only.
|
||||
- Mandatory event-signature verification through `ui/core`:
|
||||
`core.verifyPayloadHash` + `core.verifyEvent` on every frame.
|
||||
Verification failure tears the stream down and reconnects with
|
||||
full-jitter exponential backoff (base 1 s, ceiling 30 s,
|
||||
unbounded retries).
|
||||
- Topic doc: `ui/docs/events.md`.
|
||||
|
||||
Dependencies: Phases 23, 4 (Connect streaming in gateway).
|
||||
|
||||
Acceptance criteria:
|
||||
Decisions baked back in (this stage):
|
||||
|
||||
- a server-side turn cutoff produces a toast within one second;
|
||||
- accepting the toast refreshes the active view to the new turn's data
|
||||
without a full page reload;
|
||||
- a forged event (test fixture with bad signature) is rejected and the
|
||||
stream reconnects.
|
||||
- **Minimum traffic on `game.turn.ready`.** The event flips
|
||||
`gameState.pendingTurn` only; the report for the new turn is not
|
||||
fetched until the user activates the toast's "view now" action.
|
||||
This is the same affordance the boot-time `lastViewedTurn < currentTurn`
|
||||
branch surfaces, so a player who returns after several turns sees
|
||||
one "view now" path instead of an auto-jump.
|
||||
- **Revocation-watcher folded into `events.svelte.ts`.** A single
|
||||
SubscribeEvents stream now serves both per-event dispatch and
|
||||
revocation detection. Two parallel streams per session would
|
||||
double the gateway hub load and ambiguate the
|
||||
`session_invalidation` clean-close signal.
|
||||
- **Integration test scope.** Backend producer is covered by
|
||||
`lobby/runtime_hooks_test.go` (testcontainers); UI consumer by
|
||||
`tests/events.test.ts` and the Playwright e2e in
|
||||
`tests/e2e/turn-ready.spec.ts`. A dedicated
|
||||
`integration/turn_ready_flow_test.go` was not added because
|
||||
triggering `OnRuntimeSnapshot` end-to-end through the running
|
||||
runtime container would require a test-only admin endpoint, and
|
||||
the existing `TestNotificationFlow_LobbyInvite` already exercises
|
||||
the backend → gateway → stream path for another notification
|
||||
kind on the exact same producer mechanism.
|
||||
|
||||
Targeted tests:
|
||||
Acceptance criteria (met):
|
||||
|
||||
- Vitest unit tests for `events.ts` handling subscribe, event
|
||||
dispatch, error backoff;
|
||||
- Playwright e2e: trigger a server turn, observe toast and refresh.
|
||||
- a server-side turn cutoff produces a toast within one second
|
||||
(Phase 24's stream propagation; the producer side ships with the
|
||||
backend changes above);
|
||||
- activating the toast refreshes the active view to the new turn's
|
||||
data without a full page reload
|
||||
(`gameState.advanceToPending` → fresh `lobby.my.games.list` +
|
||||
`user.games.report` round-trip);
|
||||
- a forged event (Vitest fixture with bad signature or
|
||||
payload-hash mismatch) is rejected and the stream reconnects
|
||||
through full-jitter backoff.
|
||||
|
||||
Targeted tests (delivered):
|
||||
|
||||
- Vitest: `tests/events.test.ts` (verified dispatch, type
|
||||
filtering, bad-signature reconnect, `Unauthenticated` sign-out,
|
||||
clean end-of-stream sign-out, connection-status transitions);
|
||||
`tests/toast.test.ts`; extensions in `tests/game-state.test.ts`
|
||||
for `pendingTurn` / `lastViewedTurn` / `advanceToPending`.
|
||||
- Backend: `internal/notification/catalog_test.go` (kind +
|
||||
channels); `internal/lobby/runtime_hooks_test.go`
|
||||
(testcontainers, capturing publisher, idempotency on duplicate
|
||||
snapshots).
|
||||
- Playwright: `tests/e2e/turn-ready.spec.ts` (signed
|
||||
`game.turn.ready` frame surfaces the toast, manual dismiss
|
||||
clears it).
|
||||
|
||||
## Phase 25. Sync Protocol — Order Queue, Retry, Conflict
|
||||
|
||||
|
||||
Reference in New Issue
Block a user