From 676556db4ea9e6c0905a9773c37ec3059780ced6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 10 May 2026 13:23:56 +0200 Subject: [PATCH] ui/phase-19: ship-group decoder + map binding + selection store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/frontend/src/api/game-state.ts | 303 ++++++++++++++++++ ui/frontend/src/api/synthetic-report.ts | 126 ++++++++ ui/frontend/src/lib/active-view/map.svelte | 31 +- ui/frontend/src/lib/selection.svelte.ts | 44 ++- ui/frontend/src/map/render.ts | 23 +- ui/frontend/src/map/ship-groups.ts | 246 ++++++++++++++ ui/frontend/src/map/state-binding.ts | 65 +++- ui/frontend/src/map/world.ts | 7 + ui/frontend/tests/designer-ship-class.test.ts | 4 + ui/frontend/tests/game-shell-header.test.ts | 2 + ui/frontend/tests/game-shell-sidebar.test.ts | 2 + .../tests/helpers/empty-ship-groups.ts | 27 ++ ui/frontend/tests/inspector-overlay.test.ts | 2 + ui/frontend/tests/map-cargo-routes.test.ts | 3 + ui/frontend/tests/order-overlay.test.ts | 2 + .../tests/state-binding-groups.test.ts | 222 +++++++++++++ ui/frontend/tests/state-binding.test.ts | 18 +- ui/frontend/tests/table-ship-classes.test.ts | 2 + 18 files changed, 1085 insertions(+), 44 deletions(-) create mode 100644 ui/frontend/src/map/ship-groups.ts create mode 100644 ui/frontend/tests/helpers/empty-ship-groups.ts create mode 100644 ui/frontend/tests/state-binding-groups.test.ts diff --git a/ui/frontend/src/api/game-state.ts b/ui/frontend/src/api/game-state.ts index 7a1700f..3078bc1 100644 --- a/ui/frontend/src/api/game-state.ts +++ b/ui/frontend/src/api/game-state.ts @@ -32,7 +32,12 @@ import type { GalaxyClient } from "./galaxy-client"; import { UUID } from "../proto/galaxy/fbs/common"; import { GameReportRequest, + IncomingGroup, + LocalFleet, + LocalGroup, + OtherGroup, Report, + UnidentifiedGroup, } from "../proto/galaxy/fbs/report"; import type { CargoLoadType, @@ -120,6 +125,94 @@ export interface ReportRoute { entries: ReportRouteEntry[]; } +/** + * ShipGroupTech holds the four component tech levels carried by every + * ship group. Mirrors the `tech` map on `pkg/model/report.OtherGroup` + * (encoded on the wire as a `[TechEntry]` vector) but flattens the + * four well-known keys into a fixed-shape struct so the inspector can + * render them with the same call as the planet-side ship-class table. + * Keys missing from the wire default to zero. + */ +export interface ShipGroupTech { + drive: number; + weapons: number; + shields: number; + cargo: number; +} + +/** + * ReportShipGroupBase carries the fields shared by `LocalGroup` and + * `OtherGroup` server-side. `cargo` is `"NONE"` when the group is + * empty (legacy `"-"` is normalised to that literal here so the union + * with `CargoLoadType` is closed). `origin` and `range` are non-null + * iff the group is in hyperspace. + */ +export interface ReportShipGroupBase { + count: number; + class: string; + tech: ShipGroupTech; + cargo: CargoLoadType | "NONE"; + load: number; + destination: number; + origin: number | null; + range: number | null; + speed: number; + mass: number; +} + +/** + * ReportLocalShipGroup is the player's own ship group, carrying the + * group UUID (used for selection and for the upcoming Phase 20 order + * envelopes), the engine state (`In_Orbit` / `In_Space` / `In_Battle` + * / `Out_Battle`), and the optional fleet membership. + */ +export interface ReportLocalShipGroup extends ReportShipGroupBase { + id: string; + state: string; + fleet: string | null; +} + +export type ReportOtherShipGroup = ReportShipGroupBase; + +/** + * ReportIncomingShipGroup is a foreign group inbound to one of the + * player's planets. The legacy "Incoming Groups" table only exposes + * the bare path/distance/speed/mass — the actual ship class is + * unknown until the group lands and shows up in a battle roster. + */ +export interface ReportIncomingShipGroup { + origin: number; + destination: number; + distance: number; + speed: number; + mass: number; +} + +/** + * ReportUnidentifiedShipGroup is a blip on radar — no class, no + * destination, just absolute coordinates. Phase 19 renders it as a + * dim point and exposes the coordinates in a minimal inspector. + */ +export interface ReportUnidentifiedShipGroup { + x: number; + y: number; +} + +/** + * ReportLocalFleet is the player's own combat fleet — a named group + * of groups. Phase 19 surfaces only the fleet name on the + * ship-group inspector; full fleet listings are deferred. + */ +export interface ReportLocalFleet { + name: string; + groupCount: number; + destination: number; + origin: number | null; + range: number | null; + speed: number; + state: string; +} + export interface GameReport { turn: number; mapWidth: number; @@ -166,6 +259,19 @@ export interface GameReport { localPlayerWeapons: number; localPlayerShields: number; localPlayerCargo: number; + /** + * localShipGroups, otherShipGroups, incomingShipGroups, + * unidentifiedShipGroups, and localFleets land in Phase 19. Empty + * arrays are emitted whenever the report does not carry the + * matching wire field — boot state, history-mode snapshots, and + * the synthetic-report path that cannot derive a section from + * legacy text. + */ + localShipGroups: ReportLocalShipGroup[]; + otherShipGroups: ReportOtherShipGroup[]; + incomingShipGroups: ReportIncomingShipGroup[]; + unidentifiedShipGroups: ReportUnidentifiedShipGroup[]; + localFleets: ReportLocalFleet[]; } export async function fetchGameReport( @@ -299,6 +405,11 @@ function decodeReport(report: Report): GameReport { const raceName = report.race() ?? ""; const routes = decodeReportRoutes(report); const localTech = findLocalPlayerTech(report, raceName); + const localShipGroups = decodeLocalShipGroups(report); + const otherShipGroups = decodeOtherShipGroups(report); + const incomingShipGroups = decodeIncomingShipGroups(report); + const unidentifiedShipGroups = decodeUnidentifiedShipGroups(report); + const localFleets = decodeLocalFleets(report); return { turn: Number(report.turn()), @@ -313,6 +424,11 @@ function decodeReport(report: Report): GameReport { localPlayerWeapons: localTech.weapons, localPlayerShields: localTech.shields, localPlayerCargo: localTech.cargo, + localShipGroups, + otherShipGroups, + incomingShipGroups, + unidentifiedShipGroups, + localFleets, }; } @@ -352,6 +468,193 @@ function decodeReportRoutes(report: Report): ReportRoute[] { return out; } +/** + * decodeShipGroupTech walks a ship-group's `tech` vector and copies the + * four well-known keys into the fixed-shape `ShipGroupTech` struct. + * Unknown keys are dropped silently — adding a new tech component to + * the engine does not break older clients, only widens the union. + * Missing keys default to zero so the inspector never has to guard + * against `undefined`. + */ +function decodeShipGroupTech( + techAt: (i: number) => { key(): string | null; value(): number } | null, + techLength: number, +): ShipGroupTech { + const out: ShipGroupTech = { drive: 0, weapons: 0, shields: 0, cargo: 0 }; + for (let i = 0; i < techLength; i++) { + const entry = techAt(i); + if (entry === null) continue; + const key = entry.key(); + if (key === null) continue; + const value = entry.value(); + switch (key) { + case "drive": + out.drive = value; + break; + case "weapons": + out.weapons = value; + break; + case "shields": + out.shields = value; + break; + case "cargo": + out.cargo = value; + break; + } + } + return out; +} + +/** + * normaliseCargoType maps the wire `cargo` string into the closed + * union the inspector consumes. The legacy convention uses `"-"` for + * empty groups; the typed contract spells that as `"NONE"`. Unknown + * values warn and collapse to `"NONE"` so a future schema bump never + * silently corrupts the inspector. + */ +function normaliseCargoType(raw: string | null): CargoLoadType | "NONE" { + if (raw === null || raw === "" || raw === "-") return "NONE"; + if (isCargoLoadType(raw)) return raw; + console.warn(`decodeReport: unknown cargo type "${raw}"`); + return "NONE"; +} + +function decodeLocalShipGroups(report: Report): ReportLocalShipGroup[] { + const out: ReportLocalShipGroup[] = []; + for (let i = 0; i < report.localGroupLength(); i++) { + const g = report.localGroup(i); + if (g === null) continue; + const id = uuidStringFromFB(g.id()); + if (id === null) continue; + const origin = g.origin(); + const range = g.range(); + out.push({ + id, + count: Number(g.number()), + class: g.class_() ?? "", + tech: decodeShipGroupTech( + (j) => g.tech(j), + g.techLength(), + ), + cargo: normaliseCargoType(g.cargo()), + load: g.load(), + destination: Number(g.destination()), + origin: origin === null ? null : Number(origin), + range, + speed: g.speed(), + mass: g.mass(), + state: g.state() ?? "", + fleet: g.fleet(), + }); + } + return out; +} + +function decodeOtherShipGroups(report: Report): ReportOtherShipGroup[] { + const out: ReportOtherShipGroup[] = []; + for (let i = 0; i < report.otherGroupLength(); i++) { + const g = report.otherGroup(i); + if (g === null) continue; + const origin = g.origin(); + const range = g.range(); + out.push({ + count: Number(g.number()), + class: g.class_() ?? "", + tech: decodeShipGroupTech( + (j) => g.tech(j), + g.techLength(), + ), + cargo: normaliseCargoType(g.cargo()), + load: g.load(), + destination: Number(g.destination()), + origin: origin === null ? null : Number(origin), + range, + speed: g.speed(), + mass: g.mass(), + }); + } + return out; +} + +function decodeIncomingShipGroups(report: Report): ReportIncomingShipGroup[] { + const out: ReportIncomingShipGroup[] = []; + for (let i = 0; i < report.incomingGroupLength(); i++) { + const g = report.incomingGroup(i); + if (g === null) continue; + out.push({ + origin: Number(g.origin()), + destination: Number(g.destination()), + distance: g.distance(), + speed: g.speed(), + mass: g.mass(), + }); + } + return out; +} + +function decodeUnidentifiedShipGroups( + report: Report, +): ReportUnidentifiedShipGroup[] { + const out: ReportUnidentifiedShipGroup[] = []; + for (let i = 0; i < report.unidentifiedGroupLength(); i++) { + const g = report.unidentifiedGroup(i); + if (g === null) continue; + out.push({ x: g.x(), y: g.y() }); + } + return out; +} + +function decodeLocalFleets(report: Report): ReportLocalFleet[] { + const out: ReportLocalFleet[] = []; + for (let i = 0; i < report.localFleetLength(); i++) { + const f = report.localFleet(i); + if (f === null) continue; + const origin = f.origin(); + const range = f.range(); + out.push({ + name: f.name() ?? "", + groupCount: Number(f.groups()), + destination: Number(f.destination()), + origin: origin === null ? null : Number(origin), + range, + speed: f.speed(), + state: f.state() ?? "", + }); + } + return out; +} + +/** + * uuidStringFromFB stitches a `common.UUID` flatbuffer struct back + * into the canonical 36-character hex form. Inverse of + * [uuidToHiLo]. Returns `null` for a missing UUID — the caller + * decides whether to skip the row (current Phase 19 behaviour) or + * synthesise a placeholder. + */ +function uuidStringFromFB(uuid: UUID | null): string | null { + if (uuid === null) return null; + const hi = uuid.hi(); + const lo = uuid.lo(); + const hex = bigUintTo16Hex(hi) + bigUintTo16Hex(lo); + return ( + hex.slice(0, 8) + + "-" + + hex.slice(8, 12) + + "-" + + hex.slice(12, 16) + + "-" + + hex.slice(16, 20) + + "-" + + hex.slice(20, 32) + ); +} + +function bigUintTo16Hex(value: bigint): string { + let hex = (value & ((BigInt(1) << BigInt(64)) - BigInt(1))).toString(16); + while (hex.length < 16) hex = "0" + hex; + return hex; +} + const LOAD_TYPE_ORDER: Record = (() => { const map = {} as Record; CARGO_LOAD_TYPE_VALUES.forEach((value, index) => { diff --git a/ui/frontend/src/api/synthetic-report.ts b/ui/frontend/src/api/synthetic-report.ts index bb48203..a3b6915 100644 --- a/ui/frontend/src/api/synthetic-report.ts +++ b/ui/frontend/src/api/synthetic-report.ts @@ -20,10 +20,18 @@ import type { GameReport, + ReportIncomingShipGroup, + ReportLocalFleet, + ReportLocalShipGroup, + ReportOtherShipGroup, ReportPlanet, ReportRoute, + ReportUnidentifiedShipGroup, ShipClassSummary, + ShipGroupTech, } from "./game-state"; +import type { CargoLoadType } from "../sync/order-types"; +import { isCargoLoadType } from "../sync/order-types"; export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-"; @@ -96,6 +104,45 @@ interface SyntheticPlayer { cargo: number; } +interface SyntheticShipGroup { + id?: string; + number?: number; + class?: string; + tech?: Record; + cargo?: string; + load?: number; + destination?: number; + origin?: number; + range?: number; + speed?: number; + mass?: number; + state?: string; + fleet?: string; +} + +interface SyntheticIncomingGroup { + origin?: number; + destination?: number; + distance?: number; + speed?: number; + mass?: number; +} + +interface SyntheticUnidentifiedGroup { + x?: number; + y?: number; +} + +interface SyntheticLocalFleet { + name?: string; + groups?: number; + destination?: number; + origin?: number; + range?: number; + speed?: number; + state?: string; +} + interface SyntheticReportRoot { turn?: number; mapWidth?: number; @@ -108,6 +155,11 @@ interface SyntheticReportRoot { uninhabitedPlanet?: SyntheticPlanet[]; unidentifiedPlanet?: SyntheticPlanet[]; localShipClass?: SyntheticShipClass[]; + localGroup?: SyntheticShipGroup[]; + otherGroup?: SyntheticShipGroup[]; + incomingGroup?: SyntheticIncomingGroup[]; + unidentifiedGroup?: SyntheticUnidentifiedGroup[]; + localFleet?: SyntheticLocalFleet[]; } function decodeSyntheticReport(json: unknown): GameReport { @@ -146,6 +198,59 @@ function decodeSyntheticReport(json: unknown): GameReport { const routes: ReportRoute[] = []; + const localShipGroups: ReportLocalShipGroup[] = (root.localGroup ?? []).map( + (g, i) => ({ + id: typeof g.id === "string" ? g.id : `synthetic-local-group-${i}`, + count: numOr0(g.number), + class: typeof g.class === "string" ? g.class : "", + tech: toShipGroupTech(g.tech), + cargo: toCargoType(g.cargo), + load: numOr0(g.load), + destination: numOr0(g.destination), + origin: typeof g.origin === "number" ? g.origin : null, + range: typeof g.range === "number" ? g.range : null, + speed: numOr0(g.speed), + mass: numOr0(g.mass), + state: typeof g.state === "string" ? g.state : "", + fleet: typeof g.fleet === "string" ? g.fleet : null, + }), + ); + const otherShipGroups: ReportOtherShipGroup[] = (root.otherGroup ?? []).map( + (g) => ({ + count: numOr0(g.number), + class: typeof g.class === "string" ? g.class : "", + tech: toShipGroupTech(g.tech), + cargo: toCargoType(g.cargo), + load: numOr0(g.load), + destination: numOr0(g.destination), + origin: typeof g.origin === "number" ? g.origin : null, + range: typeof g.range === "number" ? g.range : null, + speed: numOr0(g.speed), + mass: numOr0(g.mass), + }), + ); + const incomingShipGroups: ReportIncomingShipGroup[] = ( + root.incomingGroup ?? [] + ).map((g) => ({ + origin: numOr0(g.origin), + destination: numOr0(g.destination), + distance: numOr0(g.distance), + speed: numOr0(g.speed), + mass: numOr0(g.mass), + })); + const unidentifiedShipGroups: ReportUnidentifiedShipGroup[] = ( + root.unidentifiedGroup ?? [] + ).map((g) => ({ x: numOr0(g.x), y: numOr0(g.y) })); + const localFleets: ReportLocalFleet[] = (root.localFleet ?? []).map((f) => ({ + name: typeof f.name === "string" ? f.name : "", + groupCount: numOr0(f.groups), + destination: numOr0(f.destination), + origin: typeof f.origin === "number" ? f.origin : null, + range: typeof f.range === "number" ? f.range : null, + speed: numOr0(f.speed), + state: typeof f.state === "string" ? f.state : "", + })); + return { turn: numOr0(root.turn), mapWidth: numOr0(root.mapWidth), @@ -159,9 +264,30 @@ function decodeSyntheticReport(json: unknown): GameReport { localPlayerWeapons: tech.weapons, localPlayerShields: tech.shields, localPlayerCargo: tech.cargo, + localShipGroups, + otherShipGroups, + incomingShipGroups, + unidentifiedShipGroups, + localFleets, }; } +function toShipGroupTech(raw: Record | undefined): ShipGroupTech { + const out: ShipGroupTech = { drive: 0, weapons: 0, shields: 0, cargo: 0 }; + if (raw === undefined || raw === null) return out; + if (typeof raw.drive === "number") out.drive = raw.drive; + if (typeof raw.weapons === "number") out.weapons = raw.weapons; + if (typeof raw.shields === "number") out.shields = raw.shields; + if (typeof raw.cargo === "number") out.cargo = raw.cargo; + return out; +} + +function toCargoType(raw: string | undefined): CargoLoadType | "NONE" { + if (raw === undefined || raw === "" || raw === "-") return "NONE"; + if (isCargoLoadType(raw)) return raw; + return "NONE"; +} + function toPlanet( p: SyntheticPlanet, kind: ReportPlanet["kind"], diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 341e1b4..b5e2d89 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -28,7 +28,8 @@ preference the store already manages. type RendererHandle, } from "../../map/index"; import { buildCargoRouteLines } from "../../map/cargo-routes"; - import { reportToWorld } from "../../map/state-binding"; + import { reportToWorld, type HitTarget } from "../../map/state-binding"; + import type { PrimitiveID } from "../../map/world"; import { GAME_STATE_CONTEXT_KEY, type GameStateStore, @@ -76,6 +77,7 @@ preference the store already manages. let mountError: string | null = $state(null); let handle: RendererHandle | null = null; + let hitLookup = new Map(); let mountedTurn: number | null = null; let mountedGameId: string | null = null; let onResize: (() => void) | null = null; @@ -213,7 +215,8 @@ preference the store already manages. handle = null; } try { - const world = reportToWorld(report); + const { world, hitLookup: nextHitLookup } = reportToWorld(report); + hitLookup = nextHitLookup; handle = await createRenderer({ canvas: canvasEl, world, @@ -339,11 +342,14 @@ preference the store already manages. } } - // handleMapClick translates a renderer click into a planet - // selection. A click that misses every primitive (empty space) is - // a deliberate no-op: the selection rule for Phase 13 is that - // only the explicit close button on the mobile sheet clears the - // current selection. + // handleMapClick translates a renderer click into a selection + // update. A click that misses every primitive (empty space) is a + // deliberate no-op: the selection rule from Phase 13 is that only + // the explicit close button on the mobile sheet clears the + // current selection. The Phase 19 ship-group surface dispatches + // through the same `hit-test` plumbing — the hitLookup map keyed + // by primitive id resolves a hit back to either a planet or a + // ship-group selection variant. function handleMapClick(cursorPx: { x: number; y: number }): void { if (handle === null || store?.report === undefined || store.report === null) { return; @@ -352,10 +358,13 @@ preference the store already manages. const hit = handle.hitAt(cursorPx); if (hit === null) return; if (hit.primitive.kind !== "point") return; - const planetId = hit.primitive.id; - const planet = store.report.planets.find((p) => p.number === planetId); - if (planet === undefined) return; - selection.selectPlanet(planet.number); + const target = hitLookup.get(hit.primitive.id); + if (target === undefined) return; + if (target.kind === "planet") { + selection.selectPlanet(target.number); + } else { + selection.selectShipGroup(target.ref); + } } onMount(() => { diff --git a/ui/frontend/src/lib/selection.svelte.ts b/ui/frontend/src/lib/selection.svelte.ts index f0a651e..9aa0e85 100644 --- a/ui/frontend/src/lib/selection.svelte.ts +++ b/ui/frontend/src/lib/selection.svelte.ts @@ -1,7 +1,7 @@ // Per-game selection state: which on-map object the user is -// currently inspecting. Phase 13 only models planet selection, so -// the union has a single variant; later phases (Phase 19 ship-group -// inspector) will widen it. +// currently inspecting. Phase 13 modelled planets only; Phase 19 +// widened the union to ship groups (own / foreign / incoming / +// unidentified). // // The store is in-memory only: lifetime matches the in-game shell // layout instance, which itself is preserved across active-view @@ -20,12 +20,30 @@ // can be tested directly without rendering any UI. /** - * Selected describes the currently selected map object. Phase 13 - * ships only the planet variant; later inspector phases extend the - * discriminated union (`ship-group`, etc.) without changing the - * store's contract. + * ShipGroupRef identifies a ship group inside the current report. + * `local` groups carry a stable engine UUID (passed through + * `report.localGroup.id` and used by the upcoming Phase 20 order + * envelopes). The remaining variants do not — they are addressed by + * their position in the matching report array, which is fine for + * the read-only inspector: a new report load reseeds the store and + * any stale index resolves to a missing entry on lookup, collapsing + * the inspector cleanly. */ -export type Selected = { kind: "planet"; id: number }; +export type ShipGroupRef = + | { variant: "local"; id: string } + | { variant: "other"; index: number } + | { variant: "incoming"; index: number } + | { variant: "unidentified"; index: number }; + +/** + * Selected describes the currently selected map object. The + * discriminated union is closed: every map-clickable surface maps + * to one of these variants. Future phases (e.g. fleet selection) + * extend by adding a new branch — extension is purely additive. + */ +export type Selected = + | { kind: "planet"; id: number } + | { kind: "shipGroup"; ref: ShipGroupRef }; /** * SELECTION_CONTEXT_KEY is the Svelte context key the in-game shell @@ -49,6 +67,16 @@ export class SelectionStore { this.selected = { kind: "planet", id }; } + /** + * selectShipGroup sets the active selection to a ship group. The + * `ref` discriminator carries the variant + the right id shape for + * lookup against the current report. + */ + selectShipGroup(ref: ShipGroupRef): void { + if (this.destroyed) return; + this.selected = { kind: "shipGroup", ref }; + } + /** * clear drops the current selection. The mobile sheet's close * button calls this; otherwise selection persists across active- diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index c263eeb..056b687 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -702,10 +702,29 @@ function drawCircle(g: Graphics, p: CirclePrim, theme: Theme): void { } function drawLine(g: Graphics, p: LinePrim, theme: Theme): void { - g.moveTo(p.x1, p.y1); - g.lineTo(p.x2, p.y2); const color = p.style.strokeColor ?? theme.lineStroke; const alpha = p.style.strokeAlpha ?? 1; const width = p.style.strokeWidthPx ?? 1; + const dash = p.style.strokeDashPx; + if (dash === undefined || dash <= 0) { + g.moveTo(p.x1, p.y1); + g.lineTo(p.x2, p.y2); + g.stroke({ color, alpha, width }); + return; + } + // PixiJS v8 has no native dashed-line API; segment the path into + // equal-length dashes (dash and gap both `dash` units). + const dx = p.x2 - p.x1; + const dy = p.y2 - p.y1; + const length = Math.hypot(dx, dy); + if (length === 0) return; + const ux = dx / length; + const uy = dy / length; + const step = dash * 2; + for (let t = 0; t < length; t += step) { + const segEnd = Math.min(t + dash, length); + g.moveTo(p.x1 + ux * t, p.y1 + uy * t); + g.lineTo(p.x1 + ux * segEnd, p.y1 + uy * segEnd); + } g.stroke({ color, alpha, width }); } diff --git a/ui/frontend/src/map/ship-groups.ts b/ui/frontend/src/map/ship-groups.ts new file mode 100644 index 0000000..b1ff8be --- /dev/null +++ b/ui/frontend/src/map/ship-groups.ts @@ -0,0 +1,246 @@ +// Phase 19 ship-group → World primitive translation. Sits next to +// `state-binding.ts` so the latter can stay focused on planets while +// the more involved group geometry (in-hyperspace interpolation, +// incoming-trajectory lines) lives here. +// +// Position rules: +// - On-planet local / other groups (origin === null) — drawn next +// to the destination planet, slightly offset so the group has its +// own hit-target distinct from the planet pixel. Multiple groups +// stationed at the same planet share the offset (Phase 19 +// limitation; a future phase fans them out or lists them in the +// planet inspector). +// - In-hyperspace local / other groups (origin / range set) — +// interpolated along the origin → destination line at `range` +// world units from the destination. +// - Incoming groups — origin and destination are always present; +// emit a dashed red trajectory line between the two and a +// clickable point at the interpolated position (range = the +// `distance` field). +// - Unidentified groups — drawn at the absolute (x, y) the radar +// reports. +// +// PrimitiveIDs are partitioned via large per-variant offsets so they +// never collide with planet ids (which run in `[0, planetCount)`). + +import type { + GameReport, + ReportIncomingShipGroup, + ReportLocalShipGroup, + ReportOtherShipGroup, + ReportPlanet, + ReportUnidentifiedShipGroup, +} from "../api/game-state"; +import type { ShipGroupRef } from "../lib/selection.svelte"; +import type { LinePrim, PointPrim, PrimitiveID, Style } from "./world"; + +/** + * SHIP_GROUP_ID_OFFSETS partitions the primitive-id namespace so a + * hit on a ship-group primitive is unambiguous: the offset alone + * disambiguates the variant and `id - offset` recovers the index + * (or, for `local`, lookup happens via the parallel hitLookup map + * since UUID strings cannot fit in a numeric primitive id). + */ +export const SHIP_GROUP_ID_OFFSETS = { + local: 100_000_000, + other: 200_000_000, + incoming: 300_000_000, + incomingLine: 350_000_000, + unidentified: 400_000_000, +} as const; + +/** ON_PLANET_OFFSET is the (dx, dy) world-unit shift applied to a + * group point that sits on a planet, so the group has a distinct + * click target from the planet itself. The offset is small enough + * that the visual association with the planet stays clear. */ +const ON_PLANET_OFFSET = { dx: 6, dy: -6 }; + +const STYLE_LOCAL_GROUP: Style = { + fillColor: 0xfff176, + fillAlpha: 0.95, + pointRadiusPx: 3, +}; + +const STYLE_OTHER_GROUP: Style = { + fillColor: 0xff6f40, + fillAlpha: 0.9, + pointRadiusPx: 3, +}; + +const STYLE_INCOMING_GROUP: Style = { + fillColor: 0xff5252, + fillAlpha: 1, + pointRadiusPx: 4, +}; + +const STYLE_INCOMING_LINE: Style = { + strokeColor: 0xff5252, + strokeAlpha: 0.85, + strokeWidthPx: 1, + strokeDashPx: 4, +}; + +const STYLE_UNIDENTIFIED_GROUP: Style = { + fillColor: 0x9aa3a8, + fillAlpha: 0.65, + pointRadiusPx: 3, +}; + +// Priority order inside `hit-test`: ship groups outrank planets so a +// hyperspace group landing on top of an unidentified planet is +// selectable. On-planet groups stay below the planet so clicks on a +// planet still resolve to the planet itself (the offset gives the +// group its own un-overlapped hit area). +const PRIORITY_LOCAL = 5; +const PRIORITY_OTHER = 5; +const PRIORITY_INCOMING_POINT = 6; +const PRIORITY_INCOMING_LINE = 0; +const PRIORITY_UNIDENTIFIED = 4; + +export interface ShipGroupPrimitives { + primitives: (PointPrim | LinePrim)[]; + lookup: Map; +} + +export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives { + const primitives: (PointPrim | LinePrim)[] = []; + const lookup = new Map(); + const planetIndex = new Map(); + for (const planet of report.planets) { + planetIndex.set(planet.number, planet); + } + + for (let i = 0; i < report.localShipGroups.length; i++) { + const group = report.localShipGroups[i]!; + const pos = computeGroupPosition(group, planetIndex); + if (pos === null) continue; + const id = SHIP_GROUP_ID_OFFSETS.local + i; + primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, STYLE_LOCAL_GROUP)); + lookup.set(id, { variant: "local", id: group.id }); + } + + for (let i = 0; i < report.otherShipGroups.length; i++) { + const group = report.otherShipGroups[i]!; + const pos = computeGroupPosition(group, planetIndex); + if (pos === null) continue; + const id = SHIP_GROUP_ID_OFFSETS.other + i; + primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_OTHER, STYLE_OTHER_GROUP)); + lookup.set(id, { variant: "other", index: i }); + } + + for (let i = 0; i < report.incomingShipGroups.length; i++) { + const group = report.incomingShipGroups[i]!; + const origin = planetIndex.get(group.origin); + const destination = planetIndex.get(group.destination); + if (origin === undefined || destination === undefined) continue; + const lineId = SHIP_GROUP_ID_OFFSETS.incomingLine + i; + primitives.push({ + kind: "line", + id: lineId, + priority: PRIORITY_INCOMING_LINE, + style: STYLE_INCOMING_LINE, + hitSlopPx: 0, + x1: origin.x, + y1: origin.y, + x2: destination.x, + y2: destination.y, + }); + const pos = interpolateAlongLine( + destination.x, + destination.y, + origin.x, + origin.y, + group.distance, + ); + const pointId = SHIP_GROUP_ID_OFFSETS.incoming + i; + primitives.push( + makePoint( + pointId, + pos.x, + pos.y, + PRIORITY_INCOMING_POINT, + STYLE_INCOMING_GROUP, + /*hitSlopPx*/ 4, + ), + ); + lookup.set(pointId, { variant: "incoming", index: i }); + } + + for (let i = 0; i < report.unidentifiedShipGroups.length; i++) { + const group: ReportUnidentifiedShipGroup = + report.unidentifiedShipGroups[i]!; + const id = SHIP_GROUP_ID_OFFSETS.unidentified + i; + primitives.push( + makePoint( + id, + group.x, + group.y, + PRIORITY_UNIDENTIFIED, + STYLE_UNIDENTIFIED_GROUP, + ), + ); + lookup.set(id, { variant: "unidentified", index: i }); + } + + return { primitives, lookup }; +} + +function computeGroupPosition( + group: ReportLocalShipGroup | ReportOtherShipGroup, + planetIndex: Map, +): { x: number; y: number } | null { + const destination = planetIndex.get(group.destination); + if (destination === undefined) return null; + if (group.origin === null || group.range === null) { + // Stationed on the destination planet; offset slightly so the + // group is distinct from the planet's own hit target. + return { + x: destination.x + ON_PLANET_OFFSET.dx, + y: destination.y + ON_PLANET_OFFSET.dy, + }; + } + const origin = planetIndex.get(group.origin); + if (origin === undefined) return null; + return interpolateAlongLine( + destination.x, + destination.y, + origin.x, + origin.y, + group.range, + ); +} + +/** + * interpolateAlongLine returns the point that sits `range` world + * units away from `(dx, dy)` toward `(ox, oy)`. The total path length + * is the Euclidean distance between the two anchors; the position is + * `dest + (range / total) × (origin - dest)`. When the anchors are + * coincident or `range` is zero the result is the destination, which + * is fine for the ship-group rendering — a degenerate group still + * gets a click target on the destination planet. + */ +function interpolateAlongLine( + dx: number, + dy: number, + ox: number, + oy: number, + range: number, +): { x: number; y: number } { + const ddx = ox - dx; + const ddy = oy - dy; + const total = Math.hypot(ddx, ddy); + if (total === 0 || range <= 0) return { x: dx, y: dy }; + const t = Math.min(1, range / total); + return { x: dx + t * ddx, y: dy + t * ddy }; +} + +function makePoint( + id: PrimitiveID, + x: number, + y: number, + priority: number, + style: Style, + hitSlopPx = 0, +): PointPrim { + return { kind: "point", id, priority, style, hitSlopPx, x, y }; +} diff --git a/ui/frontend/src/map/state-binding.ts b/ui/frontend/src/map/state-binding.ts index 176b3ac..44e0a3b 100644 --- a/ui/frontend/src/map/state-binding.ts +++ b/ui/frontend/src/map/state-binding.ts @@ -1,9 +1,11 @@ // State binding between the typed game report and the renderer's -// World. Phase 11 only emits primitives for planets; later phases -// extend the binding with ship-class reach circles (Phase 17 / 18), -// hyperspace and incoming groups (Phase 11+ via separate primitives), -// cargo routes (Phase 16), reach / visibility zones (Phase 17), and -// battle / bombing markers (Phase 27). +// World. Phase 11 emitted primitives only for planets; Phase 19 +// extends the binding with ship-group primitives (own / foreign / in- +// hyperspace / incoming / unidentified) plus a `hitLookup` map so the +// click handler can dispatch a renderer-side hit back to the right +// selection variant. Later phases extend with ship-class reach +// circles (Phase 17 / 18 in `ui/core/calc/`), reach / visibility +// zones, and battle / bombing markers (Phase 27). // // The four planet kinds in the report each map to a distinct style so // the user can tell own / other-race / uninhabited / unidentified @@ -12,7 +14,9 @@ // colours and adds theme switching. import type { GameReport, ReportPlanet } from "../api/game-state"; -import { World, type Primitive, type Style } from "./world"; +import type { ShipGroupRef } from "../lib/selection.svelte"; +import { shipGroupsToPrimitives } from "./ship-groups"; +import { World, type Primitive, type PrimitiveID, type Style } from "./world"; const STYLE_LOCAL: Style = { fillColor: 0x6dd2ff, @@ -39,11 +43,11 @@ const STYLE_UNIDENTIFIED: Style = { }; // PlanetIDs occupy the [0, 4_000_000_000) range — well below -// JavaScript's `Number.MAX_SAFE_INTEGER` — so the engine `number` (uint64) -// fits in a primitive id (number) without truncation. The binding -// uses the engine number directly as the primitive id so later phases -// can resolve a planet by its hit-test result without an extra -// lookup table. +// JavaScript's `Number.MAX_SAFE_INTEGER` — so the engine `number` +// (uint64) fits in a primitive id (number) without truncation. The +// binding uses the engine number directly as the primitive id so the +// click handler can recover a planet by hit-test result without an +// extra lookup. function styleFor(kind: ReportPlanet["kind"]): Style { switch (kind) { case "local": @@ -70,17 +74,38 @@ function priorityFor(kind: ReportPlanet["kind"]): number { } } +/** + * HitTarget describes which game entity a renderer-side hit-test + * resolves to. The click handler in `lib/active-view/map.svelte` + * looks the hit primitive's id up in the binding's hitLookup map + * and dispatches `selection.selectPlanet` or + * `selection.selectShipGroup` accordingly. + */ +export type HitTarget = + | { kind: "planet"; number: number } + | { kind: "shipGroup"; ref: ShipGroupRef }; + +export interface ReportToWorldResult { + world: World; + hitLookup: Map; +} + /** * reportToWorld translates a GameReport into a renderer-ready World - * containing one Point primitive per planet (all four planet kinds). - * The world rectangle matches `report.mapWidth` × `report.mapHeight`. + * containing one Point primitive per planet (all four planet kinds) + * plus the Phase 19 ship-group surface — own / foreign groups + * (on-planet or in-hyperspace), incoming groups (dashed trajectory + * line + clickable point), and unidentified-group blips. The world + * rectangle matches `report.mapWidth` × `report.mapHeight`. * * If the report carries zero planets (turn-zero edge cases or seeded * tests), the World is still well-formed: the renderer mounts on an * empty primitive list without errors. */ -export function reportToWorld(report: GameReport): World { +export function reportToWorld(report: GameReport): ReportToWorldResult { const primitives: Primitive[] = []; + const hitLookup = new Map(); + for (const planet of report.planets) { primitives.push({ kind: "point", @@ -91,8 +116,18 @@ export function reportToWorld(report: GameReport): World { x: planet.x, y: planet.y, }); + hitLookup.set(planet.number, { kind: "planet", number: planet.number }); } + + const groups = shipGroupsToPrimitives(report); + for (const prim of groups.primitives) { + primitives.push(prim); + } + for (const [primId, ref] of groups.lookup) { + hitLookup.set(primId, { kind: "shipGroup", ref }); + } + const width = report.mapWidth > 0 ? report.mapWidth : 1; const height = report.mapHeight > 0 ? report.mapHeight : 1; - return new World(width, height, primitives); + return { world: new World(width, height, primitives), hitLookup }; } diff --git a/ui/frontend/src/map/world.ts b/ui/frontend/src/map/world.ts index cb498bf..8e69bbf 100644 --- a/ui/frontend/src/map/world.ts +++ b/ui/frontend/src/map/world.ts @@ -24,6 +24,13 @@ export interface Style { strokeAlpha?: number; // 0..1 strokeWidthPx?: number; // pixels at any zoom pointRadiusPx?: number; // pixels at any zoom (for kind === 'point') + // strokeDashPx — when set on a `LinePrim`, the line is rendered as + // a dashed pattern whose dash and gap are both this length. When + // unset (or zero), the stroke is solid. Interpreted in the same + // world-unit space as `strokeWidthPx`, so the dash spacing scales + // with the camera. Phase 19 uses this for the IncomingGroup + // trajectory line; ignored on point and circle primitives. + strokeDashPx?: number; } // PrimitiveBase carries the fields shared by every primitive kind. diff --git a/ui/frontend/tests/designer-ship-class.test.ts b/ui/frontend/tests/designer-ship-class.test.ts index 566b3b9..16942f6 100644 --- a/ui/frontend/tests/designer-ship-class.test.ts +++ b/ui/frontend/tests/designer-ship-class.test.ts @@ -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"), { diff --git a/ui/frontend/tests/game-shell-header.test.ts b/ui/frontend/tests/game-shell-header.test.ts index 8f765a4..f218dab 100644 --- a/ui/frontend/tests/game-shell-header.test.ts +++ b/ui/frontend/tests/game-shell-header.test.ts @@ -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"; } diff --git a/ui/frontend/tests/game-shell-sidebar.test.ts b/ui/frontend/tests/game-shell-sidebar.test.ts index 30376d4..2f32ff9 100644 --- a/ui/frontend/tests/game-shell-sidebar.test.ts +++ b/ui/frontend/tests/game-shell-sidebar.test.ts @@ -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, }; } diff --git a/ui/frontend/tests/helpers/empty-ship-groups.ts b/ui/frontend/tests/helpers/empty-ship-groups.ts new file mode 100644 index 0000000..aeaf15d --- /dev/null +++ b/ui/frontend/tests/helpers/empty-ship-groups.ts @@ -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: [], +}; diff --git a/ui/frontend/tests/inspector-overlay.test.ts b/ui/frontend/tests/inspector-overlay.test.ts index 864d847..22c064a 100644 --- a/ui/frontend/tests/inspector-overlay.test.ts +++ b/ui/frontend/tests/inspector-overlay.test.ts @@ -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>; let dbName: string; @@ -86,6 +87,7 @@ function makeReport(planets: ReportPlanet[]): GameReport { localPlayerWeapons: 0, localPlayerShields: 0, localPlayerCargo: 0, + ...EMPTY_SHIP_GROUPS, }; } diff --git a/ui/frontend/tests/map-cargo-routes.test.ts b/ui/frontend/tests/map-cargo-routes.test.ts index ba9d2d4..9173f79 100644 --- a/ui/frontend/tests/map-cargo-routes.test.ts +++ b/ui/frontend/tests/map-cargo-routes.test.ts @@ -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 { 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([]); }); diff --git a/ui/frontend/tests/order-overlay.test.ts b/ui/frontend/tests/order-overlay.test.ts index 712de9e..b389c9e 100644 --- a/ui/frontend/tests/order-overlay.test.ts +++ b/ui/frontend/tests/order-overlay.test.ts @@ -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 { return { @@ -53,6 +54,7 @@ function makeReport(planets: ReportPlanet[]): GameReport { localPlayerWeapons: 0, localPlayerShields: 0, localPlayerCargo: 0, + ...EMPTY_SHIP_GROUPS, }; } diff --git a/ui/frontend/tests/state-binding-groups.test.ts b/ui/frontend/tests/state-binding-groups.test.ts new file mode 100644 index 0000000..9172db5 --- /dev/null +++ b/ui/frontend/tests/state-binding-groups.test.ts @@ -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 { + 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 { + 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 }); + }); +}); diff --git a/ui/frontend/tests/state-binding.test.ts b/ui/frontend/tests/state-binding.test.ts index 865d582..37c6340 100644 --- a/ui/frontend/tests/state-binding.test.ts +++ b/ui/frontend/tests/state-binding.test.ts @@ -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 { return { @@ -26,6 +27,7 @@ function makeReport(overrides: Partial = {}): GameReport { localPlayerWeapons: 0, localPlayerShields: 0, localPlayerCargo: 0, + ...EMPTY_SHIP_GROUPS, ...overrides, }; } @@ -55,13 +57,13 @@ function makePlanet(overrides: Partial): 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 }), diff --git a/ui/frontend/tests/table-ship-classes.test.ts b/ui/frontend/tests/table-ship-classes.test.ts index 24fa08a..427c448 100644 --- a/ui/frontend/tests/table-ship-classes.test.ts +++ b/ui/frontend/tests/table-ship-classes.test.ts @@ -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, }; }