f80c623a74
Wires the first end-to-end command through the full pipeline:
inspector rename action → local order draft → user.games.order
submit → optimistic overlay on map / inspector → server hydration
on cache miss via the new user.games.order.get message type.
Backend: GET /api/v1/user/games/{id}/orders forwards to engine
GET /api/v1/order. Gateway parses the engine PUT response into the
extended UserGamesOrderResponse FBS envelope and adds
executeUserGamesOrderGet for the read-back path. Frontend ports
ValidateTypeName to TS, lands the inline rename editor + Submit
button, and exposes a renderedReport context so consumers see the
overlay-applied snapshot.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
271 lines
9.1 KiB
TypeScript
271 lines
9.1 KiB
TypeScript
// Per-game runes store that owns the local order draft. Mirrors the
|
|
// Phase 11 `GameStateStore` lifecycle: one instance per game, created
|
|
// in `routes/games/[id]/+layout.svelte`, exposed to descendants via
|
|
// Svelte context, disposed when the layout unmounts.
|
|
//
|
|
// 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 store deliberately carries no Svelte component imports so it
|
|
// can be tested directly with a synthetic `Cache` without rendering
|
|
// any UI.
|
|
|
|
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 { validateEntityName } from "$lib/util/entity-name";
|
|
|
|
const NAMESPACE = "order-drafts";
|
|
const draftKey = (gameId: string): string => `${gameId}/draft`;
|
|
|
|
/**
|
|
* ORDER_DRAFT_CONTEXT_KEY is the Svelte context key the in-game shell
|
|
* layout uses to expose its `OrderDraftStore` instance to descendants.
|
|
* The order tab and any later command-builder UI resolve the store via
|
|
* `getContext(ORDER_DRAFT_CONTEXT_KEY)`.
|
|
*/
|
|
export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft");
|
|
|
|
type Status = "idle" | "ready" | "error";
|
|
|
|
export class OrderDraftStore {
|
|
commands: OrderCommand[] = $state([]);
|
|
statuses: Record<string, CommandStatus> = $state({});
|
|
updatedAt = $state(0);
|
|
status: Status = $state("idle");
|
|
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).
|
|
*/
|
|
needsServerHydration = $state(false);
|
|
|
|
private cache: Cache | null = null;
|
|
private gameId = "";
|
|
private destroyed = false;
|
|
|
|
/**
|
|
* init loads the persisted draft for `opts.gameId` from `opts.cache`
|
|
* into `commands` and flips `status` to `ready`. The call is
|
|
* idempotent on the same store instance — the layout always
|
|
* 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.
|
|
*/
|
|
async init(opts: { cache: Cache; gameId: string }): Promise<void> {
|
|
this.cache = opts.cache;
|
|
this.gameId = opts.gameId;
|
|
try {
|
|
const stored = await opts.cache.get<OrderCommand[]>(
|
|
NAMESPACE,
|
|
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.recomputeStatuses();
|
|
this.status = "ready";
|
|
} catch (err) {
|
|
if (this.destroyed) return;
|
|
this.status = "error";
|
|
this.error = err instanceof Error ? err.message : "load failed";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async hydrateFromServer(opts: {
|
|
client: GalaxyClient;
|
|
turn: number;
|
|
}): Promise<void> {
|
|
if (this.status !== "ready" || !this.needsServerHydration) return;
|
|
this.needsServerHydration = false;
|
|
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();
|
|
await this.persist();
|
|
} catch (err) {
|
|
if (this.destroyed) return;
|
|
console.warn(
|
|
"order-draft: server hydration failed; staying on empty draft",
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* add appends a command to the end of the draft, runs local
|
|
* validation for the new entry, and persists the updated list.
|
|
* Mutations made before `init` resolves are ignored — the layout
|
|
* always awaits `init` before exposing the store.
|
|
*/
|
|
async add(command: OrderCommand): Promise<void> {
|
|
if (this.status !== "ready") return;
|
|
this.commands = [...this.commands, command];
|
|
this.statuses = { ...this.statuses, [command.id]: validateCommand(command) };
|
|
await this.persist();
|
|
}
|
|
|
|
/**
|
|
* remove drops the command with the given id from the draft and
|
|
* persists the result. A miss is a no-op.
|
|
*/
|
|
async remove(id: string): Promise<void> {
|
|
if (this.status !== "ready") return;
|
|
const next = this.commands.filter((cmd) => cmd.id !== id);
|
|
if (next.length === this.commands.length) return;
|
|
this.commands = next;
|
|
const nextStatuses = { ...this.statuses };
|
|
delete nextStatuses[id];
|
|
this.statuses = nextStatuses;
|
|
await this.persist();
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async move(fromIndex: number, toIndex: number): Promise<void> {
|
|
if (this.status !== "ready") return;
|
|
const length = this.commands.length;
|
|
if (fromIndex < 0 || fromIndex >= length) return;
|
|
if (toIndex < 0 || toIndex >= length) return;
|
|
if (fromIndex === toIndex) return;
|
|
const next = [...this.commands];
|
|
const [picked] = next.splice(fromIndex, 1);
|
|
if (picked === undefined) return;
|
|
next.splice(toIndex, 0, picked);
|
|
this.commands = next;
|
|
await this.persist();
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
*/
|
|
markSubmitting(ids: string[]): void {
|
|
const next = { ...this.statuses };
|
|
for (const id of ids) {
|
|
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 {
|
|
const next = { ...this.statuses };
|
|
for (const [id, status] of opts.results.entries()) {
|
|
next[id] = status;
|
|
}
|
|
this.statuses = next;
|
|
this.updatedAt = opts.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 {
|
|
const next = { ...this.statuses };
|
|
for (const id of ids) {
|
|
next[id] = "rejected";
|
|
}
|
|
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 {
|
|
const next = { ...this.statuses };
|
|
for (const cmd of this.commands) {
|
|
if (next[cmd.id] === "submitting") {
|
|
next[cmd.id] = validateCommand(cmd);
|
|
}
|
|
}
|
|
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) {
|
|
next[cmd.id] = validateCommand(cmd);
|
|
}
|
|
this.statuses = next;
|
|
}
|
|
|
|
private async persist(): Promise<void> {
|
|
if (this.cache === null || this.destroyed) return;
|
|
// `commands` is `$state`, so individual entries are proxies.
|
|
// IndexedDB's structured clone refuses to clone proxies, so the
|
|
// snapshot must be taken before the put.
|
|
const snapshot = $state.snapshot(this.commands) as OrderCommand[];
|
|
await this.cache.put(NAMESPACE, draftKey(this.gameId), snapshot);
|
|
}
|
|
}
|
|
|
|
function validateCommand(cmd: OrderCommand): CommandStatus {
|
|
switch (cmd.kind) {
|
|
case "planetRename":
|
|
return validateEntityName(cmd.name).ok ? "valid" : "invalid";
|
|
case "placeholder":
|
|
// Phase 12 placeholder entries are content-free and never
|
|
// transition out of `draft` — they are not submittable.
|
|
return "draft";
|
|
}
|
|
}
|