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:
Ilia Denisov
2026-05-10 13:23:56 +02:00
parent 8839f46c25
commit 676556db4e
18 changed files with 1085 additions and 44 deletions
@@ -35,6 +35,7 @@ import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import type { IDBPDatabase } from "idb";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
@@ -109,6 +110,7 @@ function makeReport(localShipClass: ShipClassSummary[] = []): GameReport {
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
};
}
@@ -312,6 +314,7 @@ describe("ship-class designer preview pane (Phase 18)", () => {
localPlayerWeapons: 1,
localPlayerShields: 1,
localPlayerCargo: 1.2,
...EMPTY_SHIP_GROUPS,
};
const ui = mountDesigner({ report, core });
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
@@ -379,6 +382,7 @@ describe("ship-class designer preview pane (Phase 18)", () => {
localPlayerWeapons: 1,
localPlayerShields: 1,
localPlayerCargo: 1,
...EMPTY_SHIP_GROUPS,
};
const ui = mountDesigner({ report, core });
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
@@ -23,6 +23,7 @@ import {
GAME_STATE_CONTEXT_KEY,
GameStateStore,
} from "../src/lib/game-state.svelte";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
function withGameState(opts: {
gameName?: string;
@@ -45,6 +46,7 @@ function withGameState(opts: {
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
};
store.status = "ready";
}
@@ -23,6 +23,7 @@ import {
SELECTION_CONTEXT_KEY,
SelectionStore,
} from "../src/lib/selection.svelte";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
import {
RENDERED_REPORT_CONTEXT_KEY,
createRenderedReportSource,
@@ -79,6 +80,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
};
}
@@ -0,0 +1,27 @@
// EMPTY_SHIP_GROUPS supplies empty arrays for the five ship-group /
// fleet fields added to GameReport in Phase 19. Test fixtures spread
// it into their report objects so the fixture body still focuses on
// the fields under test, without forcing every spec to enumerate
// the full GameReport surface.
import type {
ReportIncomingShipGroup,
ReportLocalFleet,
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportUnidentifiedShipGroup,
} from "../../src/api/game-state";
export const EMPTY_SHIP_GROUPS: {
localShipGroups: ReportLocalShipGroup[];
otherShipGroups: ReportOtherShipGroup[];
incomingShipGroups: ReportIncomingShipGroup[];
unidentifiedShipGroups: ReportUnidentifiedShipGroup[];
localFleets: ReportLocalFleet[];
} = {
localShipGroups: [],
otherShipGroups: [],
incomingShipGroups: [],
unidentifiedShipGroups: [],
localFleets: [],
};
@@ -31,6 +31,7 @@ import { i18n } from "../src/lib/i18n/index.svelte";
import type { GameReport, ReportPlanet } from "../src/api/game-state";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB } from "../src/platform/store/idb";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
let dbName: string;
@@ -86,6 +87,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
};
}
@@ -19,6 +19,7 @@ import {
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 {
@@ -61,6 +62,7 @@ function makeReport(
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
};
}
@@ -205,6 +207,7 @@ describe("buildCargoRouteLines", () => {
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
};
expect(buildCargoRouteLines(report)).toEqual([]);
});
+2
View File
@@ -17,6 +17,7 @@ import type {
OrderCommand,
ProductionType,
} from "../src/sync/order-types";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
return {
@@ -53,6 +54,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
};
}
@@ -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 });
});
});
+10 -8
View File
@@ -11,6 +11,7 @@ import { describe, expect, test } from "vitest";
import type { GameReport, ReportPlanet } from "../src/api/game-state";
import { reportToWorld } from "../src/map/state-binding";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
return {
@@ -26,6 +27,7 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
...overrides,
};
}
@@ -55,13 +57,13 @@ function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
describe("reportToWorld", () => {
test("uses report dimensions for the World", () => {
const world = reportToWorld(makeReport({ mapWidth: 3200, mapHeight: 1600 }));
const { world } = reportToWorld(makeReport({ mapWidth: 3200, mapHeight: 1600 }));
expect(world.width).toBe(3200);
expect(world.height).toBe(1600);
});
test("emits one Point primitive per planet across all four kinds", () => {
const world = reportToWorld(
const { world } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, name: "Home", x: 100, y: 100, kind: "local", size: 12, resources: 0.5 }),
@@ -78,7 +80,7 @@ describe("reportToWorld", () => {
});
test("propagates planet number as primitive id and coordinates verbatim", () => {
const world = reportToWorld(
const { world } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 42, name: "Home", x: 123.5, y: 456.25, kind: "local", size: 10, resources: 0.5 }),
@@ -95,7 +97,7 @@ describe("reportToWorld", () => {
});
test("uses distinct styles for each planet kind", () => {
const world = reportToWorld(
const { world } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, name: "L", kind: "local", size: 1, resources: 0 }),
@@ -111,14 +113,14 @@ describe("reportToWorld", () => {
});
test("zero-planet report yields an empty primitive list and well-formed World", () => {
const world = reportToWorld(makeReport({ planets: [] }));
const { world } = reportToWorld(makeReport({ planets: [] }));
expect(world.primitives.length).toBe(0);
expect(world.width).toBeGreaterThan(0);
expect(world.height).toBeGreaterThan(0);
});
test("guards against zero / negative dimensions in the report", () => {
const world = reportToWorld(
const { world } = reportToWorld(
makeReport({ mapWidth: 0, mapHeight: -1, planets: [] }),
);
// World's constructor rejects non-positive dimensions; the
@@ -129,7 +131,7 @@ describe("reportToWorld", () => {
});
test("local planets carry higher priority than unidentified", () => {
const world = reportToWorld(
const { world } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, name: "Home", kind: "local", size: 1, resources: 0 }),
@@ -148,7 +150,7 @@ describe("reportToWorld", () => {
// into `reportToWorld`. The base world stays a clean
// representation of the report's planets so the renderer
// can rebuild the overlay without disposing Pixi.
const world = reportToWorld(
const { world } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, name: "Earth", x: 100, y: 100, kind: "local", size: 5, resources: 1 }),
@@ -27,6 +27,7 @@ import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import type { IDBPDatabase } from "idb";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
@@ -101,6 +102,7 @@ function makeReport(localShipClass: ShipClassSummary[]): GameReport {
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
};
}