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 {
|
||||
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<CargoLoadType, number> = (() => {
|
||||
const map = {} as Record<CargoLoadType, number>;
|
||||
CARGO_LOAD_TYPE_VALUES.forEach((value, index) => {
|
||||
|
||||
@@ -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<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 {
|
||||
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<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(
|
||||
p: SyntheticPlanet,
|
||||
kind: ReportPlanet["kind"],
|
||||
|
||||
Reference in New Issue
Block a user