Files
galaxy-game/ui/docs/sync-protocol.md
Ilia Denisov 2ca47eb4df 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>
2026-05-11 22:00:16 +02:00

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.