2ca47eb4df
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>
218 lines
9.6 KiB
Markdown
218 lines
9.6 KiB
Markdown
# 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.
|