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,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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user