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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user