ui/phase-16: cargo routes inspector + map pick foundation

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>
This commit is contained in:
Ilia Denisov
2026-05-09 20:01:34 +02:00
parent 5fd67ed958
commit 7c8b5aeb23
43 changed files with 4559 additions and 98 deletions
+237 -9
View File
@@ -27,6 +27,7 @@ preference the store already manages.
minScaleNoWrap,
type RendererHandle,
} from "../../map/index";
import { buildCargoRouteLines } from "../../map/cargo-routes";
import { reportToWorld } from "../../map/state-binding";
import {
GAME_STATE_CONTEXT_KEY,
@@ -40,12 +41,35 @@ preference the store already manages.
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
MAP_PICK_CONTEXT_KEY,
MapPickService,
} from "$lib/map-pick.svelte";
import {
installRendererDebugSurface,
registerMapCameraProvider,
registerMapPickStateProvider,
registerMapPrimitivesProvider,
type MapCameraSnapshot,
type MapPickStateSnapshot,
type MapPrimitiveSnapshot,
} from "$lib/debug-surface.svelte";
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const renderedReport = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
// `MapPickService` is owned by the in-game shell layout (set on
// the context tree the inspector subsections also descend from).
// Renderer changes attach / detach via `bindResolver` so a
// remount mid-pick does not orphan a pending promise. The map
// view is mounted only beneath the layout, so the service is
// always present in production; tests render the map in isolation
// and may omit it.
const pickService = getContext<MapPickService | undefined>(
MAP_PICK_CONTEXT_KEY,
);
let canvasEl: HTMLCanvasElement | null = $state(null);
let containerEl: HTMLDivElement | null = $state(null);
@@ -56,7 +80,23 @@ preference the store already manages.
let mountedGameId: string | null = null;
let onResize: (() => void) | null = null;
let detachClick: (() => void) | null = null;
let detachDebugProviders: (() => void) | null = null;
let detachDebugSurface: (() => void) | null = null;
let mounted = false;
// Mount serialization. The `$effect` may re-fire while the
// async `mountRenderer` is mid-flight (e.g. report transitions
// from null → populated → overlay-mutated during boot). Without
// the in-progress gate, parallel `createRenderer` awaits would
// leave both old and new viewport listeners on the canvas,
// double-firing every click. The gate is intentionally a plain
// `let` (not `$state`) so reads from the effect do not register
// as a reactive dependency.
let mountInProgress = false;
let pendingMountSignal = $state(0);
// Track the latest cargo-route fingerprint we pushed to the
// renderer so a no-op push (e.g. report refresh that yields the
// same overlay) doesn't churn Pixi graphics needlessly.
let lastExtrasFingerprint: string | null = null;
$effect(() => {
// Read the overlay-applied report so the map labels reflect
@@ -72,31 +112,102 @@ preference the store already manages.
if (!mounted || canvasEl === null || containerEl === null) return;
if (status !== "ready" || !report) return;
// Skip a re-mount when the same turn is reloaded for the same
// game and the wrap mode did not change. The store's `refresh`
// path lands here on tab focus; an unchanged snapshot must not
// flicker the canvas.
// Cargo-route arrows are pushed onto the live renderer via
// `setExtraPrimitives` so the overlay can change inside a
// single turn without disposing the Pixi `Application` —
// Pixi 8 does not reliably re-init on the same canvas. The
// fingerprint guard avoids redundant Pixi rebuilds when the
// overlay computation re-runs but the routes content is
// unchanged (e.g. status transitions valid → submitting →
// applied for the same command).
const extrasFingerprint = computeRoutesFingerprint(report.routes);
const sameSnapshot =
mountedTurn === report.turn &&
mountedGameId === gameId &&
handle !== null &&
handle.getMode() === mode;
if (sameSnapshot) return;
if (sameSnapshot) {
if (lastExtrasFingerprint !== extrasFingerprint) {
untrack(() => {
handle?.setExtraPrimitives(buildCargoRouteLines(report));
});
lastExtrasFingerprint = extrasFingerprint;
}
return;
}
// Read the pending-mount signal so the effect re-runs after
// the in-flight mount completes (it bumps the signal in its
// finally block). Without this, a dep change observed while
// `mountInProgress` is true would be silently dropped.
void pendingMountSignal;
if (mountInProgress) return;
untrack(() => {
void mountRenderer(report, mode);
void runSerializedMount(report, mode, extrasFingerprint);
});
});
async function runSerializedMount(
report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap",
routesFingerprint: string,
): Promise<void> {
mountInProgress = true;
try {
await mountRenderer(report, mode, routesFingerprint);
} finally {
mountInProgress = false;
// Bump the reactive signal so any dep change observed
// while the gate was up gets a fresh effect run with the
// current state.
pendingMountSignal += 1;
}
}
function computeRoutesFingerprint(
routes: NonNullable<GameStateStore["report"]>["routes"],
): string {
if (routes.length === 0) return "";
const parts = routes.map((route) => {
const entries = route.entries
.map((entry) => `${entry.loadType}->${entry.destinationPlanetNumber}`)
.join(",");
return `${route.sourcePlanetNumber}:${entries}`;
});
return parts.join(";");
}
async function mountRenderer(
report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap",
routesFingerprint: string,
): Promise<void> {
if (canvasEl === null || containerEl === null) return;
// Capture camera state before disposing so a remount inside
// the same game (e.g. cargo-route overlay change) keeps the
// user's pan/zoom. A new game / first mount has no prior
// camera, so `previousCamera` stays null and the default
// centring path runs.
const previousGameId = mountedGameId;
const targetGameId = store?.gameId ?? "";
const previousCamera =
handle !== null && previousGameId === targetGameId
? handle.getCamera()
: null;
if (detachClick !== null) {
detachClick();
detachClick = null;
}
// Detach the previous resolver before disposing — the
// renderer's `dispose` already calls `onPick(null)` on any
// open session, which `bindResolver(null)` would also do, so
// we route the cancel through one path only.
pickService?.bindResolver(null);
if (detachDebugProviders !== null) {
detachDebugProviders();
detachDebugProviders = null;
}
if (handle !== null) {
handle.dispose();
handle = null;
@@ -109,7 +220,6 @@ preference the store already manages.
mode,
preference: ["webgpu", "webgl"],
});
handle.viewport.moveCenter(world.width / 2, world.height / 2);
const minScale = minScaleNoWrap(
{
widthPx: containerEl.clientWidth,
@@ -117,12 +227,113 @@ preference the store already manages.
},
world,
);
handle.viewport.setZoom(minScale * 1.05, true);
if (previousCamera !== null) {
// Same-game remount — preserve pan/zoom. Clamp zoom
// to `minScale` so a remount that re-derives the
// minimum (e.g. a viewport resize between renderers)
// does not strand the user below the current floor.
handle.viewport.moveCenter(
previousCamera.centerX,
previousCamera.centerY,
);
handle.viewport.setZoom(
Math.max(previousCamera.scale, minScale),
true,
);
} else {
handle.viewport.moveCenter(world.width / 2, world.height / 2);
handle.viewport.setZoom(minScale * 1.05, true);
}
if (mode === "no-wrap") handle.setMode("no-wrap");
detachClick = handle.onClick(handleMapClick);
pickService?.bindResolver(({ sourcePlanetNumber, reachableIds, onResolve }) => {
if (handle === null) {
onResolve(null);
return null;
}
const planet = report.planets.find(
(p) => p.number === sourcePlanetNumber,
);
if (planet === undefined) {
onResolve(null);
return null;
}
return handle.setPickMode({
sourcePrimitiveId: sourcePlanetNumber,
sourceX: planet.x,
sourceY: planet.y,
reachableIds,
onPick: onResolve,
});
});
const detachPrim = registerMapPrimitivesProvider(() => {
const h = handle;
if (h === null) return [];
return h.getPrimitives().map<MapPrimitiveSnapshot>((p) => ({
id: p.id,
kind: p.kind,
priority: p.priority,
alpha: h.getPrimitiveAlpha(p.id),
fillColor: p.style.fillColor ?? null,
strokeColor: p.style.strokeColor ?? null,
x: p.kind === "point" ? p.x : null,
y: p.kind === "point" ? p.y : null,
}));
});
const detachPick = registerMapPickStateProvider(() => {
const h = handle;
if (h === null) {
return {
active: false,
sourcePlanetNumber: null,
reachableIds: [],
hoveredId: null,
} satisfies MapPickStateSnapshot;
}
const state = h.getPickState();
return {
active: state.active,
sourcePlanetNumber:
state.sourcePrimitiveId === null
? null
: Number(state.sourcePrimitiveId),
reachableIds:
state.reachableIds === null
? []
: Array.from(state.reachableIds).map((id) => Number(id)),
hoveredId:
state.hoveredId === null ? null : Number(state.hoveredId),
} satisfies MapPickStateSnapshot;
});
const detachCamera = registerMapCameraProvider(() => {
const h = handle;
if (h === null) return null;
const camera = h.getCamera();
const viewport = h.getViewport();
const rect = canvasEl?.getBoundingClientRect();
return {
camera,
viewport,
canvasOrigin: {
x: rect?.left ?? 0,
y: rect?.top ?? 0,
},
} satisfies MapCameraSnapshot;
});
detachDebugProviders = (): void => {
detachPrim();
detachPick();
detachCamera();
};
mountedTurn = report.turn;
mountedGameId = store?.gameId ?? "";
mountedGameId = targetGameId;
// Initial mount carries no extras yet; the post-mount
// effect run pushes the current cargo-route lines via
// `setExtraPrimitives` once `lastExtrasFingerprint`
// disagrees with the freshly computed fingerprint.
lastExtrasFingerprint = null;
mountError = null;
void routesFingerprint;
} catch (err) {
mountError = err instanceof Error ? err.message : String(err);
}
@@ -154,6 +365,14 @@ preference the store already manages.
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
};
window.addEventListener("resize", onResize);
// In DEV the in-game shell mounts on a fresh document load
// (`page.goto`), which discards anything the
// `/__debug/store` route may have installed earlier in the
// session. The renderer-side accessors are still useful for
// e2e specs driving the map, so we install them here too.
if (import.meta.env.DEV) {
detachDebugSurface = installRendererDebugSurface();
}
});
onDestroy(() => {
@@ -166,6 +385,15 @@ preference the store already manages.
detachClick();
detachClick = null;
}
pickService?.bindResolver(null);
if (detachDebugProviders !== null) {
detachDebugProviders();
detachDebugProviders = null;
}
if (detachDebugSurface !== null) {
detachDebugSurface();
detachDebugSurface = null;
}
if (handle !== null) {
handle.dispose();
handle = null;