// Battle-report fetcher used by the Battle Viewer page. // // Phase 28 migrates this surface off the raw REST passthrough onto the // `user.games.battle` ConnectRPC command — the same signed envelope the // other authenticated traffic rides. The synthetic-mode short-circuit // stays so DEV / e2e tests can render fixtures without a live gateway. import { Builder, ByteBuffer } from "flatbuffers"; import type { GalaxyClient } from "./galaxy-client"; import { uuidToHiLo } from "./game-state"; import { isSyntheticGameId } from "./synthetic-report"; import { lookupSyntheticBattle } from "./synthetic-battle"; import { BattleActionReport as FbsBattleActionReport, BattleReport as FbsBattleReport, BattleReportGroup as FbsBattleReportGroup, GameBattleRequest, RaceEntry, ShipEntry, UUID, } from "../proto/galaxy/fbs/battle"; import { ErrorResponse as FbsErrorResponse } from "../proto/galaxy/fbs/lobby"; /** * BattleReport mirrors the on-wire battle shape the BattleViewer * renders. Fields match `pkg/model/report/battle.go`; integer-keyed * maps from the underlying model are surfaced as string-keyed * `Record`s so the existing components (race / ship lookup, mass * scaling, timeline) keep their current types. */ export interface BattleReport { id: string; planet: number; planetName: string; races: Record; ships: Record; protocol: BattleActionReport[]; } export interface BattleReportGroup { race: string; className: string; tech: Record; num: number; numLeft: number; loadType: string; loadQuantity: number; inBattle: boolean; } export interface BattleActionReport { a: number; sa: number; d: number; sd: number; x: boolean; } export class BattleFetchError extends Error { constructor( public readonly status: number, message: string, ) { super(message); this.name = "BattleFetchError"; } } const MESSAGE_TYPE = "user.games.battle"; const RESULT_CODE_OK = "ok"; /** * fetchBattle returns the `BattleReport` for the supplied game, turn, * and battle id. In synthetic-report mode (DEV / e2e) the lookup is * served from `synthetic-battle.ts`; otherwise the function calls the * `user.games.battle` ConnectRPC command through the supplied * `GalaxyClient`. Throws `BattleFetchError` with the upstream HTTP * status (or `0` for transport-level failures) on error. */ export async function fetchBattle( client: GalaxyClient, gameId: string, turn: number, battleId: string, ): Promise { if (isSyntheticGameId(gameId)) { const fixture = lookupSyntheticBattle(battleId); if (fixture === null) { throw new BattleFetchError(404, "battle not found"); } return fixture; } const payload = encodeRequest(gameId, turn, battleId); const result = await client.executeCommand(MESSAGE_TYPE, payload); if (result.resultCode !== RESULT_CODE_OK) { throw decodeError(result.resultCode, result.payloadBytes); } return decodeBattleReport(result.payloadBytes); } function encodeRequest( gameId: string, turn: number, battleId: string, ): Uint8Array { const builder = new Builder(96); const [gameHi, gameLo] = uuidToHiLo(gameId); const [battleHi, battleLo] = uuidToHiLo(battleId); GameBattleRequest.startGameBattleRequest(builder); GameBattleRequest.addGameId( builder, UUID.createUUID(builder, gameHi, gameLo), ); GameBattleRequest.addTurn(builder, turn); GameBattleRequest.addBattleId( builder, UUID.createUUID(builder, battleHi, battleLo), ); builder.finish(GameBattleRequest.endGameBattleRequest(builder)); return builder.asUint8Array(); } function decodeError(resultCode: string, payload: Uint8Array): BattleFetchError { let message = resultCode; try { const errorResponse = FbsErrorResponse.getRootAsErrorResponse( new ByteBuffer(payload), ); const body = errorResponse.error(); if (body) { message = body.message() ?? resultCode; } } catch (_err) { // fall through to the raw result code } const status = mapResultCodeToStatus(resultCode); return new BattleFetchError(status, message); } function mapResultCodeToStatus(resultCode: string): number { switch (resultCode) { case "not_found": return 404; case "invalid_request": return 400; case "forbidden": return 403; case "conflict": return 409; case "service_unavailable": return 503; default: return 500; } } function decodeBattleReport(bytes: Uint8Array): BattleReport { const fb = FbsBattleReport.getRootAsBattleReport(new ByteBuffer(bytes)); const id = uuidStringFromFB(fb.id()); if (id === null) { throw new BattleFetchError(500, "battle response missing id"); } return { id, planet: Number(fb.planet()), planetName: fb.planetName() ?? "", races: decodeRaces(fb), ships: decodeShips(fb), protocol: decodeProtocol(fb), }; } function decodeRaces(fb: FbsBattleReport): Record { const out: Record = {}; const total = fb.racesLength(); const item = new RaceEntry(); for (let i = 0; i < total; i++) { if (!fb.races(i, item)) continue; const valueUUID = item.value(); const value = uuidStringFromFB(valueUUID); if (value === null) continue; out[item.key().toString()] = value; } return out; } function decodeShips(fb: FbsBattleReport): Record { const out: Record = {}; const total = fb.shipsLength(); const entry = new ShipEntry(); for (let i = 0; i < total; i++) { if (!fb.ships(i, entry)) continue; const group = entry.value(); if (group === null) continue; out[entry.key().toString()] = decodeGroup(group); } return out; } function decodeGroup(group: FbsBattleReportGroup): BattleReportGroup { const tech: Record = {}; const techLen = group.techLength(); for (let i = 0; i < techLen; i++) { const t = group.tech(i); if (!t) continue; const key = t.key(); if (key === null) continue; tech[key] = t.value(); } return { race: (group.race() ?? "") as string, className: (group.className() ?? "") as string, tech, num: Number(group.number()), numLeft: Number(group.numberLeft()), loadType: (group.loadType() ?? "") as string, loadQuantity: group.loadQuantity(), inBattle: group.inBattle(), }; } function decodeProtocol(fb: FbsBattleReport): BattleActionReport[] { const out: BattleActionReport[] = []; const total = fb.protocolLength(); const item = new FbsBattleActionReport(); for (let i = 0; i < total; i++) { if (!fb.protocol(i, item)) continue; out.push({ a: Number(item.attacker()), sa: Number(item.attackerShipClass()), d: Number(item.defender()), sd: Number(item.defenderShipClass()), x: item.destroyed(), }); } return out; } 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 { return value.toString(16).padStart(16, "0"); }