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:
Ilia Denisov
2026-05-09 11:50:09 +02:00
parent 381e41b325
commit f80c623a74
86 changed files with 7505 additions and 138 deletions
+230
View File
@@ -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 };
}
}