// 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 added a name-only `localShipClass` projection so the // planet inspector's Build-Ship sub-picker had data to render. // Phase 17 widens `ShipClassSummary` to the full attribute set // (drive / armament / weapons / shields / cargo) so the ship-class // table and designer can render every documented field, and // extends `applyOrderOverlay` with the `createShipClass` / // `removeShipClass` variants — pending Save / Delete actions are // reflected in the table immediately, without waiting for the // auto-sync round-trip. // // Phase 21 adds `localScience` (a list of `ScienceSummary` rows // decoded from `Report.local_science`) so the sciences table and // designer have data to render, and extends `applyOrderOverlay` with // the `createScience` / `removeScience` variants — pending Save / // Delete actions surface in the table and the planet production // picker's Research sub-row immediately. import { Builder, ByteBuffer } from "flatbuffers"; import type { GalaxyClient } from "./galaxy-client"; import { UUID } from "../proto/galaxy/fbs/common"; import { Bombing, GameReportRequest, IncomingGroup, LocalFleet, LocalGroup, OtherGroup, OtherScience, OthersShipClass, Report, ShipProduction, UnidentifiedGroup, } from "../proto/galaxy/fbs/report"; import type { CargoLoadType, CommandStatus, OrderCommand, ProductionType, Relation, } from "../sync/order-types"; import { CARGO_LOAD_TYPE_VALUES, isCargoLoadType, isRelation, } 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 projection of `report.ShipClass` the * ship-class table and designer render. Phase 15 carried just the * `name` for the Build-Ship sub-picker; Phase 17 added the five * tech-derived numbers so the table can sort / filter on them and * the designer can populate read-only previews. The numeric ranges * mirror `pkg/calc/validator.go.ValidateShipTypeValues` exactly: * each of `drive`, `weapons`, `shields`, `cargo` is either zero or * ≥ 1, and `armament` is a non-negative integer. */ export interface ShipClassSummary { name: string; drive: number; armament: number; weapons: number; shields: number; cargo: number; } /** * ScienceSummary is the projection of `report.Science` the sciences * table and designer render. The four tech proportions are fractions * in `[0, 1]` summing to `1.0`, mirroring * `pkg/calc/validator.go.ValidateScienceValues` exactly. The designer * presents them as percentages (`value * 100`) so users can type and * reason about whole-number proportions; the wire shape stays * fractional. Used by `lib/active-view/table-sciences.svelte`, * `lib/active-view/designer-science.svelte`, and the planet * production picker (`lib/inspectors/planet/production.svelte`). */ export interface ScienceSummary { name: string; drive: number; weapons: number; shields: number; cargo: number; } /** * 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[]; } /** * 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; } /** * ReportOtherRace is the per-other-race projection rendered by the * Phase 22 Races View. The fields mirror `report.fbs:Player` row-by- * row, with `relation` narrowed to the wire-stable `Relation` union * (the engine emits a `"-"` sentinel for the self row, which never * appears in `GameReport.races` because self is filtered out by * `decodeReport`). Tech values are float fractions — the table * renders them through the same `formatPercent` helper the sciences * table uses. * * `relation` reflects the local player's stance TOWARD this race, * not the other way around (`rules.txt` line 1162). Per the engine * (`controller/race.go.UpdateRelation`) the relation is stored * unilaterally — race A can be at war with race B while race B is * at peace with race A. * * `votesReceived` is the count of votes this race received in the * last turn cutoff tally (`Player.votes` on the wire). The total * game votes equal the sum of every non-extinct row's * `votesReceived`, since every race always votes for someone * (`controller/race.go` initialises `r.VoteFor = r.ID` on creation * and reassigns to self on extinction of the voted-for race). */ export interface ReportOtherRace { name: string; drive: number; weapons: number; shields: number; cargo: number; population: number; industry: number; planets: number; relation: Relation; votesReceived: number; } /** * ReportPlayer is the per-player projection consumed by the Phase 23 * Report View's Player Status section. Unlike `ReportOtherRace`, this * row carries the local player and extinct rows too: the section is a * status overview, not a diplomacy surface. Sorted alphabetically by * name (case-insensitive); `isLocal` flags the calling player's row so * the section can highlight it. The wire `relation` field is * intentionally omitted — the self row carries the engine's "-" * sentinel and the other-race rows already expose it via * `GameReport.races`. */ export interface ReportPlayer { name: string; drive: number; weapons: number; shields: number; cargo: number; population: number; industry: number; planets: number; votesReceived: number; extinct: boolean; isLocal: boolean; } /** * ReportOtherScience is a single row in the Phase 23 Report View's * Foreign Sciences section. Mirrors the wire `OtherScience` (carries * the owning `race` alongside the four tech proportions). Stable * order: sorted by `(race, name)` so the report's per-race sub-tables * render deterministically. */ export interface ReportOtherScience { race: string; name: string; drive: number; weapons: number; shields: number; cargo: number; } /** * ReportOtherShipClass is a single row in the Phase 23 Report View's * Foreign Ship Classes section. Mirrors the wire `OthersShipClass` * (carries the owning `race`, the five tech-derived numbers, plus the * `mass` the local ship-classes table does not surface — useful for * fleet-mass comparison against incoming groups). Stable order: * sorted by `(race, name)`. */ export interface ReportOtherShipClass { race: string; name: string; drive: number; armament: number; weapons: number; shields: number; cargo: number; mass: number; } /** * ReportBombing is a single row in the Phase 23 Report View's * Bombings section. Mirrors the wire `Bombing` (post-bombing planet * snapshot, attacker/owner identity, attack power, and the boolean * `wiped` flag that drives a visually-distinct row state). Sorted by * `planetNumber` for deterministic rendering. * * Field naming follows the existing `ReportPlanet` convention: * `capital → industryStockpile`, `material → materialsStockpile`, * `number → planetNumber`. */ export interface ReportBombing { planetNumber: number; planet: string; owner: string; attacker: string; production: string; industry: number; population: number; colonists: number; industryStockpile: number; materialsStockpile: number; attackPower: number; wiped: boolean; } /** * ReportShipProduction is a single row in the Phase 23 Report View's * Ships In Production section. Mirrors the wire `ShipProduction`. * `planetNumber` resolves against `GameReport.planets` so the section * can render the producing planet's name; `cost` is the per-ship * production cost (`ShipProductionCost(shipMass)`, not including the * per-turn material-farming term); `prodUsed` is the engine's residual * production poured into the partial ship this turn; `percent` is the * cumulative build progress as a fraction in [0, 1]; `freeIndustry` * mirrors the producing planet's free industry. Stable order: sorted * by `(planetNumber, class)`. */ /** * ReportBattle is one battle summary in the current turn. Carries the * battle UUID, planet number, and shot count — enough to render a * battle marker on the map and to link into the Battle Viewer without * fetching the full BattleReport. */ export interface ReportBattle { id: string; planet: number; shots: number; } export interface ReportShipProduction { planetNumber: number; class: string; cost: number; prodUsed: number; percent: number; freeIndustry: number; } 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[]; /** * localScience enumerates the player's own defined sciences. Each * entry carries the four tech proportions as fractions in `[0, 1]` * summing to `1.0`. Empty until at least one science is created * (`CommandScienceCreate`, Phase 21). The sciences table and the * planet production picker's Research sub-row read from this * projection (after `applyOrderOverlay`) so freshly-queued * `createScience` / `removeScience` actions surface immediately. */ localScience: ScienceSummary[]; /** * 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, localPlayerWeapons, localPlayerShields, * localPlayerCargo carry the local player's four tech levels, * read from the matching `Player` row in the report. Drive * powers reach (`40 * driveTech`, * `game/internal/model/game/race.go.FlightDistance`) and the * cargo-route picker; cargo feeds the ship-class designer's * cargo-capacity preview (`pkg/calc/ship.go.CargoCapacity` and * `CarryingMass`); weapons and shields are surfaced ahead of * Phases 19-21 (ship-group inspector, science designer) so * future patches do not need to re-extend the report decoder. * All four are zero on boot or when the report's player block * is missing the local entry. */ localPlayerDrive: number; 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[]; /** * otherRaces lists the names of every non-extinct race other than * the local player, sorted alphabetically. Drawn from the * `report.player[]` block in the FBS report (each `Player` row * carries an `extinct` flag). The ship-group inspector consumes * this list for the "transfer to race" picker; Phase 22's Races * View also uses it for the vote-recipient picker so the read * shape stays stable across stages. Empty when the report has no * `player` block (boot state, history-mode snapshots) or when the * local player is the only non-extinct race. */ otherRaces: string[]; /** * races is the richer per-other-race projection Phase 22 added * for the Races View table — same population (non-extinct, self * excluded, alphabetical) as `otherRaces`, but with each row * carrying tech levels, totals, planet count, the local player's * stance toward that race, and the race's votes received. Rows * with an unknown wire `relation` (anything other than `WAR` or * `PEACE`) default to `PEACE` so the table never blanks out the * toggle on an engine schema bump; the same row continues to * appear in the table. */ races: ReportOtherRace[]; /** * myVotes is the local player's total vote weight in the current * report, read from `Report.votes` (the engine assigns one vote * per 1000 population, see `rules.txt:1060`). Zero when the * report has not been produced yet. */ myVotes: number; /** * myVoteFor is the race the local player currently votes for, * read from `Report.vote_for`. Empty string when no value has * been recorded yet (boot state) or when the engine emitted an * empty string. The engine's default initial state is each race * voting for itself (`controller/race.go`), so a stable game's * report always carries a non-empty value. */ myVoteFor: string; /** * players is the richer per-player projection Phase 23 added for * the Report View's Player Status section. Same data source as * `races[]` (`report.player[]`) but with the local player and * extinct rows included, sorted alphabetically by name and tagged * with `isLocal`. `races[]` stays Phase 22's view (other, * non-extinct) so diplomatic-stance code paths do not churn. */ players: ReportPlayer[]; /** * otherScience is the per-race foreign-sciences projection Phase * 23 added for the Report View's Foreign Sciences section. Sorted * by `(race, name)`. Empty when the report has no foreign science * data (boot state, single-race game, legacy synthetic data). */ otherScience: ReportOtherScience[]; /** * otherShipClass is the per-race foreign-ship-classes projection * Phase 23 added for the Report View's Foreign Ship Classes * section. Sorted by `(race, name)`. Empty when the report has no * foreign ship-class data. */ otherShipClass: ReportOtherShipClass[]; /** * battles is the list of battle summaries the engine recorded for * the current turn. Each entry carries the battle UUID, the planet * it happened on, and the number of shots exchanged. The Reports * View uses `id` to link into the Battle Viewer; the map renderer * uses `planet` to locate the marker and `shots` to scale its * stroke. Empty when no battles occurred last turn. */ battles: ReportBattle[]; /** * battleIds is a convenience derived list of UUIDs from `battles`, * preserved for legacy callers (Phase 23 report section, fixtures). */ battleIds: string[]; /** * bombings is the per-bombing projection Phase 23 added for the * Report View's Bombings section. Sorted by `planetNumber`. Empty * when no planets were bombed last turn. */ bombings: ReportBombing[]; /** * shipProductions is the per-ship-production projection Phase 23 * added for the Report View's Ships In Production section. * Sorted by `(planetNumber, class)`. Empty when no planet is * currently producing a ship. */ shipProductions: ReportShipProduction[]; } 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() ?? "", drive: sc.drive(), armament: Number(sc.armament()), weapons: sc.weapons(), shields: sc.shields(), cargo: sc.cargo(), }); } const localScience: ScienceSummary[] = []; for (let i = 0; i < report.localScienceLength(); i++) { const s = report.localScience(i); if (s === null) continue; localScience.push({ name: s.name() ?? "", drive: s.drive(), weapons: s.weapons(), shields: s.shields(), cargo: s.cargo(), }); } const raceName = report.race() ?? ""; const routes = decodeReportRoutes(report); const localTech = findLocalPlayerTech(report, raceName); const otherRaces = collectOtherRaces(report, raceName); const races = collectOtherRaceRows(report, raceName); const players = decodePlayers(report, raceName); const localShipGroups = decodeLocalShipGroups(report); const otherShipGroups = decodeOtherShipGroups(report); const incomingShipGroups = decodeIncomingShipGroups(report); const unidentifiedShipGroups = decodeUnidentifiedShipGroups(report); const localFleets = decodeLocalFleets(report); const otherScience = decodeOtherScience(report); const otherShipClass = decodeOtherShipClass(report); const battles = decodeBattles(report); const battleIds = battles.map((b) => b.id); const bombings = decodeBombings(report); const shipProductions = decodeShipProductions(report); return { turn: Number(report.turn()), mapWidth: report.width(), mapHeight: report.height(), planetCount: report.planetCount(), planets, race: raceName, localShipClass, localScience, routes, localPlayerDrive: localTech.drive, localPlayerWeapons: localTech.weapons, localPlayerShields: localTech.shields, localPlayerCargo: localTech.cargo, localShipGroups, otherShipGroups, incomingShipGroups, unidentifiedShipGroups, localFleets, otherRaces, races, myVotes: report.votes(), myVoteFor: report.voteFor() ?? "", players, otherScience, otherShipClass, battles, battleIds, bombings, shipProductions, }; } /** * 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; } /** * 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 = (() => { 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]; } interface LocalPlayerTech { drive: number; weapons: number; shields: number; cargo: number; } /** * findLocalPlayerTech locates the local player's four tech levels * by matching `Player.name` against the report's `race` field (the * engine uses race name as the runtime player identifier). Returns * a zero-filled record 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 findLocalPlayerTech( report: Report, raceName: string, ): LocalPlayerTech { if (raceName === "") return { drive: 0, weapons: 0, shields: 0, cargo: 0 }; for (let i = 0; i < report.playerLength(); i++) { const player = report.player(i); if (player === null) continue; if ((player.name() ?? "") !== raceName) continue; return { drive: player.drive(), weapons: player.weapons(), shields: player.shields(), cargo: player.cargo(), }; } return { drive: 0, weapons: 0, shields: 0, cargo: 0 }; } /** * collectOtherRaces walks the `report.player[]` block and returns * the alphabetically-sorted names of every non-extinct race other * than the local player. Used by `GameReport.otherRaces` to back the * ship-group inspector's transfer-to-race picker (Phase 20) and the * Races View vote-recipient picker (Phase 22). */ function collectOtherRaces(report: Report, raceName: string): string[] { const out: string[] = []; for (let i = 0; i < report.playerLength(); i++) { const player = report.player(i); if (player === null) continue; if (player.extinct()) continue; const name = player.name() ?? ""; if (name === "" || name === raceName) continue; out.push(name); } out.sort((a, b) => a.localeCompare(b)); return out; } /** * collectOtherRaceRows walks the `report.player[]` block and returns * the richer per-race projection consumed by the Phase 22 Races * View. Same filter as `collectOtherRaces` (non-extinct, named, * self excluded), same alphabetical sort. The engine emits * `Player.relation = "-"` on the self row only — that row is * filtered out, so a non-`"WAR"`/`"PEACE"` value here would mean a * schema bump; we fall back to `"PEACE"` and keep the row visible * rather than dropping it silently. */ function collectOtherRaceRows( report: Report, raceName: string, ): ReportOtherRace[] { const out: ReportOtherRace[] = []; for (let i = 0; i < report.playerLength(); i++) { const player = report.player(i); if (player === null) continue; if (player.extinct()) continue; const name = player.name() ?? ""; if (name === "" || name === raceName) continue; const wire = player.relation() ?? ""; const relation: Relation = isRelation(wire) ? wire : "PEACE"; out.push({ name, drive: player.drive(), weapons: player.weapons(), shields: player.shields(), cargo: player.cargo(), population: player.population(), industry: player.industry(), planets: player.planets(), relation, votesReceived: player.votes(), }); } out.sort((a, b) => a.name.localeCompare(b.name)); return out; } /** * decodePlayers walks `report.player[]` and emits the full status * roster the Phase 23 Report View's Player Status section renders: * every named row including the local player and extinct races, * sorted alphabetically (case-insensitive). The local row carries * `isLocal: true` so the section can highlight it; the wire * `relation` field is intentionally dropped (self carries the engine * "-" sentinel, other rows already surface relation through * `GameReport.races`). */ function decodePlayers(report: Report, raceName: string): ReportPlayer[] { const out: ReportPlayer[] = []; for (let i = 0; i < report.playerLength(); i++) { const player = report.player(i); if (player === null) continue; const name = player.name() ?? ""; if (name === "") continue; out.push({ name, drive: player.drive(), weapons: player.weapons(), shields: player.shields(), cargo: player.cargo(), population: player.population(), industry: player.industry(), planets: player.planets(), votesReceived: player.votes(), extinct: player.extinct(), isLocal: name === raceName, }); } out.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()), ); return out; } function decodeOtherScience(report: Report): ReportOtherScience[] { const out: ReportOtherScience[] = []; for (let i = 0; i < report.otherScienceLength(); i++) { const s = report.otherScience(i); if (s === null) continue; out.push({ race: s.race() ?? "", name: s.name() ?? "", drive: s.drive(), weapons: s.weapons(), shields: s.shields(), cargo: s.cargo(), }); } out.sort((a, b) => { const byRace = a.race.localeCompare(b.race); if (byRace !== 0) return byRace; return a.name.localeCompare(b.name); }); return out; } function decodeOtherShipClass(report: Report): ReportOtherShipClass[] { const out: ReportOtherShipClass[] = []; for (let i = 0; i < report.otherShipClassLength(); i++) { const sc = report.otherShipClass(i); if (sc === null) continue; out.push({ race: sc.race() ?? "", name: sc.name() ?? "", drive: sc.drive(), armament: Number(sc.armament()), weapons: sc.weapons(), shields: sc.shields(), cargo: sc.cargo(), mass: sc.mass(), }); } out.sort((a, b) => { const byRace = a.race.localeCompare(b.race); if (byRace !== 0) return byRace; return a.name.localeCompare(b.name); }); return out; } function decodeBattles(report: Report): ReportBattle[] { const out: ReportBattle[] = []; for (let i = 0; i < report.battleLength(); i++) { const summary = report.battle(i); if (summary === null) continue; const id = uuidStringFromFB(summary.id()); if (id === null) continue; out.push({ id, planet: Number(summary.planet()), shots: Number(summary.shots()), }); } return out; } function decodeBombings(report: Report): ReportBombing[] { const out: ReportBombing[] = []; for (let i = 0; i < report.bombingLength(); i++) { const b = report.bombing(i); if (b === null) continue; out.push({ planetNumber: Number(b.number()), planet: b.planet() ?? "", owner: b.owner() ?? "", attacker: b.attacker() ?? "", production: b.production() ?? "", industry: b.industry(), population: b.population(), colonists: b.colonists(), industryStockpile: b.capital(), materialsStockpile: b.material(), attackPower: b.attackPower(), wiped: b.wiped(), }); } out.sort((a, b) => a.planetNumber - b.planetNumber); return out; } function decodeShipProductions(report: Report): ReportShipProduction[] { const out: ReportShipProduction[] = []; for (let i = 0; i < report.shipProductionLength(); i++) { const sp = report.shipProduction(i); if (sp === null) continue; out.push({ planetNumber: Number(sp.planet()), class: sp.class_() ?? "", cost: sp.cost(), prodUsed: sp.prodUsed(), percent: sp.percent(), freeIndustry: sp.free(), }); } out.sort((a, b) => { const byPlanet = a.planetNumber - b.planetNumber; if (byPlanet !== 0) return byPlanet; return a.class.localeCompare(b.class); }); return out; } /** * 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 extended it to `setProductionType`; * Phase 16 to `setCargoRoute` / `removeCargoRoute`; Phase 17 to * `createShipClass` / `removeShipClass` so the ship-class table * shows pending Save / Delete actions immediately; Phase 21 to * `createScience` / `removeScience` so the sciences table and the * planet production picker's Research sub-row mirror pending Save / * Delete actions. 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; let mutatedShipClass: ShipClassSummary[] | null = null; let mutatedScience: ScienceSummary[] | null = null; let mutatedRaces: ReportOtherRace[] | null = null; let mutatedVoteFor: string | 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 (cmd.kind === "createShipClass") { if (mutatedShipClass === null) { mutatedShipClass = [...(report.localShipClass ?? [])]; } // Skip duplicates: the engine refuses them server-side and // the designer's local validator prevents them client-side, // but a stale draft could still carry a row whose name now // collides with the server snapshot. Keeping the projection // unique avoids two rows in the table for the same name. if (mutatedShipClass.some((cls) => cls.name === cmd.name)) continue; mutatedShipClass.push({ name: cmd.name, drive: cmd.drive, armament: cmd.armament, weapons: cmd.weapons, shields: cmd.shields, cargo: cmd.cargo, }); continue; } if (cmd.kind === "removeShipClass") { if (mutatedShipClass === null) { mutatedShipClass = [...(report.localShipClass ?? [])]; } const idx = mutatedShipClass.findIndex((cls) => cls.name === cmd.name); if (idx < 0) continue; mutatedShipClass.splice(idx, 1); continue; } if (cmd.kind === "createScience") { if (mutatedScience === null) { // `?? []` guards a real failure mode: in DEV with hot // module replacement the running `gameState.report` // object can predate the decoder bump that introduced // `localScience` — its field is then `undefined` on // the live JS object even though the type declares it // as a required array. A naked spread on `undefined` // throws inside the reactive overlay getter and aborts // the map's `$effect` silently, leaving the canvas // blank until a full reload. The default keeps the // overlay well-defined for any upstream that supplies // a partial report shape. mutatedScience = [...(report.localScience ?? [])]; } // Skip duplicates by name: the engine refuses duplicates // server-side and the designer's local validator pre-checks // against the live overlay, but a stale draft could still // carry an entry whose name now collides with the server // snapshot. Keeping the projection unique avoids two rows in // the table for the same name. if (mutatedScience.some((sci) => sci.name === cmd.name)) continue; mutatedScience.push({ name: cmd.name, drive: cmd.drive, weapons: cmd.weapons, shields: cmd.shields, cargo: cmd.cargo, }); continue; } if (cmd.kind === "removeScience") { if (mutatedScience === null) { mutatedScience = [...(report.localScience ?? [])]; } const idx = mutatedScience.findIndex((sci) => sci.name === cmd.name); if (idx < 0) continue; mutatedScience.splice(idx, 1); continue; } if (cmd.kind === "setDiplomaticStance") { if (mutatedRaces === null) { // `?? []` mirrors the per-branch HMR guard pattern: a // running `gameState.report` produced before Phase 22's // shape bump may not carry `races` yet — preserve a // well-defined array on the way out so downstream // `$derived` blocks (`races.map`, `races.find`, …) // never fault on `undefined`. mutatedRaces = [...(report.races ?? [])]; } const idx = mutatedRaces.findIndex((r) => r.name === cmd.acceptor); if (idx < 0) continue; mutatedRaces[idx] = { ...mutatedRaces[idx]!, relation: cmd.relation, }; continue; } if (cmd.kind === "setVoteRecipient") { mutatedVoteFor = cmd.acceptor; continue; } } if ( mutatedPlanets === null && mutatedRoutes === null && mutatedShipClass === null && mutatedScience === null && mutatedRaces === null && mutatedVoteFor === null ) { return report; } return { ...report, planets: mutatedPlanets ?? report.planets, routes: mutatedRoutes ?? report.routes, // `?? []` mirrors the per-branch HMR guard above: an old // in-memory `report` whose shape predates a field bump must // still produce a well-defined array on the way out, otherwise // downstream `$derived` blocks (`localShipClass.map`, // `localScience.find`, …) fault and the active view blanks. localShipClass: mutatedShipClass ?? report.localShipClass ?? [], localScience: mutatedScience ?? report.localScience ?? [], races: mutatedRaces ?? report.races ?? [], myVoteFor: mutatedVoteFor ?? report.myVoteFor, // Phase 23 read-only fields. No overlay branches touch them // today; the `?? []` keeps a stale HMR-instance of `report` // (loaded before the shape bump) from blanking the Report // View when its section components iterate. players: report.players ?? [], otherScience: report.otherScience ?? [], otherShipClass: report.otherShipClass ?? [], battles: report.battles ?? [], battleIds: report.battleIds ?? [], bombings: report.bombings ?? [], shipProductions: report.shipProductions ?? [], }; } 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" }; } }