Files
galaxy-game/backend/internal/lobby/runtime_hooks.go
T
Ilia Denisov 5b07bb4e14 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>
2026-05-11 16:16:31 +02:00

324 lines
10 KiB
Go

package lobby
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
// OnRuntimeSnapshot updates the denormalised runtime view on the game
// row from a snapshot reported by the runtime module. The lobby
// transitions the game's lifecycle status when the snapshot reports a
// state change relevant to the lobby state machine:
//
// - `running` → `running` (after `starting`).
// - `engine_unreachable` / `start_failed` → `start_failed` while
// `starting`.
// - `finished` → triggers `OnGameFinished`.
//
// Per-player MaxPlanets / MaxPopulation are accumulated across the
// game lifetime so the capable-finish evaluation in `OnGameFinished`
// has the data it needs.
//
// The current implementation ships the entry point + state-machine logic; The implementation // (runtime) wires the actual call site.
func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snapshot RuntimeSnapshot) error {
game, err := s.GetGame(ctx, gameID)
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)
if err != nil {
return err
}
if next, transition := nextStatusFromSnapshot(updated.Status, snapshot); transition {
switch next {
case GameStatusFinished:
s.deps.Cache.PutGame(updated)
return s.OnGameFinished(ctx, gameID)
default:
rec, err := s.deps.Store.UpdateGameStatus(ctx, gameID, statusUpdate{
NewStatus: next,
UpdatedAt: now,
SetStarted: next == GameStatusRunning && updated.StartedAt == nil,
StartedAt: now,
})
if err != nil {
return err
}
updated = rec
}
}
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`
// (capable) or deletes them (non-capable).
func (s *Service) OnGameFinished(ctx context.Context, gameID uuid.UUID) error {
game, err := s.GetGame(ctx, gameID)
if err != nil {
return err
}
now := s.deps.Now().UTC()
if game.Status != GameStatusFinished {
updated, err := s.deps.Store.UpdateGameStatus(ctx, gameID, statusUpdate{
NewStatus: GameStatusFinished,
UpdatedAt: now,
SetFinished: true,
FinishedAt: now,
})
if err != nil {
return err
}
game = updated
}
memberships, err := s.deps.Store.ListMembershipsForGame(ctx, gameID)
if err != nil {
return err
}
statsByUser := make(map[uuid.UUID]PlayerTurnStats, len(game.RuntimeSnapshot.PlayerStats))
for _, st := range game.RuntimeSnapshot.PlayerStats {
statsByUser[st.UserID] = st
}
expiry := now.Add(s.deps.Config.PendingRegistrationTTL)
var promoteErrs []error
for _, m := range memberships {
if m.Status != MembershipStatusActive {
continue
}
stats, hasStats := statsByUser[m.UserID]
canonical := CanonicalKey(m.CanonicalKey)
if hasStats && capableFinish(stats) {
// Best-effort: drop the existing reservation row before
// inserting the pending_registration so the per-game PK
// does not block the transition.
if err := s.deps.Store.DeleteRaceName(ctx, canonical, gameID); err != nil {
promoteErrs = append(promoteErrs, fmt.Errorf("delete reservation %s: %w", canonical, err))
continue
}
s.deps.Cache.RemoveRaceName(canonical)
entry, err := s.deps.Store.InsertRaceName(ctx, raceNameInsert{
Name: m.RaceName,
Canonical: canonical,
Status: RaceNameStatusPendingRegistration,
OwnerUserID: m.UserID,
GameID: gameID,
SourceGameID: ptrUUID(gameID),
ExpiresAt: &expiry,
})
if err != nil {
promoteErrs = append(promoteErrs, fmt.Errorf("promote pending %s: %w", canonical, err))
continue
}
s.deps.Cache.PutRaceName(entry)
intent := LobbyNotification{
Kind: NotificationLobbyRaceNamePending,
IdempotencyKey: "racename-pending:" + string(canonical) + ":" + gameID.String(),
Recipients: []uuid.UUID{m.UserID},
Payload: map[string]any{
"race_name": m.RaceName,
"expires_at": expiry.Format(time.RFC3339),
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("race-name pending notification failed",
zap.String("canonical", string(canonical)),
zap.Error(pubErr))
}
continue
}
if err := s.deps.Store.DeleteRaceName(ctx, canonical, gameID); err != nil {
promoteErrs = append(promoteErrs, fmt.Errorf("delete non-capable reservation %s: %w", canonical, err))
continue
}
s.deps.Cache.RemoveRaceName(canonical)
}
s.deps.Cache.PutGame(game)
return errors.Join(promoteErrs...)
}
// OnRuntimeJobResult consumes adoption / removal events emitted by the
// runtime reconciler. The wiring connects the runtime → lobby callback
// through this entry point; the canonical mapping is:
//
// - reconciler reports `removed` → lobby cancels the game (the
// engine container is gone). Games already in `cancelled` or
// `finished` are ignored.
//
// Future job paths (start, stop, restart) may reuse the same shape.
func (s *Service) OnRuntimeJobResult(ctx context.Context, gameID uuid.UUID, result RuntimeJobResult) error {
if s == nil {
return nil
}
game, err := s.GetGame(ctx, gameID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil
}
return err
}
if game.Status == GameStatusCancelled || game.Status == GameStatusFinished {
return nil
}
if result.Status != "removed" && result.Status != "stopped" {
// Unknown status — ignore for forward compatibility.
return nil
}
now := s.deps.Now().UTC()
updated, err := s.deps.Store.UpdateGameStatus(ctx, gameID, statusUpdate{
NewStatus: GameStatusCancelled,
UpdatedAt: now,
})
if err != nil {
return err
}
s.deps.Cache.PutGame(updated)
s.deps.Logger.Info("game cancelled by runtime reconciler",
zap.String("game_id", gameID.String()),
zap.String("op", result.Op),
zap.String("status", result.Status),
zap.String("message", result.Message),
)
return nil
}
// mergeRuntimeSnapshot merges the incoming snapshot into the previous
// one, preserving running maxima of per-player planets and population
// across the game lifetime.
func mergeRuntimeSnapshot(prev, next RuntimeSnapshot) RuntimeSnapshot {
out := RuntimeSnapshot{
CurrentTurn: next.CurrentTurn,
RuntimeStatus: next.RuntimeStatus,
EngineHealth: next.EngineHealth,
ObservedAt: next.ObservedAt,
}
statsByUser := make(map[uuid.UUID]PlayerTurnStats, len(prev.PlayerStats)+len(next.PlayerStats))
for _, st := range prev.PlayerStats {
statsByUser[st.UserID] = st
}
for _, st := range next.PlayerStats {
existing, ok := statsByUser[st.UserID]
if !ok {
st.MaxPlanets = max32(st.MaxPlanets, st.CurrentPlanets)
st.MaxPopulation = max32(st.MaxPopulation, st.CurrentPopulation)
statsByUser[st.UserID] = st
continue
}
st.InitialPlanets = existing.InitialPlanets
st.InitialPopulation = existing.InitialPopulation
st.MaxPlanets = max32(existing.MaxPlanets, max32(st.MaxPlanets, st.CurrentPlanets))
st.MaxPopulation = max32(existing.MaxPopulation, max32(st.MaxPopulation, st.CurrentPopulation))
statsByUser[st.UserID] = st
}
if len(statsByUser) > 0 {
out.PlayerStats = make([]PlayerTurnStats, 0, len(statsByUser))
for _, st := range statsByUser {
out.PlayerStats = append(out.PlayerStats, st)
}
}
return out
}
// nextStatusFromSnapshot maps the runtime-reported runtime status into
// a lobby status transition. Returns (next, true) when the lobby
// status must change; (current, false) otherwise.
func nextStatusFromSnapshot(currentStatus string, snapshot RuntimeSnapshot) (string, bool) {
switch snapshot.RuntimeStatus {
case "running":
if currentStatus == GameStatusStarting {
return GameStatusRunning, true
}
case "engine_unreachable", "start_failed", "generation_failed":
if currentStatus == GameStatusStarting {
return GameStatusStartFailed, true
}
case "finished":
if currentStatus != GameStatusFinished && currentStatus != GameStatusCancelled {
return GameStatusFinished, true
}
case "stopped":
if currentStatus == GameStatusRunning || currentStatus == GameStatusPaused {
return GameStatusFinished, true
}
}
return currentStatus, false
}
// capableFinish reports whether a per-player observation satisfies the
// "capable finish" criterion documented in
// `backend/PLAN.md` §5.4: max_planets > initial AND max_population >
// initial. Either of the inputs being zero (no observation) defaults
// to non-capable.
func capableFinish(stats PlayerTurnStats) bool {
if stats.InitialPlanets == 0 || stats.InitialPopulation == 0 {
return false
}
return stats.MaxPlanets > stats.InitialPlanets &&
stats.MaxPopulation > stats.InitialPopulation
}
func max32(a, b int32) int32 {
if a > b {
return a
}
return b
}
func ptrUUID(u uuid.UUID) *uuid.UUID { v := u; return &v }