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:
+12
-3
@@ -339,9 +339,18 @@ Admin-channel kinds (`runtime.*`) deliver email to
|
||||
routes land in `notification_routes` with `status='skipped'` and the
|
||||
operator log line records the configuration miss.
|
||||
|
||||
`game.*` (`game.started`, `game.turn.ready`, `game.generation.failed`,
|
||||
`game.finished`) and `mail.dead_lettered` are reserved kinds without a
|
||||
producer in the catalog; adding them is an additive change to the
|
||||
`game.turn.ready` is emitted by `lobby.Service.OnRuntimeSnapshot`
|
||||
(`backend/internal/lobby/runtime_hooks.go`) whenever the engine's
|
||||
`current_turn` advances. The intent targets every active membership
|
||||
of the game, uses idempotency key `turn-ready:<game_id>:<turn>`, and
|
||||
carries the JSON payload `{game_id, turn}`. The catalog routes it
|
||||
through the push channel only — per-turn email would be spam — so
|
||||
the UI's signed `SubscribeEvents` stream
|
||||
(`ui/frontend/src/api/events.svelte.ts`) is the sole delivery path.
|
||||
|
||||
The remaining `game.*` (`game.started`, `game.generation.failed`,
|
||||
`game.finished`) and `mail.dead_lettered` are reserved kinds without
|
||||
a producer in the catalog; adding them is an additive change to the
|
||||
catalog vocabulary and the migration CHECK constraint.
|
||||
|
||||
Templates ship in English only; localisation belongs to clients that
|
||||
|
||||
@@ -109,6 +109,7 @@ const (
|
||||
NotificationLobbyRaceNameRegistered = "lobby.race_name.registered"
|
||||
NotificationLobbyRaceNamePending = "lobby.race_name.pending"
|
||||
NotificationLobbyRaceNameExpired = "lobby.race_name.expired"
|
||||
NotificationGameTurnReady = "game.turn.ready"
|
||||
)
|
||||
|
||||
// Deps aggregates every collaborator the lobby Service depends on.
|
||||
|
||||
@@ -30,6 +30,7 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prevTurn := game.RuntimeSnapshot.CurrentTurn
|
||||
merged := mergeRuntimeSnapshot(game.RuntimeSnapshot, snapshot)
|
||||
now := s.deps.Now().UTC()
|
||||
updated, err := s.deps.Store.UpdateGameRuntimeSnapshot(ctx, gameID, merged, now)
|
||||
@@ -55,9 +56,56 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps
|
||||
}
|
||||
}
|
||||
s.deps.Cache.PutGame(updated)
|
||||
if merged.CurrentTurn > prevTurn {
|
||||
s.publishTurnReady(ctx, gameID, merged.CurrentTurn)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishTurnReady fans out a `game.turn.ready` notification to every
|
||||
// active member of the game once the engine reports a new
|
||||
// `current_turn`. The intent is best-effort: a publisher failure is
|
||||
// logged at warn level (matching the rest of OnRuntimeSnapshot's
|
||||
// notification calls) and does not abort the snapshot bookkeeping.
|
||||
// Idempotency is anchored on (game_id, turn), so a duplicate snapshot
|
||||
// for the same turn collapses into a single notification at the
|
||||
// notification.Submit boundary.
|
||||
func (s *Service) publishTurnReady(ctx context.Context, gameID uuid.UUID, turn int32) {
|
||||
memberships, err := s.deps.Store.ListMembershipsForGame(ctx, gameID)
|
||||
if err != nil {
|
||||
s.deps.Logger.Warn("turn-ready notification: list memberships failed",
|
||||
zap.String("game_id", gameID.String()),
|
||||
zap.Int32("turn", turn),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
recipients := make([]uuid.UUID, 0, len(memberships))
|
||||
for _, m := range memberships {
|
||||
if m.Status != MembershipStatusActive {
|
||||
continue
|
||||
}
|
||||
recipients = append(recipients, m.UserID)
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
return
|
||||
}
|
||||
intent := LobbyNotification{
|
||||
Kind: NotificationGameTurnReady,
|
||||
IdempotencyKey: fmt.Sprintf("turn-ready:%s:%d", gameID, turn),
|
||||
Recipients: recipients,
|
||||
Payload: map[string]any{
|
||||
"game_id": gameID.String(),
|
||||
"turn": turn,
|
||||
},
|
||||
}
|
||||
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
|
||||
s.deps.Logger.Warn("turn-ready notification failed",
|
||||
zap.String("game_id", gameID.String()),
|
||||
zap.Int32("turn", turn),
|
||||
zap.Error(pubErr))
|
||||
}
|
||||
}
|
||||
|
||||
// OnGameFinished completes the game lifecycle: marks the game as
|
||||
// `finished`, evaluates capable-finish per active member, and
|
||||
// transitions reservation rows to either `pending_registration`
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package lobby_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/lobby"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// capturingPublisher records every `LobbyNotification` intent that the
|
||||
// lobby service emits, so a test can assert the producer side without
|
||||
// running the real notification.Submit pipeline.
|
||||
type capturingPublisher struct {
|
||||
mu sync.Mutex
|
||||
items []lobby.LobbyNotification
|
||||
}
|
||||
|
||||
func (p *capturingPublisher) PublishLobbyEvent(_ context.Context, ev lobby.LobbyNotification) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.items = append(p.items, ev)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *capturingPublisher) byKind(kind string) []lobby.LobbyNotification {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]lobby.LobbyNotification, 0, len(p.items))
|
||||
for _, ev := range p.items {
|
||||
if ev.Kind == kind {
|
||||
out = append(out, ev)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newServiceWithPublisher mirrors `newServiceForTest` but lets the
|
||||
// caller inject a custom NotificationPublisher; the runtime-hooks
|
||||
// emit path needs to observe intents directly.
|
||||
func newServiceWithPublisher(t *testing.T, db *sql.DB, now func() time.Time, max int32, publisher lobby.NotificationPublisher) *lobby.Service {
|
||||
t.Helper()
|
||||
store := lobby.NewStore(db)
|
||||
cache := lobby.NewCache()
|
||||
if err := cache.Warm(context.Background(), store); err != nil {
|
||||
t.Fatalf("warm cache: %v", err)
|
||||
}
|
||||
svc, err := lobby.NewService(lobby.Deps{
|
||||
Store: store,
|
||||
Cache: cache,
|
||||
Notification: publisher,
|
||||
Entitlement: stubEntitlement{max: max},
|
||||
Config: config.LobbyConfig{
|
||||
SweeperInterval: time.Second,
|
||||
PendingRegistrationTTL: time.Hour,
|
||||
InviteDefaultTTL: time.Hour,
|
||||
},
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new service: %v", err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// TestOnRuntimeSnapshotEmitsTurnReady verifies that an engine snapshot
|
||||
// advancing `current_turn` fans out a `game.turn.ready` intent to every
|
||||
// active member, that the idempotency key is anchored on (game_id, turn),
|
||||
// and that a snapshot with the same turn does not re-emit.
|
||||
func TestOnRuntimeSnapshotEmitsTurnReady(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
now := time.Now().UTC()
|
||||
clock := func() time.Time { return now }
|
||||
publisher := &capturingPublisher{}
|
||||
svc := newServiceWithPublisher(t, db, clock, 5, publisher)
|
||||
|
||||
owner := uuid.New()
|
||||
seedAccount(t, db, owner)
|
||||
|
||||
game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{
|
||||
OwnerUserID: &owner,
|
||||
Visibility: lobby.VisibilityPrivate,
|
||||
GameName: "Turn-Ready Fan-Out",
|
||||
MinPlayers: 1,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 1,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(time.Hour),
|
||||
TurnSchedule: "0 0 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create game: %v", err)
|
||||
}
|
||||
if _, err := svc.OpenEnrollment(context.Background(), &owner, false, game.GameID); err != nil {
|
||||
t.Fatalf("open enrollment: %v", err)
|
||||
}
|
||||
|
||||
// Seed two active members through the store so the test focuses on
|
||||
// the runtime hook, not the membership state machine.
|
||||
store := lobby.NewStore(db)
|
||||
canonicalPolicy, err := lobby.NewPolicy()
|
||||
if err != nil {
|
||||
t.Fatalf("new policy: %v", err)
|
||||
}
|
||||
memberA := uuid.New()
|
||||
memberB := uuid.New()
|
||||
seedAccount(t, db, memberA)
|
||||
seedAccount(t, db, memberB)
|
||||
for i, m := range []uuid.UUID{memberA, memberB} {
|
||||
race := fmt.Sprintf("Race%d", i+1)
|
||||
canonical, err := canonicalPolicy.Canonical(race)
|
||||
if err != nil {
|
||||
t.Fatalf("canonical %q: %v", race, err)
|
||||
}
|
||||
if _, err := db.ExecContext(context.Background(), `
|
||||
INSERT INTO backend.memberships (
|
||||
membership_id, game_id, user_id, race_name, canonical_key, status
|
||||
) VALUES ($1, $2, $3, $4, $5, 'active')
|
||||
`, uuid.New(), game.GameID, m, race, string(canonical)); err != nil {
|
||||
t.Fatalf("seed membership %s: %v", m, err)
|
||||
}
|
||||
}
|
||||
if err := svc.Cache().Warm(context.Background(), store); err != nil {
|
||||
t.Fatalf("re-warm cache: %v", err)
|
||||
}
|
||||
if _, err := svc.ReadyToStart(context.Background(), &owner, false, game.GameID); err != nil {
|
||||
t.Fatalf("ready-to-start: %v", err)
|
||||
}
|
||||
if _, err := svc.Start(context.Background(), &owner, false, game.GameID); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
// First snapshot: prev=0, current_turn=1 → emit on the very first
|
||||
// turn after the engine starts producing.
|
||||
if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{
|
||||
CurrentTurn: 1,
|
||||
RuntimeStatus: "running",
|
||||
}); err != nil {
|
||||
t.Fatalf("on-runtime-snapshot 1: %v", err)
|
||||
}
|
||||
intents := publisher.byKind(lobby.NotificationGameTurnReady)
|
||||
if len(intents) != 1 {
|
||||
t.Fatalf("after turn 1 want 1 turn-ready intent, got %d", len(intents))
|
||||
}
|
||||
first := intents[0]
|
||||
wantKey := fmt.Sprintf("turn-ready:%s:1", game.GameID)
|
||||
if first.IdempotencyKey != wantKey {
|
||||
t.Errorf("turn 1 idempotency key = %q, want %q", first.IdempotencyKey, wantKey)
|
||||
}
|
||||
if got := first.Payload["turn"]; got != int32(1) {
|
||||
t.Errorf("turn 1 payload turn = %v, want 1", got)
|
||||
}
|
||||
if got := first.Payload["game_id"]; got != game.GameID.String() {
|
||||
t.Errorf("turn 1 payload game_id = %v, want %s", got, game.GameID)
|
||||
}
|
||||
if len(first.Recipients) != 2 {
|
||||
t.Errorf("turn 1 recipients = %d, want 2", len(first.Recipients))
|
||||
}
|
||||
recipientSet := map[uuid.UUID]struct{}{}
|
||||
for _, r := range first.Recipients {
|
||||
recipientSet[r] = struct{}{}
|
||||
}
|
||||
if _, ok := recipientSet[memberA]; !ok {
|
||||
t.Errorf("turn 1 missing memberA in recipients")
|
||||
}
|
||||
if _, ok := recipientSet[memberB]; !ok {
|
||||
t.Errorf("turn 1 missing memberB in recipients")
|
||||
}
|
||||
|
||||
// Same turn re-delivered (duplicate snapshot, gateway replay) must
|
||||
// not re-emit at the lobby layer: prev catches up to merged.
|
||||
if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{
|
||||
CurrentTurn: 1,
|
||||
RuntimeStatus: "running",
|
||||
}); err != nil {
|
||||
t.Fatalf("on-runtime-snapshot 1 replay: %v", err)
|
||||
}
|
||||
if got := len(publisher.byKind(lobby.NotificationGameTurnReady)); got != 1 {
|
||||
t.Fatalf("after duplicate turn 1 want 1 intent, got %d", got)
|
||||
}
|
||||
|
||||
// Next turn advances → second emit with key anchored on turn 2.
|
||||
if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{
|
||||
CurrentTurn: 2,
|
||||
RuntimeStatus: "running",
|
||||
}); err != nil {
|
||||
t.Fatalf("on-runtime-snapshot 2: %v", err)
|
||||
}
|
||||
intents = publisher.byKind(lobby.NotificationGameTurnReady)
|
||||
if len(intents) != 2 {
|
||||
t.Fatalf("after turn 2 want 2 turn-ready intents, got %d", len(intents))
|
||||
}
|
||||
wantKey2 := fmt.Sprintf("turn-ready:%s:2", game.GameID)
|
||||
if intents[1].IdempotencyKey != wantKey2 {
|
||||
t.Errorf("turn 2 idempotency key = %q, want %q", intents[1].IdempotencyKey, wantKey2)
|
||||
}
|
||||
if got := intents[1].Payload["turn"]; got != int32(2) {
|
||||
t.Errorf("turn 2 payload turn = %v, want 2", got)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
KindRuntimeImagePullFailed = "runtime.image_pull_failed"
|
||||
KindRuntimeContainerStartFailed = "runtime.container_start_failed"
|
||||
KindRuntimeStartConfigInvalid = "runtime.start_config_invalid"
|
||||
KindGameTurnReady = "game.turn.ready"
|
||||
)
|
||||
|
||||
// CatalogEntry describes the per-kind delivery policy: which channels
|
||||
@@ -95,6 +96,9 @@ var catalog = map[string]CatalogEntry{
|
||||
Admin: true,
|
||||
MailTemplateID: KindRuntimeStartConfigInvalid,
|
||||
},
|
||||
KindGameTurnReady: {
|
||||
Channels: []string{ChannelPush},
|
||||
},
|
||||
}
|
||||
|
||||
// LookupCatalog returns the per-kind policy and a boolean reporting
|
||||
@@ -123,5 +127,6 @@ func SupportedKinds() []string {
|
||||
KindRuntimeImagePullFailed,
|
||||
KindRuntimeContainerStartFailed,
|
||||
KindRuntimeStartConfigInvalid,
|
||||
KindGameTurnReady,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestCatalogChannels(t *testing.T) {
|
||||
KindRuntimeImagePullFailed: {ChannelEmail},
|
||||
KindRuntimeContainerStartFailed: {ChannelEmail},
|
||||
KindRuntimeStartConfigInvalid: {ChannelEmail},
|
||||
KindGameTurnReady: {ChannelPush},
|
||||
}
|
||||
for kind, want := range expect {
|
||||
entry, ok := LookupCatalog(kind)
|
||||
|
||||
@@ -605,7 +605,8 @@ CREATE TABLE notifications (
|
||||
'lobby.race_name.registered', 'lobby.race_name.pending',
|
||||
'lobby.race_name.expired',
|
||||
'runtime.image_pull_failed', 'runtime.container_start_failed',
|
||||
'runtime.start_config_invalid'
|
||||
'runtime.start_config_invalid',
|
||||
'game.turn.ready'
|
||||
))
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user