// 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 = $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 { this.cache = opts.cache; this.gameId = opts.gameId; try { const stored = await opts.cache.get( 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 { 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 { 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 { 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 { 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; 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 = {}; for (const cmd of this.commands) { next[cmd.id] = validateCommand(cmd); } this.statuses = next; } private async persist(): Promise { 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"; } }