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
+150 -5
View File
@@ -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";
}
}
+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 };
}
}
+17 -2
View File
@@ -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
+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 };
}
}