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>
9.6 KiB
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); seedocs/FUNCTIONAL.md §6.3for the user-visible spec. - Gateway translates backend's
httperrenvelope into theExecuteCommandResponseenvelope without re-interpreting the code;turn_already_closedandgame_pausedsurface asresultCodevalues verbatim. - UI detects those codes in
src/sync/order-queue.svelte.tsand projects them onto theOrderDraftStorestate machine consumed byorder-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:
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 withonline === falsereturns{kind: "offline"}without invokingsubmitFn. The draft store reverts every submitting row back tovalidand parks the loop withsyncStatus = "offline". No further sends fire until the browser re-emitsonline. - When the browser flips back to
online, the queue invokes theonOnlinecallback supplied atstart(). The draft store wires this callback toscheduleSync()— 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
onlineevent ⇒ 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:
- Marks every command that was in
submittingasconflict— the matching row shows the new badge. - Records
conflictBanner = { turn, code, message }.turnis read from thegetCurrentTurncallback the layout supplied atbindClient(the turn the player was composing for); it may benullin tests that omit the callback, in which case the banner falls back to a turn-less template. - Sets
syncStatus = "conflict". The loop exits without firing thependingfollow-up —scheduleSyncshort-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) callsclearConflictForMutation, which drops the banner and re-validates everyconflict-marked command back through the local validator. The mutation then auto-syncs as usual — likely a fresh attempt against the new turn, often resulting insuccess. - A
game.turn.readypush arriving while the store is inconflicttriggersresetForNewTurn. The local draft is wiped,hydrateFromServerpulls 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'suser.games.orderfor?turn=N.
Paused UX — game_paused / game.paused
The paused-banner has two entry points:
Ordershandler reply withcode = "game_paused"— the player attempted to submit while the game was paused. The queue emits apausedoutcome; the store reverts submitting rows tovalid, recordspausedBanner = { reason, code, message }, and lockssyncStatus = "paused".game.pausedpush frame — lobby published the event when it flipped the game topaused(see backend §6.5). The layout subscribes viaeventStream.on("game.paused", ...)and callsorderDraft.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:
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.tscovers the queue's classification + online/offline plumbing in isolation;tests/order-draft.test.tscovers the store's reaction to each outcome and theresetForNewTurn/markPausedpaths;tests/order-tab.test.tsasserts the banner DOM;tests/events.test.tspins thegame.pauseddispatch. - Playwright e2e:
tests/e2e/order-sync.spec.tsdrives 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 → runningstatus flip on the lobby side without an explicit push event. The UI relies on the nextgame.turn.readyfor recovery; a dedicatedgame.resumedevent 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.