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:
@@ -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}",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user