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
+15 -3
View File
@@ -785,9 +785,21 @@ Future scale-out hooks (not in MVP):
- **runtime snapshot** — engine-status read materialised into the lobby's
denormalised view: `current_turn`, `runtime_status`,
`engine_health_summary`, `player_turn_stats`.
- **turn cutoff** — the `running → generation_in_progress` CAS transition
that closes the command window. Commands arriving after the CAS are
rejected.
- **turn cutoff** — the `running → generation_in_progress` runtime-status
flip performed by `backend/internal/runtime/scheduler.go` before each
engine `/admin/turn` call. Commands and orders arriving while the
flag is set are rejected by the user-games handlers with HTTP 409
`turn_already_closed`. The matching reopening flip
(`generation_in_progress → running`) happens on a successful tick;
a failing tick instead drives the lobby to `paused` and fans out
`game.paused` (FUNCTIONAL.md §6.3, §6.5).
- **auto-pause** — the lobby reaction to a failed runtime snapshot
(`engine_unreachable` / `generation_failed`): the game flips
`running → paused`, the order handlers refuse new submits with
HTTP 409 `game_paused`, and `lobby.publishGamePaused` fans out the
push event. Only an admin `/resume` followed by a successful tick
recovers the game; the UI relies on the next `game.turn.ready` to
clear the paused banner.
- **outbox** — the durable queue of pending mail rows in
`mail_deliveries`, drained by the mail worker.
- **freshness window** — the symmetric ±5-minute interval around server
+48 -15
View File
@@ -635,18 +635,40 @@ validity and ordering of in-game decisions. Gateway needs to know
the typed FB shape only to transcode the wire format; the per-command
semantics live in the engine.
### 6.3 Turn cutoff
### 6.3 Turn cutoff and auto-pause
A running game continuously alternates between a command-accepting
window and a generation phase. The transition `running →
generation_in_progress` is the cutoff: any command or order that
arrives after the cutoff is rejected by backend before forwarding,
because the engine no longer accepts writes for the closing turn.
After generation finishes, backend re-opens the window for the next
turn.
window and a generation phase, driven by the cron expression stored
in `runtime_records.turn_schedule`. The backend scheduler
(`backend/internal/runtime/scheduler.go`) wraps each engine
`/admin/turn` call between two `runtime_status` flips:
- Before the engine call: `running → generation_in_progress`.
The user-games command/order handlers
(`backend/internal/server/handlers_user_games.go`) consult the
per-game runtime record on every request and reject with
HTTP 409 + `code = turn_already_closed` while the runtime sits in
`generation_in_progress`. The error envelope mirrors backend's
standard `httperr` shape: `{"error": {"code":
"turn_already_closed", "message": "..."}}`.
- After a successful tick: `generation_in_progress → running`.
The order window re-opens for the new turn and the next
scheduled tick continues normally.
- After a failed tick (`engine_unreachable` /
`generation_failed`): the lobby's `OnRuntimeSnapshot` flips the
game from `running` to `paused` and publishes a `game.paused`
push event (see §6.5). The order handlers reject with HTTP 409
+ `code = game_paused` until an admin resume succeeds.
`force-next-turn` (admin) schedules a one-shot extra tick that
advances the next scheduled turn by one cron step.
advances the next scheduled turn by one cron step; the same
status-flip and rejection rules apply.
Clients distinguish the two rejections by `code`:
`turn_already_closed` means "wait for the next `game.turn.ready`
and resubmit", whereas `game_paused` means "wait for an admin
resume". The web client implements both reactions in
`ui/docs/sync-protocol.md`.
### 6.4 Reports
@@ -672,13 +694,24 @@ runtime status, per-player stats). The engine's "game finished"
report drives the `running → finished` transition ([Section 3.5](#35-cancellation-and-finish))
and triggers Race Name Directory promotions ([Section 5](#5-race-name-directory)).
Among the `game.*` notification kinds, `game.turn.ready` is wired:
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
emits one intent per advancing `current_turn`, addressed to every
active membership of the game, with idempotency key
`turn-ready:<game_id>:<turn>` and JSON payload `{game_id, turn}`. The
catalog routes the intent through the push channel only; email is
deliberately omitted to avoid per-turn spam.
Among the `game.*` notification kinds, `game.turn.ready` and
`game.paused` are wired:
- `game.turn.ready`
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
emits one intent per advancing `current_turn`, addressed to every
active membership of the game, with idempotency key
`turn-ready:<game_id>:<turn>` and JSON payload `{game_id, turn}`.
- `game.paused` — the same hook publishes one intent per transition
into `paused` driven by an `engine_unreachable` /
`generation_failed` runtime snapshot, addressed to every active
membership, with idempotency key `paused:<game_id>:<turn>` and
JSON payload `{game_id, turn, reason}`. The runtime status that
triggered the transition is carried as `reason` so the UI can
differentiate the copy in a future revision.
Both kinds route through the push channel only; email is
deliberately omitted to avoid per-turn / per-pause spam.
The remaining `game.*` kinds (`game.started`, `game.generation.failed`,
`game.finished`) and `mail.dead_lettered` are reserved without a
+50 -14
View File
@@ -653,17 +653,40 @@ Backend не парсит содержимое payload команд или пр
FB-форму только чтобы транскодировать wire-формат; per-command-
семантика живёт в движке.
### 6.3 Окно хода
### 6.3 Окно хода и auto-pause
Запущенная игра постоянно чередуется между окном приёма команд
и фазой генерации. Переход `running → generation_in_progress`
cutoff: любая команда или приказ, пришедшие после cutoff,
отклоняются backend до форварда, потому что движок больше не
принимает запись для закрывающегося хода. После окончания
генерации backend заново открывает окно для следующего хода.
и фазой генерации, управляемой cron-выражением из
`runtime_records.turn_schedule`. Backend-планировщик
(`backend/internal/runtime/scheduler.go`) оборачивает каждый
engine `/admin/turn` двумя `runtime_status`-флипами:
- Перед engine-вызовом: `running → generation_in_progress`.
User-games-handler'ы команд/приказов
(`backend/internal/server/handlers_user_games.go`) на каждом
запросе сверяются с per-game runtime-записью и отклоняют с
HTTP 409 + `code = turn_already_closed`, пока runtime в
`generation_in_progress`. Тело ошибки — стандартный
`httperr`-конверт: `{"error": {"code": "turn_already_closed",
"message": "..."}}`.
- После успешного тика: `generation_in_progress → running`.
Окно приказов открывается на новый ход, следующий тик идёт
как обычно.
- После провалившегося тика (`engine_unreachable` /
`generation_failed`): `lobby.OnRuntimeSnapshot` переводит игру
`running → paused` и публикует push-эвент `game.paused`
(см. §6.5). Order-handler'ы отклоняют запросы с HTTP 409 +
`code = game_paused`, пока админ не выполнит resume.
`force-next-turn` (admin) планирует one-shot-доп-тик, который
сдвигает следующий запланированный ход на один cron-шаг.
сдвигает следующий запланированный ход на один cron-шаг; те же
правила status-flip и отклонения применимы.
Клиенты различают два варианта отказа по `code`:
`turn_already_closed` — «дождись следующего `game.turn.ready` и
отправь ещё раз», `game_paused` — «дождись resume администратором».
Web-клиент реализует оба сценария согласно
`ui/docs/sync-protocol.md`.
### 6.4 Отчёты
@@ -690,13 +713,26 @@ status, per-player-stats). Engine-отчёт "game finished" гонит
([Раздел 3.5](#35-отмена-и-завершение)) и триггерит Race Name
Directory-промоушен ([Раздел 5](#5-реестр-названий-рас)).
Из `game.*`-видов уведомлений подключён `game.turn.ready`:
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
выпускает один intent на каждое увеличение `current_turn`, адресуя
его всем активным membership-ам игры, с idempotency-ключом
`turn-ready:<game_id>:<turn>` и JSON-payload-ом `{game_id, turn}`.
Каталог направляет intent только в push-канал; email-фан-аут
сознательно опущен, чтобы избежать спама на каждом ходе.
Из `game.*`-видов уведомлений подключены `game.turn.ready` и
`game.paused`:
- `game.turn.ready`
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
выпускает один intent на каждое увеличение `current_turn`,
адресуя его всем активным membership-ам игры, с
idempotency-ключом `turn-ready:<game_id>:<turn>` и
JSON-payload-ом `{game_id, turn}`.
- `game.paused` — тот же хук публикует один intent на каждое
выставление статуса `paused` по runtime-снапшоту
(`engine_unreachable` / `generation_failed`), адресуя его всем
активным membership-ам игры, с idempotency-ключом
`paused:<game_id>:<turn>` и JSON-payload-ом
`{game_id, turn, reason}`. `reason` несёт runtime-статус,
спровоцировавший переход, чтобы UI смог в будущем
дифференцировать копию.
Оба вида направляются только в push-канал; email-фан-аут
сознательно опущен, чтобы избежать спама на каждом ходе/паузе.
Остальные `game.*`-виды (`game.started`, `game.generation.failed`,
`game.finished`) и `mail.dead_lettered` зарезервированы без поставщика;