676556db4e
Wires Phase 19's data and rendering layers without yet adding the
inspector UI:
- game-state.ts grows ReportLocalShipGroup / ReportOtherShipGroup
/ ReportIncomingShipGroup / ReportUnidentifiedShipGroup /
ReportLocalFleet types and walks the matching FlatBuffers
vectors (LocalGroup, OtherGroup, IncomingGroup,
UnidentifiedGroup, LocalFleet) inside decodeReport. The Tech
map is folded into the fixed-shape ShipGroupTech struct;
cargo strings normalise to the closed CargoLoadType | "NONE"
union; UUIDs come back as canonical 36-char strings.
- synthetic-report.ts mirrors the new fields so the DEV-only
lobby loader can feed JSON produced by legacy-report-to-json
straight into the live UI surface.
- selection.svelte.ts widens its discriminated union with a
`kind: "shipGroup"` branch carrying a ShipGroupRef
(local UUID / other / incoming / unidentified by index).
- world.ts adds Style.strokeDashPx and render.ts.drawLine
honours it via manual segmentation (PixiJS v8 has no native
dash API). Ignored on points and circles.
- state-binding.ts now returns { world, hitLookup }: the
hit-lookup map keys every primitive id back to a concrete
HitTarget so the click handler can dispatch to selectPlanet
or selectShipGroup. Ship-group primitives live in a separate
ship-groups.ts that emits one point per local / other /
unidentified group, plus a dashed origin→destination line +
clickable point per incoming group. Position is interpolated
along the trajectory for in-hyperspace groups.
- map.svelte threads the hitLookup into handleMapClick.
Vitest:
- tests/helpers/empty-ship-groups.ts exposes EMPTY_SHIP_GROUPS
so existing fixtures can spread the new five empty arrays
without enumerating every field.
- state-binding-groups.test.ts covers each group variant's
primitive geometry and lookup correctness.
- All previously-existing fixture builders pick up the spread
so GameReport stays a complete object.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
244 lines
6.7 KiB
TypeScript
244 lines
6.7 KiB
TypeScript
// 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";
|
||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||
|
||
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,
|
||
localPlayerWeapons: 0,
|
||
localPlayerShields: 0,
|
||
localPlayerCargo: 0,
|
||
...EMPTY_SHIP_GROUPS,
|
||
};
|
||
}
|
||
|
||
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,
|
||
localPlayerWeapons: 0,
|
||
localPlayerShields: 0,
|
||
localPlayerCargo: 0,
|
||
...EMPTY_SHIP_GROUPS,
|
||
};
|
||
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);
|
||
});
|
||
});
|