// Drives the order submit pipeline: builds a FlatBuffers // `UserGamesOrder` payload from the local draft, calls // `client.executeCommand("user.games.order", ...)`, and translates // the engine response into per-command results the draft store can // merge with `applyResults`. // // The engine populates `cmdApplied` and `cmdErrorCode` on every // returned command (see `game/openapi.yaml`), so the happy path // reads real per-command outcomes. An empty response `commands` // array — the gateway's defensive fallback when no body comes back // — collapses to a batch-level "all applied" verdict so the player // is never left with submitted-without-result rows. // // Failures fall into two buckets: // - the gateway answers with a non-`ok` `resultCode` (auth / // transcoder / engine validation); the result is `ok: false` // and every submitted entry should flip to `rejected`; // - the request itself throws (network, signature mismatch, decoder // panic); the exception bubbles up to the caller, which leaves // the draft entries in `submitting` for the operator to retry. 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 { CommandItem, CommandPayload, CommandPlanetProduce, CommandPlanetRename, PlanetProduction, UserGamesOrder, UserGamesOrderResponse, } from "../proto/galaxy/fbs/order"; import type { OrderCommand, ProductionType } from "./order-types"; const MESSAGE_TYPE = "user.games.order"; export class SubmitError extends Error { readonly resultCode: string; readonly code: string; constructor(resultCode: string, code: string, message: string) { super(message); this.name = "SubmitError"; this.resultCode = resultCode; this.code = code; } } export type CommandOutcome = "applied" | "rejected"; export interface SubmitSuccess { ok: true; results: Map; errorCodes: Map; updatedAt: number; } export interface SubmitFailure { ok: false; resultCode: string; code: string; message: string; } export type SubmitResult = SubmitSuccess | SubmitFailure; export interface SubmitOptions { updatedAt?: number; } /** * submitOrder posts the `commands` slice through `user.games.order`, * decodes the FBS response, and returns per-command outcomes the * caller (the order tab) feeds back into `OrderDraftStore.applyResults`. * * @param client GalaxyClient owning the signed-gRPC transport. * @param gameId Stringified UUID of the game whose order is submitted. * @param commands Subset of the local draft to send. The caller has * already filtered out non-`valid` entries. * @param options.updatedAt Optional engine-assigned timestamp from a * prior submit — Phase 14 always sends `0` because stale-order * detection is not yet wired client-side. */ export async function submitOrder( client: GalaxyClient, gameId: string, commands: OrderCommand[], options: SubmitOptions = {}, ): Promise { const payload = buildOrderPayload(gameId, commands, options.updatedAt ?? 0); const result = await client.executeCommand(MESSAGE_TYPE, payload); if (result.resultCode !== "ok") { const { code, message } = decodeError(result.payloadBytes, result.resultCode); return { ok: false, resultCode: result.resultCode, code, message, }; } return decodeOrderResponse(result.payloadBytes, commands); } function buildOrderPayload( gameId: string, commands: OrderCommand[], updatedAt: number, ): Uint8Array { const builder = new Builder(256); const itemOffsets = commands.map((cmd) => encodeCommandItem(builder, cmd)); const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets); const [hi, lo] = uuidToHiLo(gameId); const gameIdOffset = UUID.createUUID(builder, hi, lo); UserGamesOrder.startUserGamesOrder(builder); UserGamesOrder.addGameId(builder, gameIdOffset); UserGamesOrder.addUpdatedAt(builder, BigInt(updatedAt)); UserGamesOrder.addCommands(builder, commandsVec); const offset = UserGamesOrder.endUserGamesOrder(builder); builder.finish(offset); return builder.asUint8Array(); } function encodeCommandItem(builder: Builder, cmd: OrderCommand): number { const cmdIdOffset = builder.createString(cmd.id); const { payloadType, payloadOffset } = encodeCommandPayload(builder, cmd); CommandItem.startCommandItem(builder); CommandItem.addCmdId(builder, cmdIdOffset); CommandItem.addPayloadType(builder, payloadType); CommandItem.addPayload(builder, payloadOffset); return CommandItem.endCommandItem(builder); } function encodeCommandPayload( builder: Builder, cmd: OrderCommand, ): { payloadType: CommandPayload; payloadOffset: number } { switch (cmd.kind) { case "planetRename": { const nameOffset = builder.createString(cmd.name); const offset = CommandPlanetRename.createCommandPlanetRename( builder, BigInt(cmd.planetNumber), nameOffset, ); return { payloadType: CommandPayload.CommandPlanetRename, payloadOffset: offset, }; } case "setProductionType": { const subjectOffset = builder.createString(cmd.subject); const offset = CommandPlanetProduce.createCommandPlanetProduce( builder, BigInt(cmd.planetNumber), productionTypeToFBS(cmd.productionType), subjectOffset, ); return { payloadType: CommandPayload.CommandPlanetProduce, payloadOffset: offset, }; } case "placeholder": throw new SubmitError( "invalid_request", "invalid_request", `placeholder commands cannot be submitted (cmd id ${cmd.id})`, ); } } /** * productionTypeToFBS converts the wire-stable `ProductionType` literal * to the FlatBuffers enum value. Mirrors `planetProductionToFBS` in * `pkg/transcoder/order.go`. The two sides are kept in lock-step so the * gateway can decode whatever the frontend produces without a * translation step. */ export function productionTypeToFBS(value: ProductionType): PlanetProduction { switch (value) { case "MAT": return PlanetProduction.MAT; case "CAP": return PlanetProduction.CAP; case "DRIVE": return PlanetProduction.DRIVE; case "WEAPONS": return PlanetProduction.WEAPONS; case "SHIELDS": return PlanetProduction.SHIELDS; case "CARGO": return PlanetProduction.CARGO; case "SCIENCE": return PlanetProduction.SCIENCE; case "SHIP": return PlanetProduction.SHIP; } } function decodeOrderResponse( payload: Uint8Array, commands: OrderCommand[], ): SubmitSuccess { const results = new Map(); const errorCodes = new Map(); let updatedAt = 0; if (payload.length === 0) { // Empty envelope (gateway fallback). Apply batch-level verdict. for (const cmd of commands) { results.set(cmd.id, "applied"); errorCodes.set(cmd.id, null); } return { ok: true, results, errorCodes, updatedAt }; } const buffer = new ByteBuffer(payload); const response = UserGamesOrderResponse.getRootAsUserGamesOrderResponse(buffer); updatedAt = Number(response.updatedAt()); const length = response.commandsLength(); if (length === 0) { for (const cmd of commands) { results.set(cmd.id, "applied"); errorCodes.set(cmd.id, null); } return { ok: true, results, errorCodes, updatedAt }; } for (let i = 0; i < length; i++) { const item = response.commands(i); if (item === null) continue; const cmdId = item.cmdId(); if (cmdId === null) continue; const applied = item.cmdApplied(); const errorCode = item.cmdErrorCode(); results.set(cmdId, applied === false ? "rejected" : "applied"); errorCodes.set(cmdId, errorCode === null ? null : Number(errorCode)); } // Defensive: any submitted command not echoed back falls back to // applied so the draft entry leaves `submitting`. for (const cmd of commands) { if (!results.has(cmd.id)) { results.set(cmd.id, "applied"); errorCodes.set(cmd.id, null); } } return { ok: true, results, errorCodes, updatedAt }; } 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 }; } }