// 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. 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 { CommandStatus, OrderCommand } 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; } 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; } 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, }); } return { turn: Number(report.turn()), mapWidth: report.width(), mapHeight: report.height(), planetCount: report.planetCount(), planets, race: report.race() ?? "", }; } /** * 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 understands `planetRename` only — * every other variant passes 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; for (const cmd of commands) { const status = statuses[cmd.id]; if ( status !== "valid" && status !== "submitting" && status !== "applied" ) { continue; } if (cmd.kind !== "planetRename") continue; 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 }; } if (mutatedPlanets === null) return report; return { ...report, planets: mutatedPlanets }; } 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" }; } }