// 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(); 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-` 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": , "battles": { : } }` * — 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; } /** * 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; 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 | 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; }