7c8b5aeb23
Add per-planet cargo routes (COL/CAP/MAT/EMP) to the inspector with a renderer-driven destination picker (faded out-of-reach planets, cursor-line anchor, hover-highlight) and per-route arrows on the map. The pick-mode primitives are exposed via `MapPickService` so ship-group dispatch in Phase 19/20 can reuse the same surface. Pass A — generic map foundation: - hit-test now sizes the click zone to `pointRadiusPx + slopPx` so the visible disc is always part of the target. - `RendererHandle` gains `onPointerMove`, `onHoverChange`, `setPickMode`, `getPickState`, `getPrimitiveAlpha`, `setExtraPrimitives`, `getPrimitives`. The click dispatcher is centralised: pick-mode swallows clicks atomically so the standard selection consumers do not race against teardown. - `MapPickService` (`lib/map-pick.svelte.ts`) wraps the renderer contract in a promise-shaped `pick(...)`. The in-game shell layout owns the service so sidebar and bottom-sheet inspectors see the same instance. - Debug-surface registry exposes `getMapPrimitives`, `getMapPickState`, `getMapCamera` to e2e specs without spawning a separate debug page after navigation. Pass B — cargo-route feature: - `CargoLoadType`, `setCargoRoute`, `removeCargoRoute` typed variants with `(source, loadType)` collapse rule on the order draft; round-trip through the FBS encoder/decoder. - `GameReport` decodes `routes` and the local player's drive tech for the inline reach formula (40 × drive). `applyOrderOverlay` upserts/drops route entries for valid/submitting/applied commands. - `lib/inspectors/planet/cargo-routes.svelte` renders the four-slot section. `Add` / `Edit` call `MapPickService.pick`, `Remove` emits `removeCargoRoute`. - `map/cargo-routes.ts` builds shaft + arrowhead primitives per cargo type; the map view pushes them through `setExtraPrimitives` so the renderer never re-inits Pixi on route mutations (Pixi 8 doesn't support that on a reused canvas). Docs: - `docs/cargo-routes-ux.md` covers engine semantics + UI map. - `docs/renderer.md` documents pick mode and the debug surface. - `docs/calc-bridge.md` records the Phase 16 reach waiver. - `PLAN.md` rewrites Phase 16 to reflect the foundation + feature split and the decisions baked in (map-driven picker, inline reach, optimistic overlay via `setExtraPrimitives`). Tests: - `tests/map-pick-mode.test.ts` — pure overlay-spec helper. - `tests/map-cargo-routes.test.ts` — `buildCargoRouteLines`. - `tests/inspector-planet-cargo-routes.test.ts` — slot rendering, picker invocation, collapse, cancel, remove. - Extensions to `order-draft`, `submit`, `order-load`, `order-overlay`, `state-binding`, `inspector-planet`, `inspector-overlay`, `game-shell-sidebar`, `game-shell-header`. - `tests/e2e/cargo-routes.spec.ts` — Playwright happy path: add COL, add CAP, remove COL, asserting both the inspector and the arrow count via `__galaxyDebug.getMapPrimitives()`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
496 lines
17 KiB
TypeScript
496 lines
17 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 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
|
|
// 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 { submitOrder } from "./submit";
|
|
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 type SyncStatus = "idle" | "syncing" | "synced" | "error";
|
|
|
|
export class OrderDraftStore {
|
|
commands: OrderCommand[] = $state([]);
|
|
statuses: Record<string, CommandStatus> = $state({});
|
|
updatedAt = $state(0);
|
|
status: Status = $state("idle");
|
|
error: string | null = $state(null);
|
|
|
|
/**
|
|
* 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`.
|
|
*/
|
|
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`
|
|
* 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.
|
|
*
|
|
* 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;
|
|
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.recomputeStatuses();
|
|
this.status = "ready";
|
|
} catch (err) {
|
|
if (this.destroyed) return;
|
|
this.status = "error";
|
|
this.error = err instanceof Error ? err.message : "load failed";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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") return;
|
|
this.client = opts.client;
|
|
// Guard against placeholder game ids the Phase 10 e2e specs
|
|
// still use — auto-sync needs a real UUID for the FBS request
|
|
// envelope, and a parser exception here would only be visible
|
|
// as a noisy `console.warn` deep in the layout boot path.
|
|
if (!isUuid(this.gameId)) {
|
|
this.syncStatus = "idle";
|
|
return;
|
|
}
|
|
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;
|
|
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, 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.
|
|
*
|
|
* Collapse rules:
|
|
*
|
|
* - `setProductionType` collapses by `planetNumber`: a new
|
|
* entry supersedes any prior `setProductionType` for the
|
|
* same planet, so the draft holds at most one production
|
|
* choice per planet.
|
|
* - `setCargoRoute` and `removeCargoRoute` share a collapse
|
|
* key on `(sourcePlanetNumber, loadType)` — the engine
|
|
* stores a single (planet, type) → destination mapping, so
|
|
* a newer entry for the same slot supersedes any prior
|
|
* `set` or `remove` for that slot. Different load-types or
|
|
* different sources coexist.
|
|
* - `planetRename` and `placeholder` append unconditionally;
|
|
* each rename is a distinct user-visible action.
|
|
*/
|
|
async add(command: OrderCommand): Promise<void> {
|
|
if (this.status !== "ready") return;
|
|
const removed: string[] = [];
|
|
let nextCommands: OrderCommand[];
|
|
if (command.kind === "setProductionType") {
|
|
nextCommands = [];
|
|
for (const existing of this.commands) {
|
|
if (
|
|
existing.kind === "setProductionType" &&
|
|
existing.planetNumber === command.planetNumber
|
|
) {
|
|
removed.push(existing.id);
|
|
continue;
|
|
}
|
|
nextCommands.push(existing);
|
|
}
|
|
nextCommands.push(command);
|
|
} else if (
|
|
command.kind === "setCargoRoute" ||
|
|
command.kind === "removeCargoRoute"
|
|
) {
|
|
nextCommands = [];
|
|
for (const existing of this.commands) {
|
|
if (
|
|
(existing.kind === "setCargoRoute" ||
|
|
existing.kind === "removeCargoRoute") &&
|
|
existing.sourcePlanetNumber === command.sourcePlanetNumber &&
|
|
existing.loadType === command.loadType
|
|
) {
|
|
removed.push(existing.id);
|
|
continue;
|
|
}
|
|
nextCommands.push(existing);
|
|
}
|
|
nextCommands.push(command);
|
|
} else {
|
|
nextCommands = [...this.commands, command];
|
|
}
|
|
this.commands = nextCommands;
|
|
const nextStatuses = { ...this.statuses };
|
|
for (const id of removed) {
|
|
delete nextStatuses[id];
|
|
}
|
|
nextStatuses[command.id] = validateCommand(command);
|
|
this.statuses = nextStatuses;
|
|
await this.persist();
|
|
this.scheduleSync();
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
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();
|
|
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. 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;
|
|
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();
|
|
this.scheduleSync();
|
|
}
|
|
|
|
/**
|
|
* forceSync re-runs the auto-sync without requiring a mutation.
|
|
* Used by the order tab's retry-on-error affordance.
|
|
*/
|
|
forceSync(): void {
|
|
this.scheduleSync();
|
|
}
|
|
|
|
dispose(): void {
|
|
this.destroyed = true;
|
|
this.cache = null;
|
|
this.client = null;
|
|
}
|
|
|
|
private scheduleSync(): void {
|
|
if (this.client === null) return;
|
|
// Same UUID guard as `hydrateFromServer` — placeholder game
|
|
// ids in test fixtures must not blow up the auto-sync path.
|
|
if (!isUuid(this.gameId)) 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) {
|
|
// `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;
|
|
}
|
|
|
|
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 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 = updatedAt;
|
|
}
|
|
|
|
private markRejectedInternal(ids: string[]): void {
|
|
const next = { ...this.statuses };
|
|
for (const id of ids) {
|
|
next[id] = "rejected";
|
|
}
|
|
this.statuses = next;
|
|
}
|
|
|
|
private revertSubmittingToValidInternal(): void {
|
|
const next = { ...this.statuses };
|
|
for (const cmd of this.commands) {
|
|
if (next[cmd.id] === "submitting") {
|
|
next[cmd.id] = validateCommand(cmd);
|
|
}
|
|
}
|
|
this.statuses = next;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
function isUuid(value: string): boolean {
|
|
return UUID_RE.test(value);
|
|
}
|
|
|
|
function validateCommand(cmd: OrderCommand): CommandStatus {
|
|
switch (cmd.kind) {
|
|
case "planetRename":
|
|
return validateEntityName(cmd.name).ok ? "valid" : "invalid";
|
|
case "setProductionType":
|
|
// Mirrors the engine's `subject=Production` validator
|
|
// (`game/internal/router/validator.go`): SCIENCE and SHIP
|
|
// require a non-empty entity-name-valid subject; the other
|
|
// six production types accept any subject (typically empty)
|
|
// because the engine only consults the subject for those
|
|
// two cases.
|
|
if (
|
|
cmd.productionType === "SCIENCE" ||
|
|
cmd.productionType === "SHIP"
|
|
) {
|
|
return validateEntityName(cmd.subject).ok ? "valid" : "invalid";
|
|
}
|
|
return "valid";
|
|
case "setCargoRoute":
|
|
// The picker pre-checks reach (and so refuses to emit a
|
|
// route to an unreachable destination) and the engine
|
|
// re-validates ownership / reach server-side. Locally we
|
|
// only refuse a self-route — the FBS validator
|
|
// (`pkg/model/order/order.go`) accepts every other
|
|
// (origin, destination, load_type) triple.
|
|
if (cmd.sourcePlanetNumber === cmd.destinationPlanetNumber) {
|
|
return "invalid";
|
|
}
|
|
return "valid";
|
|
case "removeCargoRoute":
|
|
// `removeCargoRoute` carries no destination; the only
|
|
// engine-side check is ownership of the source planet,
|
|
// which the inspector enforces by only mounting the
|
|
// component on `kind === "local"`.
|
|
return "valid";
|
|
case "placeholder":
|
|
// Phase 12 placeholder entries are content-free and never
|
|
// transition out of `draft` — they are not submittable.
|
|
return "draft";
|
|
}
|
|
}
|