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:
@@ -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`),
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# UI sync protocol
|
||||
|
||||
Phase 25 wires the transport-level policy that keeps the local
|
||||
order draft consistent with the gateway across two failure modes
|
||||
that Phase 14 punted on: transient network outages and turn
|
||||
cutoffs the player did not anticipate. The wiring also reacts to
|
||||
admin-initiated game pauses signalled by the new `game.paused`
|
||||
push event.
|
||||
|
||||
The contract lives at three layers:
|
||||
|
||||
- **Backend** owns the turn-cutoff guard and the auto-pause on
|
||||
generation failure (`backend/internal/runtime/scheduler.go`,
|
||||
`backend/internal/lobby/runtime_hooks.go`,
|
||||
`backend/internal/server/handlers_user_games.go`); see
|
||||
`docs/FUNCTIONAL.md §6.3` for the user-visible spec.
|
||||
- **Gateway** translates backend's `httperr` envelope into the
|
||||
`ExecuteCommandResponse` envelope without re-interpreting the
|
||||
code; `turn_already_closed` and `game_paused` surface as
|
||||
`resultCode` values verbatim.
|
||||
- **UI** detects those codes in
|
||||
[`src/sync/order-queue.svelte.ts`](../frontend/src/sync/order-queue.svelte.ts)
|
||||
and projects them onto the
|
||||
[`OrderDraftStore`](../frontend/src/sync/order-draft.svelte.ts)
|
||||
state machine consumed by
|
||||
[`order-tab.svelte`](../frontend/src/lib/sidebar/order-tab.svelte).
|
||||
|
||||
This document covers the UI side of the protocol — send-loop
|
||||
semantics, retry budgets, conflict UX, paused UX, and the
|
||||
recovery paths back to a normal `synced` state.
|
||||
|
||||
## Send-loop semantics
|
||||
|
||||
The order draft store no longer calls `submitOrder` directly. Every
|
||||
auto-sync attempt goes through `OrderQueue.send(submitFn)`, which
|
||||
acts as a thin policy gate:
|
||||
|
||||
```text
|
||||
mutation ──▶ scheduleSync ──▶ runSync ──▶ queue.send(submitFn)
|
||||
│
|
||||
▼
|
||||
classify outcome:
|
||||
- success
|
||||
- rejected
|
||||
- conflict
|
||||
- paused
|
||||
- offline
|
||||
- failed
|
||||
```
|
||||
|
||||
The classification is dispatched into the store:
|
||||
|
||||
| outcome | command status flip | syncStatus | banner side-effect |
|
||||
| ----------- | ---------------------------------- | ---------- | ----------------------------------------- |
|
||||
| `success` | per-command `applied` / `rejected` | `synced` | none |
|
||||
| `rejected` | submitting → `rejected` | `error` | none (the row colour is enough) |
|
||||
| `conflict` | submitting → `conflict` | `conflict` | `conflictBanner = { turn, code, message }` |
|
||||
| `paused` | submitting → `valid` | `paused` | `pausedBanner = { reason, code, message }` |
|
||||
| `offline` | submitting → `valid` | `offline` | none — the status bar carries the copy |
|
||||
| `failed` | submitting → `valid` | `error` | `syncError = reason` |
|
||||
|
||||
Only one submit is in flight at a time. Mutations made during a
|
||||
flight set `pending = true` so the loop runs one more iteration
|
||||
with a fresh snapshot once the active call settles.
|
||||
|
||||
## Offline detection and retry budget
|
||||
|
||||
`OrderQueue.start` subscribes to the browser's `online` / `offline`
|
||||
events and primes `OrderQueue.online` from `navigator.onLine`. The
|
||||
queue intentionally treats offline as a fast-fail:
|
||||
|
||||
- A `send()` invocation with `online === false` returns
|
||||
`{kind: "offline"}` without invoking `submitFn`. The draft store
|
||||
reverts every submitting row back to `valid` and parks the loop
|
||||
with `syncStatus = "offline"`. No further sends fire until the
|
||||
browser re-emits `online`.
|
||||
- When the browser flips back to `online`, the queue invokes the
|
||||
`onOnline` callback supplied at `start()`. The draft store wires
|
||||
this callback to `scheduleSync()` — exactly one new attempt per
|
||||
online flip. The store's existing single-slot pending machinery
|
||||
takes care of the rest (further mutations during that attempt
|
||||
coalesce into one follow-up).
|
||||
|
||||
There is no inline retry inside `OrderQueue.send`. The
|
||||
plan's "retry once on reconnect" budget is therefore literal:
|
||||
|
||||
- offline ⇒ hold (no attempt)
|
||||
- next `online` event ⇒ one attempt
|
||||
- attempt succeeds ⇒ `syncStatus = "synced"`
|
||||
- attempt throws ⇒ `syncStatus = "error"` and the existing
|
||||
manual-retry affordance in the order tab applies
|
||||
|
||||
A throw mid-flight while `navigator.onLine` is still `true` is
|
||||
treated as a regular failure (`failed` outcome). A throw whose
|
||||
`onlineProbe` check returns `false` collapses into `offline`, so
|
||||
flaky connectivity does not get classified as a hard error.
|
||||
|
||||
## Conflict UX — `turn_already_closed`
|
||||
|
||||
When the gateway returns `resultCode = "turn_already_closed"` (or
|
||||
the per-error-body `code` matches it), the queue emits a
|
||||
`conflict` outcome. The store:
|
||||
|
||||
1. Marks every command that was in `submitting` as `conflict` —
|
||||
the matching row shows the new badge.
|
||||
2. Records `conflictBanner = { turn, code, message }`. `turn` is
|
||||
read from the `getCurrentTurn` callback the layout supplied at
|
||||
`bindClient` (the turn the player was composing for); it may be
|
||||
`null` in tests that omit the callback, in which case the
|
||||
banner falls back to a turn-less template.
|
||||
3. Sets `syncStatus = "conflict"`. The loop exits without firing
|
||||
the `pending` follow-up — `scheduleSync` short-circuits while
|
||||
the store is in conflict, so a flurry of keystrokes does not
|
||||
re-elicit the same gateway reply.
|
||||
|
||||
The banner clears on either of two signals:
|
||||
|
||||
- **Any local mutation** (`add`, `remove`, `move`) calls
|
||||
`clearConflictForMutation`, which drops the banner and
|
||||
re-validates every `conflict`-marked command back through the
|
||||
local validator. The mutation then auto-syncs as usual — likely
|
||||
a fresh attempt against the new turn, often resulting in
|
||||
`success`.
|
||||
- **A `game.turn.ready` push** arriving while the store is in
|
||||
`conflict` triggers `resetForNewTurn`. The local draft is
|
||||
wiped, `hydrateFromServer` pulls the new turn's empty order,
|
||||
and the banner clears. Old commands for the prior turn become
|
||||
history (read-only) and live on the server's `user.games.order`
|
||||
for `?turn=N`.
|
||||
|
||||
## Paused UX — `game_paused` / `game.paused`
|
||||
|
||||
The paused-banner has two entry points:
|
||||
|
||||
- **`Orders` handler reply with `code = "game_paused"`** — the
|
||||
player attempted to submit while the game was paused. The
|
||||
queue emits a `paused` outcome; the store reverts submitting
|
||||
rows to `valid`, records `pausedBanner = { reason, code, message }`,
|
||||
and locks `syncStatus = "paused"`.
|
||||
- **`game.paused` push frame** — lobby published the event when
|
||||
it flipped the game to `paused` (see backend §6.5). The layout
|
||||
subscribes via `eventStream.on("game.paused", ...)` and calls
|
||||
`orderDraft.markPaused({ reason })`, which arrives at the same
|
||||
state via a different path.
|
||||
|
||||
While in `paused` the auto-sync loop refuses to fire (the
|
||||
`scheduleSync` early-exit). The store's `hydrateFromServer` also
|
||||
short-circuits if `syncStatus === "paused"` to avoid clobbering
|
||||
the banner with a fresh `synced` flip.
|
||||
|
||||
Recovery is the same as conflict: a `game.turn.ready` push
|
||||
clears the pause via `resetForNewTurn`. The matching admin flow
|
||||
on the backend is an explicit `/resume` followed by a successful
|
||||
scheduler tick that emits the next turn.
|
||||
|
||||
## Recovery via `resetForNewTurn`
|
||||
|
||||
`resetForNewTurn` is the single entry point that wipes both
|
||||
banners and rebuilds the draft against a fresh turn:
|
||||
|
||||
```ts
|
||||
async resetForNewTurn(opts: { client; turn }) {
|
||||
this.commands = [];
|
||||
this.statuses = {};
|
||||
this.updatedAt = 0;
|
||||
this.conflictBanner = null;
|
||||
this.pausedBanner = null;
|
||||
this.syncStatus = "idle";
|
||||
this.syncError = null;
|
||||
await this.persist();
|
||||
await this.hydrateFromServer({ client, turn });
|
||||
}
|
||||
```
|
||||
|
||||
The layout calls it from the `game.turn.ready` subscription
|
||||
whenever the prior `syncStatus` was either `conflict` or
|
||||
`paused`. A regular turn advance (no banner active) keeps the
|
||||
existing behaviour: `markPendingTurn` shows the toast and the
|
||||
player chooses when to navigate; the local draft survives the
|
||||
transition unchanged.
|
||||
|
||||
## Test surface
|
||||
|
||||
- **Vitest unit**:
|
||||
[`tests/order-queue.test.ts`](../frontend/tests/order-queue.test.ts)
|
||||
covers the queue's classification + online/offline plumbing in
|
||||
isolation;
|
||||
[`tests/order-draft.test.ts`](../frontend/tests/order-draft.test.ts)
|
||||
covers the store's reaction to each outcome and the
|
||||
`resetForNewTurn` / `markPaused` paths;
|
||||
[`tests/order-tab.test.ts`](../frontend/tests/order-tab.test.ts)
|
||||
asserts the banner DOM;
|
||||
[`tests/events.test.ts`](../frontend/tests/events.test.ts)
|
||||
pins the `game.paused` dispatch.
|
||||
- **Playwright e2e**:
|
||||
[`tests/e2e/order-sync.spec.ts`](../frontend/tests/e2e/order-sync.spec.ts)
|
||||
drives the order tab through the conflict and paused-push
|
||||
scenarios with mocked gateway and signed-event frames.
|
||||
|
||||
The offline-online round-trip is covered at the Vitest level
|
||||
because Playwright's `context.setOffline(true)` is a coarse
|
||||
network mute that conflicts with the dev-server bootstrap and
|
||||
the in-test fixture key wiring; the store-level test uses
|
||||
injected `onlineProbe` / `addEventListener` to drive the same
|
||||
state machine deterministically.
|
||||
|
||||
## Known follow-ups
|
||||
|
||||
- Admin resume currently produces a `running → paused → running`
|
||||
status flip on the lobby side without an explicit push event.
|
||||
The UI relies on the next `game.turn.ready` for recovery; a
|
||||
dedicated `game.resumed` event would let the banner clear
|
||||
immediately without waiting for the next cron tick. Not part
|
||||
of this phase.
|
||||
- The conflict banner shows the player-facing template
|
||||
unmodified; a future revision may interpolate the explicit
|
||||
cutoff timestamp once the server adds it to the error body.
|
||||
Reference in New Issue
Block a user