2ca47eb4df
Backend now owns the turn-cutoff and pause guards the order tab relies on: the scheduler flips runtime_status between generation_in_progress and running around every engine tick, a failed tick auto-pauses the game through OnRuntimeSnapshot, and a new game.paused notification kind fans out alongside game.turn.ready. The user-games handlers reject submits with HTTP 409 turn_already_closed or game_paused depending on the runtime state. UI delegates auto-sync to a new OrderQueue: offline detection, single retry on reconnect, conflict / paused classification. OrderDraftStore surfaces conflictBanner / pausedBanner runes, clears them on local mutation or on a game.turn.ready push via resetForNewTurn. The order tab renders the matching banners and the new conflict per-row badge; i18n bundles cover en + ru. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
396 lines
13 KiB
Go
396 lines
13 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
|
|
}
|
|
transitionedToPaused := false
|
|
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
|
|
if next == GameStatusPaused {
|
|
transitionedToPaused = true
|
|
}
|
|
}
|
|
}
|
|
s.deps.Cache.PutGame(updated)
|
|
if merged.CurrentTurn > prevTurn {
|
|
s.publishTurnReady(ctx, gameID, merged.CurrentTurn)
|
|
}
|
|
if transitionedToPaused {
|
|
s.publishGamePaused(ctx, gameID, merged.CurrentTurn, snapshot.RuntimeStatus)
|
|
}
|
|
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))
|
|
}
|
|
}
|
|
|
|
// publishGamePaused fans out a `game.paused` notification to every
|
|
// active member of the game when the lobby flips the game to
|
|
// `paused` in reaction to a runtime snapshot (typically a failed
|
|
// turn generation). The intent is best-effort: a publisher failure
|
|
// is logged at warn level and does not abort the snapshot
|
|
// bookkeeping. Idempotency is anchored on (game_id, turn) so a
|
|
// repeated `generation_failed` snapshot for the same turn collapses
|
|
// into a single notification at the notification.Submit boundary.
|
|
//
|
|
// reason carries the raw runtime status that triggered the pause
|
|
// (`engine_unreachable` / `generation_failed`); the UI displays a
|
|
// status-agnostic banner today but the payload is preserved so a
|
|
// future revision of the order tab can differentiate.
|
|
func (s *Service) publishGamePaused(ctx context.Context, gameID uuid.UUID, turn int32, reason string) {
|
|
memberships, err := s.deps.Store.ListMembershipsForGame(ctx, gameID)
|
|
if err != nil {
|
|
s.deps.Logger.Warn("game-paused 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: NotificationGamePaused,
|
|
IdempotencyKey: fmt.Sprintf("paused:%s:%d", gameID, turn),
|
|
Recipients: recipients,
|
|
Payload: map[string]any{
|
|
"game_id": gameID.String(),
|
|
"turn": turn,
|
|
"reason": reason,
|
|
},
|
|
}
|
|
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
|
|
s.deps.Logger.Warn("game-paused 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.
|
|
//
|
|
// The map intentionally distinguishes the pre-running boot path
|
|
// (`starting → start_failed`) from the in-flight failure path
|
|
// (`running → paused`). Paused games can be resumed by the admin via
|
|
// the explicit `/resume` transition; the runtime keeps the engine
|
|
// container alive, the scheduler short-circuits ticks while paused,
|
|
// and any user-games command/order is rejected by the order handler
|
|
// with `turn_already_closed` until the game resumes.
|
|
func nextStatusFromSnapshot(currentStatus string, snapshot RuntimeSnapshot) (string, bool) {
|
|
switch snapshot.RuntimeStatus {
|
|
case "running":
|
|
if currentStatus == GameStatusStarting {
|
|
return GameStatusRunning, true
|
|
}
|
|
case "engine_unreachable", "generation_failed":
|
|
if currentStatus == GameStatusStarting {
|
|
return GameStatusStartFailed, true
|
|
}
|
|
if currentStatus == GameStatusRunning {
|
|
return GameStatusPaused, true
|
|
}
|
|
case "start_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 }
|