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
+163
View File
@@ -0,0 +1,163 @@
// 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,
CommandPlanetRename,
UserGamesOrderGet,
UserGamesOrderGetResponse,
} from "../proto/galaxy/fbs/order";
import type { OrderCommand } 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[];
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<FetchedOrder> {
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: [], 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 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);
}
return {
commands,
updatedAt: Number(order.updatedAt()),
};
}
type CommandItemView = NonNullable<
ReturnType<NonNullable<ReturnType<UserGamesOrderGetResponse["order"]>>["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() ?? "",
};
}
default:
console.warn(
`fetchOrder: skipping unknown command kind (payloadType=${payloadType})`,
);
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 };
}
}