eb5018342e
* Bug fix: theme flip no longer leaves planets oversized. The camera-preserving remount now calls a new `RendererHandle.refreshCameraDerivedDraws` explicitly after the manual moveCenter/setZoom pair so the post-mount geometry tracks `viewport.scaled` even if pixi-viewport's `'zoomed'` listener races the next Ticker tick. * Doc #3: clicks on a planet label route through the same hit-test path as a click on the disc. The label `Container` now has a pointer hit area sized to the text + frame padding; pointertap simulates a click at the planet centre, so selection and pick-mode resolution behave identically. * Doc #4: battle X-crosses + cargo arrowhead wings grow sub-linearly with zoom (PLANET_SIZE_ZOOM_ALPHA). New `Style.softLengthAnchor` ('center' / 'start') makes the renderer treat the recorded endpoints as the geometry "at the reference scale" and rescale around the midpoint (X-cross) or the start endpoint (arrow wings). Arrowhead base length is halved from 6 to 3 world units to match the owner's "in half" request. * Doc #5: picker overlay loses the anchor ring at the source, the cursor line drops to a cargo-route-thin 0.6 px stroke, and the hover ring around the destination is replaced by a planet-style outline (visible disc + 1 px padding) in the `pickHighlight` accent — so candidate destinations read like selection in warm yellow. * Doc #6: regression test pins the in-disc hit zone. * Perf #1: camera-driven redraws are throttled onto the next Ticker tick. A rapid wheel / pinch burst now coalesces into at most one `clear() + redraw` pass per painted frame, which keeps the 500-planet map responsive on zoom and toggle flips. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
270 lines
7.9 KiB
TypeScript
270 lines
7.9 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,
|
||
buildCargoRouteLines,
|
||
} from "../src/map/cargo-routes";
|
||
import { DARK_THEME, LIGHT_THEME } from "../src/map/world";
|
||
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);
|
||
// F8-12 / #4 follow-up: shafts and wings now use different
|
||
// Style objects so the arrowhead wings can carry
|
||
// `softLengthAnchor: "start"`. Colour / priority remain shared
|
||
// across both, which is what the de-dupe loop here verifies.
|
||
const colourByPriority = new Map<number, number | undefined>();
|
||
const softLengthByLineId = new Map<number, string | undefined>();
|
||
for (const line of lines) {
|
||
const existing = colourByPriority.get(line.priority);
|
||
if (existing === undefined) {
|
||
colourByPriority.set(line.priority, line.style.strokeColor);
|
||
} else {
|
||
expect(line.style.strokeColor).toBe(existing);
|
||
}
|
||
softLengthByLineId.set(line.id & 0xf, line.style.softLengthAnchor);
|
||
}
|
||
// Shaft (offset 0) stays linear; wings (offsets 1/2) get the
|
||
// new softening anchor so the arrowhead grows sub-linearly.
|
||
expect(softLengthByLineId.get(0)).toBeUndefined();
|
||
expect(softLengthByLineId.get(1)).toBe("start");
|
||
expect(softLengthByLineId.get(2)).toBe("start");
|
||
// Default (dark) palette colours, one per load type.
|
||
expect(colourByPriority.get(8)).toBe(DARK_THEME.routeCol);
|
||
expect(colourByPriority.get(7)).toBe(DARK_THEME.routeCap);
|
||
expect(colourByPriority.get(6)).toBe(DARK_THEME.routeMat);
|
||
expect(colourByPriority.get(5)).toBe(DARK_THEME.routeEmp);
|
||
});
|
||
|
||
test("uses the supplied palette's stroke colours", () => {
|
||
const report = makeReport(
|
||
[
|
||
makePlanet({ number: 1, x: 100, y: 100 }),
|
||
makePlanet({ number: 2, x: 200, y: 100 }),
|
||
],
|
||
1,
|
||
[{ loadType: "COL", destinationPlanetNumber: 2 }],
|
||
);
|
||
const [shaft] = buildCargoRouteLines(report, undefined, LIGHT_THEME);
|
||
expect(shaft.style.strokeColor).toBe(LIGHT_THEME.routeCol);
|
||
expect(LIGHT_THEME.routeCol).not.toBe(DARK_THEME.routeCol);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|