ui/phase-12: order composer skeleton
OrderDraftStore persists per-game command drafts in Cache; the sidebar Order tab renders the list with a per-row delete control. The layout passes a `historyMode` prop through Sidebar / BottomTabs as a constant `false`, so Phase 26 only flips the source. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// 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 { OrderCommand } from "./order-types";
|
||||
|
||||
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([]);
|
||||
status: Status = $state("idle");
|
||||
error: string | null = $state(null);
|
||||
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
this.commands = Array.isArray(stored) ? [...stored] : [];
|
||||
this.status = "ready";
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
this.status = "error";
|
||||
this.error = err instanceof Error ? err.message : "load failed";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* add appends a command to the end of the draft 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];
|
||||
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;
|
||||
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();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.destroyed = true;
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Typed shape of a single command entry inside the local order
|
||||
// draft. Phase 12 intentionally ships exactly one variant
|
||||
// (`placeholder`) — Phase 14 lands the first real command
|
||||
// (`planetRename`) together with the inspector UI that constructs
|
||||
// it and the submit pipeline that drains the draft to the server.
|
||||
//
|
||||
// `OrderCommand` is a discriminated union on the `kind` field so
|
||||
// later variants can extend the union without changing the array
|
||||
// shape persisted in `Cache`. The whole draft round-trips through
|
||||
// IndexedDB structured clone, so every variant must use only
|
||||
// JSON-friendly value types (`string`, `number`, `boolean`,
|
||||
// nested plain objects, and `Uint8Array`).
|
||||
|
||||
/**
|
||||
* PlaceholderCommand is the single variant shipped with the Phase 12
|
||||
* skeleton. It carries a stable `id` (used by remove and as a
|
||||
* `data-testid` suffix) and a human-readable `label` rendered in the
|
||||
* order tab's vertical list. The variant is deliberately content-free
|
||||
* so test fixtures and the empty composer skeleton do not pre-bias
|
||||
* Phase 14's first real command shape.
|
||||
*/
|
||||
export interface PlaceholderCommand {
|
||||
readonly kind: "placeholder";
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OrderCommand is the discriminated union of every command shape the
|
||||
* local order draft can hold. The `kind` field is the discriminator;
|
||||
* narrowing on it enables exhaustive `switch` statements at every
|
||||
* call site. Phase 14 will widen the union with `planetRename`.
|
||||
*/
|
||||
export type OrderCommand = PlaceholderCommand;
|
||||
|
||||
/**
|
||||
* CommandStatus is the lifecycle of a single command from the moment
|
||||
* it lands in the draft to the moment the server resolves it. The
|
||||
* skeleton stores only the type description; Phase 14 adds the
|
||||
* `valid` / `invalid` transitions driven by local validation, and
|
||||
* Phase 25 introduces `submitting` / `applied` / `rejected` driven
|
||||
* by the submit pipeline.
|
||||
*
|
||||
* The state machine is:
|
||||
*
|
||||
* draft → valid → submitting → applied
|
||||
* ↘ invalid ↘ rejected
|
||||
*
|
||||
* A command is `draft` until local validation has run, then `valid`
|
||||
* or `invalid`. On submit the entry transitions to `submitting`,
|
||||
* then to `applied` or `rejected` once the gateway responds.
|
||||
*/
|
||||
export type CommandStatus =
|
||||
| "draft"
|
||||
| "valid"
|
||||
| "invalid"
|
||||
| "submitting"
|
||||
| "applied"
|
||||
| "rejected";
|
||||
Reference in New Issue
Block a user