// Typed wrapper around `GalaxyClient.executeCommand("user.games.report", // ...)`. The signed-gRPC wire shape is the FlatBuffers // `report.GameReportRequest` for the request and `report.Report` for // the response (see `pkg/schema/fbs/report.fbs`). Full ship / fleet / // science decoding lands in Phases 17-22. // // Phase 13 expanded the per-planet projection so the inspector can // render every documented field without a second round-trip. Each // planet field is optional: the FBS schema carries different field // sets for `LocalPlanet`, `OtherPlanet`, `UninhabitedPlanet`, and // `UnidentifiedPlanet`, and the wrapper preserves that nullability // instead of inventing zero values. // // Phase 14 adds `applyOrderOverlay`: every applied / submitting // rename in the local draft swaps the planet name on the rendered // report so the player sees their intent reflected immediately, // without waiting for the next turn cutoff. // // Phase 15 extends the projection with a minimal `localShipClass` // summary so the planet inspector's Build-Ship sub-picker has data // to render. Phase 17 (ship-class CRUD) widens `ShipClassSummary` // when the designer ships need the full attribute set. import { Builder, ByteBuffer } from "flatbuffers"; import type { GalaxyClient } from "./galaxy-client"; import { UUID } from "../proto/galaxy/fbs/common"; import { GameReportRequest, Report, } from "../proto/galaxy/fbs/report"; import type { CargoLoadType, CommandStatus, OrderCommand, ProductionType, } from "../sync/order-types"; import { CARGO_LOAD_TYPE_VALUES, isCargoLoadType } from "../sync/order-types"; const MESSAGE_TYPE = "user.games.report"; export class GameStateError extends Error { readonly resultCode: string; readonly code: string; constructor(resultCode: string, code: string, message: string) { super(message); this.name = "GameStateError"; this.resultCode = resultCode; this.code = code; } } export interface ReportPlanet { number: number; name: string; x: number; y: number; kind: "local" | "other" | "uninhabited" | "unidentified"; owner: string | null; size: number | null; resources: number | null; // Engine field naming carries history: `capital` ($) is the // industry stockpile, `material` (M) is the materials stockpile. // `pkg/model/report/planet.go` is the source of truth for these. industryStockpile: number | null; materialsStockpile: number | null; industry: number | null; population: number | null; colonists: number | null; production: string | null; freeIndustry: number | null; } /** * ShipClassSummary is the slim projection of `report.ShipClass` the * planet inspector's Build-Ship sub-picker needs in Phase 15. Only * the human-visible `name` is carried — the engine command shape * (`CommandPlanetProduce.subject`) takes the class name, not its * underlying tech values. Phase 17 widens this type when the ship * designer needs the full attribute set. */ export interface ShipClassSummary { name: string; } /** * ReportRouteEntry is one slot of a planet's cargo-route table — * a (loadType, destinationPlanetNumber) pair. The engine stores * the entries as `map[RouteType]uint` per planet * (`game/internal/model/game/planet.go`); this type flattens that * map into an array so iteration order is stable for tests and * the map-arrow renderer. */ export interface ReportRouteEntry { loadType: CargoLoadType; destinationPlanetNumber: number; } /** * ReportRoute groups every cargo-route slot configured on a * single source planet. `entries` is sorted by * `CARGO_LOAD_TYPE_VALUES` priority (COL → CAP → MAT → EMP) so * the inspector and the map renderer see deterministic order. */ export interface ReportRoute { sourcePlanetNumber: number; entries: ReportRouteEntry[]; } export interface GameReport { turn: number; mapWidth: number; mapHeight: number; planetCount: number; planets: ReportPlanet[]; /** * race is the calling player's race name as resolved by the * engine from the runtime player mapping. Empty when the engine * has not produced a report yet (boot state). */ race: string; /** * localShipClass enumerates the player's own designed ship classes * by name. Empty until at least one class is created * (`CommandShipClassCreate`, Phase 17). The Build-Ship sub-picker * shows a localized "no ship classes" placeholder when this is * empty. */ localShipClass: ShipClassSummary[]; /** * routes lists every cargo route the player has configured. * Each entry is keyed by source planet; the per-planet * `entries` array is sorted in turn-cutoff load order * (`CARGO_LOAD_TYPE_VALUES`). Empty when no routes are set or * when the report does not carry the route field. */ routes: ReportRoute[]; /** * localPlayerDrive is the local player's drive tech level. The * engine's reach formula is `40 * driveTech` * (`game/internal/model/game/race.go.FlightDistance`); the * cargo-route picker filters destinations through it, so the * value is propagated all the way through `applyOrderOverlay` * to the inspector subsection. Zero on boot or when the * report's player block is missing the local entry. */ localPlayerDrive: number; } export async function fetchGameReport( client: GalaxyClient, gameId: string, turn: number, ): Promise { const builder = new Builder(64); const [hi, lo] = uuidToHiLo(gameId); const gameIdOffset = UUID.createUUID(builder, hi, lo); GameReportRequest.startGameReportRequest(builder); GameReportRequest.addGameId(builder, gameIdOffset); GameReportRequest.addTurn(builder, turn); builder.finish(GameReportRequest.endGameReportRequest(builder)); const result = await client.executeCommand(MESSAGE_TYPE, builder.asUint8Array()); if (result.resultCode !== "ok") { const { code, message } = decodeErrorMessage(result.payloadBytes); throw new GameStateError(result.resultCode, code, message); } const buffer = new ByteBuffer(result.payloadBytes); const report = Report.getRootAsReport(buffer); return decodeReport(report); } function decodeReport(report: Report): GameReport { const planets: ReportPlanet[] = []; for (let i = 0; i < report.localPlanetLength(); i++) { const p = report.localPlanet(i); if (p === null) continue; planets.push({ number: Number(p.number()), name: p.name() ?? "", x: p.x(), y: p.y(), kind: "local", owner: null, size: p.size(), resources: p.resources(), industryStockpile: p.capital(), materialsStockpile: p.material(), industry: p.industry(), population: p.population(), colonists: p.colonists(), production: p.production() ?? null, freeIndustry: p.freeIndustry(), }); } for (let i = 0; i < report.otherPlanetLength(); i++) { const p = report.otherPlanet(i); if (p === null) continue; planets.push({ number: Number(p.number()), name: p.name() ?? "", x: p.x(), y: p.y(), kind: "other", owner: p.owner() ?? null, size: p.size(), resources: p.resources(), industryStockpile: p.capital(), materialsStockpile: p.material(), industry: p.industry(), population: p.population(), colonists: p.colonists(), production: p.production() ?? null, freeIndustry: p.freeIndustry(), }); } for (let i = 0; i < report.uninhabitedPlanetLength(); i++) { const p = report.uninhabitedPlanet(i); if (p === null) continue; planets.push({ number: Number(p.number()), name: p.name() ?? "", x: p.x(), y: p.y(), kind: "uninhabited", owner: null, size: p.size(), resources: p.resources(), industryStockpile: p.capital(), materialsStockpile: p.material(), industry: null, population: null, colonists: null, production: null, freeIndustry: null, }); } for (let i = 0; i < report.unidentifiedPlanetLength(); i++) { const p = report.unidentifiedPlanet(i); if (p === null) continue; planets.push({ number: Number(p.number()), name: "", x: p.x(), y: p.y(), kind: "unidentified", owner: null, size: null, resources: null, industryStockpile: null, materialsStockpile: null, industry: null, population: null, colonists: null, production: null, freeIndustry: null, }); } const localShipClass: ShipClassSummary[] = []; for (let i = 0; i < report.localShipClassLength(); i++) { const sc = report.localShipClass(i); if (sc === null) continue; localShipClass.push({ name: sc.name() ?? "" }); } const raceName = report.race() ?? ""; const routes = decodeReportRoutes(report); const localPlayerDrive = findLocalPlayerDrive(report, raceName); return { turn: Number(report.turn()), mapWidth: report.width(), mapHeight: report.height(), planetCount: report.planetCount(), planets, race: raceName, localShipClass, routes, localPlayerDrive, }; } /** * decodeReportRoutes flattens `report.route()[]` into the typed * `ReportRoute[]`. Each `Route` carries `planet` (source) and an * array of `RouteEntry` rows where `key` is the destination * planet number and `value` is the load-type string. Entries * with unknown load-types are dropped with a `console.warn` so a * future schema bump never silently corrupts the inspector. */ function decodeReportRoutes(report: Report): ReportRoute[] { const out: ReportRoute[] = []; for (let i = 0; i < report.routeLength(); i++) { const route = report.route(i); if (route === null) continue; const sourcePlanetNumber = Number(route.planet()); const entries: ReportRouteEntry[] = []; for (let j = 0; j < route.routeLength(); j++) { const entry = route.route(j); if (entry === null) continue; const value = entry.value() ?? ""; if (!isCargoLoadType(value)) { console.warn( `decodeReport: skipping RouteEntry with unknown load-type "${value}"`, ); continue; } entries.push({ loadType: value, destinationPlanetNumber: Number(entry.key()), }); } entries.sort(compareRouteEntriesByLoadType); out.push({ sourcePlanetNumber, entries }); } return out; } const LOAD_TYPE_ORDER: Record = (() => { const map = {} as Record; CARGO_LOAD_TYPE_VALUES.forEach((value, index) => { map[value] = index; }); return map; })(); function compareRouteEntriesByLoadType( a: ReportRouteEntry, b: ReportRouteEntry, ): number { return LOAD_TYPE_ORDER[a.loadType] - LOAD_TYPE_ORDER[b.loadType]; } /** * findLocalPlayerDrive locates the local player's drive tech * level by matching `Player.name` against the report's `race` * field (the engine uses race name as the runtime player * identifier). Returns 0 when the lookup fails — boot state, an * incomplete report, or a future schema bump that switches to * UUIDs. Wrapping the lookup in one helper keeps the migration * cost contained. */ function findLocalPlayerDrive(report: Report, raceName: string): number { if (raceName === "") return 0; for (let i = 0; i < report.playerLength(); i++) { const player = report.player(i); if (player === null) continue; if ((player.name() ?? "") !== raceName) continue; return player.drive(); } return 0; } /** * uuidToHiLo splits the canonical 36-character UUID string * (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian * uint64 halves used by `common.UUID`. Mirrors `pkg/transcoder/uuid.go`. */ export function uuidToHiLo(value: string): [bigint, bigint] { const hex = value.replace(/-/g, "").toLowerCase(); if (hex.length !== 32 || /[^0-9a-f]/.test(hex)) { throw new GameStateError( "invalid_request", "invalid_request", `invalid uuid: ${value}`, ); } const hi = BigInt(`0x${hex.slice(0, 16)}`); const lo = BigInt(`0x${hex.slice(16, 32)}`); return [hi, lo]; } /** * applyOrderOverlay returns a copy of `report` with every locally- * valid or still-in-flight or applied command from `commands` * projected on top. Phase 14 introduced the overlay for * `planetRename`; Phase 15 extends it to `setProductionType` so the * inspector segment / map label reflect the chosen production target * before the engine confirms it. Other variants pass through. The * function is pure: callers re-derive the overlay whenever the draft * or the report change. * * `statuses` maps command id → status. Entries with `valid`, * `submitting`, or `applied` participate in the overlay — together * they describe "the player's committed intent for this turn": * locally-valid (auto-sync about to fire), in-flight on the wire, * or acknowledged by the engine. Entries with `draft`, `invalid`, * or `rejected` skip the overlay so the player keeps the server's * (un-renamed) view. */ export function applyOrderOverlay( report: GameReport, commands: OrderCommand[], statuses: Record, ): GameReport { if (commands.length === 0) return report; let mutatedPlanets: ReportPlanet[] | null = null; let mutatedRoutes: ReportRoute[] | null = null; for (const cmd of commands) { const status = statuses[cmd.id]; if ( status !== "valid" && status !== "submitting" && status !== "applied" ) { continue; } if (cmd.kind === "planetRename") { const idx = report.planets.findIndex( (p) => p.number === cmd.planetNumber, ); if (idx < 0) continue; if (mutatedPlanets === null) { mutatedPlanets = [...report.planets]; } mutatedPlanets[idx] = { ...mutatedPlanets[idx]!, name: cmd.name }; continue; } if (cmd.kind === "setProductionType") { const idx = report.planets.findIndex( (p) => p.number === cmd.planetNumber, ); if (idx < 0) continue; if (mutatedPlanets === null) { mutatedPlanets = [...report.planets]; } mutatedPlanets[idx] = { ...mutatedPlanets[idx]!, production: productionDisplayFromCommand( cmd.productionType, cmd.subject, ), }; continue; } if (cmd.kind === "setCargoRoute") { if (mutatedRoutes === null) { mutatedRoutes = cloneRoutes(report.routes); } upsertRouteEntry(mutatedRoutes, cmd.sourcePlanetNumber, { loadType: cmd.loadType, destinationPlanetNumber: cmd.destinationPlanetNumber, }); continue; } if (cmd.kind === "removeCargoRoute") { if (mutatedRoutes === null) { mutatedRoutes = cloneRoutes(report.routes); } deleteRouteEntry(mutatedRoutes, cmd.sourcePlanetNumber, cmd.loadType); continue; } } if (mutatedPlanets === null && mutatedRoutes === null) return report; return { ...report, planets: mutatedPlanets ?? report.planets, routes: mutatedRoutes ?? report.routes, }; } function cloneRoutes(routes: ReportRoute[]): ReportRoute[] { return routes.map((r) => ({ sourcePlanetNumber: r.sourcePlanetNumber, entries: r.entries.map((e) => ({ ...e })), })); } function upsertRouteEntry( routes: ReportRoute[], sourcePlanetNumber: number, entry: ReportRouteEntry, ): void { let route = routes.find((r) => r.sourcePlanetNumber === sourcePlanetNumber); if (route === undefined) { route = { sourcePlanetNumber, entries: [] }; routes.push(route); } const idx = route.entries.findIndex((e) => e.loadType === entry.loadType); if (idx >= 0) { route.entries[idx] = entry; } else { route.entries.push(entry); } route.entries.sort(compareRouteEntriesByLoadType); } function deleteRouteEntry( routes: ReportRoute[], sourcePlanetNumber: number, loadType: CargoLoadType, ): void { const routeIndex = routes.findIndex( (r) => r.sourcePlanetNumber === sourcePlanetNumber, ); if (routeIndex < 0) return; const route = routes[routeIndex]!; route.entries = route.entries.filter((e) => e.loadType !== loadType); if (route.entries.length === 0) { routes.splice(routeIndex, 1); } } /** * productionDisplayFromCommand mirrors the engine's * `Cache.PlanetProductionDisplayName` * (`game/internal/controller/planet.go`) for the optimistic overlay. * Keeping the strings byte-equal with the next server report avoids * a flicker when the overlay drops on the next turn cutoff. */ export function productionDisplayFromCommand( productionType: ProductionType, subject: string, ): string { switch (productionType) { case "MAT": return "Material"; case "CAP": return "Capital"; case "DRIVE": return "Drive"; case "WEAPONS": return "Weapons"; case "SHIELDS": return "Shields"; case "CARGO": return "Cargo"; case "SCIENCE": case "SHIP": return subject; } } function decodeErrorMessage(payload: Uint8Array): { code: string; message: string } { if (payload.length === 0) { return { code: "internal_error", message: "empty error payload" }; } try { const text = new TextDecoder().decode(payload); const parsed = JSON.parse(text) as { code?: string; message?: string }; return { code: typeof parsed.code === "string" ? parsed.code : "internal_error", message: typeof parsed.message === "string" ? parsed.message : text, }; } catch { return { code: "internal_error", message: "non-json error payload" }; } }