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 }