915b4372dd
Adds the second end-to-end command (`setProductionType`) with a collapse-by-`planetNumber` rule on the order draft, the segmented production-controls component on the planet inspector, the FBS encoder/decoder pair for `CommandPlanetProduce`, and the `localShipClass` projection on `GameReport`. Forecast number is deferred and tracked in the new `ui/docs/calc-bridge.md`.
274 lines
8.4 KiB
TypeScript
274 lines
8.4 KiB
TypeScript
// 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<string, CommandOutcome>;
|
|
errorCodes: Map<string, number | null>;
|
|
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<SubmitResult> {
|
|
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<string, CommandOutcome>();
|
|
const errorCodes = new Map<string, number | null>();
|
|
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 };
|
|
}
|
|
}
|