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