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
+175
View File
@@ -0,0 +1,175 @@
// Map-side cargo-route arrows. Each `ReportRouteEntry` becomes a
// short arrow from the source planet to its destination, drawn as
// three `LinePrim` segments — one shaft and two arrowhead wings —
// styled per load type so the four cargo kinds are
// distinguishable at a glance. Phase 16 ships placeholder
// colours; Phase 35 polish picks final values.
//
// Geometry uses `torusShortestDelta` so an arrow that crosses the
// torus seam takes the wrap, not the long way round, matching the
// engine's reach test (`util.ShortDistance`,
// `pkg/util/map.go.deltas`).
import type { GameReport, ReportPlanet } from "../api/game-state";
import type { CargoLoadType } from "../sync/order-types";
import { torusShortestDelta } from "./math";
import type { LinePrim, PrimitiveID, Style } from "./world";
export const STYLE_ROUTE_COL: Style = {
strokeColor: 0x4fc3f7,
strokeAlpha: 0.95,
strokeWidthPx: 2,
};
export const STYLE_ROUTE_CAP: Style = {
strokeColor: 0xffb74d,
strokeAlpha: 0.95,
strokeWidthPx: 2,
};
export const STYLE_ROUTE_MAT: Style = {
strokeColor: 0x81c784,
strokeAlpha: 0.95,
strokeWidthPx: 2,
};
export const STYLE_ROUTE_EMP: Style = {
strokeColor: 0x90a4ae,
strokeAlpha: 0.85,
strokeWidthPx: 1,
};
const STYLE_BY_LOAD_TYPE: Record<CargoLoadType, Style> = {
COL: STYLE_ROUTE_COL,
CAP: STYLE_ROUTE_CAP,
MAT: STYLE_ROUTE_MAT,
EMP: STYLE_ROUTE_EMP,
};
/** Per-load-type priority. Higher wins hit-test ties; planets sit
* at 1..4 (`state-binding.ts.priorityFor`), so route arrows always
* lose to planet primitives. The internal ordering follows the
* engine's COL > CAP > MAT > EMP preference so when two arrows
* overlap exactly, the higher-priority cargo wins the click. */
const PRIORITY_BY_LOAD_TYPE: Record<CargoLoadType, number> = {
COL: 8,
CAP: 7,
MAT: 6,
EMP: 5,
};
const LOAD_TYPE_INDEX: Record<CargoLoadType, number> = {
COL: 0,
CAP: 1,
MAT: 2,
EMP: 3,
};
/** High-bit prefix on every cargo-route line id so it cannot
* collide with a planet number (planets use uint64 numbers ≪
* 2^31). The renderer's hit-test treats ids opaquely; the
* inspector never resolves a planet by a line id, so the prefix
* is internal-only. */
export const ROUTE_LINE_ID_PREFIX = 0x80000000;
const SHAFT_OFFSET = 0;
const WING_LEFT_OFFSET = 1;
const WING_RIGHT_OFFSET = 2;
/** Arrowhead size in world units. Picked so the head is visible
* at default zoom but does not eat the destination planet glyph. */
const HEAD_LENGTH_WORLD = 6;
/** Half-angle of the arrowhead opening, in radians (~25°). */
const HEAD_HALF_ANGLE = (25 * Math.PI) / 180;
/**
* buildCargoRouteLines emits one `LinePrim` per shaft + two per
* arrowhead wing for every (source, loadType, destination) entry
* in `report.routes`. Skips routes whose source or destination is
* not present in the planet list (e.g. a destination newly
* unidentified after a turn cutoff). Pure: relies only on the
* report; no DOM access; no Pixi calls.
*/
export function buildCargoRouteLines(report: GameReport): LinePrim[] {
if (report.routes.length === 0) return [];
const planetById = new Map<number, ReportPlanet>();
for (const planet of report.planets) {
planetById.set(planet.number, planet);
}
const lines: LinePrim[] = [];
for (const route of report.routes) {
const source = planetById.get(route.sourcePlanetNumber);
if (source === undefined) continue;
for (const entry of route.entries) {
const dest = planetById.get(entry.destinationPlanetNumber);
if (dest === undefined) continue;
const dx = torusShortestDelta(source.x, dest.x, report.mapWidth);
const dy = torusShortestDelta(source.y, dest.y, report.mapHeight);
const length = Math.hypot(dx, dy);
if (length === 0) continue;
const headX = source.x + dx;
const headY = source.y + dy;
const ux = dx / length;
const uy = dy / length;
const cosA = Math.cos(HEAD_HALF_ANGLE);
const sinA = Math.sin(HEAD_HALF_ANGLE);
const leftX = headX - HEAD_LENGTH_WORLD * (ux * cosA + uy * sinA);
const leftY = headY - HEAD_LENGTH_WORLD * (uy * cosA - ux * sinA);
const rightX = headX - HEAD_LENGTH_WORLD * (ux * cosA - uy * sinA);
const rightY = headY - HEAD_LENGTH_WORLD * (uy * cosA + ux * sinA);
const baseId = routeLineBaseId(
route.sourcePlanetNumber,
entry.loadType,
);
const style = STYLE_BY_LOAD_TYPE[entry.loadType];
const priority = PRIORITY_BY_LOAD_TYPE[entry.loadType];
lines.push({
kind: "line",
id: baseId + SHAFT_OFFSET,
priority,
style,
hitSlopPx: 0,
x1: source.x,
y1: source.y,
x2: headX,
y2: headY,
});
lines.push({
kind: "line",
id: baseId + WING_LEFT_OFFSET,
priority,
style,
hitSlopPx: 0,
x1: headX,
y1: headY,
x2: leftX,
y2: leftY,
});
lines.push({
kind: "line",
id: baseId + WING_RIGHT_OFFSET,
priority,
style,
hitSlopPx: 0,
x1: headX,
y1: headY,
x2: rightX,
y2: rightY,
});
}
}
return lines;
}
/** Unique numeric id for a route's three line primitives. The
* three segments occupy `baseId + 0..2`. Encoded as
* `prefix | (source << 8) | (loadTypeIndex << 4)` so a planet
* number up to 2^23 and the four load-type slots fit without
* collision. */
function routeLineBaseId(
sourcePlanetNumber: number,
loadType: CargoLoadType,
): PrimitiveID {
return (
ROUTE_LINE_ID_PREFIX |
((sourcePlanetNumber & 0x7fffff) << 8) |
(LOAD_TYPE_INDEX[loadType] << 4)
);
}