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