f80c623a74
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>
164 lines
4.7 KiB
TypeScript
164 lines
4.7 KiB
TypeScript
// 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 };
|
|
}
|
|
}
|