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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user