// Reads back the player's stored order for the current turn through // `user.games.order.get`. Used by `OrderDraftStore` only when the // local cache row is absent (fresh install, cleared storage, or a // brand-new device): the local draft is the source of truth, so a // present-but-empty cache row means "no commands" and is honoured // over the server snapshot. import { Builder, ByteBuffer } from "flatbuffers"; import type { GalaxyClient } from "../api/galaxy-client"; import { uuidToHiLo } from "../api/game-state"; import { UUID } from "../proto/galaxy/fbs/common"; import { CommandPayload, CommandPlanetProduce, CommandPlanetRename, CommandPlanetRouteRemove, CommandPlanetRouteSet, CommandRaceRelation, CommandRaceVote, CommandScienceCreate, CommandScienceRemove, CommandShipClassCreate, CommandShipClassRemove, CommandShipGroupBreak, CommandShipGroupDismantle, CommandShipGroupJoinFleet, CommandShipGroupLoad, CommandShipGroupSend, CommandShipGroupTransfer, CommandShipGroupUnload, CommandShipGroupUpgrade, PlanetProduction, PlanetRouteLoadType, Relation, ShipGroupCargo, ShipGroupUpgradeTech, UserGamesOrderGet, UserGamesOrderGetResponse, } from "../proto/galaxy/fbs/order"; import type { CargoLoadType, OrderCommand, ProductionType, Relation as RelationLiteral, ShipGroupCargo as ShipGroupCargoLiteral, ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral, } from "./order-types"; const MESSAGE_TYPE = "user.games.order.get"; export class OrderLoadError extends Error { readonly resultCode: string; readonly code: string; constructor(resultCode: string, code: string, message: string) { super(message); this.name = "OrderLoadError"; this.resultCode = resultCode; this.code = code; } } export interface FetchedOrder { commands: OrderCommand[]; // Per-command status keyed by cmdId. Populated from the engine's // stored order so a returning player sees the same per-command // verdict (applied / rejected) the previous submission produced — // not a synthetic "applied" derived from the local cache. statuses: Map; // Per-command engine-formatted error code/message, keyed by cmdId. // Both maps carry an entry for every loaded command; the value is // null when the command was applied (no error). The message lets // the UI surface the rejection reason without a code → text catalog. errorCodes: Map; errorMessages: Map; updatedAt: number; } /** * fetchOrder issues `user.games.order.get` for the given game and * turn, decodes the response, and returns the typed draft. A * `found = false` answer (no order stored on the server) surfaces as * an empty `commands` array — the caller treats this as a clean * draft. Unknown command kinds in the response are skipped with a * console warning so a backend-side schema bump never silently * corrupts the local draft. */ export async function fetchOrder( client: GalaxyClient, gameId: string, turn: number, ): Promise { if (turn < 0) { throw new OrderLoadError( "invalid_request", "invalid_request", `turn must be non-negative, got ${turn}`, ); } const payload = buildRequest(gameId, turn); const result = await client.executeCommand(MESSAGE_TYPE, payload); if (result.resultCode !== "ok") { const { code, message } = decodeError(result.payloadBytes, result.resultCode); throw new OrderLoadError(result.resultCode, code, message); } return decodeResponse(result.payloadBytes); } function buildRequest(gameId: string, turn: number): Uint8Array { const builder = new Builder(64); const [hi, lo] = uuidToHiLo(gameId); const gameIdOffset = UUID.createUUID(builder, hi, lo); UserGamesOrderGet.startUserGamesOrderGet(builder); UserGamesOrderGet.addGameId(builder, gameIdOffset); UserGamesOrderGet.addTurn(builder, BigInt(turn)); const offset = UserGamesOrderGet.endUserGamesOrderGet(builder); builder.finish(offset); return builder.asUint8Array(); } function decodeResponse(payload: Uint8Array): FetchedOrder { if (payload.length === 0) { throw new OrderLoadError( "internal_error", "internal_error", "empty user.games.order.get payload", ); } const buffer = new ByteBuffer(payload); const response = UserGamesOrderGetResponse.getRootAsUserGamesOrderGetResponse(buffer); if (!response.found()) { return { commands: [], statuses: new Map(), errorCodes: new Map(), errorMessages: new Map(), updatedAt: 0, }; } const order = response.order(); if (order === null) { throw new OrderLoadError( "internal_error", "internal_error", "order missing while found=true", ); } const commands: OrderCommand[] = []; const statuses = new Map(); const errorCodes = new Map(); const errorMessages = new Map(); const length = order.commandsLength(); for (let i = 0; i < length; i++) { const item = order.commands(i); if (item === null) continue; const cmd = decodeCommand(item); if (cmd === null) continue; commands.push(cmd); // The engine echoes `cmd_applied = false` only when the order // was rejected per-command; missing / true both mean applied. const applied = item.cmdApplied(); statuses.set(cmd.id, applied === false ? "rejected" : "applied"); const code = item.cmdErrorCode(); errorCodes.set(cmd.id, code === null ? null : Number(code)); const msg = item.cmdErrorMessage(); errorMessages.set(cmd.id, msg === null ? null : msg); } return { commands, statuses, errorCodes, errorMessages, updatedAt: Number(order.updatedAt()), }; } type CommandItemView = NonNullable< ReturnType>["commands"]> >; function decodeCommand(item: CommandItemView): OrderCommand | null { if (item === null) return null; const id = item.cmdId(); if (id === null) return null; const payloadType = item.payloadType(); switch (payloadType) { case CommandPayload.CommandPlanetRename: { const inner = new CommandPlanetRename(); item.payload(inner); return { kind: "planetRename", id, planetNumber: Number(inner.number()), name: inner.name() ?? "", }; } case CommandPayload.CommandPlanetProduce: { const inner = new CommandPlanetProduce(); item.payload(inner); const productionType = productionTypeFromFBS(inner.production()); if (productionType === null) { console.warn( `fetchOrder: skipping CommandPlanetProduce with unknown production enum (${inner.production()})`, ); return null; } return { kind: "setProductionType", id, planetNumber: Number(inner.number()), productionType, subject: inner.subject() ?? "", }; } case CommandPayload.CommandPlanetRouteSet: { const inner = new CommandPlanetRouteSet(); item.payload(inner); const loadType = cargoLoadTypeFromFBS(inner.loadType()); if (loadType === null) { console.warn( `fetchOrder: skipping CommandPlanetRouteSet with unknown load_type enum (${inner.loadType()})`, ); return null; } return { kind: "setCargoRoute", id, sourcePlanetNumber: Number(inner.origin()), destinationPlanetNumber: Number(inner.destination()), loadType, }; } case CommandPayload.CommandPlanetRouteRemove: { const inner = new CommandPlanetRouteRemove(); item.payload(inner); const loadType = cargoLoadTypeFromFBS(inner.loadType()); if (loadType === null) { console.warn( `fetchOrder: skipping CommandPlanetRouteRemove with unknown load_type enum (${inner.loadType()})`, ); return null; } return { kind: "removeCargoRoute", id, sourcePlanetNumber: Number(inner.origin()), loadType, }; } case CommandPayload.CommandShipClassCreate: { const inner = new CommandShipClassCreate(); item.payload(inner); return { kind: "createShipClass", id, name: inner.name() ?? "", drive: inner.drive(), armament: Number(inner.armament()), weapons: inner.weapons(), shields: inner.shields(), cargo: inner.cargo(), }; } case CommandPayload.CommandShipClassRemove: { const inner = new CommandShipClassRemove(); item.payload(inner); return { kind: "removeShipClass", id, name: inner.name() ?? "", }; } case CommandPayload.CommandScienceCreate: { const inner = new CommandScienceCreate(); item.payload(inner); return { kind: "createScience", id, name: inner.name() ?? "", drive: inner.drive(), weapons: inner.weapons(), shields: inner.shields(), cargo: inner.cargo(), }; } case CommandPayload.CommandScienceRemove: { const inner = new CommandScienceRemove(); item.payload(inner); return { kind: "removeScience", id, name: inner.name() ?? "", }; } case CommandPayload.CommandShipGroupBreak: { const inner = new CommandShipGroupBreak(); item.payload(inner); return { kind: "breakShipGroup", id, groupId: inner.id() ?? "", newGroupId: inner.newId() ?? "", quantity: Number(inner.quantity()), }; } case CommandPayload.CommandShipGroupSend: { const inner = new CommandShipGroupSend(); item.payload(inner); return { kind: "sendShipGroup", id, groupId: inner.id() ?? "", destinationPlanetNumber: Number(inner.destination()), }; } case CommandPayload.CommandShipGroupLoad: { const inner = new CommandShipGroupLoad(); item.payload(inner); const cargo = shipGroupCargoFromFBS(inner.cargo()); if (cargo === null) { console.warn( `fetchOrder: skipping CommandShipGroupLoad with unknown cargo enum (${inner.cargo()})`, ); return null; } return { kind: "loadShipGroup", id, groupId: inner.id() ?? "", cargo, quantity: inner.quantity(), }; } case CommandPayload.CommandShipGroupUnload: { const inner = new CommandShipGroupUnload(); item.payload(inner); return { kind: "unloadShipGroup", id, groupId: inner.id() ?? "", quantity: inner.quantity(), }; } case CommandPayload.CommandShipGroupUpgrade: { const inner = new CommandShipGroupUpgrade(); item.payload(inner); const tech = shipGroupUpgradeTechFromFBS(inner.tech()); if (tech === null) { console.warn( `fetchOrder: skipping CommandShipGroupUpgrade with unknown tech enum (${inner.tech()})`, ); return null; } return { kind: "upgradeShipGroup", id, groupId: inner.id() ?? "", tech, level: inner.level(), }; } case CommandPayload.CommandShipGroupDismantle: { const inner = new CommandShipGroupDismantle(); item.payload(inner); return { kind: "dismantleShipGroup", id, groupId: inner.id() ?? "", }; } case CommandPayload.CommandShipGroupTransfer: { const inner = new CommandShipGroupTransfer(); item.payload(inner); return { kind: "transferShipGroup", id, groupId: inner.id() ?? "", acceptor: inner.acceptor() ?? "", }; } case CommandPayload.CommandShipGroupJoinFleet: { const inner = new CommandShipGroupJoinFleet(); item.payload(inner); return { kind: "joinFleetShipGroup", id, groupId: inner.id() ?? "", name: inner.name() ?? "", }; } case CommandPayload.CommandRaceRelation: { const inner = new CommandRaceRelation(); item.payload(inner); const relation = relationFromFBS(inner.relation()); if (relation === null) { console.warn( `fetchOrder: skipping CommandRaceRelation with unknown relation enum (${inner.relation()})`, ); return null; } return { kind: "setDiplomaticStance", id, acceptor: inner.acceptor() ?? "", relation, }; } case CommandPayload.CommandRaceVote: { const inner = new CommandRaceVote(); item.payload(inner); return { kind: "setVoteRecipient", id, acceptor: inner.acceptor() ?? "", }; } default: console.warn( `fetchOrder: skipping unknown command kind (payloadType=${payloadType})`, ); return null; } } /** * productionTypeFromFBS reverses `productionTypeToFBS` from * `submit.ts`. `PlanetProduction.UNKNOWN` and any out-of-band value * yield `null` so the caller drops the entry instead of fabricating a * synthetic kind. */ export function productionTypeFromFBS( value: PlanetProduction, ): ProductionType | null { switch (value) { case PlanetProduction.MAT: return "MAT"; case PlanetProduction.CAP: return "CAP"; case PlanetProduction.DRIVE: return "DRIVE"; case PlanetProduction.WEAPONS: return "WEAPONS"; case PlanetProduction.SHIELDS: return "SHIELDS"; case PlanetProduction.CARGO: return "CARGO"; case PlanetProduction.SCIENCE: return "SCIENCE"; case PlanetProduction.SHIP: return "SHIP"; case PlanetProduction.UNKNOWN: return null; default: return null; } } /** * cargoLoadTypeFromFBS reverses `cargoLoadTypeToFBS` from * `submit.ts`. `PlanetRouteLoadType.UNKNOWN` and any out-of-band * value yield `null` so the caller drops the entry rather than * fabricating a synthetic load type. */ export function cargoLoadTypeFromFBS( value: PlanetRouteLoadType, ): CargoLoadType | null { switch (value) { case PlanetRouteLoadType.COL: return "COL"; case PlanetRouteLoadType.CAP: return "CAP"; case PlanetRouteLoadType.MAT: return "MAT"; case PlanetRouteLoadType.EMP: return "EMP"; case PlanetRouteLoadType.UNKNOWN: return null; default: return null; } } /** * shipGroupCargoFromFBS reverses `shipGroupCargoToFBS` from * `submit.ts`. `ShipGroupCargo.UNKNOWN` and any out-of-band value * yield `null` so the caller drops the entry rather than * fabricating a synthetic cargo type. */ export function shipGroupCargoFromFBS( value: ShipGroupCargo, ): ShipGroupCargoLiteral | null { switch (value) { case ShipGroupCargo.COL: return "COL"; case ShipGroupCargo.CAP: return "CAP"; case ShipGroupCargo.MAT: return "MAT"; case ShipGroupCargo.UNKNOWN: return null; default: return null; } } /** * shipGroupUpgradeTechFromFBS reverses `shipGroupUpgradeTechToFBS` * from `submit.ts`. `ShipGroupUpgradeTech.UNKNOWN` and any * out-of-band value yield `null`. */ export function shipGroupUpgradeTechFromFBS( value: ShipGroupUpgradeTech, ): ShipGroupUpgradeTechLiteral | null { switch (value) { case ShipGroupUpgradeTech.ALL: return "ALL"; case ShipGroupUpgradeTech.DRIVE: return "DRIVE"; case ShipGroupUpgradeTech.WEAPONS: return "WEAPONS"; case ShipGroupUpgradeTech.SHIELDS: return "SHIELDS"; case ShipGroupUpgradeTech.CARGO: return "CARGO"; case ShipGroupUpgradeTech.UNKNOWN: return null; default: return null; } } /** * relationFromFBS reverses `relationToFBS` from `submit.ts`. * `Relation.UNKNOWN` and any out-of-band value yield `null` so the * caller drops the entry rather than fabricating a synthetic stance. */ export function relationFromFBS(value: Relation): RelationLiteral | null { switch (value) { case Relation.WAR: return "WAR"; case Relation.PEACE: return "PEACE"; case Relation.UNKNOWN: return null; default: return null; } } function decodeError( payload: Uint8Array, resultCode: string, ): { code: string; message: string } { if (payload.length === 0) { return { code: resultCode, message: resultCode }; } 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 : resultCode, message: typeof parsed.message === "string" ? parsed.message : text, }; } catch { return { code: resultCode, message: resultCode }; } }