ui/phase-25: backend turn-cutoff guard + auto-pause + UI sync protocol

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>
This commit is contained in:
Ilia Denisov
2026-05-11 22:00:16 +02:00
parent bbdcc36e05
commit 2ca47eb4df
35 changed files with 2539 additions and 143 deletions
+73 -1
View File
@@ -37,6 +37,7 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps
if err != nil {
return err
}
transitionedToPaused := false
if next, transition := nextStatusFromSnapshot(updated.Status, snapshot); transition {
switch next {
case GameStatusFinished:
@@ -53,12 +54,18 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps
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
}
@@ -106,6 +113,56 @@ func (s *Service) publishTurnReady(ctx context.Context, gameID uuid.UUID, turn i
}
}
// 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`
@@ -278,13 +335,28 @@ func mergeRuntimeSnapshot(prev, next RuntimeSnapshot) RuntimeSnapshot {
// 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", "start_failed", "generation_failed":
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
}