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:
@@ -32,7 +32,12 @@ import type { GalaxyClient } from "./galaxy-client";
|
|||||||
import { UUID } from "../proto/galaxy/fbs/common";
|
import { UUID } from "../proto/galaxy/fbs/common";
|
||||||
import {
|
import {
|
||||||
GameReportRequest,
|
GameReportRequest,
|
||||||
|
IncomingGroup,
|
||||||
|
LocalFleet,
|
||||||
|
LocalGroup,
|
||||||
|
OtherGroup,
|
||||||
Report,
|
Report,
|
||||||
|
UnidentifiedGroup,
|
||||||
} from "../proto/galaxy/fbs/report";
|
} from "../proto/galaxy/fbs/report";
|
||||||
import type {
|
import type {
|
||||||
CargoLoadType,
|
CargoLoadType,
|
||||||
@@ -120,6 +125,94 @@ export interface ReportRoute {
|
|||||||
entries: ReportRouteEntry[];
|
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 {
|
export interface GameReport {
|
||||||
turn: number;
|
turn: number;
|
||||||
mapWidth: number;
|
mapWidth: number;
|
||||||
@@ -166,6 +259,19 @@ export interface GameReport {
|
|||||||
localPlayerWeapons: number;
|
localPlayerWeapons: number;
|
||||||
localPlayerShields: number;
|
localPlayerShields: number;
|
||||||
localPlayerCargo: 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(
|
export async function fetchGameReport(
|
||||||
@@ -299,6 +405,11 @@ function decodeReport(report: Report): GameReport {
|
|||||||
const raceName = report.race() ?? "";
|
const raceName = report.race() ?? "";
|
||||||
const routes = decodeReportRoutes(report);
|
const routes = decodeReportRoutes(report);
|
||||||
const localTech = findLocalPlayerTech(report, raceName);
|
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 {
|
return {
|
||||||
turn: Number(report.turn()),
|
turn: Number(report.turn()),
|
||||||
@@ -313,6 +424,11 @@ function decodeReport(report: Report): GameReport {
|
|||||||
localPlayerWeapons: localTech.weapons,
|
localPlayerWeapons: localTech.weapons,
|
||||||
localPlayerShields: localTech.shields,
|
localPlayerShields: localTech.shields,
|
||||||
localPlayerCargo: localTech.cargo,
|
localPlayerCargo: localTech.cargo,
|
||||||
|
localShipGroups,
|
||||||
|
otherShipGroups,
|
||||||
|
incomingShipGroups,
|
||||||
|
unidentifiedShipGroups,
|
||||||
|
localFleets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +468,193 @@ function decodeReportRoutes(report: Report): ReportRoute[] {
|
|||||||
return out;
|
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<CargoLoadType, number> = (() => {
|
const LOAD_TYPE_ORDER: Record<CargoLoadType, number> = (() => {
|
||||||
const map = {} as Record<CargoLoadType, number>;
|
const map = {} as Record<CargoLoadType, number>;
|
||||||
CARGO_LOAD_TYPE_VALUES.forEach((value, index) => {
|
CARGO_LOAD_TYPE_VALUES.forEach((value, index) => {
|
||||||
|
|||||||
@@ -20,10 +20,18 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
GameReport,
|
GameReport,
|
||||||
|
ReportIncomingShipGroup,
|
||||||
|
ReportLocalFleet,
|
||||||
|
ReportLocalShipGroup,
|
||||||
|
ReportOtherShipGroup,
|
||||||
ReportPlanet,
|
ReportPlanet,
|
||||||
ReportRoute,
|
ReportRoute,
|
||||||
|
ReportUnidentifiedShipGroup,
|
||||||
ShipClassSummary,
|
ShipClassSummary,
|
||||||
|
ShipGroupTech,
|
||||||
} from "./game-state";
|
} from "./game-state";
|
||||||
|
import type { CargoLoadType } from "../sync/order-types";
|
||||||
|
import { isCargoLoadType } from "../sync/order-types";
|
||||||
|
|
||||||
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
|
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
|
||||||
|
|
||||||
@@ -96,6 +104,45 @@ interface SyntheticPlayer {
|
|||||||
cargo: number;
|
cargo: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SyntheticShipGroup {
|
||||||
|
id?: string;
|
||||||
|
number?: number;
|
||||||
|
class?: string;
|
||||||
|
tech?: Record<string, number>;
|
||||||
|
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 {
|
interface SyntheticReportRoot {
|
||||||
turn?: number;
|
turn?: number;
|
||||||
mapWidth?: number;
|
mapWidth?: number;
|
||||||
@@ -108,6 +155,11 @@ interface SyntheticReportRoot {
|
|||||||
uninhabitedPlanet?: SyntheticPlanet[];
|
uninhabitedPlanet?: SyntheticPlanet[];
|
||||||
unidentifiedPlanet?: SyntheticPlanet[];
|
unidentifiedPlanet?: SyntheticPlanet[];
|
||||||
localShipClass?: SyntheticShipClass[];
|
localShipClass?: SyntheticShipClass[];
|
||||||
|
localGroup?: SyntheticShipGroup[];
|
||||||
|
otherGroup?: SyntheticShipGroup[];
|
||||||
|
incomingGroup?: SyntheticIncomingGroup[];
|
||||||
|
unidentifiedGroup?: SyntheticUnidentifiedGroup[];
|
||||||
|
localFleet?: SyntheticLocalFleet[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeSyntheticReport(json: unknown): GameReport {
|
function decodeSyntheticReport(json: unknown): GameReport {
|
||||||
@@ -146,6 +198,59 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
|||||||
|
|
||||||
const routes: ReportRoute[] = [];
|
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 {
|
return {
|
||||||
turn: numOr0(root.turn),
|
turn: numOr0(root.turn),
|
||||||
mapWidth: numOr0(root.mapWidth),
|
mapWidth: numOr0(root.mapWidth),
|
||||||
@@ -159,9 +264,30 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
|||||||
localPlayerWeapons: tech.weapons,
|
localPlayerWeapons: tech.weapons,
|
||||||
localPlayerShields: tech.shields,
|
localPlayerShields: tech.shields,
|
||||||
localPlayerCargo: tech.cargo,
|
localPlayerCargo: tech.cargo,
|
||||||
|
localShipGroups,
|
||||||
|
otherShipGroups,
|
||||||
|
incomingShipGroups,
|
||||||
|
unidentifiedShipGroups,
|
||||||
|
localFleets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toShipGroupTech(raw: Record<string, number> | 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(
|
function toPlanet(
|
||||||
p: SyntheticPlanet,
|
p: SyntheticPlanet,
|
||||||
kind: ReportPlanet["kind"],
|
kind: ReportPlanet["kind"],
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ preference the store already manages.
|
|||||||
type RendererHandle,
|
type RendererHandle,
|
||||||
} from "../../map/index";
|
} from "../../map/index";
|
||||||
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
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 {
|
import {
|
||||||
GAME_STATE_CONTEXT_KEY,
|
GAME_STATE_CONTEXT_KEY,
|
||||||
type GameStateStore,
|
type GameStateStore,
|
||||||
@@ -76,6 +77,7 @@ preference the store already manages.
|
|||||||
let mountError: string | null = $state(null);
|
let mountError: string | null = $state(null);
|
||||||
|
|
||||||
let handle: RendererHandle | null = null;
|
let handle: RendererHandle | null = null;
|
||||||
|
let hitLookup = new Map<PrimitiveID, HitTarget>();
|
||||||
let mountedTurn: number | null = null;
|
let mountedTurn: number | null = null;
|
||||||
let mountedGameId: string | null = null;
|
let mountedGameId: string | null = null;
|
||||||
let onResize: (() => void) | null = null;
|
let onResize: (() => void) | null = null;
|
||||||
@@ -213,7 +215,8 @@ preference the store already manages.
|
|||||||
handle = null;
|
handle = null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const world = reportToWorld(report);
|
const { world, hitLookup: nextHitLookup } = reportToWorld(report);
|
||||||
|
hitLookup = nextHitLookup;
|
||||||
handle = await createRenderer({
|
handle = await createRenderer({
|
||||||
canvas: canvasEl,
|
canvas: canvasEl,
|
||||||
world,
|
world,
|
||||||
@@ -339,11 +342,14 @@ preference the store already manages.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMapClick translates a renderer click into a planet
|
// handleMapClick translates a renderer click into a selection
|
||||||
// selection. A click that misses every primitive (empty space) is
|
// update. A click that misses every primitive (empty space) is a
|
||||||
// a deliberate no-op: the selection rule for Phase 13 is that
|
// deliberate no-op: the selection rule from Phase 13 is that only
|
||||||
// only the explicit close button on the mobile sheet clears the
|
// the explicit close button on the mobile sheet clears the
|
||||||
// current selection.
|
// 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 {
|
function handleMapClick(cursorPx: { x: number; y: number }): void {
|
||||||
if (handle === null || store?.report === undefined || store.report === null) {
|
if (handle === null || store?.report === undefined || store.report === null) {
|
||||||
return;
|
return;
|
||||||
@@ -352,10 +358,13 @@ preference the store already manages.
|
|||||||
const hit = handle.hitAt(cursorPx);
|
const hit = handle.hitAt(cursorPx);
|
||||||
if (hit === null) return;
|
if (hit === null) return;
|
||||||
if (hit.primitive.kind !== "point") return;
|
if (hit.primitive.kind !== "point") return;
|
||||||
const planetId = hit.primitive.id;
|
const target = hitLookup.get(hit.primitive.id);
|
||||||
const planet = store.report.planets.find((p) => p.number === planetId);
|
if (target === undefined) return;
|
||||||
if (planet === undefined) return;
|
if (target.kind === "planet") {
|
||||||
selection.selectPlanet(planet.number);
|
selection.selectPlanet(target.number);
|
||||||
|
} else {
|
||||||
|
selection.selectShipGroup(target.ref);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Per-game selection state: which on-map object the user is
|
// Per-game selection state: which on-map object the user is
|
||||||
// currently inspecting. Phase 13 only models planet selection, so
|
// currently inspecting. Phase 13 modelled planets only; Phase 19
|
||||||
// the union has a single variant; later phases (Phase 19 ship-group
|
// widened the union to ship groups (own / foreign / incoming /
|
||||||
// inspector) will widen it.
|
// unidentified).
|
||||||
//
|
//
|
||||||
// The store is in-memory only: lifetime matches the in-game shell
|
// The store is in-memory only: lifetime matches the in-game shell
|
||||||
// layout instance, which itself is preserved across active-view
|
// layout instance, which itself is preserved across active-view
|
||||||
@@ -20,12 +20,30 @@
|
|||||||
// can be tested directly without rendering any UI.
|
// can be tested directly without rendering any UI.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selected describes the currently selected map object. Phase 13
|
* ShipGroupRef identifies a ship group inside the current report.
|
||||||
* ships only the planet variant; later inspector phases extend the
|
* `local` groups carry a stable engine UUID (passed through
|
||||||
* discriminated union (`ship-group`, etc.) without changing the
|
* `report.localGroup.id` and used by the upcoming Phase 20 order
|
||||||
* store's contract.
|
* 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
|
* SELECTION_CONTEXT_KEY is the Svelte context key the in-game shell
|
||||||
@@ -49,6 +67,16 @@ export class SelectionStore {
|
|||||||
this.selected = { kind: "planet", id };
|
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
|
* clear drops the current selection. The mobile sheet's close
|
||||||
* button calls this; otherwise selection persists across active-
|
* button calls this; otherwise selection persists across active-
|
||||||
|
|||||||
@@ -702,10 +702,29 @@ function drawCircle(g: Graphics, p: CirclePrim, theme: Theme): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawLine(g: Graphics, p: LinePrim, 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 color = p.style.strokeColor ?? theme.lineStroke;
|
||||||
const alpha = p.style.strokeAlpha ?? 1;
|
const alpha = p.style.strokeAlpha ?? 1;
|
||||||
const width = p.style.strokeWidthPx ?? 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 });
|
g.stroke({ color, alpha, width });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<PrimitiveID, ShipGroupRef>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives {
|
||||||
|
const primitives: (PointPrim | LinePrim)[] = [];
|
||||||
|
const lookup = new Map<PrimitiveID, ShipGroupRef>();
|
||||||
|
const planetIndex = new Map<number, ReportPlanet>();
|
||||||
|
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<number, ReportPlanet>,
|
||||||
|
): { 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 };
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
// State binding between the typed game report and the renderer's
|
// State binding between the typed game report and the renderer's
|
||||||
// World. Phase 11 only emits primitives for planets; later phases
|
// World. Phase 11 emitted primitives only for planets; Phase 19
|
||||||
// extend the binding with ship-class reach circles (Phase 17 / 18),
|
// extends the binding with ship-group primitives (own / foreign / in-
|
||||||
// hyperspace and incoming groups (Phase 11+ via separate primitives),
|
// hyperspace / incoming / unidentified) plus a `hitLookup` map so the
|
||||||
// cargo routes (Phase 16), reach / visibility zones (Phase 17), and
|
// click handler can dispatch a renderer-side hit back to the right
|
||||||
// battle / bombing markers (Phase 27).
|
// 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 four planet kinds in the report each map to a distinct style so
|
||||||
// the user can tell own / other-race / uninhabited / unidentified
|
// the user can tell own / other-race / uninhabited / unidentified
|
||||||
@@ -12,7 +14,9 @@
|
|||||||
// colours and adds theme switching.
|
// colours and adds theme switching.
|
||||||
|
|
||||||
import type { GameReport, ReportPlanet } from "../api/game-state";
|
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 = {
|
const STYLE_LOCAL: Style = {
|
||||||
fillColor: 0x6dd2ff,
|
fillColor: 0x6dd2ff,
|
||||||
@@ -39,11 +43,11 @@ const STYLE_UNIDENTIFIED: Style = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// PlanetIDs occupy the [0, 4_000_000_000) range — well below
|
// PlanetIDs occupy the [0, 4_000_000_000) range — well below
|
||||||
// JavaScript's `Number.MAX_SAFE_INTEGER` — so the engine `number` (uint64)
|
// JavaScript's `Number.MAX_SAFE_INTEGER` — so the engine `number`
|
||||||
// fits in a primitive id (number) without truncation. The binding
|
// (uint64) fits in a primitive id (number) without truncation. The
|
||||||
// uses the engine number directly as the primitive id so later phases
|
// binding uses the engine number directly as the primitive id so the
|
||||||
// can resolve a planet by its hit-test result without an extra
|
// click handler can recover a planet by hit-test result without an
|
||||||
// lookup table.
|
// extra lookup.
|
||||||
function styleFor(kind: ReportPlanet["kind"]): Style {
|
function styleFor(kind: ReportPlanet["kind"]): Style {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "local":
|
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<PrimitiveID, HitTarget>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* reportToWorld translates a GameReport into a renderer-ready World
|
* reportToWorld translates a GameReport into a renderer-ready World
|
||||||
* containing one Point primitive per planet (all four planet kinds).
|
* containing one Point primitive per planet (all four planet kinds)
|
||||||
* The world rectangle matches `report.mapWidth` × `report.mapHeight`.
|
* 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
|
* If the report carries zero planets (turn-zero edge cases or seeded
|
||||||
* tests), the World is still well-formed: the renderer mounts on an
|
* tests), the World is still well-formed: the renderer mounts on an
|
||||||
* empty primitive list without errors.
|
* empty primitive list without errors.
|
||||||
*/
|
*/
|
||||||
export function reportToWorld(report: GameReport): World {
|
export function reportToWorld(report: GameReport): ReportToWorldResult {
|
||||||
const primitives: Primitive[] = [];
|
const primitives: Primitive[] = [];
|
||||||
|
const hitLookup = new Map<PrimitiveID, HitTarget>();
|
||||||
|
|
||||||
for (const planet of report.planets) {
|
for (const planet of report.planets) {
|
||||||
primitives.push({
|
primitives.push({
|
||||||
kind: "point",
|
kind: "point",
|
||||||
@@ -91,8 +116,18 @@ export function reportToWorld(report: GameReport): World {
|
|||||||
x: planet.x,
|
x: planet.x,
|
||||||
y: planet.y,
|
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 width = report.mapWidth > 0 ? report.mapWidth : 1;
|
||||||
const height = report.mapHeight > 0 ? report.mapHeight : 1;
|
const height = report.mapHeight > 0 ? report.mapHeight : 1;
|
||||||
return new World(width, height, primitives);
|
return { world: new World(width, height, primitives), hitLookup };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export interface Style {
|
|||||||
strokeAlpha?: number; // 0..1
|
strokeAlpha?: number; // 0..1
|
||||||
strokeWidthPx?: number; // pixels at any zoom
|
strokeWidthPx?: number; // pixels at any zoom
|
||||||
pointRadiusPx?: number; // pixels at any zoom (for kind === 'point')
|
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.
|
// PrimitiveBase carries the fields shared by every primitive kind.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { IDBCache } from "../src/platform/store/idb-cache";
|
|||||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||||
import type { Cache } from "../src/platform/store/index";
|
import type { Cache } from "../src/platform/store/index";
|
||||||
import type { IDBPDatabase } from "idb";
|
import type { IDBPDatabase } from "idb";
|
||||||
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
|
|
||||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||||
|
|
||||||
@@ -109,6 +110,7 @@ function makeReport(localShipClass: ShipClassSummary[] = []): GameReport {
|
|||||||
localPlayerWeapons: 0,
|
localPlayerWeapons: 0,
|
||||||
localPlayerShields: 0,
|
localPlayerShields: 0,
|
||||||
localPlayerCargo: 0,
|
localPlayerCargo: 0,
|
||||||
|
...EMPTY_SHIP_GROUPS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +314,7 @@ describe("ship-class designer preview pane (Phase 18)", () => {
|
|||||||
localPlayerWeapons: 1,
|
localPlayerWeapons: 1,
|
||||||
localPlayerShields: 1,
|
localPlayerShields: 1,
|
||||||
localPlayerCargo: 1.2,
|
localPlayerCargo: 1.2,
|
||||||
|
...EMPTY_SHIP_GROUPS,
|
||||||
};
|
};
|
||||||
const ui = mountDesigner({ report, core });
|
const ui = mountDesigner({ report, core });
|
||||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
|
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
|
||||||
@@ -379,6 +382,7 @@ describe("ship-class designer preview pane (Phase 18)", () => {
|
|||||||
localPlayerWeapons: 1,
|
localPlayerWeapons: 1,
|
||||||
localPlayerShields: 1,
|
localPlayerShields: 1,
|
||||||
localPlayerCargo: 1,
|
localPlayerCargo: 1,
|
||||||
|
...EMPTY_SHIP_GROUPS,
|
||||||
};
|
};
|
||||||
const ui = mountDesigner({ report, core });
|
const ui = mountDesigner({ report, core });
|
||||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
|
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
GAME_STATE_CONTEXT_KEY,
|
GAME_STATE_CONTEXT_KEY,
|
||||||
GameStateStore,
|
GameStateStore,
|
||||||
} from "../src/lib/game-state.svelte";
|
} from "../src/lib/game-state.svelte";
|
||||||
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
|
|
||||||
function withGameState(opts: {
|
function withGameState(opts: {
|
||||||
gameName?: string;
|
gameName?: string;
|
||||||
@@ -45,6 +46,7 @@ function withGameState(opts: {
|
|||||||
localPlayerWeapons: 0,
|
localPlayerWeapons: 0,
|
||||||
localPlayerShields: 0,
|
localPlayerShields: 0,
|
||||||
localPlayerCargo: 0,
|
localPlayerCargo: 0,
|
||||||
|
...EMPTY_SHIP_GROUPS,
|
||||||
};
|
};
|
||||||
store.status = "ready";
|
store.status = "ready";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
SELECTION_CONTEXT_KEY,
|
SELECTION_CONTEXT_KEY,
|
||||||
SelectionStore,
|
SelectionStore,
|
||||||
} from "../src/lib/selection.svelte";
|
} from "../src/lib/selection.svelte";
|
||||||
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
import {
|
import {
|
||||||
RENDERED_REPORT_CONTEXT_KEY,
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
createRenderedReportSource,
|
createRenderedReportSource,
|
||||||
@@ -79,6 +80,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
|||||||
localPlayerWeapons: 0,
|
localPlayerWeapons: 0,
|
||||||
localPlayerShields: 0,
|
localPlayerShields: 0,
|
||||||
localPlayerCargo: 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 type { GameReport, ReportPlanet } from "../src/api/game-state";
|
||||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
import { openGalaxyDB } from "../src/platform/store/idb";
|
import { openGalaxyDB } from "../src/platform/store/idb";
|
||||||
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
|
|
||||||
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
|
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
|
||||||
let dbName: string;
|
let dbName: string;
|
||||||
@@ -86,6 +87,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
|||||||
localPlayerWeapons: 0,
|
localPlayerWeapons: 0,
|
||||||
localPlayerShields: 0,
|
localPlayerShields: 0,
|
||||||
localPlayerCargo: 0,
|
localPlayerCargo: 0,
|
||||||
|
...EMPTY_SHIP_GROUPS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
STYLE_ROUTE_MAT,
|
STYLE_ROUTE_MAT,
|
||||||
buildCargoRouteLines,
|
buildCargoRouteLines,
|
||||||
} from "../src/map/cargo-routes";
|
} from "../src/map/cargo-routes";
|
||||||
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
|
|
||||||
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
|
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
|
||||||
return {
|
return {
|
||||||
@@ -61,6 +62,7 @@ function makeReport(
|
|||||||
localPlayerWeapons: 0,
|
localPlayerWeapons: 0,
|
||||||
localPlayerShields: 0,
|
localPlayerShields: 0,
|
||||||
localPlayerCargo: 0,
|
localPlayerCargo: 0,
|
||||||
|
...EMPTY_SHIP_GROUPS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +207,7 @@ describe("buildCargoRouteLines", () => {
|
|||||||
localPlayerWeapons: 0,
|
localPlayerWeapons: 0,
|
||||||
localPlayerShields: 0,
|
localPlayerShields: 0,
|
||||||
localPlayerCargo: 0,
|
localPlayerCargo: 0,
|
||||||
|
...EMPTY_SHIP_GROUPS,
|
||||||
};
|
};
|
||||||
expect(buildCargoRouteLines(report)).toEqual([]);
|
expect(buildCargoRouteLines(report)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
OrderCommand,
|
OrderCommand,
|
||||||
ProductionType,
|
ProductionType,
|
||||||
} from "../src/sync/order-types";
|
} from "../src/sync/order-types";
|
||||||
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
|
|
||||||
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
|
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
|
||||||
return {
|
return {
|
||||||
@@ -53,6 +54,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
|||||||
localPlayerWeapons: 0,
|
localPlayerWeapons: 0,
|
||||||
localPlayerShields: 0,
|
localPlayerShields: 0,
|
||||||
localPlayerCargo: 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { describe, expect, test } from "vitest";
|
|||||||
|
|
||||||
import type { GameReport, ReportPlanet } from "../src/api/game-state";
|
import type { GameReport, ReportPlanet } from "../src/api/game-state";
|
||||||
import { reportToWorld } from "../src/map/state-binding";
|
import { reportToWorld } from "../src/map/state-binding";
|
||||||
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
|
|
||||||
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
||||||
return {
|
return {
|
||||||
@@ -26,6 +27,7 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
|||||||
localPlayerWeapons: 0,
|
localPlayerWeapons: 0,
|
||||||
localPlayerShields: 0,
|
localPlayerShields: 0,
|
||||||
localPlayerCargo: 0,
|
localPlayerCargo: 0,
|
||||||
|
...EMPTY_SHIP_GROUPS,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -55,13 +57,13 @@ function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
|
|||||||
|
|
||||||
describe("reportToWorld", () => {
|
describe("reportToWorld", () => {
|
||||||
test("uses report dimensions for the World", () => {
|
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.width).toBe(3200);
|
||||||
expect(world.height).toBe(1600);
|
expect(world.height).toBe(1600);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("emits one Point primitive per planet across all four kinds", () => {
|
test("emits one Point primitive per planet across all four kinds", () => {
|
||||||
const world = reportToWorld(
|
const { world } = reportToWorld(
|
||||||
makeReport({
|
makeReport({
|
||||||
planets: [
|
planets: [
|
||||||
makePlanet({ number: 1, name: "Home", x: 100, y: 100, kind: "local", size: 12, resources: 0.5 }),
|
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", () => {
|
test("propagates planet number as primitive id and coordinates verbatim", () => {
|
||||||
const world = reportToWorld(
|
const { world } = reportToWorld(
|
||||||
makeReport({
|
makeReport({
|
||||||
planets: [
|
planets: [
|
||||||
makePlanet({ number: 42, name: "Home", x: 123.5, y: 456.25, kind: "local", size: 10, resources: 0.5 }),
|
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", () => {
|
test("uses distinct styles for each planet kind", () => {
|
||||||
const world = reportToWorld(
|
const { world } = reportToWorld(
|
||||||
makeReport({
|
makeReport({
|
||||||
planets: [
|
planets: [
|
||||||
makePlanet({ number: 1, name: "L", kind: "local", size: 1, resources: 0 }),
|
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", () => {
|
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.primitives.length).toBe(0);
|
||||||
expect(world.width).toBeGreaterThan(0);
|
expect(world.width).toBeGreaterThan(0);
|
||||||
expect(world.height).toBeGreaterThan(0);
|
expect(world.height).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guards against zero / negative dimensions in the report", () => {
|
test("guards against zero / negative dimensions in the report", () => {
|
||||||
const world = reportToWorld(
|
const { world } = reportToWorld(
|
||||||
makeReport({ mapWidth: 0, mapHeight: -1, planets: [] }),
|
makeReport({ mapWidth: 0, mapHeight: -1, planets: [] }),
|
||||||
);
|
);
|
||||||
// World's constructor rejects non-positive dimensions; the
|
// World's constructor rejects non-positive dimensions; the
|
||||||
@@ -129,7 +131,7 @@ describe("reportToWorld", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("local planets carry higher priority than unidentified", () => {
|
test("local planets carry higher priority than unidentified", () => {
|
||||||
const world = reportToWorld(
|
const { world } = reportToWorld(
|
||||||
makeReport({
|
makeReport({
|
||||||
planets: [
|
planets: [
|
||||||
makePlanet({ number: 1, name: "Home", kind: "local", size: 1, resources: 0 }),
|
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
|
// into `reportToWorld`. The base world stays a clean
|
||||||
// representation of the report's planets so the renderer
|
// representation of the report's planets so the renderer
|
||||||
// can rebuild the overlay without disposing Pixi.
|
// can rebuild the overlay without disposing Pixi.
|
||||||
const world = reportToWorld(
|
const { world } = reportToWorld(
|
||||||
makeReport({
|
makeReport({
|
||||||
planets: [
|
planets: [
|
||||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100, kind: "local", size: 5, resources: 1 }),
|
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 { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||||
import type { Cache } from "../src/platform/store/index";
|
import type { Cache } from "../src/platform/store/index";
|
||||||
import type { IDBPDatabase } from "idb";
|
import type { IDBPDatabase } from "idb";
|
||||||
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
|
|
||||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ function makeReport(localShipClass: ShipClassSummary[]): GameReport {
|
|||||||
localPlayerWeapons: 0,
|
localPlayerWeapons: 0,
|
||||||
localPlayerShields: 0,
|
localPlayerShields: 0,
|
||||||
localPlayerCargo: 0,
|
localPlayerCargo: 0,
|
||||||
|
...EMPTY_SHIP_GROUPS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user