ui/phase-19: ship-group decoder + map binding + selection store
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>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
// 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 renders a clickable point near the planet", () => {
|
||||
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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
// 1 planet point + 1 ship-group point.
|
||||
expect(world.primitives.length).toBe(2);
|
||||
const groupPrimId = SHIP_GROUP_ID_OFFSETS.local + 0;
|
||||
const group = world.primitives.find((p) => p.id === groupPrimId);
|
||||
expect(group).toBeDefined();
|
||||
if (group?.kind !== "point") throw new Error("expected point");
|
||||
// Off-planet rendering: not exactly on (100, 100).
|
||||
expect(group.x === home.x && group.y === home.y).toBe(false);
|
||||
expect(hitLookup.get(groupPrimId)).toEqual({
|
||||
kind: "shipGroup",
|
||||
ref: { variant: "local", id: "uuid-local-1" },
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user