225f89fad6
Tests · UI / test (push) Successful in 3m16s
Stage 2 of the dev-as-prod-mirror rework. The legacy-report (synthetic) report loader is already available in the dev-deploy UI: it is gated by the build-time flag VITE_GALAXY_DEV_AFFORDANCES (set "true" in dev-deploy.yaml line 89, unset in prod-build.yaml so prod strips it), not by import.meta.env.DEV. Correct the stale header comment that claimed import.meta.env.DEV. No functional change — the desired "loader in dev, absent in prod" posture already holds.
700 lines
20 KiB
TypeScript
700 lines
20 KiB
TypeScript
// DEV-only synthetic-report loader. Backs the "Load synthetic report"
|
|
// affordance on the lobby (visible when the build-time flag
|
|
// `VITE_GALAXY_DEV_AFFORDANCES === "true"` — the dev and dev-deploy
|
|
// bundles; stripped from prod) and the in-game shell layout's bypass
|
|
// for the synthetic game id range.
|
|
//
|
|
// The accepted JSON shape mirrors `pkg/model/report.Report` as
|
|
// emitted by `tools/local-dev/legacy-report/cmd/legacy-report-to-json`.
|
|
// Whenever the UI's `decodeReport` (`api/game-state.ts`) is extended
|
|
// to read a new field, this decoder must be extended in lock-step
|
|
// AND the Go CLI must learn to populate that field — see the
|
|
// synthetic-report parity rule in `ui/PLAN.md`.
|
|
//
|
|
// `personalExitWarning` / `racesLeavingSoon` are the exception the
|
|
// rule allows: they are runtime inactivity-countdown state the engine
|
|
// derives per turn, not anything present in a static legacy text
|
|
// report, so the legacy CLI leaves them empty (the parity rule's
|
|
// "cannot be derived from the legacy text format" escape hatch). This
|
|
// decoder still reads them defensively so a hand-authored synthetic
|
|
// JSON fixture can exercise the report's exit-warning UI.
|
|
//
|
|
// The in-memory map deliberately does not survive a page reload:
|
|
// synthetic mode is a debug affordance, not a session, and the
|
|
// layout redirects to /lobby when a synthetic id is opened with no
|
|
// matching entry.
|
|
//
|
|
// Routes are always emitted empty: the legacy text report has no
|
|
// dedicated cargo-routes section, and `applyOrderOverlay` already
|
|
// handles an empty `routes` array.
|
|
|
|
import type {
|
|
GameReport,
|
|
ReportBombing,
|
|
ReportIncomingShipGroup,
|
|
ReportLocalFleet,
|
|
ReportLocalShipGroup,
|
|
ReportOtherRace,
|
|
ReportOtherScience,
|
|
ReportOtherShipClass,
|
|
ReportOtherShipGroup,
|
|
ReportPlanet,
|
|
ReportPlayer,
|
|
ReportRoute,
|
|
ReportShipProduction,
|
|
ReportUnidentifiedShipGroup,
|
|
ScienceSummary,
|
|
ShipClassSummary,
|
|
ShipGroupTech,
|
|
} from "./game-state";
|
|
import type { CargoLoadType, Relation } from "../sync/order-types";
|
|
import { isCargoLoadType, isRelation } from "../sync/order-types";
|
|
import type { BattleReport } from "./battle-fetch";
|
|
import { registerSyntheticBattle } from "./synthetic-battle";
|
|
|
|
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
|
|
|
|
const SYNTHETIC_REPORTS = new Map<string, GameReport>();
|
|
|
|
export function isSyntheticGameId(gameId: string): boolean {
|
|
return gameId.startsWith(SYNTHETIC_GAME_ID_PREFIX);
|
|
}
|
|
|
|
export class SyntheticReportError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "SyntheticReportError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* loadSyntheticReportFromJSON validates the passed payload, decodes
|
|
* it into a `GameReport`, registers it in the in-memory map under a
|
|
* fresh `synthetic-<uuid>` id, and returns both the id and the
|
|
* decoded report.
|
|
*
|
|
* Accepts two on-disk shapes:
|
|
*
|
|
* 1. Envelope (Phase 27 legacy-report CLI):
|
|
* `{ "version": 1, "report": <Report>, "battles": { <uuid>: <BattleReport> } }`
|
|
* — battles are forwarded to `registerSyntheticBattle` so the
|
|
* Battle Viewer can resolve them offline.
|
|
* 2. Bare Report (pre-envelope synthetic JSON files) — same as
|
|
* before; battle UUIDs in the report can still be clicked, but
|
|
* the Viewer page will show "battle not found" because no
|
|
* fixture was registered.
|
|
*
|
|
* Throws `SyntheticReportError` for malformed input in either shape.
|
|
*/
|
|
export function loadSyntheticReportFromJSON(json: unknown): {
|
|
gameId: string;
|
|
report: GameReport;
|
|
} {
|
|
const { reportPayload, battles } = extractEnvelope(json);
|
|
const report = decodeSyntheticReport(reportPayload);
|
|
for (const battle of battles) {
|
|
registerSyntheticBattle(battle);
|
|
}
|
|
const gameId = SYNTHETIC_GAME_ID_PREFIX + crypto.randomUUID();
|
|
SYNTHETIC_REPORTS.set(gameId, report);
|
|
return { gameId, report };
|
|
}
|
|
|
|
interface SyntheticEnvelope {
|
|
version?: number;
|
|
report?: unknown;
|
|
battles?: Record<string, BattleReport>;
|
|
}
|
|
|
|
/**
|
|
* extractEnvelope distinguishes the v1 envelope shape from a bare
|
|
* Report payload. The envelope check is `version === 1` to leave room
|
|
* for future format bumps and to avoid mistaking a bare Report whose
|
|
* top-level fields happen to include `report`/`battles` (none do
|
|
* today) for an envelope.
|
|
*/
|
|
function extractEnvelope(json: unknown): {
|
|
reportPayload: unknown;
|
|
battles: BattleReport[];
|
|
} {
|
|
if (typeof json !== "object" || json === null) {
|
|
// Defer the error to `decodeSyntheticReport`; it already
|
|
// raises a `SyntheticReportError` with the right message.
|
|
return { reportPayload: json, battles: [] };
|
|
}
|
|
const env = json as SyntheticEnvelope;
|
|
if (env.version === 1 && env.report !== undefined) {
|
|
const battlesMap = env.battles ?? {};
|
|
const battles: BattleReport[] = [];
|
|
for (const value of Object.values(battlesMap)) {
|
|
if (value && typeof value === "object") {
|
|
battles.push(value);
|
|
}
|
|
}
|
|
return { reportPayload: env.report, battles };
|
|
}
|
|
return { reportPayload: json, battles: [] };
|
|
}
|
|
|
|
/** getSyntheticReport returns the report registered under `gameId`,
|
|
* or `undefined` if the entry was lost (e.g. page reload). */
|
|
export function getSyntheticReport(gameId: string): GameReport | undefined {
|
|
return SYNTHETIC_REPORTS.get(gameId);
|
|
}
|
|
|
|
interface SyntheticPlanet {
|
|
number: number;
|
|
name?: string;
|
|
x: number;
|
|
y: number;
|
|
size?: number;
|
|
resources?: number;
|
|
capital?: number;
|
|
material?: number;
|
|
industry?: number;
|
|
population?: number;
|
|
colonists?: number;
|
|
production?: string;
|
|
freeIndustry?: number;
|
|
owner?: string;
|
|
}
|
|
|
|
interface SyntheticShipClass {
|
|
name: string;
|
|
drive: number;
|
|
armament: number;
|
|
weapons: number;
|
|
shields: number;
|
|
cargo: number;
|
|
}
|
|
|
|
interface SyntheticPlayer {
|
|
name: string;
|
|
drive: number;
|
|
weapons: number;
|
|
shields: number;
|
|
cargo: number;
|
|
population?: number;
|
|
industry?: number;
|
|
planets?: number;
|
|
relation?: string;
|
|
votes?: number;
|
|
extinct?: boolean;
|
|
}
|
|
|
|
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;
|
|
race?: 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 SyntheticScience {
|
|
name?: string;
|
|
drive?: number;
|
|
weapons?: number;
|
|
shields?: number;
|
|
cargo?: number;
|
|
}
|
|
|
|
interface SyntheticOtherScience extends SyntheticScience {
|
|
race?: string;
|
|
}
|
|
|
|
interface SyntheticOtherShipClass extends SyntheticShipClass {
|
|
race?: string;
|
|
mass?: number;
|
|
}
|
|
|
|
interface SyntheticBattle {
|
|
id?: string;
|
|
planet?: number;
|
|
shots?: number;
|
|
}
|
|
|
|
interface SyntheticBombing {
|
|
planet?: number; // wire field "number"
|
|
planetName?: string; // wire field "planetName"
|
|
owner?: string;
|
|
attacker?: string;
|
|
production?: string;
|
|
industry?: number;
|
|
population?: number;
|
|
colonists?: number;
|
|
capital?: number;
|
|
material?: number;
|
|
attack?: number;
|
|
wiped?: boolean;
|
|
}
|
|
|
|
interface SyntheticShipProductionRow {
|
|
planet?: number;
|
|
class?: string;
|
|
cost?: number;
|
|
prodUsed?: number;
|
|
percent?: number;
|
|
free?: number;
|
|
}
|
|
|
|
interface SyntheticRaceExitNotice {
|
|
race?: string;
|
|
turnsLeft?: number;
|
|
}
|
|
|
|
interface SyntheticReportRoot {
|
|
turn?: number;
|
|
mapWidth?: number;
|
|
mapHeight?: number;
|
|
mapPlanets?: number;
|
|
race?: string;
|
|
votes?: number;
|
|
voteFor?: string;
|
|
player?: SyntheticPlayer[];
|
|
localPlanet?: SyntheticPlanet[];
|
|
otherPlanet?: SyntheticPlanet[];
|
|
uninhabitedPlanet?: SyntheticPlanet[];
|
|
unidentifiedPlanet?: SyntheticPlanet[];
|
|
localShipClass?: SyntheticShipClass[];
|
|
otherShipClass?: SyntheticOtherShipClass[];
|
|
localScience?: SyntheticScience[];
|
|
otherScience?: SyntheticOtherScience[];
|
|
localGroup?: SyntheticShipGroup[];
|
|
otherGroup?: SyntheticShipGroup[];
|
|
incomingGroup?: SyntheticIncomingGroup[];
|
|
unidentifiedGroup?: SyntheticUnidentifiedGroup[];
|
|
localFleet?: SyntheticLocalFleet[];
|
|
battle?: SyntheticBattle[];
|
|
bombing?: SyntheticBombing[];
|
|
shipProduction?: SyntheticShipProductionRow[];
|
|
personalExitWarning?: number;
|
|
racesLeavingSoon?: SyntheticRaceExitNotice[];
|
|
}
|
|
|
|
function decodeSyntheticReport(json: unknown): GameReport {
|
|
if (typeof json !== "object" || json === null) {
|
|
throw new SyntheticReportError("synthetic report must be a JSON object");
|
|
}
|
|
const root = json as SyntheticReportRoot;
|
|
|
|
const planets: ReportPlanet[] = [];
|
|
for (const p of root.localPlanet ?? []) {
|
|
planets.push(toPlanet(p, "local", null));
|
|
}
|
|
for (const p of root.otherPlanet ?? []) {
|
|
planets.push(toPlanet(p, "other", p.owner ?? null));
|
|
}
|
|
for (const p of root.uninhabitedPlanet ?? []) {
|
|
planets.push(toPlanet(p, "uninhabited", null));
|
|
}
|
|
for (const p of root.unidentifiedPlanet ?? []) {
|
|
planets.push(toPlanet(p, "unidentified", null));
|
|
}
|
|
|
|
const localShipClass: ShipClassSummary[] = (root.localShipClass ?? []).map(
|
|
(sc) => ({
|
|
name: sc.name,
|
|
drive: numOr0(sc.drive),
|
|
armament: Math.trunc(numOr0(sc.armament)),
|
|
weapons: numOr0(sc.weapons),
|
|
shields: numOr0(sc.shields),
|
|
cargo: numOr0(sc.cargo),
|
|
}),
|
|
);
|
|
|
|
const localScience: ScienceSummary[] = (root.localScience ?? []).map((sc) => ({
|
|
name: typeof sc.name === "string" ? sc.name : "",
|
|
drive: numOr0(sc.drive),
|
|
weapons: numOr0(sc.weapons),
|
|
shields: numOr0(sc.shields),
|
|
cargo: numOr0(sc.cargo),
|
|
}));
|
|
|
|
const race = typeof root.race === "string" ? root.race : "";
|
|
const tech = findLocalPlayerTech(root.player ?? [], race);
|
|
|
|
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,
|
|
race: typeof g.race === "string" ? g.race : race,
|
|
}),
|
|
);
|
|
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),
|
|
race: typeof g.race === "string" ? g.race : "",
|
|
}),
|
|
);
|
|
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 : "",
|
|
}));
|
|
|
|
const otherScience: ReportOtherScience[] = (root.otherScience ?? []).map(
|
|
(sc) => ({
|
|
race: typeof sc.race === "string" ? sc.race : "",
|
|
name: typeof sc.name === "string" ? sc.name : "",
|
|
drive: numOr0(sc.drive),
|
|
weapons: numOr0(sc.weapons),
|
|
shields: numOr0(sc.shields),
|
|
cargo: numOr0(sc.cargo),
|
|
}),
|
|
);
|
|
otherScience.sort((a, b) => {
|
|
const byRace = a.race.localeCompare(b.race);
|
|
if (byRace !== 0) return byRace;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
const otherShipClass: ReportOtherShipClass[] = (root.otherShipClass ?? []).map(
|
|
(sc) => ({
|
|
race: typeof sc.race === "string" ? sc.race : "",
|
|
name: typeof sc.name === "string" ? sc.name : "",
|
|
drive: numOr0(sc.drive),
|
|
armament: Math.trunc(numOr0(sc.armament)),
|
|
weapons: numOr0(sc.weapons),
|
|
shields: numOr0(sc.shields),
|
|
cargo: numOr0(sc.cargo),
|
|
// `mass` is on the wire but synthetic fixtures may omit
|
|
// it; fall back to 0 rather than reject the row.
|
|
mass: typeof sc.mass === "number" ? sc.mass : 0,
|
|
}),
|
|
);
|
|
otherShipClass.sort((a, b) => {
|
|
const byRace = a.race.localeCompare(b.race);
|
|
if (byRace !== 0) return byRace;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
const battles = (root.battle ?? [])
|
|
.filter(
|
|
(v): v is SyntheticBattle =>
|
|
typeof v === "object" && v !== null && typeof v.id === "string" && v.id !== "",
|
|
)
|
|
.map((b) => ({
|
|
id: b.id as string,
|
|
planet: numOr0(b.planet),
|
|
shots: numOr0(b.shots),
|
|
}));
|
|
const battleIds = battles.map((b) => b.id);
|
|
|
|
const bombings: ReportBombing[] = (root.bombing ?? []).map((b) => ({
|
|
planetNumber: numOr0(b.planet),
|
|
planet: typeof b.planetName === "string" ? b.planetName : "",
|
|
owner: typeof b.owner === "string" ? b.owner : "",
|
|
attacker: typeof b.attacker === "string" ? b.attacker : "",
|
|
production: typeof b.production === "string" ? b.production : "",
|
|
industry: numOr0(b.industry),
|
|
population: numOr0(b.population),
|
|
colonists: numOr0(b.colonists),
|
|
industryStockpile: numOr0(b.capital),
|
|
materialsStockpile: numOr0(b.material),
|
|
attackPower: numOr0(b.attack),
|
|
wiped: b.wiped === true,
|
|
}));
|
|
bombings.sort((a, b) => a.planetNumber - b.planetNumber);
|
|
|
|
const shipProductions: ReportShipProduction[] = (root.shipProduction ?? []).map(
|
|
(sp) => ({
|
|
planetNumber: numOr0(sp.planet),
|
|
class: typeof sp.class === "string" ? sp.class : "",
|
|
cost: numOr0(sp.cost),
|
|
prodUsed: numOr0(sp.prodUsed),
|
|
percent: numOr0(sp.percent),
|
|
freeIndustry: numOr0(sp.free),
|
|
}),
|
|
);
|
|
shipProductions.sort((a, b) => {
|
|
const byPlanet = a.planetNumber - b.planetNumber;
|
|
if (byPlanet !== 0) return byPlanet;
|
|
return a.class.localeCompare(b.class);
|
|
});
|
|
|
|
const racesLeavingSoon: { race: string; turnsLeft: number }[] = (
|
|
root.racesLeavingSoon ?? []
|
|
).map((n) => ({
|
|
race: typeof n.race === "string" ? n.race : "",
|
|
turnsLeft: numOr0(n.turnsLeft),
|
|
}));
|
|
|
|
return {
|
|
turn: numOr0(root.turn),
|
|
mapWidth: numOr0(root.mapWidth),
|
|
mapHeight: numOr0(root.mapHeight),
|
|
planetCount: numOr0(root.mapPlanets),
|
|
planets,
|
|
race,
|
|
localShipClass,
|
|
localScience,
|
|
routes,
|
|
localPlayerDrive: tech.drive,
|
|
localPlayerWeapons: tech.weapons,
|
|
localPlayerShields: tech.shields,
|
|
localPlayerCargo: tech.cargo,
|
|
localShipGroups,
|
|
otherShipGroups,
|
|
incomingShipGroups,
|
|
unidentifiedShipGroups,
|
|
localFleets,
|
|
otherRaces: collectOtherRacesFromSynthetic(root, race),
|
|
races: collectOtherRaceRowsFromSynthetic(root, race),
|
|
myVotes: numOr0(root.votes),
|
|
myVoteFor: typeof root.voteFor === "string" ? root.voteFor : "",
|
|
players: collectPlayersFromSynthetic(root, race),
|
|
otherScience,
|
|
otherShipClass,
|
|
battles,
|
|
battleIds,
|
|
bombings,
|
|
shipProductions,
|
|
personalExitWarning: numOr0(root.personalExitWarning),
|
|
racesLeavingSoon,
|
|
};
|
|
}
|
|
|
|
function collectPlayersFromSynthetic(
|
|
root: SyntheticReportRoot,
|
|
raceName: string,
|
|
): ReportPlayer[] {
|
|
const out: ReportPlayer[] = [];
|
|
for (const player of root.player ?? []) {
|
|
const name = typeof player.name === "string" ? player.name : "";
|
|
if (name === "") continue;
|
|
out.push({
|
|
name,
|
|
drive: numOr0(player.drive),
|
|
weapons: numOr0(player.weapons),
|
|
shields: numOr0(player.shields),
|
|
cargo: numOr0(player.cargo),
|
|
population: numOr0(player.population),
|
|
industry: numOr0(player.industry),
|
|
planets: Math.trunc(numOr0(player.planets)),
|
|
votesReceived: numOr0(player.votes),
|
|
extinct: player.extinct === true,
|
|
isLocal: name === raceName,
|
|
});
|
|
}
|
|
out.sort((a, b) =>
|
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
|
);
|
|
return out;
|
|
}
|
|
|
|
function collectOtherRacesFromSynthetic(
|
|
root: SyntheticReportRoot,
|
|
raceName: string,
|
|
): string[] {
|
|
const out: string[] = [];
|
|
for (const player of root.player ?? []) {
|
|
if (player.extinct === true) continue;
|
|
const name = typeof player.name === "string" ? player.name : "";
|
|
if (name === "" || name === raceName) continue;
|
|
out.push(name);
|
|
}
|
|
out.sort((a, b) => a.localeCompare(b));
|
|
return out;
|
|
}
|
|
|
|
function collectOtherRaceRowsFromSynthetic(
|
|
root: SyntheticReportRoot,
|
|
raceName: string,
|
|
): ReportOtherRace[] {
|
|
const out: ReportOtherRace[] = [];
|
|
for (const player of root.player ?? []) {
|
|
if (player.extinct === true) continue;
|
|
const name = typeof player.name === "string" ? player.name : "";
|
|
if (name === "" || name === raceName) continue;
|
|
const wire = typeof player.relation === "string" ? player.relation : "";
|
|
const relation: Relation = isRelation(wire) ? wire : "PEACE";
|
|
out.push({
|
|
name,
|
|
drive: numOr0(player.drive),
|
|
weapons: numOr0(player.weapons),
|
|
shields: numOr0(player.shields),
|
|
cargo: numOr0(player.cargo),
|
|
population: numOr0(player.population),
|
|
industry: numOr0(player.industry),
|
|
planets: Math.trunc(numOr0(player.planets)),
|
|
relation,
|
|
votesReceived: numOr0(player.votes),
|
|
});
|
|
}
|
|
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
return out;
|
|
}
|
|
|
|
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"],
|
|
owner: string | null,
|
|
): ReportPlanet {
|
|
const has = (v: number | undefined): number | null =>
|
|
typeof v === "number" ? v : null;
|
|
if (kind === "unidentified") {
|
|
return {
|
|
number: numOr0(p.number),
|
|
name: "",
|
|
x: numOr0(p.x),
|
|
y: numOr0(p.y),
|
|
kind,
|
|
owner,
|
|
size: null,
|
|
resources: null,
|
|
industryStockpile: null,
|
|
materialsStockpile: null,
|
|
industry: null,
|
|
population: null,
|
|
colonists: null,
|
|
production: null,
|
|
freeIndustry: null,
|
|
};
|
|
}
|
|
if (kind === "uninhabited") {
|
|
return {
|
|
number: numOr0(p.number),
|
|
name: typeof p.name === "string" ? p.name : "",
|
|
x: numOr0(p.x),
|
|
y: numOr0(p.y),
|
|
kind,
|
|
owner,
|
|
size: has(p.size),
|
|
resources: has(p.resources),
|
|
industryStockpile: has(p.capital),
|
|
materialsStockpile: has(p.material),
|
|
industry: null,
|
|
population: null,
|
|
colonists: null,
|
|
production: null,
|
|
freeIndustry: null,
|
|
};
|
|
}
|
|
return {
|
|
number: numOr0(p.number),
|
|
name: typeof p.name === "string" ? p.name : "",
|
|
x: numOr0(p.x),
|
|
y: numOr0(p.y),
|
|
kind,
|
|
owner,
|
|
size: has(p.size),
|
|
resources: has(p.resources),
|
|
industryStockpile: has(p.capital),
|
|
materialsStockpile: has(p.material),
|
|
industry: has(p.industry),
|
|
population: has(p.population),
|
|
colonists: has(p.colonists),
|
|
production: typeof p.production === "string" ? p.production : null,
|
|
freeIndustry: has(p.freeIndustry),
|
|
};
|
|
}
|
|
|
|
function findLocalPlayerTech(
|
|
players: SyntheticPlayer[],
|
|
race: string,
|
|
): { drive: number; weapons: number; shields: number; cargo: number } {
|
|
if (race === "") {
|
|
return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
|
|
}
|
|
const local = players.find((p) => p.name === race);
|
|
if (local === undefined) {
|
|
return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
|
|
}
|
|
return {
|
|
drive: numOr0(local.drive),
|
|
weapons: numOr0(local.weapons),
|
|
shields: numOr0(local.shields),
|
|
cargo: numOr0(local.cargo),
|
|
};
|
|
}
|
|
|
|
function numOr0(v: unknown): number {
|
|
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
}
|