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

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); 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 and projects them onto the OrderDraftStore state machine consumed by 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:

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:

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

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.