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