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:
+25
-7
@@ -333,20 +333,38 @@ cannot guarantee.
|
||||
| `runtime.image_pull_failed` | admin email | `game_id`, `image_ref` |
|
||||
| `runtime.container_start_failed` | admin email | `game_id` |
|
||||
| `runtime.start_config_invalid` | admin email | `game_id`, `reason` |
|
||||
| `game.turn.ready` | push | `game_id`, `turn` |
|
||||
| `game.paused` | push | `game_id`, `turn`, `reason` |
|
||||
|
||||
Admin-channel kinds (`runtime.*`) deliver email to
|
||||
`BACKEND_NOTIFICATION_ADMIN_EMAIL`; when the variable is empty, those
|
||||
routes land in `notification_routes` with `status='skipped'` and the
|
||||
operator log line records the configuration miss.
|
||||
|
||||
`game.turn.ready` is emitted by `lobby.Service.OnRuntimeSnapshot`
|
||||
(`backend/internal/lobby/runtime_hooks.go`) whenever the engine's
|
||||
`current_turn` advances. The intent targets every active membership
|
||||
of the game, uses idempotency key `turn-ready:<game_id>:<turn>`, and
|
||||
carries the JSON payload `{game_id, turn}`. The catalog routes it
|
||||
through the push channel only — per-turn email would be spam — so
|
||||
`game.turn.ready` and `game.paused` are emitted by
|
||||
`lobby.Service.OnRuntimeSnapshot`
|
||||
(`backend/internal/lobby/runtime_hooks.go`):
|
||||
|
||||
- `game.turn.ready` fires whenever the engine's `current_turn`
|
||||
advances. Idempotency key `turn-ready:<game_id>:<turn>`, JSON
|
||||
payload `{game_id, turn}`.
|
||||
- `game.paused` fires whenever the same hook flips the game
|
||||
`running → paused` because a runtime snapshot landed with
|
||||
`engine_unreachable` / `generation_failed`. Idempotency key
|
||||
`paused:<game_id>:<turn>`, JSON payload
|
||||
`{game_id, turn, reason}` (reason carries the runtime status
|
||||
that triggered the transition). The runtime scheduler
|
||||
(`backend/internal/runtime/scheduler.go`) forwards the failing
|
||||
snapshot through `Service.publishFailureSnapshot` so a single
|
||||
failing tick reliably reaches lobby.
|
||||
|
||||
Both kinds target every active membership and route through the
|
||||
push channel only — per-turn / per-pause email would be spam — so
|
||||
the UI's signed `SubscribeEvents` stream
|
||||
(`ui/frontend/src/api/events.svelte.ts`) is the sole delivery path.
|
||||
(`ui/frontend/src/api/events.svelte.ts`) is the sole delivery
|
||||
path. The order tab consumes them via
|
||||
`OrderDraftStore.resetForNewTurn` / `markPaused`
|
||||
(`ui/docs/sync-protocol.md`).
|
||||
|
||||
The remaining `game.*` (`game.started`, `game.generation.failed`,
|
||||
`game.finished`) and `mail.dead_lettered` are reserved kinds without
|
||||
|
||||
Reference in New Issue
Block a user