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
+234
View File
@@ -0,0 +1,234 @@
// Pure-function coverage for `map/cargo-routes.ts.buildCargoRouteLines`.
// The renderer turns each `ReportRouteEntry` into one shaft plus two
// arrowhead wings; the tests assert geometry on a flat fixture, on a
// torus seam-crossing fixture, and the per-load-type style/priority
// mapping. Pixi-free — the helper is a pure projection of the report.
import { describe, expect, test } from "vitest";
import type {
GameReport,
ReportPlanet,
ReportRouteEntry,
} from "../src/api/game-state";
import {
ROUTE_LINE_ID_PREFIX,
STYLE_ROUTE_CAP,
STYLE_ROUTE_COL,
STYLE_ROUTE_EMP,
STYLE_ROUTE_MAT,
buildCargoRouteLines,
} from "../src/map/cargo-routes";
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
return {
number: 0,
name: "",
x: 0,
y: 0,
kind: "local",
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
...overrides,
};
}
function makeReport(
planets: ReportPlanet[],
source: number,
entries: ReportRouteEntry[],
mapWidth = 1000,
mapHeight = 1000,
): GameReport {
return {
turn: 1,
mapWidth,
mapHeight,
planetCount: planets.length,
planets,
race: "Earthlings",
localShipClass: [],
routes: [{ sourcePlanetNumber: source, entries }],
localPlayerDrive: 1,
};
}
describe("buildCargoRouteLines", () => {
test("emits one shaft + two wings per route entry", () => {
const report = makeReport(
[
makePlanet({ number: 1, x: 100, y: 100 }),
makePlanet({ number: 2, x: 300, y: 100 }),
],
1,
[{ loadType: "COL", destinationPlanetNumber: 2 }],
);
const lines = buildCargoRouteLines(report);
expect(lines.length).toBe(3);
expect(lines.every((l) => l.kind === "line")).toBe(true);
});
test("shaft endpoints follow the no-wrap straight line", () => {
const report = makeReport(
[
makePlanet({ number: 1, x: 100, y: 100 }),
makePlanet({ number: 2, x: 300, y: 100 }),
],
1,
[{ loadType: "COL", destinationPlanetNumber: 2 }],
);
const [shaft] = buildCargoRouteLines(report);
expect(shaft).toBeDefined();
if (shaft === undefined) return;
expect(shaft.x1).toBe(100);
expect(shaft.y1).toBe(100);
expect(shaft.x2).toBe(300);
expect(shaft.y2).toBe(100);
});
test("shaft uses the torus-shortest delta on the seam", () => {
// Source at x=950, dest at x=50 in a world 1000 wide. The
// shorter wrap is +100 (right past x=1000 to x=1050), not
// 900 (left to x=50).
const report = makeReport(
[
makePlanet({ number: 1, x: 950, y: 500 }),
makePlanet({ number: 2, x: 50, y: 500 }),
],
1,
[{ loadType: "MAT", destinationPlanetNumber: 2 }],
1000,
1000,
);
const [shaft] = buildCargoRouteLines(report);
expect(shaft).toBeDefined();
if (shaft === undefined) return;
expect(shaft.x1).toBe(950);
expect(shaft.x2).toBe(1050); // 950 + 100
expect(shaft.y2).toBe(500);
});
test("each load type maps to the documented style and priority", () => {
const report = makeReport(
[
makePlanet({ number: 1, x: 100, y: 100 }),
makePlanet({ number: 2, x: 200, y: 100 }),
makePlanet({ number: 3, x: 300, y: 100 }),
makePlanet({ number: 4, x: 400, y: 100 }),
makePlanet({ number: 5, x: 500, y: 100 }),
],
1,
[
{ loadType: "COL", destinationPlanetNumber: 2 },
{ loadType: "CAP", destinationPlanetNumber: 3 },
{ loadType: "MAT", destinationPlanetNumber: 4 },
{ loadType: "EMP", destinationPlanetNumber: 5 },
],
);
const lines = buildCargoRouteLines(report);
expect(lines.length).toBe(12);
const styleByPriority = new Map<number, typeof lines[number]["style"]>();
for (const line of lines) {
const existing = styleByPriority.get(line.priority);
if (existing === undefined) styleByPriority.set(line.priority, line.style);
else expect(existing).toBe(line.style);
}
expect(styleByPriority.get(8)).toBe(STYLE_ROUTE_COL);
expect(styleByPriority.get(7)).toBe(STYLE_ROUTE_CAP);
expect(styleByPriority.get(6)).toBe(STYLE_ROUTE_MAT);
expect(styleByPriority.get(5)).toBe(STYLE_ROUTE_EMP);
});
test("line ids carry the ROUTE_LINE_ID_PREFIX high bit", () => {
const report = makeReport(
[
makePlanet({ number: 1, x: 100, y: 100 }),
makePlanet({ number: 2, x: 200, y: 100 }),
],
1,
[{ loadType: "COL", destinationPlanetNumber: 2 }],
);
const lines = buildCargoRouteLines(report);
for (const line of lines) {
expect((line.id & ROUTE_LINE_ID_PREFIX) !== 0).toBe(true);
}
// Three distinct ids — one per segment.
const ids = new Set(lines.map((l) => l.id));
expect(ids.size).toBe(3);
});
test("skips routes whose source or destination is missing", () => {
const report = makeReport(
[makePlanet({ number: 1, x: 100, y: 100 })],
1,
[
{ loadType: "COL", destinationPlanetNumber: 999 }, // unknown dest
],
);
expect(buildCargoRouteLines(report).length).toBe(0);
});
test("skips zero-length routes (source == destination coords)", () => {
const report = makeReport(
[
makePlanet({ number: 1, x: 100, y: 100 }),
makePlanet({ number: 2, x: 100, y: 100 }),
],
1,
[{ loadType: "COL", destinationPlanetNumber: 2 }],
);
expect(buildCargoRouteLines(report).length).toBe(0);
});
test("returns an empty array when no routes are configured", () => {
const report: GameReport = {
turn: 1,
mapWidth: 1000,
mapHeight: 1000,
planetCount: 1,
planets: [makePlanet({ number: 1, x: 100, y: 100 })],
race: "Earthlings",
localShipClass: [],
routes: [],
localPlayerDrive: 1,
};
expect(buildCargoRouteLines(report)).toEqual([]);
});
test("arrowhead wings symmetric around the shaft direction", () => {
const report = makeReport(
[
makePlanet({ number: 1, x: 0, y: 0 }),
makePlanet({ number: 2, x: 100, y: 0 }),
],
1,
[{ loadType: "COL", destinationPlanetNumber: 2 }],
);
const [shaft, leftWing, rightWing] = buildCargoRouteLines(report);
expect(shaft).toBeDefined();
expect(leftWing).toBeDefined();
expect(rightWing).toBeDefined();
if (
shaft === undefined ||
leftWing === undefined ||
rightWing === undefined
)
return;
// Both wings start at the head.
expect(leftWing.x1).toBe(shaft.x2);
expect(leftWing.y1).toBe(shaft.y2);
expect(rightWing.x1).toBe(shaft.x2);
expect(rightWing.y1).toBe(shaft.y2);
// And land symmetrically around the y axis (shaft along +x).
expect(leftWing.y2 + rightWing.y2).toBeCloseTo(0);
expect(leftWing.x2).toBeCloseTo(rightWing.x2);
});
});