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:
+15
-3
@@ -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
@@ -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
@@ -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` зарезервированы без поставщика;
|
||||
|
||||
Reference in New Issue
Block a user