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
+77 -22
View File
@@ -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