ui/phase-14: auto-sync order draft + always GET on boot + header headline

Replaces the manual Submit button with an auto-sync pipeline driven
by `OrderDraftStore`: every successful add / remove / move
coalesces a `submitOrder` call so the engine always mirrors the
local draft. Removing the last command sends an empty cmd[] PUT —
the engine, repo, and rest model now accept that as a valid
"player cleared their draft" state.

`hydrateFromServer` is now invoked unconditionally on game boot so
a fresh device picks up the player's stored order, and the local
cache is overwritten by the server's view (server is the source of
truth).

Header replaces the static "race ?" + turn counter with a single
headline string `<race> @ <game>, turn <n>`, sourced from the
engine's Report.race + the lobby's GameSummary.gameName + the live
turn number, with a `?` fallback while any piece is loading.

Tests:
- engine: empty PUT round-trips, repo round-trips empty Commands
- order-draft: auto-sync sends full draft on every mutation,
  rejected response surfaces error sync status, rapid mutations
  coalesce, server hydration overwrites cache
- order-tab: per-row status flips through the auto-sync lifecycle,
  remove → empty cmd[] PUT, rejected → retry button
- inspector overlay: applied + valid + submitting all participate
  in the optimistic projection
- header: live race / game / turn rendering with fall-back

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-09 13:34:10 +02:00
parent 68d8607eaa
commit 229c43beb5
26 changed files with 1144 additions and 728 deletions
+27 -12
View File
@@ -67,6 +67,12 @@ export interface GameReport {
mapHeight: number;
planetCount: number;
planets: ReportPlanet[];
/**
* race is the calling player's race name as resolved by the
* engine from the runtime player mapping. Empty when the engine
* has not produced a report yet (boot state).
*/
race: string;
}
export async function fetchGameReport(
@@ -189,6 +195,7 @@ function decodeReport(report: Report): GameReport {
mapHeight: report.height(),
planetCount: report.planetCount(),
planets,
race: report.race() ?? "",
};
}
@@ -212,18 +219,20 @@ export function uuidToHiLo(value: string): [bigint, bigint] {
}
/**
* applyOrderOverlay returns a copy of `report` with every applied or
* still-in-flight (`submitting`) command from `commands` projected on
* top. Phase 14 understands `planetRename` only — every other variant
* passes through. The function is pure: callers re-derive the
* overlay whenever the draft or the report change.
* applyOrderOverlay returns a copy of `report` with every locally-
* valid or still-in-flight or applied command from `commands`
* projected on top. Phase 14 understands `planetRename` only —
* every other variant passes through. The function is pure:
* callers re-derive the overlay whenever the draft or the report
* change.
*
* `statuses` maps command id → status. Entries with `applied` or
* `submitting` participate in the overlay; everything else (`draft`,
* `valid`, `invalid`, `rejected`) is treated as "not yet committed
* by the player" and skipped. This matches the order-composer model:
* the player sees their own committed intent, not their unfinished
* edits.
* `statuses` maps command id → status. Entries with `valid`,
* `submitting`, or `applied` participate in the overlay — together
* they describe "the player's committed intent for this turn":
* locally-valid (auto-sync about to fire), in-flight on the wire,
* or acknowledged by the engine. Entries with `draft`, `invalid`,
* or `rejected` skip the overlay so the player keeps the server's
* (un-renamed) view.
*/
export function applyOrderOverlay(
report: GameReport,
@@ -234,7 +243,13 @@ export function applyOrderOverlay(
let mutatedPlanets: ReportPlanet[] | null = null;
for (const cmd of commands) {
const status = statuses[cmd.id];
if (status !== "applied" && status !== "submitting") continue;
if (
status !== "valid" &&
status !== "submitting" &&
status !== "applied"
) {
continue;
}
if (cmd.kind !== "planetRename") continue;
const idx = report.planets.findIndex((p) => p.number === cmd.planetNumber);
if (idx < 0) continue;
+8
View File
@@ -37,6 +37,13 @@ type Status = "idle" | "loading" | "ready" | "error";
export class GameStateStore {
gameId: string = $state("");
/**
* gameName mirrors the lobby's `game_name` for the running game.
* Lifted from the lobby record on `setGame`; empty during boot
* and set once the lobby query resolves. Used by the header to
* compose the `<race> @ <game>, turn N` display.
*/
gameName: string = $state("");
status: Status = $state("idle");
report: GameReport | null = $state(null);
wrapMode: WrapMode = $state("torus");
@@ -95,6 +102,7 @@ export class GameStateStore {
this.error = `game ${gameId} is not in your list`;
return;
}
this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn;
await this.loadTurn(summary.currentTurn);
} catch (err) {
+47 -10
View File
@@ -1,16 +1,25 @@
<!--
Top header for the in-game shell. Composes the four artifacts called
out by `ui/PLAN.md` Phase 10: race name (static placeholder), turn
counter (static placeholder), view dropdown / hamburger, account
menu. The sidebar-toggle slot to its left appears only on tablet
viewports (7681024 px) and is wired by `+layout.svelte`.
Top header for the in-game shell. Composes the in-game ID strip
(race name @ game name, turn N), view dropdown / hamburger, and the
account menu. The sidebar-toggle slot to its left appears only on
tablet viewports (7681024 px) and is wired by `+layout.svelte`.
The race name is read from the engine's `Report.race`, the game
name from the lobby's `GameSummary.gameName`. While either piece
is missing (boot, network error) we fall back to the
`game.shell.unknown` placeholder so the header chrome keeps its
shape.
The connection-state indicator from the IA section is intentionally
absent until Phase 24 wires push-event state.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import TurnCounter from "./turn-counter.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
import ViewMenu from "./view-menu.svelte";
import AccountMenu from "./account-menu.svelte";
@@ -20,14 +29,42 @@ absent until Phase 24 wires push-event state.
onToggleSidebar: () => void;
};
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props();
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
const raceName = $derived.by(() => {
const name = gameState?.report?.race;
return name === undefined || name === ""
? i18n.t("game.shell.unknown")
: name;
});
const gameName = $derived.by(() => {
const name = gameState?.gameName ?? "";
return name === "" ? i18n.t("game.shell.unknown") : name;
});
const turn = $derived.by(() => {
const report = gameState?.report;
return report === null || report === undefined
? i18n.t("game.shell.unknown")
: String(report.turn);
});
const headline = $derived(
i18n.t("game.shell.headline", {
race: raceName,
game: gameName,
turn,
}),
);
</script>
<header class="game-shell-header" data-testid="game-shell-header">
<div class="left">
<span class="race" data-testid="race-name">
{i18n.t("game.shell.race_placeholder")}
<span class="headline" data-testid="game-shell-headline">
{headline}
</span>
<TurnCounter />
</div>
<div class="right">
<button
@@ -69,7 +106,7 @@ absent until Phase 24 wires push-event state.
gap: 0.75rem;
min-width: 0;
}
.race {
.headline {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
@@ -1,37 +0,0 @@
<!--
Phase 11 turn counter: reads the live turn number from the per-game
`GameStateStore` provided through context by
`routes/games/[id]/+layout.svelte`. Renders the static `?` placeholder
from `game.shell.turn_unknown` when the store has not yet produced a
report (boot, network error, no membership) so the header chrome
keeps its width across loading transitions.
Phase 26 will turn this into a clickable trigger that opens the
turn navigator; Phase 24 wires push-event-driven turn-ready toasts
that may flash this counter when a new turn is ready.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { GAME_STATE_CONTEXT_KEY, type GameStateStore } from "$lib/game-state.svelte";
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const display = $derived.by(() => {
const report = store?.report ?? null;
if (report === null) return i18n.t("game.shell.turn_unknown");
return String(report.turn);
});
</script>
<span class="turn" data-testid="turn-counter" data-turn={display}>
{i18n.t("game.shell.turn_label")}&nbsp;{display}
</span>
<style>
.turn {
font-size: 0.95rem;
color: #ddd;
white-space: nowrap;
}
</style>
+7 -6
View File
@@ -83,9 +83,8 @@ const en = {
"lobby.error.internal_error": "internal server error",
"lobby.error.unknown": "{message}",
"game.shell.race_placeholder": "race ?",
"game.shell.turn_label": "turn",
"game.shell.turn_unknown": "?",
"game.shell.unknown": "?",
"game.shell.headline": "{race} @ {game}, turn {turn}",
"game.shell.connection.online": "online",
"game.shell.connection.reconnecting": "reconnecting…",
"game.shell.connection.offline": "offline",
@@ -120,8 +119,11 @@ const en = {
"game.sidebar.empty.inspector": "select an object on the map",
"game.sidebar.empty.order": "order is empty",
"game.sidebar.order.command_delete": "delete",
"game.sidebar.order.submit": "submit",
"game.sidebar.order.submit_in_flight": "submitting…",
"game.sidebar.order.sync.idle": "no changes yet",
"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.retry": "retry",
"game.sidebar.order.status.draft": "draft",
"game.sidebar.order.status.valid": "valid",
"game.sidebar.order.status.invalid": "invalid",
@@ -130,7 +132,6 @@ const en = {
"game.sidebar.order.status.rejected": "rejected",
"game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "rename planet {planet} → {name}",
"game.sidebar.order.error.batch_failed": "submit failed: {message}",
"game.bottom_tabs.map": "map",
"game.bottom_tabs.calc": "calc",
"game.bottom_tabs.order": "order",
+7 -6
View File
@@ -84,9 +84,8 @@ const ru: Record<keyof typeof en, string> = {
"lobby.error.internal_error": "внутренняя ошибка сервера",
"lobby.error.unknown": "{message}",
"game.shell.race_placeholder": "раса ?",
"game.shell.turn_label": "ход",
"game.shell.turn_unknown": "?",
"game.shell.unknown": "?",
"game.shell.headline": "{race} @ {game}, ход {turn}",
"game.shell.connection.online": "онлайн",
"game.shell.connection.reconnecting": "переподключение…",
"game.shell.connection.offline": "офлайн",
@@ -121,8 +120,11 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.empty.inspector": "выберите объект на карте",
"game.sidebar.empty.order": "приказ пуст",
"game.sidebar.order.command_delete": "удалить",
"game.sidebar.order.submit": "отправить",
"game.sidebar.order.submit_in_flight": "отправка…",
"game.sidebar.order.sync.idle": "нет изменений",
"game.sidebar.order.sync.in_flight": "синхронизация…",
"game.sidebar.order.sync.synced": "сохранено на сервере",
"game.sidebar.order.sync.error": "ошибка синхронизации: {message}",
"game.sidebar.order.sync.retry": "повторить",
"game.sidebar.order.status.draft": "черновик",
"game.sidebar.order.status.valid": "готова",
"game.sidebar.order.status.invalid": "ошибка",
@@ -131,7 +133,6 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.order.status.rejected": "отклонена",
"game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}",
"game.sidebar.order.error.batch_failed": "ошибка отправки: {message}",
"game.bottom_tabs.map": "карта",
"game.bottom_tabs.calc": "калк",
"game.bottom_tabs.order": "приказ",
+66 -124
View File
@@ -1,19 +1,18 @@
<!--
Order composer tool. Resolves the per-game `OrderDraftStore`,
`GameStateStore`, and `GalaxyClient` from context (all set by
`routes/games/[id]/+layout.svelte`) and renders the local draft as
a vertical list with per-row status, a delete button, and a Submit
button at the bottom.
Order composer tool. Resolves the per-game `OrderDraftStore` from
context (set by `routes/games/[id]/+layout.svelte`) and renders the
local draft as a vertical list with per-row status and a delete
button.
Phase 14 wires the first end-to-end command: clicking Submit calls
`submitOrder` for every entry in `valid` status, flips the in-flight
rows to `submitting`, then merges the per-command verdict back into
the draft once the gateway responds. The optimistic overlay in
`renderedReport` continues to show the player's intent while the
order is in flight, so the inspector and the map reflect the new
name even before the server applies it at turn cutoff.
Phase 14 wires the auto-sync pipeline directly into the draft
store: every successful `add` / `remove` / `move` triggers a
`submitOrder` call so the server always mirrors the local draft.
This view shows the resulting per-command status (`valid`,
`submitting`, `applied`, `rejected`) and a small status bar at the
bottom that surfaces the latest sync result. The earlier explicit
Submit button is gone — there is no separate "send" step anymore.
Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
(Playwright) and via direct store / mocked-client construction
(Vitest).
-->
@@ -24,26 +23,11 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
import {
GALAXY_CLIENT_CONTEXT_KEY,
type GalaxyClientHandle,
} from "$lib/galaxy-client-context.svelte";
import type { CommandStatus, OrderCommand } from "../../sync/order-types";
import { submitOrder } from "../../sync/submit";
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
const galaxyClient = getContext<GalaxyClientHandle | undefined>(
GALAXY_CLIENT_CONTEXT_KEY,
);
const statusKeyMap: Record<CommandStatus, TranslationKey> = {
draft: "game.sidebar.order.status.draft",
@@ -54,30 +38,6 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
rejected: "game.sidebar.order.status.rejected",
};
let submitInFlight = $state(false);
let submitError = $state<string | null>(null);
const submittable = $derived.by(() => {
if (draft === undefined) return [] as OrderCommand[];
return draft.commands.filter(
(cmd) => draft.statuses[cmd.id] === "valid",
);
});
const hasInvalid = $derived.by(() => {
if (draft === undefined) return false;
return draft.commands.some((cmd) => draft.statuses[cmd.id] === "invalid");
});
const submitDisabled = $derived(
draft === undefined ||
galaxyClient === undefined ||
galaxyClient.client === null ||
submitInFlight ||
submittable.length === 0 ||
hasInvalid,
);
function describe(cmd: OrderCommand): string {
switch (cmd.kind) {
case "placeholder":
@@ -95,50 +55,6 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
function statusOf(cmd: OrderCommand): CommandStatus {
return draft?.statuses[cmd.id] ?? "draft";
}
async function submit(): Promise<void> {
if (
draft === undefined ||
galaxyClient === undefined ||
galaxyClient.client === null ||
gameState === undefined
)
return;
if (submittable.length === 0 || hasInvalid) return;
const ids = submittable.map((cmd) => cmd.id);
const snapshot = submittable.slice();
submitInFlight = true;
submitError = null;
draft.markSubmitting(ids);
try {
const result = await submitOrder(
galaxyClient.client,
gameState.gameId,
snapshot,
{ updatedAt: draft.updatedAt },
);
if (result.ok) {
draft.applyResults({
results: result.results,
updatedAt: result.updatedAt,
});
if (gameState !== undefined) {
await gameState.refresh();
}
} else {
draft.markRejected(ids);
submitError = i18n.t("game.sidebar.order.error.batch_failed", {
message: result.message,
});
}
} catch (err) {
draft.revertSubmittingToValid();
submitError =
err instanceof Error ? err.message : "submit failed";
} finally {
submitInFlight = false;
}
}
</script>
<section class="tool" data-testid="sidebar-tool-order">
@@ -177,20 +93,37 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
</li>
{/each}
</ol>
<button
type="button"
class="submit"
data-testid="order-submit"
disabled={submitDisabled}
onclick={() => void submit()}
{/if}
{#if draft !== undefined}
<div
class="sync sync-{draft.syncStatus}"
data-testid="order-sync"
data-sync-status={draft.syncStatus}
>
{submitInFlight
? i18n.t("game.sidebar.order.submit_in_flight")
: i18n.t("game.sidebar.order.submit")}
</button>
{#if submitError !== null}
<p class="error" data-testid="order-submit-error">{submitError}</p>
{/if}
<span class="sync-label">
{#if draft.syncStatus === "syncing"}
{i18n.t("game.sidebar.order.sync.in_flight")}
{:else if draft.syncStatus === "synced"}
{i18n.t("game.sidebar.order.sync.synced")}
{:else if draft.syncStatus === "error"}
{i18n.t("game.sidebar.order.sync.error", {
message: draft.syncError ?? "",
})}
{:else}
{i18n.t("game.sidebar.order.sync.idle")}
{/if}
</span>
{#if draft.syncStatus === "error"}
<button
type="button"
class="sync-retry"
data-testid="order-sync-retry"
onclick={() => draft.forceSync()}
>
{i18n.t("game.sidebar.order.sync.retry")}
</button>
{/if}
</div>
{/if}
</section>
@@ -274,26 +207,35 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
color: #e8eaf6;
border-color: #6d8cff;
}
.submit {
.sync {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.8rem;
color: #aab;
}
.sync-error {
color: #d97a7a;
}
.sync-synced {
color: #8be9a3;
}
.sync-syncing {
color: #6d8cff;
}
.sync-retry {
font: inherit;
font-size: 0.9rem;
padding: 0.4rem 1rem;
background: #1d2440;
color: #e8eaf6;
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.submit:not(:disabled):hover {
.sync-retry:hover {
color: #e8eaf6;
border-color: #6d8cff;
}
.submit:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.error {
margin: 0.5rem 0 0;
color: #d97a7a;
font-size: 0.85rem;
}
</style>
@@ -164,12 +164,17 @@ fresh.
orderDraft.init({ cache, gameId }),
]);
galaxyClient.set(client);
if (orderDraft.needsServerHydration) {
await orderDraft.hydrateFromServer({
client,
turn: gameState.currentTurn,
});
}
orderDraft.bindClient(client);
// 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
// edit must catch up at re-sync time. The hydration
// is non-fatal: a network error keeps the local
// cache and surfaces through `draft.syncStatus`.
await orderDraft.hydrateFromServer({
client,
turn: gameState.currentTurn,
});
} catch (err) {
gameState.failBootstrap(describeBootstrapError(err));
}
+199 -78
View File
@@ -6,11 +6,16 @@
// Draft state is persisted into the platform `Cache` under the
// `order-drafts` namespace with a per-game key, so a reload, a
// browser restart, or a navigation through the lobby and back into
// the same game restores the previously composed list. Phase 14
// will add the submit pipeline that drains the draft to the server;
// Phase 26 will hide the order tab in history mode through a flag
// passed by the layout (the store itself remains alive across that
// transition so the draft survives history-mode round-trips).
// the same game restores the previously composed list.
//
// Phase 14 wires the auto-sync pipeline: every successful mutation
// (`add` / `remove` / `move`) coalesces a `submitOrder` call so the
// server always mirrors the local draft. The Submit button is gone —
// the player's intent is the source of truth and the engine is kept
// in lock-step. Phase 26 will hide the order tab in history mode
// through a flag passed by the layout (the store itself remains
// alive across that transition so the draft survives history-mode
// round-trips).
//
// The store deliberately carries no Svelte component imports so it
// can be tested directly with a synthetic `Cache` without rendering
@@ -20,6 +25,7 @@ import type { Cache } from "../platform/store/index";
import type { GalaxyClient } from "../api/galaxy-client";
import { fetchOrder } from "./order-load";
import type { CommandStatus, OrderCommand } from "./order-types";
import { submitOrder } from "./submit";
import { validateEntityName } from "$lib/util/entity-name";
const NAMESPACE = "order-drafts";
@@ -35,6 +41,8 @@ export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft");
type Status = "idle" | "ready" | "error";
export type SyncStatus = "idle" | "syncing" | "synced" | "error";
export class OrderDraftStore {
commands: OrderCommand[] = $state([]);
statuses: Record<string, CommandStatus> = $state({});
@@ -43,18 +51,26 @@ export class OrderDraftStore {
error: string | null = $state(null);
/**
* needsServerHydration is `true` when the cache row for this game
* was absent at `init` time. The layout reads it after both
* `gameState.init` and `orderDraft.init` resolve and, if `true`,
* calls `hydrateFromServer` once the current turn is known.
* An explicitly empty cache row sets it to `false` (the user has
* an empty draft, not a missing one).
* 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`.
*/
needsServerHydration = $state(false);
syncStatus: SyncStatus = $state("idle");
syncError: string | 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;
/**
* init loads the persisted draft for `opts.gameId` from `opts.cache`
@@ -63,11 +79,11 @@ export class OrderDraftStore {
* constructs a fresh store per game, so there is no need to support
* mid-life game switching here.
*
* When the cache row is absent, `needsServerHydration` is set to
* `true`; the layout fans out a `hydrateFromServer` call once the
* current turn is known. An explicitly empty cache row is treated
* as "user has an empty draft" and skipped — local intent always
* wins over server snapshot.
* The cache load is the fast path so the order tab paints
* immediately on reopening the game; `hydrateFromServer` (called
* by the layout once the current turn is known) is the
* authoritative read that always overwrites the local cache when
* the server has a stored order.
*/
async init(opts: { cache: Cache; gameId: string }): Promise<void> {
this.cache = opts.cache;
@@ -78,13 +94,7 @@ export class OrderDraftStore {
draftKey(opts.gameId),
);
if (this.destroyed) return;
if (stored === undefined) {
this.commands = [];
this.needsServerHydration = true;
} else {
this.commands = Array.isArray(stored) ? [...stored] : [];
this.needsServerHydration = false;
}
this.commands = Array.isArray(stored) ? [...stored] : [];
this.recomputeStatuses();
this.status = "ready";
} catch (err) {
@@ -95,37 +105,64 @@ export class OrderDraftStore {
}
/**
* hydrateFromServer fetches the player's stored order from the
* gateway when the cache row was absent at boot. The result is
* merged into `commands` and persisted so subsequent reloads
* prefer the cached version. Failures are non-fatal — the draft
* stays empty and the user can keep composing.
* bindClient stores the per-game `GalaxyClient` so subsequent
* mutations can drive the auto-sync pipeline. The layout calls
* this after the boot `Promise.all` resolves and before
* `hydrateFromServer`, so any mutation that lands afterwards goes
* through the network.
*/
bindClient(client: GalaxyClient): void {
this.client = client;
}
/**
* hydrateFromServer issues `user.games.order.get` for the current
* turn and overwrites the local cache with the server's stored
* order. The server is the source of truth: a player who logged
* in from a fresh device must see their existing orders, and a
* cache that's out-of-sync (e.g. a stale browser tab) is
* superseded by the gateway's view. A `found = false` answer
* empties the local draft. Network failures keep the local cache
* intact and surface as `syncStatus = "error"`.
*/
async hydrateFromServer(opts: {
client: GalaxyClient;
turn: number;
}): Promise<void> {
if (this.status !== "ready" || !this.needsServerHydration) return;
this.needsServerHydration = false;
if (this.status !== "ready") return;
this.client = opts.client;
this.syncStatus = "syncing";
this.syncError = null;
try {
const fetched = await fetchOrder(opts.client, this.gameId, opts.turn);
if (this.destroyed) return;
this.commands = fetched.commands;
this.updatedAt = fetched.updatedAt;
this.recomputeStatuses();
// Server-fetched commands echo cmdApplied=true for entries
// that survived previous turns; keep them as `applied` so
// the overlay continues to project them on the inspector.
const next = { ...this.statuses };
for (const cmd of this.commands) {
if (next[cmd.id] === "valid") {
next[cmd.id] = "applied";
}
}
this.statuses = next;
await this.persist();
this.syncStatus = "synced";
} catch (err) {
if (this.destroyed) return;
console.warn(
"order-draft: server hydration failed; staying on empty draft",
err,
);
this.syncStatus = "error";
this.syncError = err instanceof Error ? err.message : "fetch failed";
console.warn("order-draft: server hydration failed", err);
}
}
/**
* add appends a command to the end of the draft, runs local
* validation for the new entry, and persists the updated list.
* validation for the new entry, persists the updated list, and
* triggers an auto-sync to keep the server in lock-step.
* Mutations made before `init` resolves are ignored — the layout
* always awaits `init` before exposing the store.
*/
@@ -134,11 +171,15 @@ export class OrderDraftStore {
this.commands = [...this.commands, command];
this.statuses = { ...this.statuses, [command.id]: validateCommand(command) };
await this.persist();
this.scheduleSync();
}
/**
* remove drops the command with the given id from the draft and
* persists the result. A miss is a no-op.
* remove drops the command with the given id from the draft,
* persists the result, and triggers an auto-sync. A miss is a
* no-op. Even removing the last command sends an explicit empty
* order to the server so its stored state matches the local one
* (the engine accepts an empty `cmd[]` per the order handler).
*/
async remove(id: string): Promise<void> {
if (this.status !== "ready") return;
@@ -149,13 +190,16 @@ export class OrderDraftStore {
delete nextStatuses[id];
this.statuses = nextStatuses;
await this.persist();
this.scheduleSync();
}
/**
* move relocates the command at `fromIndex` to `toIndex`, shifting
* the intermediate commands. Out-of-range indices and identical
* positions are no-ops; both indices are clamped against the
* current `commands` length.
* current `commands` length. Triggers an auto-sync — the server
* stores commands in submission order and the engine relies on
* that order at turn cutoff.
*/
async move(fromIndex: number, toIndex: number): Promise<void> {
if (this.status !== "ready") return;
@@ -169,49 +213,137 @@ export class OrderDraftStore {
next.splice(toIndex, 0, picked);
this.commands = next;
await this.persist();
this.scheduleSync();
}
/**
* markSubmitting flips the status of every entry in `ids` to
* `submitting` so the order tab can disable per-row controls and
* show a spinner. The state machine runs `valid → submitting →
* applied | rejected` (see ui/docs/order-composer.md).
* forceSync re-runs the auto-sync without requiring a mutation.
* Used by the order tab's retry-on-error affordance.
*/
markSubmitting(ids: string[]): void {
forceSync(): void {
this.scheduleSync();
}
dispose(): void {
this.destroyed = true;
this.cache = null;
this.client = null;
}
private scheduleSync(): void {
if (this.client === null) return;
if (this.syncing !== null) {
this.pending = true;
return;
}
this.syncing = this.runSync().finally(() => {
this.syncing = null;
});
}
private async runSync(): Promise<void> {
while (true) {
this.pending = false;
const client = this.client;
if (client === null || this.destroyed) return;
// Capture the snapshot up-front: the in-flight request
// reflects the draft as it was when the mutation landed,
// even if the user adds another command before the
// gateway responds.
const snapshot: OrderCommand[] = $state.snapshot(
this.commands,
) as OrderCommand[];
// Auto-sync sends every command the player still has in
// the draft except the locally-invalid ones (we can't
// expect the server to accept a name that fails our own
// validator) and the Phase 12 placeholder. `applied` and
// `rejected` entries are re-sent so the server's stored
// view always mirrors the local one — re-applying an
// already-applied command is idempotent at the engine
// level (the rename ends at the same name).
const submittable = snapshot.filter((cmd) => {
const status = this.statuses[cmd.id];
return status !== "invalid" && status !== "draft";
});
const submittingIds = submittable.map((cmd) => cmd.id);
this.markSubmittingInternal(submittingIds);
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);
// 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",
);
this.syncStatus = anyRejected ? "error" : "synced";
this.syncError = anyRejected
? "engine rejected one or more commands"
: null;
} else {
this.markRejectedInternal(submittingIds);
this.syncStatus = "error";
this.syncError = result.message;
}
} 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 markSubmittingInternal(ids: string[]): void {
const next = { ...this.statuses };
for (const id of ids) {
next[id] = "submitting";
// `applied` rows stay applied while the wire request is in
// flight — re-sending an already-applied command is a
// no-op idempotent operation, and flipping the badge back
// to `submitting` would flicker the inspector overlay.
if (next[id] === "valid" || next[id] === "rejected") {
next[id] = "submitting";
}
}
this.statuses = next;
}
/**
* applyResults merges the verdict map returned by `submitOrder`
* into the per-command status map. Entries not present in the
* map keep their current status — useful when only a subset of
* commands round-tripped to the server. The engine-assigned
* `updatedAt` is also stashed for the next submit's stale-order
* detection (kept as plumbing only in Phase 14).
*/
applyResults(opts: {
results: Map<string, CommandStatus>;
updatedAt: number;
}): void {
private applyResultsInternal(
results: Map<string, CommandStatus>,
updatedAt: number,
): void {
const liveIds = new Set(this.commands.map((cmd) => cmd.id));
const next = { ...this.statuses };
for (const [id, status] of opts.results.entries()) {
for (const [id, status] of results.entries()) {
// Drop verdicts for commands the user removed while the
// request was in flight — they are no longer in the
// draft, so re-introducing a stale `applied` row would
// confuse the order tab and the overlay.
if (!liveIds.has(id)) continue;
next[id] = status;
}
this.statuses = next;
this.updatedAt = opts.updatedAt;
this.updatedAt = updatedAt;
}
/**
* markRejected switches every supplied id to `rejected`. Used by
* the order tab when `submitOrder` returns `ok: false` — the
* gateway didn't process any command, so the entire batch is
* treated as rejected.
*/
markRejected(ids: string[]): void {
private markRejectedInternal(ids: string[]): void {
const next = { ...this.statuses };
for (const id of ids) {
next[id] = "rejected";
@@ -219,13 +351,7 @@ export class OrderDraftStore {
this.statuses = next;
}
/**
* revertSubmittingToValid resets every entry currently in
* `submitting` back to its pre-submit status (typically `valid`).
* Called when the network layer throws an exception so the
* operator can retry without the rows looking stuck mid-flight.
*/
revertSubmittingToValid(): void {
private revertSubmittingToValidInternal(): void {
const next = { ...this.statuses };
for (const cmd of this.commands) {
if (next[cmd.id] === "submitting") {
@@ -235,11 +361,6 @@ export class OrderDraftStore {
this.statuses = next;
}
dispose(): void {
this.destroyed = true;
this.cache = null;
}
private recomputeStatuses(): void {
const next: Record<string, CommandStatus> = {};
for (const cmd of this.commands) {