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:
@@ -17,7 +17,10 @@
|
||||
// any UI.
|
||||
|
||||
import type { Cache } from "../platform/store/index";
|
||||
import type { OrderCommand } from "./order-types";
|
||||
import type { GalaxyClient } from "../api/galaxy-client";
|
||||
import { fetchOrder } from "./order-load";
|
||||
import type { CommandStatus, OrderCommand } from "./order-types";
|
||||
import { validateEntityName } from "$lib/util/entity-name";
|
||||
|
||||
const NAMESPACE = "order-drafts";
|
||||
const draftKey = (gameId: string): string => `${gameId}/draft`;
|
||||
@@ -34,9 +37,21 @@ type Status = "idle" | "ready" | "error";
|
||||
|
||||
export class OrderDraftStore {
|
||||
commands: OrderCommand[] = $state([]);
|
||||
statuses: Record<string, CommandStatus> = $state({});
|
||||
updatedAt = $state(0);
|
||||
status: Status = $state("idle");
|
||||
error: string | null = $state(null);
|
||||
|
||||
/**
|
||||
* needsServerHydration is `true` when the cache row for this game
|
||||
* was absent at `init` time. The layout reads it after both
|
||||
* `gameState.init` and `orderDraft.init` resolve and, if `true`,
|
||||
* calls `hydrateFromServer` once the current turn is known.
|
||||
* An explicitly empty cache row sets it to `false` (the user has
|
||||
* an empty draft, not a missing one).
|
||||
*/
|
||||
needsServerHydration = $state(false);
|
||||
|
||||
private cache: Cache | null = null;
|
||||
private gameId = "";
|
||||
private destroyed = false;
|
||||
@@ -47,6 +62,12 @@ export class OrderDraftStore {
|
||||
* idempotent on the same store instance — the layout always
|
||||
* constructs a fresh store per game, so there is no need to support
|
||||
* mid-life game switching here.
|
||||
*
|
||||
* When the cache row is absent, `needsServerHydration` is set to
|
||||
* `true`; the layout fans out a `hydrateFromServer` call once the
|
||||
* current turn is known. An explicitly empty cache row is treated
|
||||
* as "user has an empty draft" and skipped — local intent always
|
||||
* wins over server snapshot.
|
||||
*/
|
||||
async init(opts: { cache: Cache; gameId: string }): Promise<void> {
|
||||
this.cache = opts.cache;
|
||||
@@ -57,7 +78,14 @@ export class OrderDraftStore {
|
||||
draftKey(opts.gameId),
|
||||
);
|
||||
if (this.destroyed) return;
|
||||
this.commands = Array.isArray(stored) ? [...stored] : [];
|
||||
if (stored === undefined) {
|
||||
this.commands = [];
|
||||
this.needsServerHydration = true;
|
||||
} else {
|
||||
this.commands = Array.isArray(stored) ? [...stored] : [];
|
||||
this.needsServerHydration = false;
|
||||
}
|
||||
this.recomputeStatuses();
|
||||
this.status = "ready";
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
@@ -67,13 +95,44 @@ export class OrderDraftStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* add appends a command to the end of the draft and persists the
|
||||
* updated list. Mutations made before `init` resolves are ignored —
|
||||
* the layout always awaits `init` before exposing the store.
|
||||
* hydrateFromServer fetches the player's stored order from the
|
||||
* gateway when the cache row was absent at boot. The result is
|
||||
* merged into `commands` and persisted so subsequent reloads
|
||||
* prefer the cached version. Failures are non-fatal — the draft
|
||||
* stays empty and the user can keep composing.
|
||||
*/
|
||||
async hydrateFromServer(opts: {
|
||||
client: GalaxyClient;
|
||||
turn: number;
|
||||
}): Promise<void> {
|
||||
if (this.status !== "ready" || !this.needsServerHydration) return;
|
||||
this.needsServerHydration = false;
|
||||
try {
|
||||
const fetched = await fetchOrder(opts.client, this.gameId, opts.turn);
|
||||
if (this.destroyed) return;
|
||||
this.commands = fetched.commands;
|
||||
this.updatedAt = fetched.updatedAt;
|
||||
this.recomputeStatuses();
|
||||
await this.persist();
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
console.warn(
|
||||
"order-draft: server hydration failed; staying on empty draft",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* add appends a command to the end of the draft, runs local
|
||||
* validation for the new entry, and persists the updated list.
|
||||
* Mutations made before `init` resolves are ignored — the layout
|
||||
* always awaits `init` before exposing the store.
|
||||
*/
|
||||
async add(command: OrderCommand): Promise<void> {
|
||||
if (this.status !== "ready") return;
|
||||
this.commands = [...this.commands, command];
|
||||
this.statuses = { ...this.statuses, [command.id]: validateCommand(command) };
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
@@ -86,6 +145,9 @@ export class OrderDraftStore {
|
||||
const next = this.commands.filter((cmd) => cmd.id !== id);
|
||||
if (next.length === this.commands.length) return;
|
||||
this.commands = next;
|
||||
const nextStatuses = { ...this.statuses };
|
||||
delete nextStatuses[id];
|
||||
this.statuses = nextStatuses;
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
@@ -109,11 +171,83 @@ export class OrderDraftStore {
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* markSubmitting flips the status of every entry in `ids` to
|
||||
* `submitting` so the order tab can disable per-row controls and
|
||||
* show a spinner. The state machine runs `valid → submitting →
|
||||
* applied | rejected` (see ui/docs/order-composer.md).
|
||||
*/
|
||||
markSubmitting(ids: string[]): void {
|
||||
const next = { ...this.statuses };
|
||||
for (const id of ids) {
|
||||
next[id] = "submitting";
|
||||
}
|
||||
this.statuses = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* applyResults merges the verdict map returned by `submitOrder`
|
||||
* into the per-command status map. Entries not present in the
|
||||
* map keep their current status — useful when only a subset of
|
||||
* commands round-tripped to the server. The engine-assigned
|
||||
* `updatedAt` is also stashed for the next submit's stale-order
|
||||
* detection (kept as plumbing only in Phase 14).
|
||||
*/
|
||||
applyResults(opts: {
|
||||
results: Map<string, CommandStatus>;
|
||||
updatedAt: number;
|
||||
}): void {
|
||||
const next = { ...this.statuses };
|
||||
for (const [id, status] of opts.results.entries()) {
|
||||
next[id] = status;
|
||||
}
|
||||
this.statuses = next;
|
||||
this.updatedAt = opts.updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* markRejected switches every supplied id to `rejected`. Used by
|
||||
* the order tab when `submitOrder` returns `ok: false` — the
|
||||
* gateway didn't process any command, so the entire batch is
|
||||
* treated as rejected.
|
||||
*/
|
||||
markRejected(ids: string[]): void {
|
||||
const next = { ...this.statuses };
|
||||
for (const id of ids) {
|
||||
next[id] = "rejected";
|
||||
}
|
||||
this.statuses = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* revertSubmittingToValid resets every entry currently in
|
||||
* `submitting` back to its pre-submit status (typically `valid`).
|
||||
* Called when the network layer throws an exception so the
|
||||
* operator can retry without the rows looking stuck mid-flight.
|
||||
*/
|
||||
revertSubmittingToValid(): void {
|
||||
const next = { ...this.statuses };
|
||||
for (const cmd of this.commands) {
|
||||
if (next[cmd.id] === "submitting") {
|
||||
next[cmd.id] = validateCommand(cmd);
|
||||
}
|
||||
}
|
||||
this.statuses = next;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.destroyed = true;
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
private recomputeStatuses(): void {
|
||||
const next: Record<string, CommandStatus> = {};
|
||||
for (const cmd of this.commands) {
|
||||
next[cmd.id] = validateCommand(cmd);
|
||||
}
|
||||
this.statuses = next;
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
if (this.cache === null || this.destroyed) return;
|
||||
// `commands` is `$state`, so individual entries are proxies.
|
||||
@@ -123,3 +257,14 @@ export class OrderDraftStore {
|
||||
await this.cache.put(NAMESPACE, draftKey(this.gameId), snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCommand(cmd: OrderCommand): CommandStatus {
|
||||
switch (cmd.kind) {
|
||||
case "planetRename":
|
||||
return validateEntityName(cmd.name).ok ? "valid" : "invalid";
|
||||
case "placeholder":
|
||||
// Phase 12 placeholder entries are content-free and never
|
||||
// transition out of `draft` — they are not submittable.
|
||||
return "draft";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,28 @@ export interface PlaceholderCommand {
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlanetRenameCommand is the first real command variant — Phase 14
|
||||
* lands the rename action together with the submit pipeline. The
|
||||
* `name` is locally validated against `validateEntityName` (the TS
|
||||
* port of `pkg/util/string.go.ValidateTypeName`) before the entry is
|
||||
* accepted into the draft; the same rules run server-side, so a
|
||||
* locally-valid command is always accepted at the wire level.
|
||||
*/
|
||||
export interface PlanetRenameCommand {
|
||||
readonly kind: "planetRename";
|
||||
readonly id: string;
|
||||
readonly planetNumber: number;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OrderCommand is the discriminated union of every command shape the
|
||||
* local order draft can hold. The `kind` field is the discriminator;
|
||||
* narrowing on it enables exhaustive `switch` statements at every
|
||||
* call site. Phase 14 will widen the union with `planetRename`.
|
||||
* call site.
|
||||
*/
|
||||
export type OrderCommand = PlaceholderCommand;
|
||||
export type OrderCommand = PlaceholderCommand | PlanetRenameCommand;
|
||||
|
||||
/**
|
||||
* CommandStatus is the lifecycle of a single command from the moment
|
||||
|
||||
@@ -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