54733bfb14
Send joins Modernize / Dismantle / Transfer as a lockable command: once any of the four lands in the draft for a group, every action button on its inspector is disabled with a "command pending" tooltip and the banner names the queued kind. Load / Unload / Split / Join Fleet stay non-locking — they stack legitimately on the engine side. Two dashed overlays now run alongside the cargo-route arrows: - Yellow dashed track for own in-space groups, drawn from the origin planet to the destination (matches the in-space point colour so eye reads both as one entity). - Green dashed track for every wire-valid sendShipGroup command in the order draft, drawn from the source group's orbit planet to the chosen destination. Disappears when the command is removed from the order tab, when the engine rejects it, or when the group has left orbit (in-space track replaces it). Both tracks are wrap-aware via torusShortestDelta and never participate in hit-test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
260 lines
7.5 KiB
TypeScript
260 lines
7.5 KiB
TypeScript
// Vitest coverage for the Phase 19 ship-group → World binding. The
|
|
// `reportToWorld` function now blends planet and ship-group
|
|
// primitives in one pass and returns a hitLookup map keyed by the
|
|
// primitive id; these tests assert that each ship-group variant
|
|
// (own on-planet, own in-hyperspace, foreign in-hyperspace,
|
|
// incoming, unidentified) shows up with the expected position,
|
|
// style, priority, and lookup entry.
|
|
|
|
import "@testing-library/jest-dom/vitest";
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import type {
|
|
GameReport,
|
|
ReportPlanet,
|
|
} from "../src/api/game-state";
|
|
import { reportToWorld } from "../src/map/state-binding";
|
|
import { SHIP_GROUP_ID_OFFSETS } from "../src/map/ship-groups";
|
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
|
|
|
function planet(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(overrides: Partial<GameReport> = {}): GameReport {
|
|
return {
|
|
turn: 1,
|
|
mapWidth: 1000,
|
|
mapHeight: 1000,
|
|
planetCount: 0,
|
|
planets: [],
|
|
race: "Earthlings",
|
|
localShipClass: [],
|
|
routes: [],
|
|
localPlayerDrive: 0,
|
|
localPlayerWeapons: 0,
|
|
localPlayerShields: 0,
|
|
localPlayerCargo: 0,
|
|
...EMPTY_SHIP_GROUPS,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("reportToWorld — ship groups", () => {
|
|
test("on-planet local group is NOT rendered on the map (planet inspector hosts it)", () => {
|
|
const home = planet({ number: 17, x: 100, y: 100, kind: "local" });
|
|
const { world, hitLookup } = reportToWorld(
|
|
makeReport({
|
|
planets: [home],
|
|
localShipGroups: [
|
|
{
|
|
id: "uuid-local-1",
|
|
count: 2,
|
|
class: "Frontier",
|
|
tech: { drive: 5, weapons: 0, shields: 0, cargo: 1 },
|
|
cargo: "NONE",
|
|
load: 0,
|
|
destination: 17,
|
|
origin: null,
|
|
range: null,
|
|
speed: 0,
|
|
mass: 12,
|
|
state: "In_Orbit",
|
|
fleet: null,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
// Only the planet itself contributes a primitive; the on-planet
|
|
// group is intentionally invisible on the map. Phase 19's
|
|
// `lib/inspectors/planet/ship-groups.svelte` lists it inside the
|
|
// planet inspector instead.
|
|
expect(world.primitives.length).toBe(1);
|
|
expect(hitLookup.has(SHIP_GROUP_ID_OFFSETS.local + 0)).toBe(false);
|
|
expect(hitLookup.get(17)).toEqual({ kind: "planet", number: 17 });
|
|
});
|
|
|
|
test("in-hyperspace local group renders at the interpolated position", () => {
|
|
const dest = planet({ number: 1, x: 0, y: 0 });
|
|
const orig = planet({ number: 2, x: 100, y: 0 });
|
|
const { world } = reportToWorld(
|
|
makeReport({
|
|
planets: [dest, orig],
|
|
localShipGroups: [
|
|
{
|
|
id: "uuid-local-fly",
|
|
count: 1,
|
|
class: "Cruiser",
|
|
tech: { drive: 10, weapons: 0, shields: 0, cargo: 0 },
|
|
cargo: "NONE",
|
|
load: 0,
|
|
destination: 1,
|
|
origin: 2,
|
|
range: 25,
|
|
speed: 0,
|
|
mass: 50,
|
|
state: "In_Space",
|
|
fleet: null,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const groupPrimId = SHIP_GROUP_ID_OFFSETS.local + 0;
|
|
const group = world.primitives.find((p) => p.id === groupPrimId);
|
|
if (group?.kind !== "point") throw new Error("expected point");
|
|
// dest=(0,0), orig=(100,0), range=25 → 25 units toward orig from
|
|
// dest along the segment of length 100 → (25, 0).
|
|
expect(group.x).toBe(25);
|
|
expect(group.y).toBe(0);
|
|
|
|
// Yellow dashed track from origin to destination matches the
|
|
// in-space point colour.
|
|
const lineId = SHIP_GROUP_ID_OFFSETS.localLine + 0;
|
|
const line = world.primitives.find((p) => p.id === lineId);
|
|
if (line?.kind !== "line") throw new Error("expected line");
|
|
expect(line.x1).toBe(100);
|
|
expect(line.y1).toBe(0);
|
|
expect(line.x2).toBe(0);
|
|
expect(line.y2).toBe(0);
|
|
expect(line.style.strokeColor).toBe(0xfff176);
|
|
expect(line.style.strokeDashPx).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("incoming-group line crosses the torus seam via the shortest path", () => {
|
|
const dest = planet({ number: 1, x: 5, y: 50 });
|
|
const orig = planet({ number: 9, x: 95, y: 50 });
|
|
const { world } = reportToWorld(
|
|
makeReport({
|
|
mapWidth: 100,
|
|
mapHeight: 100,
|
|
planets: [dest, orig],
|
|
incomingShipGroups: [
|
|
{
|
|
origin: 9,
|
|
destination: 1,
|
|
distance: 5,
|
|
speed: 5,
|
|
mass: 1,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const line = world.primitives.find(
|
|
(p) => p.id === SHIP_GROUP_ID_OFFSETS.incomingLine + 0,
|
|
);
|
|
if (line?.kind !== "line") throw new Error("expected line");
|
|
// Origin (95) → unwrapped destination at 105 (origin.x + (-10) is
|
|
// the no-wrap path). The shortest delta from 95 to 5 on width 100
|
|
// is +10, so we expect line.x2 = 95 + 10 = 105.
|
|
expect(line.x1).toBe(95);
|
|
expect(line.x2).toBe(105);
|
|
});
|
|
|
|
test("incoming group emits one dashed line + one clickable point", () => {
|
|
const dest = planet({ number: 1, x: 0, y: 0 });
|
|
const orig = planet({ number: 9, x: 100, y: 0 });
|
|
const { world, hitLookup } = reportToWorld(
|
|
makeReport({
|
|
planets: [dest, orig],
|
|
incomingShipGroups: [
|
|
{
|
|
origin: 9,
|
|
destination: 1,
|
|
distance: 40,
|
|
speed: 20,
|
|
mass: 4,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const lineId = SHIP_GROUP_ID_OFFSETS.incomingLine + 0;
|
|
const pointId = SHIP_GROUP_ID_OFFSETS.incoming + 0;
|
|
const line = world.primitives.find((p) => p.id === lineId);
|
|
if (line?.kind !== "line") throw new Error("expected line for incoming");
|
|
expect(line.x1).toBe(100); // origin
|
|
expect(line.x2).toBe(0); // destination
|
|
expect(line.style.strokeDashPx).toBeGreaterThan(0);
|
|
const point = world.primitives.find((p) => p.id === pointId);
|
|
if (point?.kind !== "point") throw new Error("expected point for incoming");
|
|
expect(point.x).toBe(40); // distance=40 from dest along line of len 100
|
|
expect(point.y).toBe(0);
|
|
// Hit lookup is registered only for the clickable point, not
|
|
// the dashed trajectory line.
|
|
expect(hitLookup.get(pointId)).toEqual({
|
|
kind: "shipGroup",
|
|
ref: { variant: "incoming", index: 0 },
|
|
});
|
|
expect(hitLookup.has(lineId)).toBe(false);
|
|
});
|
|
|
|
test("unidentified group renders at its absolute coordinates", () => {
|
|
const { world, hitLookup } = reportToWorld(
|
|
makeReport({
|
|
unidentifiedShipGroups: [{ x: 555, y: 222 }],
|
|
}),
|
|
);
|
|
const id = SHIP_GROUP_ID_OFFSETS.unidentified + 0;
|
|
const point = world.primitives.find((p) => p.id === id);
|
|
if (point?.kind !== "point") throw new Error("expected point");
|
|
expect(point.x).toBe(555);
|
|
expect(point.y).toBe(222);
|
|
expect(hitLookup.get(id)).toEqual({
|
|
kind: "shipGroup",
|
|
ref: { variant: "unidentified", index: 0 },
|
|
});
|
|
});
|
|
|
|
test("group whose destination is missing from the report is dropped", () => {
|
|
const { world } = reportToWorld(
|
|
makeReport({
|
|
planets: [],
|
|
localShipGroups: [
|
|
{
|
|
id: "uuid-orphan",
|
|
count: 1,
|
|
class: "Drone",
|
|
tech: { drive: 1, weapons: 0, shields: 0, cargo: 0 },
|
|
cargo: "NONE",
|
|
load: 0,
|
|
destination: 999, // not in planets
|
|
origin: null,
|
|
range: null,
|
|
speed: 0,
|
|
mass: 1,
|
|
state: "In_Orbit",
|
|
fleet: null,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
// Only the (empty) planet list contributes — no group primitive.
|
|
expect(world.primitives.length).toBe(0);
|
|
});
|
|
|
|
test("planet hitLookup entries are registered alongside ship groups", () => {
|
|
const { hitLookup } = reportToWorld(
|
|
makeReport({
|
|
planets: [planet({ number: 42, x: 0, y: 0, kind: "local" })],
|
|
}),
|
|
);
|
|
expect(hitLookup.get(42)).toEqual({ kind: "planet", number: 42 });
|
|
});
|
|
});
|