# 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.