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>
This commit is contained in:
Ilia Denisov
2026-05-11 22:00:16 +02:00
parent bbdcc36e05
commit 2ca47eb4df
35 changed files with 2539 additions and 143 deletions
+120 -26
View File
@@ -2671,43 +2671,137 @@ Targeted tests (delivered):
`game.turn.ready` frame surfaces the toast, manual dismiss
clears it).
## Phase 25. Sync Protocol — Order Queue, Retry, Conflict
## Phase 25. Sync Protocol — Turn Cutoff, Conflict, Auto-Pause
Status: pending.
Status: in progress.
Goal: make the order draft survive network failures and turn cutoffs
gracefully, with explicit user feedback on conflicts.
Goal: make the order draft survive transient connectivity issues
**and** the real turn-cutoff machinery, with explicit user feedback
on conflicts and on admin-pause states. The phase is intentionally
cross-module: the UI side leans on a backend turn-cutoff guard and
auto-pause that did not exist before; both land together so the
contract is end-to-end.
Artifacts:
Decisions baked in during implementation:
- `ui/frontend/src/sync/order-queue.ts` send loop: on disconnect, hold
the most recent submit; on reconnect, retry once; on persistent
failure, surface error to the order tab
- conflict detection: if the server returns `turn_already_closed` for
a submit, mark the entire draft as `conflict` and surface a
`Turn N closed before your order was accepted. Edit and resubmit.`
banner in the order tab
- topic doc `ui/docs/sync-protocol.md` covering queue semantics,
retry budgets, and conflict UX
- Turn-cutoff enforcement lives in `backend` (not in `game-engine`).
The scheduler flips `runtime_status` to `generation_in_progress`
before each engine tick and back to `running` after; the
user-games handlers reject every command/order in
non-running runtime states.
- A failed engine tick auto-pauses the game (`running → paused`)
through `lobby.OnRuntimeSnapshot`, and the lobby publishes a
matching `game.paused` push event. Admin resume remains the
only way out of `paused`.
- The wire-level error codes are `turn_already_closed` (cutoff
conflict) and `game_paused` (paused / starting / finished / removed).
Gateway carries them through `projectUserBackendError` unchanged.
- The UI draft store delegates to a new `OrderQueue` (single-slot
pending, single retry on reconnect via `onOnline` callback). On
`game.turn.ready` after a conflict / pause, the layout calls
`OrderDraftStore.resetForNewTurn` which wipes the draft and
re-hydrates from the server for the new turn (old commands are
preserved server-side and can be read back via
`user.games.order.get?turn=N`).
Dependencies: Phases 14, 24.
Backend artifacts:
- `backend/internal/notification/catalog.go`: new
`KindGamePaused = "game.paused"` and `catalog`/`SupportedKinds`
entries; matching `NotificationGamePaused` constant in
`backend/internal/lobby/lobby.go`; CHECK-constraint widened in
`backend/internal/postgres/migrations/00001_init.sql`.
- `backend/internal/lobby/runtime_hooks.go`:
`nextStatusFromSnapshot` flips `running → paused` on
`engine_unreachable` / `generation_failed`; new
`publishGamePaused` mirrors `publishTurnReady`, idempotency key
`paused:<game_id>:<turn>`, payload `{game_id, turn, reason}`.
- `backend/internal/runtime/scheduler.go`: `tick` wraps the engine
call with `generation_in_progress` / `running` flips and forwards
failure snapshots to lobby through
`Service.publishFailureSnapshot`.
- `backend/internal/runtime/service.go`: `CheckOrdersAccept` plus
the pure `OrdersAcceptStatus` helper used by both `Orders` and
`Commands` user-games handlers.
- `backend/internal/server/httperr/httperr.go`: new
`CodeTurnAlreadyClosed`, `CodeGamePaused`; openapi.yaml
`ErrorBody.code` enum extended.
- `backend/internal/server/handlers_user_games.go`:
`requireOrdersOpen` runs before forwarding, maps sentinels to
HTTP 409 + the matching code.
UI artifacts:
- `ui/frontend/src/sync/order-queue.svelte.ts` (new) — `OrderQueue`
class with offline detection, classification of
`turn_already_closed` / `game_paused`, dependency-injected
online probe + event listeners. Pure-function helper
`classifyResult` reused from tests.
- `ui/frontend/src/sync/order-types.ts` — `CommandStatus` gains
`conflict`.
- `ui/frontend/src/sync/order-draft.svelte.ts` — wires
`OrderQueue` through `runSync`, adds `conflict` / `paused` /
`offline` to `SyncStatus`, plus `conflictBanner` /
`pausedBanner` runes, `markPaused`, `resetForNewTurn`,
`clearConflictForMutation`, sticky-`paused` guard in
`hydrateFromServer`. `bindClient(client, { getCurrentTurn })`
lets the conflict banner interpolate the turn number.
- `ui/frontend/src/lib/sidebar/order-tab.svelte` — renders
conflict / paused banners and the new `conflict` per-row badge;
status bar carries the offline / conflict / paused copy.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new keys for
`sync.{offline,conflict,paused}`, `conflict.banner`
(with `{turn}` interpolation) plus `banner_no_turn` fallback,
`paused.banner`, `status.conflict`.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
subscribes to `game.paused`; `game.turn.ready` handler now
triggers `resetForNewTurn` when the prior `syncStatus` was
`conflict` / `paused`. `bindClient` is invoked with
`getCurrentTurn: () => gameState.currentTurn`.
- `ui/docs/sync-protocol.md` (new) — send-loop semantics, retry
budget, conflict and paused UX, recovery paths.
- `ui/docs/order-composer.md` — stale Phase 25 paragraph
replaced with a pointer to the new topic doc; state-machine
diagram extended with the `conflict` transition.
Dependencies: Phases 14, 24; backend notification / lobby /
runtime modules.
Acceptance criteria:
- submitting an order while offline queues it and submits successfully
on reconnect;
- a turn cutoff between draft and submit produces a visible conflict
banner with no data loss;
- the order tab clearly distinguishes `draft`, `submitting`,
`accepted`, `rejected`, `conflict` states per command.
- submitting an order while offline queues it and submits
successfully on reconnect (one attempt on the next `online`
event, no inline retry storm);
- a turn cutoff between draft and submit produces a visible
conflict banner with the turn number; the local draft is
preserved until the next `game.turn.ready`, then the layout
wipes it and re-hydrates from the server for `turn = N+1`;
- a runtime failure during generation flips the game into
`paused`, emits `game.paused`, and the order tab shows the
pause banner; submits are blocked until the next
`game.turn.ready` clears the state;
- the order tab clearly distinguishes `draft`, `valid`,
`invalid`, `submitting`, `applied`, `rejected`, and
`conflict` states per command.
Targeted tests:
- Vitest unit tests for `order-queue` covering all state transitions;
- Playwright e2e: simulate network drop using Playwright's offline
mode, submit an order, restore network, confirm submission;
- regression test: force a turn cutoff during submit, assert conflict
banner appears.
- Backend: `runtime_hooks_unit_test.go` for
`nextStatusFromSnapshot`, `orders_accept_test.go` for the
per-record decision, plus existing testcontainer-backed
`runtime_hooks_test.go` covering the published intent. Catalog
/ event tests extended with `game.paused`.
- UI Vitest: `tests/order-queue.test.ts` (classification +
offline plumbing), extended `tests/order-draft.test.ts`
(conflict marks commands, mutation clears banner, pause
blocks sync, offline holds + flushes on `online`,
`resetForNewTurn` re-hydrates), extended
`tests/order-tab.test.ts` (banner DOM + sync-status
attribute), extended `tests/events.test.ts` (`game.paused`
dispatch).
- Playwright e2e: `tests/e2e/order-sync.spec.ts` — conflict
banner on `turn_already_closed` reply and paused banner on
the signed `game.paused` frame.
## Phase 26. History Mode
+24 -7
View File
@@ -36,11 +36,18 @@ entry by `cmdId`. Successfully applied entries stay visible in
the draft (the player keeps composing until turn cutoff);
rejected entries stay until the player edits or removes them.
Phase 25 is reserved for one extension on top of this: per-line
sequencing if a future use case needs to submit commands
individually rather than in one batch. The wire shape is already
flexible enough — the response carries an array of results — so
Phase 25 only changes the client-side iteration policy.
Phase 25 layers a transport-level policy on top of this baseline
without changing the batch semantics. The submit pipeline now
goes through `OrderQueue` (see
[`sync-protocol.md`](sync-protocol.md)): the queue holds the
submit while the browser is offline, classifies
`turn_already_closed` and `game_paused` server replies into
matching banners on the order tab, and exits the loop on the
sticky states so a stream of mutations does not re-elicit the
same gateway reply. Recovery from a `conflict` or `paused`
banner happens on the next `game.turn.ready` push frame via
`OrderDraftStore.resetForNewTurn`, which clears the local draft
and re-hydrates from the server for the new turn.
## Local-validation invariant
@@ -63,8 +70,10 @@ submit pipeline filters the draft to `valid` entries only — any
```text
draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied
╲ │
╲──validate──▶ invalid ╲──nack──▶ rejected
╲ │
╲──validate──▶ invalid ╲──nack──▶ rejected
╲────turn_already_closed──▶ conflict
```
Transitions:
@@ -76,6 +85,14 @@ Transitions:
the draft and sends it to the gateway.
- **`submitting → applied` / `submitting → rejected`**: the gateway
responded; the entry is no longer in flight.
- **`submitting → conflict`** (Phase 25): the gateway returned
`resultCode = "turn_already_closed"`. The order tab surfaces a
banner above the command list. Any subsequent mutation
re-validates the conflict row back to `valid` / `invalid`; a
matching `game.turn.ready` push frame triggers
`resetForNewTurn`, which wipes the draft entirely. See
[`sync-protocol.md`](sync-protocol.md) for the full state
table and recovery paths.
Phase 14 lands the local validators (`draft → valid | invalid`),
the submit pipeline (`valid → submitting → applied | rejected`),
+217
View File
@@ -0,0 +1,217 @@
# 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.
+7
View File
@@ -128,13 +128,20 @@ const en = {
"game.sidebar.order.sync.in_flight": "syncing…",
"game.sidebar.order.sync.synced": "synced with server",
"game.sidebar.order.sync.error": "sync failed: {message}",
"game.sidebar.order.sync.offline": "queued — offline, will retry on reconnect",
"game.sidebar.order.sync.conflict": "turn closed before submit",
"game.sidebar.order.sync.paused": "game paused — orders disabled",
"game.sidebar.order.sync.retry": "retry",
"game.sidebar.order.conflict.banner": "Turn {turn} closed before your order was accepted. Edit and resubmit.",
"game.sidebar.order.conflict.banner_no_turn": "Turn closed before your order was accepted. Edit and resubmit.",
"game.sidebar.order.paused.banner": "Game paused. Orders are not accepted until it resumes.",
"game.sidebar.order.status.draft": "draft",
"game.sidebar.order.status.valid": "valid",
"game.sidebar.order.status.invalid": "invalid",
"game.sidebar.order.status.submitting": "submitting",
"game.sidebar.order.status.applied": "applied",
"game.sidebar.order.status.rejected": "rejected",
"game.sidebar.order.status.conflict": "conflict",
"game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "rename planet {planet} → {name}",
"game.sidebar.order.label.planet_production": "set production on planet {planet} → {target}",
+7
View File
@@ -129,13 +129,20 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.order.sync.in_flight": "синхронизация…",
"game.sidebar.order.sync.synced": "сохранено на сервере",
"game.sidebar.order.sync.error": "ошибка синхронизации: {message}",
"game.sidebar.order.sync.offline": "очередь — нет связи, повторим при восстановлении",
"game.sidebar.order.sync.conflict": "ход закрылся до отправки",
"game.sidebar.order.sync.paused": "игра на паузе — приказы не принимаются",
"game.sidebar.order.sync.retry": "повторить",
"game.sidebar.order.conflict.banner": "Ход {turn} закрылся до того, как приказ был принят. Отредактируй и отправь ещё раз.",
"game.sidebar.order.conflict.banner_no_turn": "Ход закрылся до того, как приказ был принят. Отредактируй и отправь ещё раз.",
"game.sidebar.order.paused.banner": "Игра на паузе. Приказы не принимаются, пока она не возобновится.",
"game.sidebar.order.status.draft": "черновик",
"game.sidebar.order.status.valid": "готова",
"game.sidebar.order.status.invalid": "ошибка",
"game.sidebar.order.status.submitting": "отправка",
"game.sidebar.order.status.applied": "принята",
"game.sidebar.order.status.rejected": "отклонена",
"game.sidebar.order.status.conflict": "конфликт",
"game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}",
"game.sidebar.order.label.planet_production": "сменить производство планеты {planet} → {target}",
@@ -37,6 +37,7 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
submitting: "game.sidebar.order.status.submitting",
applied: "game.sidebar.order.status.applied",
rejected: "game.sidebar.order.status.rejected",
conflict: "game.sidebar.order.status.conflict",
};
function describe(cmd: OrderCommand): string {
@@ -152,6 +153,32 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
<section class="tool" data-testid="sidebar-tool-order">
<h3>{i18n.t("game.sidebar.tab.order")}</h3>
{#if draft !== undefined && draft.pausedBanner !== null}
<div
class="banner banner-paused"
data-testid="order-paused-banner"
data-paused-reason={draft.pausedBanner.reason}
role="status"
>
{i18n.t("game.sidebar.order.paused.banner")}
</div>
{/if}
{#if draft !== undefined && draft.conflictBanner !== null}
<div
class="banner banner-conflict"
data-testid="order-conflict-banner"
data-conflict-turn={draft.conflictBanner.turn ?? ""}
role="status"
>
{#if draft.conflictBanner.turn !== null}
{i18n.t("game.sidebar.order.conflict.banner", {
turn: String(draft.conflictBanner.turn),
})}
{:else}
{i18n.t("game.sidebar.order.conflict.banner_no_turn")}
{/if}
</div>
{/if}
{#if draft === undefined || draft.commands.length === 0}
<p class="empty" data-testid="order-empty">
{i18n.t("game.sidebar.empty.order")}
@@ -202,6 +229,12 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
{i18n.t("game.sidebar.order.sync.error", {
message: draft.syncError ?? "",
})}
{:else if draft.syncStatus === "offline"}
{i18n.t("game.sidebar.order.sync.offline")}
{:else if draft.syncStatus === "conflict"}
{i18n.t("game.sidebar.order.sync.conflict")}
{:else if draft.syncStatus === "paused"}
{i18n.t("game.sidebar.order.sync.paused")}
{:else}
{i18n.t("game.sidebar.order.sync.idle")}
{/if}
@@ -286,6 +319,27 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
color: #6d8cff;
border-color: #2f3f6d;
}
.status-conflict {
color: #d99a4b;
border-color: #6d4a2f;
}
.banner {
margin: 0 0 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
line-height: 1.3;
}
.banner-conflict {
color: #f1bf78;
background: #2a1f10;
border: 1px solid #6d4a2f;
}
.banner-paused {
color: #d4d4d4;
background: #1a1f2a;
border: 1px solid #2f3f55;
}
.delete {
font: inherit;
font-size: 0.85rem;
@@ -317,6 +371,15 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
.sync-syncing {
color: #6d8cff;
}
.sync-offline {
color: #b9a566;
}
.sync-conflict {
color: #d99a4b;
}
.sync-paused {
color: #d4d4d4;
}
.sync-retry {
font: inherit;
font-size: 0.8rem;
@@ -229,11 +229,13 @@ fresh.
return new Uint8Array(digest);
}
// `unsubTurnReady` carries the `eventStream.on(...)` disposer for
// the game-scoped turn-ready handler. The layout registers the
// handler once the local `GameStateStore` is initialised so an
// event arriving before `currentTurn` is known cannot misfire.
// `unsubTurnReady` / `unsubGamePaused` carry the
// `eventStream.on(...)` disposers for the game-scoped push
// handlers. The layout registers them once the local
// `GameStateStore` is initialised so an event arriving before
// `currentTurn` is known cannot misfire.
let unsubTurnReady: (() => void) | null = null;
let unsubGamePaused: (() => void) | null = null;
const turnReadyDecoder = new TextDecoder("utf-8");
function parseTurnReadyPayload(
@@ -261,6 +263,27 @@ fresh.
}
}
function parseGamePausedPayload(
event: VerifiedEvent,
): { gameId: string; reason: string } | null {
try {
const text = turnReadyDecoder.decode(event.payloadBytes);
const json: unknown = JSON.parse(text);
if (typeof json !== "object" || json === null) {
return null;
}
const record = json as Record<string, unknown>;
const eventGameId = record.game_id;
if (typeof eventGameId !== "string") {
return null;
}
const reason = typeof record.reason === "string" ? record.reason : "";
return { gameId: eventGameId, reason };
} catch {
return null;
}
}
let activeTurnReadyToastId: string | null = null;
$effect(() => {
@@ -340,20 +363,42 @@ fresh.
// while `gameState.init` is still in flight is not
// dropped by the singleton stream. `markPendingTurn`
// already protects against turns that do not advance
// past the current snapshot.
// past the current snapshot. Phase 25: a turn-ready
// frame arriving while the draft is in `conflict` or
// `paused` state also resets the draft and rehydrates
// from the server for the new turn — the old commands
// became history at the cutoff.
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
const parsed = parseTurnReadyPayload(event);
if (parsed === null || parsed.gameId !== gameId) {
return;
}
gameState.markPendingTurn(parsed.turn);
if (
orderDraft.syncStatus === "conflict" ||
orderDraft.syncStatus === "paused"
) {
void orderDraft.resetForNewTurn({
client,
turn: parsed.turn,
});
}
});
unsubGamePaused = eventStream.on("game.paused", (event) => {
const parsed = parseGamePausedPayload(event);
if (parsed === null || parsed.gameId !== gameId) {
return;
}
orderDraft.markPaused({ reason: parsed.reason });
});
await Promise.all([
gameState.init({ client, cache, gameId }),
orderDraft.init({ cache, gameId }),
]);
galaxyClient.set(client);
orderDraft.bindClient(client);
orderDraft.bindClient(client, {
getCurrentTurn: () => gameState.currentTurn,
});
// The server is always polled at game boot — its
// stored order may be fresher than the local cache
// (e.g. user is on a new device), and an offline
@@ -375,6 +420,10 @@ fresh.
unsubTurnReady();
unsubTurnReady = null;
}
if (unsubGamePaused !== null) {
unsubGamePaused();
unsubGamePaused = null;
}
gameState.dispose();
orderDraft.dispose();
selection.dispose();
+286 -34
View File
@@ -24,6 +24,7 @@
import type { Cache } from "../platform/store/index";
import type { GalaxyClient } from "../api/galaxy-client";
import { fetchOrder } from "./order-load";
import { OrderQueue, type OrderQueueStartOptions } from "./order-queue.svelte";
import {
isRelation,
isShipGroupCargo,
@@ -49,7 +50,47 @@ export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft");
type Status = "idle" | "ready" | "error";
export type SyncStatus = "idle" | "syncing" | "synced" | "error";
/**
* SyncStatus is the order-tab status-bar projection of the auto-sync
* pipeline. Phase 14 introduced the `idle`/`syncing`/`synced`/`error`
* triplet; Phase 25 adds `offline` (queued during a network outage,
* will retry on reconnect), `conflict` (server told us the turn was
* already closed; banner pending), and `paused` (game in pause; no
* submits until it resumes).
*/
export type SyncStatus =
| "idle"
| "syncing"
| "synced"
| "error"
| "offline"
| "conflict"
| "paused";
/**
* ConflictBanner is the optimistic-conflict UX state displayed
* above the order list when a submit landed after the turn cutoff.
* `turn` is the value the player thought was open at submit time;
* it is read from the `getCurrentTurn` callback supplied to
* `bindClient`. The banner is cleared by `resetForNewTurn` (next
* `game.turn.ready`) or by any local mutation.
*/
export interface ConflictBanner {
turn: number | null;
code: string;
message: string;
}
/**
* PausedBanner is displayed when the server tells us the game is
* paused. The banner is cleared by `resetForNewTurn` once the game
* resumes (a fresh `game.turn.ready` event).
*/
export interface PausedBanner {
code: string;
message: string;
reason: string;
}
export class OrderDraftStore {
commands: OrderCommand[] = $state([]);
@@ -61,24 +102,52 @@ export class OrderDraftStore {
/**
* syncStatus reflects the auto-sync pipeline state for the order
* tab status bar:
* - `idle` — no sync attempted yet (e.g. fresh draft after
* hydration or before the first mutation).
* - `syncing` — a `submitOrder` call is in flight.
* - `synced` — the last sync succeeded; statuses match the
* server's view.
* - `error` — the last sync failed (network or non-`ok`); the
* next mutation triggers a retry, or the user can
* force a re-sync via `forceSync`.
* - `idle` — no sync attempted yet (e.g. fresh draft after
* hydration or before the first mutation).
* - `syncing` — a `submitOrder` call is in flight.
* - `synced` — the last sync succeeded; statuses match the
* server's view.
* - `error` — the last sync failed (network or non-`ok`); the
* next mutation triggers a retry, or the user can
* force a re-sync via `forceSync`.
* - `offline` — the browser is offline; the last submit was
* held. A fresh send fires on the next `online`
* flip via the queue callback.
* - `conflict` — the gateway returned `turn_already_closed`;
* the in-flight commands are marked `conflict`
* and `conflictBanner` carries the user-facing
* copy.
* - `paused` — the gateway returned `game_paused` (or a
* `game.paused` push frame arrived); no submits
* fire until `resetForNewTurn` clears it.
*/
syncStatus: SyncStatus = $state("idle");
syncError: string | null = $state(null);
/**
* conflictBanner is non-null whenever `syncStatus === "conflict"`.
* The order tab renders the banner above the command list with
* the turn number interpolated; clearing it is the
* `resetForNewTurn` / mutation responsibility.
*/
conflictBanner: ConflictBanner | null = $state(null);
/**
* pausedBanner is non-null whenever `syncStatus === "paused"`.
* The order tab renders a pause-specific banner separate from
* the conflict path.
*/
pausedBanner: PausedBanner | null = $state(null);
private cache: Cache | null = null;
private gameId = "";
private destroyed = false;
private client: GalaxyClient | null = null;
private syncing: Promise<void> | null = null;
private pending = false;
private queue = new OrderQueue();
private queueStarted = false;
private getCurrentTurn: (() => number) | null = null;
/**
* init loads the persisted draft for `opts.gameId` from `opts.cache`
@@ -93,7 +162,9 @@ export class OrderDraftStore {
* authoritative read that always overwrites the local cache when
* the server has a stored order.
*/
async init(opts: { cache: Cache; gameId: string }): Promise<void> {
async init(
opts: { cache: Cache; gameId: string; queue?: OrderQueueStartOptions },
): Promise<void> {
this.cache = opts.cache;
this.gameId = opts.gameId;
try {
@@ -110,6 +181,7 @@ export class OrderDraftStore {
this.status = "error";
this.error = err instanceof Error ? err.message : "load failed";
}
this.startQueue(opts.queue);
}
/**
@@ -118,9 +190,18 @@ export class OrderDraftStore {
* this after the boot `Promise.all` resolves and before
* `hydrateFromServer`, so any mutation that lands afterwards goes
* through the network.
*
* Phase 25: `opts.getCurrentTurn` lets the conflict banner
* interpolate the turn number the player was composing for. The
* layout passes `() => gameState.currentTurn`; tests may omit it,
* in which case the banner falls back to a turn-less template.
*/
bindClient(client: GalaxyClient): void {
bindClient(
client: GalaxyClient,
opts: { getCurrentTurn?: () => number } = {},
): void {
this.client = client;
this.getCurrentTurn = opts.getCurrentTurn ?? null;
}
/**
@@ -138,6 +219,14 @@ export class OrderDraftStore {
turn: number;
}): Promise<void> {
if (this.status !== "ready") return;
// Phase 25: a `game.paused` push frame may arrive before the
// initial hydrate completes (the layout subscribes early to
// avoid losing in-flight frames). The pause is stickier than a
// freshly-loaded snapshot — keep the banner up and skip the
// fetch entirely. A subsequent `resetForNewTurn` (triggered by
// `game.turn.ready` after the game resumes) re-runs the
// hydration from scratch.
if (this.syncStatus === "paused") return;
this.client = opts.client;
// Guard against placeholder game ids the Phase 10 e2e specs
// still use — auto-sync needs a real UUID for the FBS request
@@ -152,6 +241,11 @@ export class OrderDraftStore {
try {
const fetched = await fetchOrder(opts.client, this.gameId, opts.turn);
if (this.destroyed) return;
// If `markPaused` landed between the initial syncStatus
// flip and the awaited fetch, the pause is the
// authoritative state — do not overwrite it with synced.
// The fetched commands are still adopted so a later
// `resetForNewTurn` can build on top of them.
this.commands = fetched.commands;
this.updatedAt = fetched.updatedAt;
this.recomputeStatuses();
@@ -166,11 +260,15 @@ export class OrderDraftStore {
}
this.statuses = next;
await this.persist();
this.syncStatus = "synced";
if ((this.syncStatus as SyncStatus) !== "paused") {
this.syncStatus = "synced";
}
} catch (err) {
if (this.destroyed) return;
this.syncStatus = "error";
this.syncError = err instanceof Error ? err.message : "fetch failed";
if ((this.syncStatus as SyncStatus) !== "paused") {
this.syncStatus = "error";
this.syncError = err instanceof Error ? err.message : "fetch failed";
}
console.warn("order-draft: server hydration failed", err);
}
}
@@ -207,6 +305,7 @@ export class OrderDraftStore {
*/
async add(command: OrderCommand): Promise<void> {
if (this.status !== "ready") return;
this.clearConflictForMutation();
const removed: string[] = [];
let nextCommands: OrderCommand[];
if (command.kind === "setProductionType") {
@@ -288,6 +387,7 @@ export class OrderDraftStore {
if (this.status !== "ready") return;
const next = this.commands.filter((cmd) => cmd.id !== id);
if (next.length === this.commands.length) return;
this.clearConflictForMutation();
this.commands = next;
const nextStatuses = { ...this.statuses };
delete nextStatuses[id];
@@ -310,6 +410,7 @@ export class OrderDraftStore {
if (fromIndex < 0 || fromIndex >= length) return;
if (toIndex < 0 || toIndex >= length) return;
if (fromIndex === toIndex) return;
this.clearConflictForMutation();
const next = [...this.commands];
const [picked] = next.splice(fromIndex, 1);
if (picked === undefined) return;
@@ -327,10 +428,61 @@ export class OrderDraftStore {
this.scheduleSync();
}
/**
* markPaused projects an incoming `game.paused` push event into
* the store: the order tab shows the pause banner, the auto-sync
* loop short-circuits, and any submitting rows revert to `valid`
* (the matching engine state is still the old one). The layout
* calls this from the `game.paused` subscription. `reason`
* carries the raw runtime status published by lobby
* (`engine_unreachable` / `generation_failed`); the UI ignores
* it today but the payload is preserved for future copy
* differentiation.
*/
markPaused(opts: { reason: string; message?: string }): void {
if (this.status !== "ready") return;
this.revertSubmittingToValidInternal();
this.pausedBanner = {
code: "game_paused",
message: opts.message ?? "Game paused. Orders are not accepted until it resumes.",
reason: opts.reason,
};
this.syncStatus = "paused";
this.syncError = null;
}
/**
* resetForNewTurn drops the local draft, clears every Phase 25
* banner, and hydrates from the server for the supplied turn.
* The layout calls this from the `game.turn.ready` subscription
* when the prior `syncStatus` was `conflict` or `paused`. The
* effect mirrors a fresh boot: cache wipe → fetch → seed.
*/
async resetForNewTurn(opts: {
client: GalaxyClient;
turn: number;
}): Promise<void> {
if (this.status !== "ready") return;
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: opts.client, turn: opts.turn });
}
dispose(): void {
this.destroyed = true;
this.cache = null;
this.client = null;
this.getCurrentTurn = null;
if (this.queueStarted) {
this.queue.stop();
this.queueStarted = false;
}
}
private scheduleSync(): void {
@@ -338,6 +490,16 @@ export class OrderDraftStore {
// Same UUID guard as `hydrateFromServer` — placeholder game
// ids in test fixtures must not blow up the auto-sync path.
if (!isUuid(this.gameId)) return;
// Conflict / paused states are sticky: the order tab is
// waiting for the next `game.turn.ready` (conflict) or for
// the admin to resume (paused). Local mutations clear the
// conflict; the layout's `markPaused`/`resetForNewTurn` clear
// the pause. Trying to send mid-state would re-elicit the
// same gateway reply on every keystroke and overwrite the
// banner with the same message.
if (this.syncStatus === "conflict" || this.syncStatus === "paused") {
return;
}
if (this.syncing !== null) {
this.pending = true;
return;
@@ -378,45 +540,98 @@ export class OrderDraftStore {
this.syncStatus = "syncing";
this.syncError = null;
try {
const result = await submitOrder(
client,
this.gameId,
submittable,
{ updatedAt: this.updatedAt },
);
if (this.destroyed) return;
if (result.ok) {
this.applyResultsInternal(result.results, result.updatedAt);
const outcome = await this.queue.send(() =>
submitOrder(client, this.gameId, submittable, {
updatedAt: this.updatedAt,
}),
);
if (this.destroyed) return;
switch (outcome.kind) {
case "success": {
this.applyResultsInternal(
outcome.result.results,
outcome.result.updatedAt,
);
// Even with `result.ok === true` an individual
// command may have been rejected by the engine
// (e.g. validation passed transcoders but failed
// the in-game rule). Surface that as an error in
// the sync bar so the player notices and can fix
// or remove the offending command.
const anyRejected = Array.from(result.results.values()).some(
(s) => s === "rejected",
);
const anyRejected = Array.from(
outcome.result.results.values(),
).some((s) => s === "rejected");
this.syncStatus = anyRejected ? "error" : "synced";
this.syncError = anyRejected
? "engine rejected one or more commands"
: null;
} else {
break;
}
case "rejected": {
this.markRejectedInternal(submittingIds);
this.syncStatus = "error";
this.syncError = result.message;
this.syncError = outcome.failure.message;
break;
}
case "conflict": {
this.markConflictInternal(submittingIds);
this.conflictBanner = {
turn: this.getCurrentTurn?.() ?? null,
code: outcome.code,
message: outcome.message,
};
this.syncStatus = "conflict";
this.syncError = null;
// Stickiness: conflict overrides any pending
// mutations until the next `game.turn.ready` or a
// local edit clears the banner.
return;
}
case "paused": {
this.revertSubmittingToValidInternal();
this.pausedBanner = {
code: outcome.code,
message: outcome.message,
reason: outcome.code,
};
this.syncStatus = "paused";
this.syncError = null;
return;
}
case "offline": {
this.revertSubmittingToValidInternal();
this.syncStatus = "offline";
this.syncError = null;
return;
}
case "failed": {
this.revertSubmittingToValidInternal();
this.syncStatus = "error";
this.syncError = outcome.reason;
break;
}
} catch (err) {
if (this.destroyed) return;
this.revertSubmittingToValidInternal();
this.syncStatus = "error";
this.syncError = err instanceof Error ? err.message : "sync failed";
}
if (!this.pending) return;
}
}
private startQueue(opts?: OrderQueueStartOptions): void {
if (this.queueStarted) return;
this.queue.start({
onOnline: () => {
if (this.destroyed) return;
if (this.syncStatus === "offline") {
this.scheduleSync();
}
},
onlineProbe: opts?.onlineProbe,
addEventListener: opts?.addEventListener,
removeEventListener: opts?.removeEventListener,
});
this.queueStarted = true;
}
private markSubmittingInternal(ids: string[]): void {
const next = { ...this.statuses };
for (const id of ids) {
@@ -457,6 +672,43 @@ export class OrderDraftStore {
this.statuses = next;
}
private markConflictInternal(ids: string[]): void {
const next = { ...this.statuses };
for (const id of ids) {
next[id] = "conflict";
}
this.statuses = next;
}
/**
* clearConflictForMutation drops the conflict banner and
* re-validates every `conflict`-marked command back to its
* pre-submit status. Called from every mutation (`add`,
* `remove`, `move`) so the user-driven "Edit and resubmit" flow
* works without an extra dismiss step.
*/
private clearConflictForMutation(): void {
if (this.syncStatus !== "conflict" && this.conflictBanner === null) {
return;
}
const next = { ...this.statuses };
let mutated = false;
for (const cmd of this.commands) {
if (next[cmd.id] === "conflict") {
next[cmd.id] = validateCommand(cmd);
mutated = true;
}
}
if (mutated) {
this.statuses = next;
}
this.conflictBanner = null;
if (this.syncStatus === "conflict") {
this.syncStatus = "idle";
this.syncError = null;
}
}
private revertSubmittingToValidInternal(): void {
const next = { ...this.statuses };
for (const cmd of this.commands) {
+231
View File
@@ -0,0 +1,231 @@
// Wraps the order submit pipeline (`sync/submit.ts`) with the Phase
// 25 transport semantics:
//
// - **offline detection** via `navigator.onLine` and the browser
// `online` / `offline` events. While offline, `send()` returns an
// `offline` outcome immediately and the caller is expected to
// leave the in-flight commands in their pre-submit state.
// - **single retry on reconnect** is realised at the consumer
// level: when the browser fires `online`, the queue invokes the
// `onOnline` callback the consumer supplied at `start()`. The
// consumer (`OrderDraftStore`) decides whether to schedule a
// fresh `runSync()` — that single attempt is the retry budget.
// - **conflict / paused classification**: a non-`ok` SubmitResult
// whose `resultCode` or `code` is `turn_already_closed` becomes
// a `conflict` outcome; `game_paused` becomes a `paused`
// outcome. Any other non-`ok` reply stays a `rejected` outcome
// and the consumer keeps the existing per-command behaviour.
//
// The class is dependency-injected so Vitest can drive the
// `online` / `offline` listeners without touching the JSDOM
// globals; production code falls back to `window`/`navigator`.
import type { SubmitFailure, SubmitResult, SubmitSuccess } from "./submit";
/**
* QueueOutcome is the discriminated union the draft store consumes
* after asking the queue to submit a snapshot. Each variant tells
* the consumer exactly which side-effect to apply to the
* per-command statuses and the banner state.
*/
export type QueueOutcome =
| { kind: "success"; result: SubmitSuccess }
| { kind: "rejected"; failure: SubmitFailure }
| { kind: "conflict"; code: string; message: string }
| { kind: "paused"; code: string; message: string }
| { kind: "offline" }
| { kind: "failed"; reason: string };
/**
* OrderQueueStartOptions carries the live primitives the queue
* cannot resolve on its own. Tests inject deterministic stubs;
* production passes `undefined` for everything except `onOnline`.
*/
export interface OrderQueueStartOptions {
/**
* onOnline is invoked when the browser flips from offline to
* online (or when `start()` is called while already online and
* the consumer wants an opportunistic flush). The consumer
* decides whether a fresh `send()` is appropriate.
*/
onOnline: () => void;
/**
* onlineProbe returns the current online state. Defaults to
* `navigator.onLine`; tests inject a closure over a mutable flag.
*/
onlineProbe?: () => boolean;
/**
* addEventListener / removeEventListener are the hooks the queue
* uses to subscribe to the global `online` / `offline` events.
* Defaults to `window.addEventListener` / `window.removeEventListener`;
* tests inject manual emitters.
*/
addEventListener?: (event: string, handler: () => void) => void;
removeEventListener?: (event: string, handler: () => void) => void;
}
const CODE_TURN_ALREADY_CLOSED = "turn_already_closed";
const CODE_GAME_PAUSED = "game_paused";
/**
* OrderQueue holds the transport-side policy for the order draft
* store. One instance per draft store; lifecycle is bound to the
* store's `init` / `dispose`.
*/
export class OrderQueue {
/**
* online mirrors the latest browser online signal. Tests assert
* on this rune to drive their state machine; production code
* uses it via the draft store's `syncStatus` projection.
*/
online: boolean = $state(true);
private onlineProbe: () => boolean = defaultOnlineProbe;
private addEventListener: (event: string, handler: () => void) => void = defaultAddEventListener;
private removeEventListener: (event: string, handler: () => void) => void = defaultRemoveEventListener;
private onOnlineCallback: (() => void) | null = null;
private handleOnline: (() => void) | null = null;
private handleOffline: (() => void) | null = null;
/**
* start subscribes to the browser online/offline events and
* primes `online` from the current probe value. Calling start a
* second time without `stop()` between them is a no-op so the
* draft store's `init` stays idempotent under double mount.
*/
start(opts: OrderQueueStartOptions): void {
if (this.onOnlineCallback !== null) return;
this.onOnlineCallback = opts.onOnline;
if (opts.onlineProbe !== undefined) {
this.onlineProbe = opts.onlineProbe;
}
if (opts.addEventListener !== undefined) {
this.addEventListener = opts.addEventListener;
}
if (opts.removeEventListener !== undefined) {
this.removeEventListener = opts.removeEventListener;
}
this.online = this.onlineProbe();
this.handleOnline = () => {
this.online = true;
this.onOnlineCallback?.();
};
this.handleOffline = () => {
this.online = false;
};
this.addEventListener("online", this.handleOnline);
this.addEventListener("offline", this.handleOffline);
}
/**
* stop unsubscribes from the browser events and forgets the
* consumer callback. Subsequent `send()` calls still classify
* an injected `SubmitResult` correctly, but no online flips will
* be propagated until `start()` runs again.
*/
stop(): void {
if (this.handleOnline !== null) {
this.removeEventListener("online", this.handleOnline);
this.handleOnline = null;
}
if (this.handleOffline !== null) {
this.removeEventListener("offline", this.handleOffline);
this.handleOffline = null;
}
this.onOnlineCallback = null;
}
/**
* send drives one submit attempt:
*
* - If the queue is currently offline, returns `{kind:"offline"}`
* without invoking submitFn. The consumer is expected to
* leave the in-flight commands in their pre-submit state and
* wait for the `onOnline` callback.
* - Otherwise invokes submitFn. Any throw is reclassified:
* a fresh `onlineProbe()` returning false collapses into
* `offline`; otherwise the throw becomes `failed`.
* - A successful `SubmitResult` is classified into `success`,
* `rejected`, `conflict`, or `paused` depending on the
* non-`ok` `resultCode` / `code` fields.
*
* The queue intentionally does NOT retry inline. The plan's
* "retry once on reconnect" budget is realised by the consumer
* (the draft store) hooking the `onOnline` callback to
* `scheduleSync()` — at most one fresh `send()` per online flip.
*/
async send(submitFn: () => Promise<SubmitResult>): Promise<QueueOutcome> {
if (!this.online) {
return { kind: "offline" };
}
let result: SubmitResult;
try {
result = await submitFn();
} catch (err) {
if (!this.onlineProbe()) {
this.online = false;
return { kind: "offline" };
}
return { kind: "failed", reason: errorMessage(err) };
}
return classifyResult(result);
}
}
/**
* classifyResult maps a `SubmitResult` onto the queue outcome the
* draft store consumes. Exported for unit-tests; the inline path
* uses it through `OrderQueue.send`.
*/
export function classifyResult(result: SubmitResult): QueueOutcome {
if (result.ok) {
return { kind: "success", result };
}
const code = pickCode(result);
if (code === CODE_TURN_ALREADY_CLOSED) {
return { kind: "conflict", code, message: result.message };
}
if (code === CODE_GAME_PAUSED) {
return { kind: "paused", code, message: result.message };
}
return { kind: "rejected", failure: result };
}
function pickCode(failure: SubmitFailure): string {
// The gateway sets `resultCode = backendError.Code` for non-ok
// replies (see `gateway/internal/backendclient/user_commands.go`
// `projectUserBackendError`). The FBS-encoded payload body is
// parsed by `submit.ts.decodeError`, which falls back to the
// `resultCode` when the body cannot be decoded; we therefore
// prefer `code` only when it differs from the result code, but
// either field carries the same authoritative value.
if (failure.code && failure.code !== failure.resultCode) {
return failure.code;
}
return failure.resultCode;
}
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
return "submit failed";
}
function defaultOnlineProbe(): boolean {
if (typeof navigator === "undefined") {
return true;
}
return navigator.onLine !== false;
}
function defaultAddEventListener(event: string, handler: () => void): void {
if (typeof window === "undefined") return;
window.addEventListener(event, handler);
}
function defaultRemoveEventListener(event: string, handler: () => void): void {
if (typeof window === "undefined") return;
window.removeEventListener(event, handler);
}
+14 -7
View File
@@ -558,20 +558,26 @@ export function isCargoLoadType(value: string): value is CargoLoadType {
/**
* CommandStatus is the lifecycle of a single command from the moment
* it lands in the draft to the moment the server resolves it. The
* skeleton stores only the type description; Phase 14 adds the
* `valid` / `invalid` transitions driven by local validation, and
* Phase 25 introduces `submitting` / `applied` / `rejected` driven
* by the submit pipeline.
* it lands in the draft to the moment the server resolves it. Phase
* 14 adds the `valid` / `invalid` transitions driven by local
* validation and the `submitting` / `applied` / `rejected` triplet
* driven by the submit pipeline; Phase 25 adds `conflict` for
* commands whose submit landed after the turn cutoff
* (`turn_already_closed` from the gateway).
*
* The state machine is:
*
* draft → valid → submitting → applied
* ↘ invalid ↘ rejected
* ↘ conflict
*
* A command is `draft` until local validation has run, then `valid`
* or `invalid`. On submit the entry transitions to `submitting`,
* then to `applied` or `rejected` once the gateway responds.
* then to `applied` / `rejected` / `conflict` once the gateway
* responds. A `conflict` row stays in the draft until the next
* `game.turn.ready` triggers a `resetForNewTurn`, or the user edits
* the draft (any mutation re-validates the conflict back to `valid`
* or `invalid`).
*/
export type CommandStatus =
| "draft"
@@ -579,4 +585,5 @@ export type CommandStatus =
| "invalid"
| "submitting"
| "applied"
| "rejected";
| "rejected"
| "conflict";
+340
View File
@@ -0,0 +1,340 @@
// Phase 25 end-to-end coverage for the sync protocol additions on
// the order tab: the offline / online flip, the
// `turn_already_closed` conflict banner, and the `game.paused` push
// frame. Each test boots an authenticated session, mocks the lobby
// + report + order routes, drives an order mutation through the
// inspector, and asserts the matching banner / sync-status DOM.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ByteBuffer } from "flatbuffers";
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
import { UUID } from "../../src/proto/galaxy/fbs/common";
import {
UserGamesOrder,
UserGamesOrderGet,
} from "../../src/proto/galaxy/fbs/order";
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import { forgeGatewayEventFrame } from "./fixtures/sign-event";
import {
buildMyGamesListPayload,
type GameFixture,
} from "./fixtures/lobby-fbs";
import { buildReportPayload } from "./fixtures/report-fbs";
import {
buildOrderGetResponsePayload,
buildOrderResponsePayload,
type CommandResultFixture,
} from "./fixtures/order-fbs";
const SESSION_ID = "phase-25-order-sync-session";
const GAME_ID = "25252525-2525-2525-2525-252525252525";
const WORLD = 4000;
const CENTRE = WORLD / 2;
const TURN = 4;
type SubmitVerdict = "applied" | "rejected" | "turn_already_closed" | "game_paused";
interface MockOpts {
/** Initial server-side order returned by `user.games.order.get`. */
storedOrder?: CommandResultFixture[];
/** How the first `user.games.order` submit replies. */
initialSubmitVerdict: SubmitVerdict;
/**
* If set, the SubscribeEvents stream emits this frame instead of
* holding the connection open. Used by the paused-banner test.
*/
subscribeFrame?: { eventType: string; payload: Uint8Array };
}
interface MockHandle {
/** Setter the test uses to flip the verdict mid-run. */
setSubmitVerdict(next: SubmitVerdict): void;
/** Read-only counter for assertion. */
get submitCallCount(): number;
}
async function mockGateway(page: Page, opts: MockOpts): Promise<MockHandle> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Phase 25 Game",
gameType: "private",
status: "running",
ownerUserId: "user-1",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
createdAtMs: BigInt(Date.now() - 86_400_000),
updatedAtMs: BigInt(Date.now()),
currentTurn: TURN,
};
let storedOrder = (opts.storedOrder ?? []).slice();
let submitVerdict: SubmitVerdict = opts.initialSubmitVerdict;
let submitCalls = 0;
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let resultCode = "ok";
let payload: Uint8Array;
let bodyOverride: string | null = null;
switch (req.messageType) {
case "lobby.my.games.list":
payload = buildMyGamesListPayload([game]);
break;
case "user.games.report": {
GameReportRequest.getRootAsGameReportRequest(
new ByteBuffer(req.payloadBytes),
).gameId(new UUID());
payload = buildReportPayload({
turn: TURN,
mapWidth: WORLD,
mapHeight: WORLD,
localPlanets: [
{
number: 17,
name: "Earth",
x: CENTRE,
y: CENTRE,
size: 1000,
resources: 10,
capital: 0,
material: 0,
population: 850,
colonists: 25,
industry: 700,
production: "drive",
freeIndustry: 175,
},
],
});
break;
}
case "user.games.order": {
submitCalls += 1;
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new ByteBuffer(req.payloadBytes),
);
const length = decoded.commandsLength();
const fixtures: CommandResultFixture[] = [];
for (let i = 0; i < length; i++) {
const item = decoded.commands(i);
if (item === null) continue;
const cmdId = item.cmdId() ?? "";
const inner = new (await import(
"../../src/proto/galaxy/fbs/order"
)).CommandPlanetRename();
item.payload(inner);
const submittedName = inner.name() ?? "";
const applied = submitVerdict === "applied";
fixtures.push({
kind: "planetRename",
cmdId,
planetNumber: Number(inner.number()),
name: submittedName,
applied,
errorCode: applied ? null : 1,
});
}
if (submitVerdict === "turn_already_closed") {
resultCode = "turn_already_closed";
bodyOverride = JSON.stringify({
code: "turn_already_closed",
message: "turn closed before submit",
});
} else if (submitVerdict === "game_paused") {
resultCode = "game_paused";
bodyOverride = JSON.stringify({
code: "game_paused",
message: "game is paused",
});
}
if (submitVerdict === "applied") {
storedOrder = fixtures;
}
payload =
bodyOverride !== null
? new TextEncoder().encode(bodyOverride)
: buildOrderResponsePayload(GAME_ID, fixtures, Date.now());
break;
}
case "user.games.order.get": {
UserGamesOrderGet.getRootAsUserGamesOrderGet(
new ByteBuffer(req.payloadBytes),
);
payload = buildOrderGetResponsePayload(
GAME_ID,
storedOrder,
Date.now(),
storedOrder.length > 0,
);
break;
}
default:
resultCode = "internal_error";
payload = new Uint8Array();
}
const body = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode,
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body,
});
},
);
let subscribeServed = false;
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
async (route) => {
if (opts.subscribeFrame !== undefined && !subscribeServed) {
subscribeServed = true;
const frame = await forgeGatewayEventFrame({
eventType: opts.subscribeFrame.eventType,
eventId: "evt-phase25-1",
timestampMs: BigInt(Date.now()),
requestId: "req-phase25-1",
traceId: "trace-phase25-1",
payloadBytes: opts.subscribeFrame.payload,
});
await route.fulfill({
status: 200,
contentType: "application/connect+json",
body: Buffer.from(frame),
});
return;
}
await new Promise<void>(() => {});
},
);
return {
setSubmitVerdict(next) {
submitVerdict = next;
},
get submitCallCount() {
return submitCalls;
},
};
}
async function bootSession(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
await page.evaluate(
(gameId) => window.__galaxyDebug!.clearOrderDraft(gameId),
GAME_ID,
);
}
async function clickPlanetCentre(page: Page): Promise<void> {
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (box === null) throw new Error("canvas has no bounding box");
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
async function startRename(page: Page, newName: string): Promise<void> {
await clickPlanetCentre(page);
const sidebar = page.getByTestId("sidebar-tool-inspector");
await sidebar.getByTestId("inspector-planet-rename-action").click();
const input = sidebar.getByTestId("inspector-planet-rename-input");
await input.fill(newName);
await sidebar.getByTestId("inspector-planet-rename-confirm").click();
}
test("turn_already_closed surfaces the conflict banner on the order tab", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 25 spec covers desktop layout; mobile inherits the same store",
);
await mockGateway(page, { initialSubmitVerdict: "turn_already_closed" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await startRename(page, "Conflict-Earth");
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-conflict-banner")).toBeVisible({
timeout: 5_000,
});
await expect(orderTool.getByTestId("order-conflict-banner")).toContainText(
"Edit and resubmit",
);
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"conflict",
);
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"conflict",
);
});
test("game.paused push frame surfaces the paused banner", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 25 spec covers desktop layout; mobile inherits the same store",
);
const payload = new TextEncoder().encode(
JSON.stringify({
game_id: GAME_ID,
turn: TURN,
reason: "generation_failed",
}),
);
await mockGateway(page, {
initialSubmitVerdict: "applied",
subscribeFrame: { eventType: "game.paused", payload },
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-paused-banner")).toBeVisible({
timeout: 5_000,
});
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"paused",
);
});
+30
View File
@@ -291,6 +291,36 @@ describe("EventStream", () => {
eventStream.stop();
});
test("game.paused events dispatch to the matching handler (Phase 25)", async () => {
const handler = vi.fn();
eventStream.on("game.paused", handler);
const payload = new TextEncoder().encode(
JSON.stringify({
game_id: "11111111-2222-3333-4444-555555555555",
turn: 7,
reason: "generation_failed",
}),
);
const event = buildEvent("game.paused", payload);
const client = makeRouter(async function* () {
yield event;
});
eventStream.start({
core: mockCore(),
keypair: mockKeypair(),
deviceSessionId: "device-1",
gatewayResponsePublicKey: new Uint8Array(32),
client,
sleep: async () => {},
random: () => 0,
});
await vi.waitFor(() => {
expect(handler).toHaveBeenCalled();
});
expect(handler.mock.calls[0]?.[0].eventType).toBe("game.paused");
eventStream.stop();
});
test("connectionStatus transitions through connecting → connected → idle", async () => {
expect(eventStream.connectionStatus).toBe("idle");
const event = buildEvent(
+57 -18
View File
@@ -33,10 +33,25 @@ interface RecordedCall {
commandIds: string[];
}
/**
* RecordingOutcome enumerates the synthetic server reactions a test
* can drive through `recordingClient.setOutcome`. Phase 25 adds the
* `turn_already_closed` and `game_paused` codes (the order-queue
* classifies them into `conflict` / `paused` outcomes) and `throw`
* which lets the test exercise the network-error branch of
* `OrderQueue.send`.
*/
export type RecordingOutcome =
| "ok"
| "rejected"
| "turn_already_closed"
| "game_paused"
| "throw";
interface RecordingHandle {
client: GalaxyClient;
calls: RecordedCall[];
setOutcome(outcome: "ok" | "rejected"): void;
setOutcome(outcome: RecordingOutcome): void;
waitForCalls(n: number): Promise<void>;
waitForIdle(): Promise<void>;
}
@@ -51,11 +66,11 @@ interface RecordingHandle {
*/
export function recordingClient(
gameId: string,
initialOutcome: "ok" | "rejected",
initialOutcome: RecordingOutcome,
options: { delayMs?: number } = {},
): RecordingHandle {
const calls: RecordedCall[] = [];
let outcome: "ok" | "rejected" = initialOutcome;
let outcome: RecordingOutcome = initialOutcome;
let inFlight = 0;
const waiters: (() => void)[] = [];
@@ -81,21 +96,45 @@ export function recordingClient(
if (id !== null) commandIds.push(id);
}
calls.push({ messageType, commandIds });
if (outcome === "ok") {
return {
resultCode: "ok",
payloadBytes: encodeApplied(gameId, commandIds, true),
};
switch (outcome) {
case "ok":
return {
resultCode: "ok",
payloadBytes: encodeApplied(gameId, commandIds, true),
};
case "turn_already_closed":
return {
resultCode: "turn_already_closed",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "turn_already_closed",
message: "turn closed before submit",
}),
),
};
case "game_paused":
return {
resultCode: "game_paused",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "game_paused",
message: "game is paused",
}),
),
};
case "throw":
throw new Error("network down");
default:
return {
resultCode: "invalid_request",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "validation_failed",
message: "rejected by fixture",
}),
),
};
}
return {
resultCode: "invalid_request",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "validation_failed",
message: "rejected by fixture",
}),
),
};
}
throw new Error(`unexpected messageType ${messageType}`);
} finally {
@@ -113,7 +152,7 @@ export function recordingClient(
return {
client,
calls,
setOutcome(next: "ok" | "rejected") {
setOutcome(next: RecordingOutcome) {
outcome = next;
},
async waitForCalls(n: number) {
+186
View File
@@ -623,3 +623,189 @@ describe("OrderDraftStore auto-sync", () => {
store.dispose();
});
});
describe("OrderDraftStore Phase 25 conflict / paused / offline", () => {
test("turn_already_closed marks the in-flight commands as conflict", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "turn_already_closed");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client, { getCurrentTurn: () => 7 });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(store.syncStatus).toBe("conflict");
expect(store.statuses["id-1"]).toBe("conflict");
expect(store.conflictBanner).not.toBeNull();
expect(store.conflictBanner?.turn).toBe(7);
expect(store.conflictBanner?.code).toBe("turn_already_closed");
store.dispose();
});
test("mutating after a conflict clears the banner and revalidates", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "turn_already_closed");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client, { getCurrentTurn: () => 3 });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(store.syncStatus).toBe("conflict");
// Adding a second command must wipe the conflict banner and
// re-validate the prior conflict-marked entry. Auto-sync
// re-fires (still seeing turn_already_closed) and the
// store ends up back in conflict for the new attempt.
handle.setOutcome("ok");
await store.add({
kind: "planetRename",
id: "id-2",
planetNumber: 2,
name: "Mars",
});
await handle.waitForCalls(2);
expect(store.statuses["id-1"]).toBe("applied");
expect(store.statuses["id-2"]).toBe("applied");
expect(store.syncStatus).toBe("synced");
expect(store.conflictBanner).toBeNull();
store.dispose();
});
test("game_paused outcome surfaces the pause banner and locks sync", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "game_paused");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(store.syncStatus).toBe("paused");
expect(store.pausedBanner).not.toBeNull();
expect(store.statuses["id-1"]).toBe("valid"); // reverted, not in flight
// While paused, additional mutations should not trigger another
// submit — the queue would just hit the same wall.
const before = handle.calls.length;
store.forceSync();
await new Promise<void>((resolve) => setTimeout(resolve, 20));
expect(handle.calls.length).toBe(before);
store.dispose();
});
test("markPaused projects a push event into the store", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.markPaused({ reason: "generation_failed" });
expect(store.syncStatus).toBe("paused");
expect(store.pausedBanner?.reason).toBe("generation_failed");
store.dispose();
});
test("resetForNewTurn clears the conflict banner and rehydrates", async () => {
const { fakeFetchClient, recordingClient } = await import(
"./helpers/fake-order-client"
);
const recHandle = recordingClient(GAME_ID, "turn_already_closed");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(recHandle.client, { getCurrentTurn: () => 5 });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await recHandle.waitForCalls(1);
expect(store.syncStatus).toBe("conflict");
const { client: fetchClient } = fakeFetchClient(GAME_ID, [
{
kind: "planetRename",
id: "fresh-1",
planetNumber: 9,
name: "Hydrated",
},
], 11);
await store.resetForNewTurn({ client: fetchClient, turn: 6 });
expect(store.conflictBanner).toBeNull();
expect(store.syncStatus).toBe("synced");
expect(store.commands.map((c) => c.id)).toEqual(["fresh-1"]);
expect(store.updatedAt).toBe(11);
store.dispose();
});
test("offline outcome holds the submit until online flips", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "ok");
const store = new OrderDraftStore();
// Stub the browser event surface so we can flip online/offline
// deterministically and assert the queue's reaction.
const listeners = new Map<string, Set<() => void>>();
let online = false;
await store.init({
cache,
gameId: GAME_ID,
queue: {
onlineProbe: () => online,
addEventListener: (event, handler) => {
let bucket = listeners.get(event);
if (bucket === undefined) {
bucket = new Set();
listeners.set(event, bucket);
}
bucket.add(handler);
},
removeEventListener: (event, handler) => {
listeners.get(event)?.delete(handler);
},
onOnline: () => undefined,
},
});
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
// Submission must NOT have left the queue while offline.
await new Promise<void>((resolve) => setTimeout(resolve, 20));
expect(handle.calls).toHaveLength(0);
expect(store.syncStatus).toBe("offline");
expect(store.statuses["id-1"]).toBe("valid");
// Flip online and fire the browser `online` event; the queue's
// onOnline callback (set inside OrderDraftStore) schedules a
// fresh sync.
online = true;
const onlineBucket = listeners.get("online");
onlineBucket?.forEach((h) => h());
await handle.waitForCalls(1);
expect(store.statuses["id-1"]).toBe("applied");
expect(store.syncStatus).toBe("synced");
store.dispose();
});
});
+232
View File
@@ -0,0 +1,232 @@
// `OrderQueue` unit tests under JSDOM. The queue isolates the
// browser online/offline plumbing and the conflict / paused
// classification from the rest of the draft store, so these tests
// drive it directly with injected listeners and synthesised
// `SubmitResult`s.
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { OrderQueue, classifyResult } from "../src/sync/order-queue.svelte";
import type {
SubmitFailure,
SubmitResult,
SubmitSuccess,
} from "../src/sync/submit";
interface FakeBrowser {
online: boolean;
listeners: Map<string, Set<() => void>>;
fireOnline: () => void;
fireOffline: () => void;
}
function makeBrowser(initial: boolean): FakeBrowser {
const listeners = new Map<string, Set<() => void>>();
const browser: FakeBrowser = {
online: initial,
listeners,
fireOnline: () => {
browser.online = true;
const bucket = listeners.get("online");
if (bucket !== undefined) for (const h of [...bucket]) h();
},
fireOffline: () => {
browser.online = false;
const bucket = listeners.get("offline");
if (bucket !== undefined) for (const h of [...bucket]) h();
},
};
return browser;
}
function startQueue(
browser: FakeBrowser,
onOnline: () => void = () => undefined,
): OrderQueue {
const queue = new OrderQueue();
queue.start({
onOnline,
onlineProbe: () => browser.online,
addEventListener: (event, handler) => {
let bucket = browser.listeners.get(event);
if (bucket === undefined) {
bucket = new Set();
browser.listeners.set(event, bucket);
}
bucket.add(handler);
},
removeEventListener: (event, handler) => {
const bucket = browser.listeners.get(event);
if (bucket !== undefined) bucket.delete(handler);
},
});
return queue;
}
function success(updatedAt = 1): SubmitSuccess {
return {
ok: true,
results: new Map([["id", "applied"]]),
errorCodes: new Map([["id", null]]),
updatedAt,
};
}
function failure(resultCode: string, code = resultCode, message = ""): SubmitFailure {
return {
ok: false,
resultCode,
code,
message: message || resultCode,
};
}
describe("classifyResult", () => {
test("ok result maps to success", () => {
const out = classifyResult(success());
expect(out.kind).toBe("success");
});
test("turn_already_closed resultCode maps to conflict", () => {
const out = classifyResult(failure("turn_already_closed"));
expect(out.kind).toBe("conflict");
if (out.kind === "conflict") {
expect(out.code).toBe("turn_already_closed");
}
});
test("game_paused resultCode maps to paused", () => {
const out = classifyResult(failure("game_paused", "game_paused", "paused"));
expect(out.kind).toBe("paused");
if (out.kind === "paused") {
expect(out.code).toBe("game_paused");
expect(out.message).toBe("paused");
}
});
test("turn_already_closed via inner code (resultCode opaque) maps to conflict", () => {
// gateway may set resultCode to something opaque while the
// FBS error body carries the actionable code.
const out = classifyResult(failure("conflict", "turn_already_closed"));
expect(out.kind).toBe("conflict");
});
test("unknown failure code stays a rejected outcome", () => {
const out = classifyResult(failure("validation_failed"));
expect(out.kind).toBe("rejected");
});
});
describe("OrderQueue.send", () => {
let browser: FakeBrowser;
let queue: OrderQueue;
beforeEach(() => {
browser = makeBrowser(true);
queue = startQueue(browser);
});
afterEach(() => {
queue.stop();
});
test("offline at call time short-circuits without invoking submit", async () => {
browser.fireOffline();
const submitFn = vi.fn<() => Promise<SubmitResult>>();
const outcome = await queue.send(submitFn);
expect(outcome.kind).toBe("offline");
expect(submitFn).not.toHaveBeenCalled();
});
test("ok result is forwarded as success", async () => {
const outcome = await queue.send(async () => success(42));
expect(outcome.kind).toBe("success");
if (outcome.kind === "success") {
expect(outcome.result.updatedAt).toBe(42);
}
});
test("turn_already_closed reply maps to conflict outcome", async () => {
const outcome = await queue.send(async () =>
failure("turn_already_closed", "turn_already_closed", "turn closed"),
);
expect(outcome.kind).toBe("conflict");
if (outcome.kind === "conflict") {
expect(outcome.message).toBe("turn closed");
}
});
test("game_paused reply maps to paused outcome", async () => {
const outcome = await queue.send(async () =>
failure("game_paused", "game_paused", "paused"),
);
expect(outcome.kind).toBe("paused");
});
test("throw while online maps to failed outcome", async () => {
const outcome = await queue.send(async () => {
throw new Error("boom");
});
expect(outcome.kind).toBe("failed");
if (outcome.kind === "failed") {
expect(outcome.reason).toBe("boom");
}
});
test("throw with offline probe maps to offline outcome", async () => {
const outcome = await queue.send(async () => {
browser.online = false;
throw new Error("network down");
});
expect(outcome.kind).toBe("offline");
expect(queue.online).toBe(false);
});
test("online event triggers the onOnline callback", () => {
const seen: number[] = [];
const newQueue = new OrderQueue();
newQueue.start({
onOnline: () => seen.push(seen.length + 1),
onlineProbe: () => browser.online,
addEventListener: (event, handler) => {
let bucket = browser.listeners.get(event);
if (bucket === undefined) {
bucket = new Set();
browser.listeners.set(event, bucket);
}
bucket.add(handler);
},
removeEventListener: (event, handler) => {
const bucket = browser.listeners.get(event);
if (bucket !== undefined) bucket.delete(handler);
},
});
browser.fireOffline();
expect(newQueue.online).toBe(false);
browser.fireOnline();
expect(newQueue.online).toBe(true);
expect(seen).toEqual([1]);
newQueue.stop();
});
test("start is idempotent for the same queue instance", () => {
queue.start({
onOnline: () => undefined,
onlineProbe: () => browser.online,
addEventListener: () => {
throw new Error("must not be called");
},
removeEventListener: () => undefined,
});
expect(queue.online).toBe(true);
});
test("stop unsubscribes from browser events", () => {
queue.stop();
const before = queue.online;
browser.fireOffline();
// After stop, the queue no longer reflects browser flips.
expect(queue.online).toBe(before);
});
});
+54
View File
@@ -175,4 +175,58 @@ describe("order-tab", () => {
});
draft.dispose();
});
test("turn_already_closed surfaces the conflict banner with the turn", async () => {
const handle = recordingClient(GAME_ID, "turn_already_closed");
const { draft, context } = await makeDraft([]);
draft.bindClient(handle.client, { getCurrentTurn: () => 12 });
const ui = render(OrderTab, { context });
await draft.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
await waitFor(() => {
const banner = ui.getByTestId("order-conflict-banner");
expect(banner).toBeVisible();
expect(banner).toHaveTextContent("Turn 12");
expect(banner).toHaveAttribute("data-conflict-turn", "12");
});
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent("conflict");
expect(ui.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"conflict",
);
draft.dispose();
});
test("game_paused surfaces the paused banner and blocks retry", async () => {
const handle = recordingClient(GAME_ID, "game_paused");
const { draft, context } = await makeDraft([]);
draft.bindClient(handle.client);
const ui = render(OrderTab, { context });
await draft.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
await waitFor(() => {
expect(ui.getByTestId("order-paused-banner")).toBeVisible();
});
expect(ui.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"paused",
);
// No retry button is shown for paused state.
expect(ui.queryByTestId("order-sync-retry")).toBeNull();
draft.dispose();
});
});