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:
@@ -110,6 +110,7 @@ const (
|
||||
NotificationLobbyRaceNamePending = "lobby.race_name.pending"
|
||||
NotificationLobbyRaceNameExpired = "lobby.race_name.expired"
|
||||
NotificationGameTurnReady = "game.turn.ready"
|
||||
NotificationGamePaused = "game.paused"
|
||||
)
|
||||
|
||||
// Deps aggregates every collaborator the lobby Service depends on.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package lobby
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestNextStatusFromSnapshot covers the pure status-mapping function
|
||||
// that drives `OnRuntimeSnapshot`'s lifecycle transitions. The Phase
|
||||
// 25 contribution is the `running → paused` branch on
|
||||
// `engine_unreachable` / `generation_failed`: the order handler relies
|
||||
// on the `paused` game status to reject late submits with
|
||||
// `turn_already_closed`.
|
||||
func TestNextStatusFromSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
currentStatus string
|
||||
runtimeStatus string
|
||||
wantStatus string
|
||||
wantTransit bool
|
||||
}{
|
||||
{
|
||||
name: "starting then running flips to running",
|
||||
currentStatus: GameStatusStarting,
|
||||
runtimeStatus: "running",
|
||||
wantStatus: GameStatusRunning,
|
||||
wantTransit: true,
|
||||
},
|
||||
{
|
||||
name: "running on running snapshot does not transit",
|
||||
currentStatus: GameStatusRunning,
|
||||
runtimeStatus: "running",
|
||||
wantStatus: GameStatusRunning,
|
||||
wantTransit: false,
|
||||
},
|
||||
{
|
||||
name: "starting then engine_unreachable flips to start_failed",
|
||||
currentStatus: GameStatusStarting,
|
||||
runtimeStatus: "engine_unreachable",
|
||||
wantStatus: GameStatusStartFailed,
|
||||
wantTransit: true,
|
||||
},
|
||||
{
|
||||
name: "starting then generation_failed flips to start_failed",
|
||||
currentStatus: GameStatusStarting,
|
||||
runtimeStatus: "generation_failed",
|
||||
wantStatus: GameStatusStartFailed,
|
||||
wantTransit: true,
|
||||
},
|
||||
{
|
||||
name: "running then engine_unreachable flips to paused",
|
||||
currentStatus: GameStatusRunning,
|
||||
runtimeStatus: "engine_unreachable",
|
||||
wantStatus: GameStatusPaused,
|
||||
wantTransit: true,
|
||||
},
|
||||
{
|
||||
name: "running then generation_failed flips to paused",
|
||||
currentStatus: GameStatusRunning,
|
||||
runtimeStatus: "generation_failed",
|
||||
wantStatus: GameStatusPaused,
|
||||
wantTransit: true,
|
||||
},
|
||||
{
|
||||
name: "paused stays paused on repeated failed snapshot",
|
||||
currentStatus: GameStatusPaused,
|
||||
runtimeStatus: "generation_failed",
|
||||
wantStatus: GameStatusPaused,
|
||||
wantTransit: false,
|
||||
},
|
||||
{
|
||||
name: "starting then start_failed flips to start_failed",
|
||||
currentStatus: GameStatusStarting,
|
||||
runtimeStatus: "start_failed",
|
||||
wantStatus: GameStatusStartFailed,
|
||||
wantTransit: true,
|
||||
},
|
||||
{
|
||||
name: "running ignores start_failed",
|
||||
currentStatus: GameStatusRunning,
|
||||
runtimeStatus: "start_failed",
|
||||
wantStatus: GameStatusRunning,
|
||||
wantTransit: false,
|
||||
},
|
||||
{
|
||||
name: "running on finished flips to finished",
|
||||
currentStatus: GameStatusRunning,
|
||||
runtimeStatus: "finished",
|
||||
wantStatus: GameStatusFinished,
|
||||
wantTransit: true,
|
||||
},
|
||||
{
|
||||
name: "finished stays finished on finished snapshot",
|
||||
currentStatus: GameStatusFinished,
|
||||
runtimeStatus: "finished",
|
||||
wantStatus: GameStatusFinished,
|
||||
wantTransit: false,
|
||||
},
|
||||
{
|
||||
name: "cancelled stays cancelled on finished snapshot",
|
||||
currentStatus: GameStatusCancelled,
|
||||
runtimeStatus: "finished",
|
||||
wantStatus: GameStatusCancelled,
|
||||
wantTransit: false,
|
||||
},
|
||||
{
|
||||
name: "paused on stopped snapshot flips to finished",
|
||||
currentStatus: GameStatusPaused,
|
||||
runtimeStatus: "stopped",
|
||||
wantStatus: GameStatusFinished,
|
||||
wantTransit: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, transit := nextStatusFromSnapshot(tt.currentStatus, RuntimeSnapshot{
|
||||
RuntimeStatus: tt.runtimeStatus,
|
||||
})
|
||||
if got != tt.wantStatus {
|
||||
t.Errorf("status = %q, want %q", got, tt.wantStatus)
|
||||
}
|
||||
if transit != tt.wantTransit {
|
||||
t.Errorf("transit = %v, want %v", transit, tt.wantTransit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user