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
+24 -7
View File
@@ -36,11 +36,18 @@ entry by `cmdId`. Successfully applied entries stay visible in
the draft (the player keeps composing until turn cutoff);
rejected entries stay until the player edits or removes them.
Phase 25 is reserved for one extension on top of this: per-line
sequencing if a future use case needs to submit commands
individually rather than in one batch. The wire shape is already
flexible enough — the response carries an array of results — so
Phase 25 only changes the client-side iteration policy.
Phase 25 layers a transport-level policy on top of this baseline
without changing the batch semantics. The submit pipeline now
goes through `OrderQueue` (see
[`sync-protocol.md`](sync-protocol.md)): the queue holds the
submit while the browser is offline, classifies
`turn_already_closed` and `game_paused` server replies into
matching banners on the order tab, and exits the loop on the
sticky states so a stream of mutations does not re-elicit the
same gateway reply. Recovery from a `conflict` or `paused`
banner happens on the next `game.turn.ready` push frame via
`OrderDraftStore.resetForNewTurn`, which clears the local draft
and re-hydrates from the server for the new turn.
## Local-validation invariant
@@ -63,8 +70,10 @@ submit pipeline filters the draft to `valid` entries only — any
```text
draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied
╲ │
╲──validate──▶ invalid ╲──nack──▶ rejected
╲ │
╲──validate──▶ invalid ╲──nack──▶ rejected
╲────turn_already_closed──▶ conflict
```
Transitions:
@@ -76,6 +85,14 @@ Transitions:
the draft and sends it to the gateway.
- **`submitting → applied` / `submitting → rejected`**: the gateway
responded; the entry is no longer in flight.
- **`submitting → conflict`** (Phase 25): the gateway returned
`resultCode = "turn_already_closed"`. The order tab surfaces a
banner above the command list. Any subsequent mutation
re-validates the conflict row back to `valid` / `invalid`; a
matching `game.turn.ready` push frame triggers
`resetForNewTurn`, which wipes the draft entirely. See
[`sync-protocol.md`](sync-protocol.md) for the full state
table and recovery paths.
Phase 14 lands the local validators (`draft → valid | invalid`),
the submit pipeline (`valid → submitting → applied | rejected`),