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