ui/phase-14: rename planet end-to-end + order read-back
Wires the first end-to-end command through the full pipeline:
inspector rename action → local order draft → user.games.order
submit → optimistic overlay on map / inspector → server hydration
on cache miss via the new user.games.order.get message type.
Backend: GET /api/v1/user/games/{id}/orders forwards to engine
GET /api/v1/order. Gateway parses the engine PUT response into the
extended UserGamesOrderResponse FBS envelope and adds
executeUserGamesOrderGet for the read-back path. Frontend ports
ValidateTypeName to TS, lands the inline rename editor + Submit
button, and exposes a renderedReport context so consumers see the
overlay-applied snapshot.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
// 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,
|
||||
CommandPlanetRename,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderResponse,
|
||||
} from "../proto/galaxy/fbs/order";
|
||||
import type { OrderCommand } 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 "placeholder":
|
||||
throw new SubmitError(
|
||||
"invalid_request",
|
||||
"invalid_request",
|
||||
`placeholder commands cannot be submitted (cmd id ${cmd.id})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user