Files
galaxy-game/ui/frontend/src/sync/order-draft.svelte.ts
T
Ilia Denisov 2d17760a5e ui/phase-26: history mode (turn navigator + read-only banner)
Split GameStateStore into currentTurn (server's latest) and viewedTurn
(displayed snapshot) so history excursions don't corrupt the resume
bookmark or the live-turn bound. Add viewTurn / returnToCurrent /
historyMode rune, plus a game-history cache namespace that stores
past-turn reports for fast re-entry. OrderDraftStore.bindClient takes
a getHistoryMode getter and short-circuits add / remove / move while
the user is viewing a past turn; RenderedReportSource skips the order
overlay in the same case. Header replaces the static "turn N" with a
clickable triplet (TurnNavigator), the layout mounts HistoryBanner
under the header, and visibility-refresh is a no-op while history is
active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:13:19 +02:00

934 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Per-game runes store that owns the local order draft. Mirrors the
// Phase 11 `GameStateStore` lifecycle: one instance per game, created
// in `routes/games/[id]/+layout.svelte`, exposed to descendants via
// Svelte context, disposed when the layout unmounts.
//
// Draft state is persisted into the platform `Cache` under the
// `order-drafts` namespace with a per-game key, so a reload, a
// browser restart, or a navigation through the lobby and back into
// the same game restores the previously composed list.
//
// Phase 14 wires the auto-sync pipeline: every successful mutation
// (`add` / `remove` / `move`) coalesces a `submitOrder` call so the
// server always mirrors the local draft. The Submit button is gone —
// the player's intent is the source of truth and the engine is kept
// in lock-step. Phase 26 will hide the order tab in history mode
// through a flag passed by the layout (the store itself remains
// alive across that transition so the draft survives history-mode
// round-trips).
//
// The store deliberately carries no Svelte component imports so it
// can be tested directly with a synthetic `Cache` without rendering
// any UI.
import type { Cache } from "../platform/store/index";
import type { GalaxyClient } from "../api/galaxy-client";
import { fetchOrder } from "./order-load";
import { OrderQueue, type OrderQueueStartOptions } from "./order-queue.svelte";
import {
isRelation,
isShipGroupCargo,
isShipGroupUpgradeTech,
type CommandStatus,
type OrderCommand,
} from "./order-types";
import { submitOrder } from "./submit";
import { validateEntityName } from "$lib/util/entity-name";
import { validateShipClass } from "$lib/util/ship-class-validation";
import { validateScience } from "$lib/util/science-validation";
const NAMESPACE = "order-drafts";
const draftKey = (gameId: string): string => `${gameId}/draft`;
/**
* ORDER_DRAFT_CONTEXT_KEY is the Svelte context key the in-game shell
* layout uses to expose its `OrderDraftStore` instance to descendants.
* The order tab and any later command-builder UI resolve the store via
* `getContext(ORDER_DRAFT_CONTEXT_KEY)`.
*/
export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft");
type Status = "idle" | "ready" | "error";
/**
* SyncStatus is the order-tab status-bar projection of the auto-sync
* pipeline. Phase 14 introduced the `idle`/`syncing`/`synced`/`error`
* triplet; Phase 25 adds `offline` (queued during a network outage,
* will retry on reconnect), `conflict` (server told us the turn was
* already closed; banner pending), and `paused` (game in pause; no
* submits until it resumes).
*/
export type SyncStatus =
| "idle"
| "syncing"
| "synced"
| "error"
| "offline"
| "conflict"
| "paused";
/**
* ConflictBanner is the optimistic-conflict UX state displayed
* above the order list when a submit landed after the turn cutoff.
* `turn` is the value the player thought was open at submit time;
* it is read from the `getCurrentTurn` callback supplied to
* `bindClient`. The banner is cleared by `resetForNewTurn` (next
* `game.turn.ready`) or by any local mutation.
*/
export interface ConflictBanner {
turn: number | null;
code: string;
message: string;
}
/**
* PausedBanner is displayed when the server tells us the game is
* paused. The banner is cleared by `resetForNewTurn` once the game
* resumes (a fresh `game.turn.ready` event).
*/
export interface PausedBanner {
code: string;
message: string;
reason: string;
}
export class OrderDraftStore {
commands: OrderCommand[] = $state([]);
statuses: Record<string, CommandStatus> = $state({});
updatedAt = $state(0);
status: Status = $state("idle");
error: string | null = $state(null);
/**
* syncStatus reflects the auto-sync pipeline state for the order
* tab status bar:
* - `idle` — no sync attempted yet (e.g. fresh draft after
* hydration or before the first mutation).
* - `syncing` — a `submitOrder` call is in flight.
* - `synced` — the last sync succeeded; statuses match the
* server's view.
* - `error` — the last sync failed (network or non-`ok`); the
* next mutation triggers a retry, or the user can
* force a re-sync via `forceSync`.
* - `offline` — the browser is offline; the last submit was
* held. A fresh send fires on the next `online`
* flip via the queue callback.
* - `conflict` — the gateway returned `turn_already_closed`;
* the in-flight commands are marked `conflict`
* and `conflictBanner` carries the user-facing
* copy.
* - `paused` — the gateway returned `game_paused` (or a
* `game.paused` push frame arrived); no submits
* fire until `resetForNewTurn` clears it.
*/
syncStatus: SyncStatus = $state("idle");
syncError: string | null = $state(null);
/**
* conflictBanner is non-null whenever `syncStatus === "conflict"`.
* The order tab renders the banner above the command list with
* the turn number interpolated; clearing it is the
* `resetForNewTurn` / mutation responsibility.
*/
conflictBanner: ConflictBanner | null = $state(null);
/**
* pausedBanner is non-null whenever `syncStatus === "paused"`.
* The order tab renders a pause-specific banner separate from
* the conflict path.
*/
pausedBanner: PausedBanner | null = $state(null);
private cache: Cache | null = null;
private gameId = "";
private destroyed = false;
private client: GalaxyClient | null = null;
private syncing: Promise<void> | null = null;
private pending = false;
private queue = new OrderQueue();
private queueStarted = false;
private getCurrentTurn: (() => number) | null = null;
private getHistoryMode: (() => boolean) | null = null;
/**
* init loads the persisted draft for `opts.gameId` from `opts.cache`
* into `commands` and flips `status` to `ready`. The call is
* 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.
*
* The cache load is the fast path so the order tab paints
* immediately on reopening the game; `hydrateFromServer` (called
* by the layout once the current turn is known) is the
* authoritative read that always overwrites the local cache when
* the server has a stored order.
*/
async init(
opts: { cache: Cache; gameId: string; queue?: OrderQueueStartOptions },
): Promise<void> {
this.cache = opts.cache;
this.gameId = opts.gameId;
try {
const stored = await opts.cache.get<OrderCommand[]>(
NAMESPACE,
draftKey(opts.gameId),
);
if (this.destroyed) return;
this.commands = Array.isArray(stored) ? [...stored] : [];
this.recomputeStatuses();
this.status = "ready";
} catch (err) {
if (this.destroyed) return;
this.status = "error";
this.error = err instanceof Error ? err.message : "load failed";
}
this.startQueue(opts.queue);
}
/**
* bindClient stores the per-game `GalaxyClient` so subsequent
* mutations can drive the auto-sync pipeline. The layout calls
* this after the boot `Promise.all` resolves and before
* `hydrateFromServer`, so any mutation that lands afterwards goes
* through the network.
*
* Phase 25: `opts.getCurrentTurn` lets the conflict banner
* interpolate the turn number the player was composing for. The
* layout passes `() => gameState.currentTurn`; tests may omit it,
* in which case the banner falls back to a turn-less template.
*
* Phase 26: `opts.getHistoryMode` lets `add` / `remove` / `move`
* short-circuit while the user is viewing a past turn. Without
* the gate, inspector affordances built in Phases 1422 would
* happily push commands into the draft even though the order tab
* is hidden and the read-only banner is visible. Tests may omit
* it; the default is "never in history mode".
*/
bindClient(
client: GalaxyClient,
opts: {
getCurrentTurn?: () => number;
getHistoryMode?: () => boolean;
} = {},
): void {
this.client = client;
this.getCurrentTurn = opts.getCurrentTurn ?? null;
this.getHistoryMode = opts.getHistoryMode ?? null;
}
/**
* hydrateFromServer issues `user.games.order.get` for the current
* turn and overwrites the local cache with the server's stored
* order. The server is the source of truth: a player who logged
* in from a fresh device must see their existing orders, and a
* cache that's out-of-sync (e.g. a stale browser tab) is
* superseded by the gateway's view. A `found = false` answer
* empties the local draft. Network failures keep the local cache
* intact and surface as `syncStatus = "error"`.
*/
async hydrateFromServer(opts: {
client: GalaxyClient;
turn: number;
}): Promise<void> {
if (this.status !== "ready") return;
// Phase 25: a `game.paused` push frame may arrive before the
// initial hydrate completes (the layout subscribes early to
// avoid losing in-flight frames). The pause is stickier than a
// freshly-loaded snapshot — keep the banner up and skip the
// fetch entirely. A subsequent `resetForNewTurn` (triggered by
// `game.turn.ready` after the game resumes) re-runs the
// hydration from scratch.
if (this.syncStatus === "paused") return;
this.client = opts.client;
// Guard against placeholder game ids the Phase 10 e2e specs
// still use — auto-sync needs a real UUID for the FBS request
// envelope, and a parser exception here would only be visible
// as a noisy `console.warn` deep in the layout boot path.
if (!isUuid(this.gameId)) {
this.syncStatus = "idle";
return;
}
this.syncStatus = "syncing";
this.syncError = null;
try {
const fetched = await fetchOrder(opts.client, this.gameId, opts.turn);
if (this.destroyed) return;
// If `markPaused` landed between the initial syncStatus
// flip and the awaited fetch, the pause is the
// authoritative state — do not overwrite it with synced.
// The fetched commands are still adopted so a later
// `resetForNewTurn` can build on top of them.
this.commands = fetched.commands;
this.updatedAt = fetched.updatedAt;
this.recomputeStatuses();
// Server-fetched commands echo cmdApplied=true for entries
// that survived previous turns; keep them as `applied` so
// the overlay continues to project them on the inspector.
const next = { ...this.statuses };
for (const cmd of this.commands) {
if (next[cmd.id] === "valid") {
next[cmd.id] = "applied";
}
}
this.statuses = next;
await this.persist();
if ((this.syncStatus as SyncStatus) !== "paused") {
this.syncStatus = "synced";
}
} catch (err) {
if (this.destroyed) return;
if ((this.syncStatus as SyncStatus) !== "paused") {
this.syncStatus = "error";
this.syncError = err instanceof Error ? err.message : "fetch failed";
}
console.warn("order-draft: server hydration failed", err);
}
}
/**
* add appends a command to the end of the draft, runs local
* validation for the new entry, persists the updated list, and
* triggers an auto-sync to keep the server in lock-step.
* Mutations made before `init` resolves are ignored — the layout
* always awaits `init` before exposing the store.
*
* Collapse rules:
*
* - `setProductionType` collapses by `planetNumber`: a new
* entry supersedes any prior `setProductionType` for the
* same planet, so the draft holds at most one production
* choice per planet.
* - `setCargoRoute` and `removeCargoRoute` share a collapse
* key on `(sourcePlanetNumber, loadType)` — the engine
* stores a single (planet, type) → destination mapping, so
* a newer entry for the same slot supersedes any prior
* `set` or `remove` for that slot. Different load-types or
* different sources coexist.
* - `setDiplomaticStance` collapses by `acceptor`: the engine
* tracks a single war/peace stance per opponent, so a newer
* entry supersedes any prior `setDiplomaticStance` for the
* same other race.
* - `setVoteRecipient` collapses singleton: per `rules.txt`
* each race controls a single vote slot, so a newer entry
* supersedes any prior `setVoteRecipient` regardless of the
* acceptor.
* - `planetRename` and `placeholder` append unconditionally;
* each rename is a distinct user-visible action.
*/
async add(command: OrderCommand): Promise<void> {
if (this.status !== "ready") return;
// Phase 26: history mode hides the order tab and treats every
// view as read-only. The inspector affordances are not aware of
// the mode, so the gate lives here — one chokepoint protects
// every Phase 1422 caller without per-component edits.
if (this.getHistoryMode?.() === true) return;
this.clearConflictForMutation();
const removed: string[] = [];
let nextCommands: OrderCommand[];
if (command.kind === "setProductionType") {
nextCommands = [];
for (const existing of this.commands) {
if (
existing.kind === "setProductionType" &&
existing.planetNumber === command.planetNumber
) {
removed.push(existing.id);
continue;
}
nextCommands.push(existing);
}
nextCommands.push(command);
} else if (
command.kind === "setCargoRoute" ||
command.kind === "removeCargoRoute"
) {
nextCommands = [];
for (const existing of this.commands) {
if (
(existing.kind === "setCargoRoute" ||
existing.kind === "removeCargoRoute") &&
existing.sourcePlanetNumber === command.sourcePlanetNumber &&
existing.loadType === command.loadType
) {
removed.push(existing.id);
continue;
}
nextCommands.push(existing);
}
nextCommands.push(command);
} else if (command.kind === "setDiplomaticStance") {
nextCommands = [];
for (const existing of this.commands) {
if (
existing.kind === "setDiplomaticStance" &&
existing.acceptor === command.acceptor
) {
removed.push(existing.id);
continue;
}
nextCommands.push(existing);
}
nextCommands.push(command);
} else if (command.kind === "setVoteRecipient") {
nextCommands = [];
for (const existing of this.commands) {
if (existing.kind === "setVoteRecipient") {
removed.push(existing.id);
continue;
}
nextCommands.push(existing);
}
nextCommands.push(command);
} else {
nextCommands = [...this.commands, command];
}
this.commands = nextCommands;
const nextStatuses = { ...this.statuses };
for (const id of removed) {
delete nextStatuses[id];
}
nextStatuses[command.id] = validateCommand(command);
this.statuses = nextStatuses;
await this.persist();
this.scheduleSync();
}
/**
* remove drops the command with the given id from the draft,
* persists the result, and triggers an auto-sync. A miss is a
* no-op. Even removing the last command sends an explicit empty
* order to the server so its stored state matches the local one
* (the engine accepts an empty `cmd[]` per the order handler).
*/
async remove(id: string): Promise<void> {
if (this.status !== "ready") return;
if (this.getHistoryMode?.() === true) return;
const next = this.commands.filter((cmd) => cmd.id !== id);
if (next.length === this.commands.length) return;
this.clearConflictForMutation();
this.commands = next;
const nextStatuses = { ...this.statuses };
delete nextStatuses[id];
this.statuses = nextStatuses;
await this.persist();
this.scheduleSync();
}
/**
* move relocates the command at `fromIndex` to `toIndex`, shifting
* the intermediate commands. Out-of-range indices and identical
* positions are no-ops; both indices are clamped against the
* current `commands` length. Triggers an auto-sync — the server
* stores commands in submission order and the engine relies on
* that order at turn cutoff.
*/
async move(fromIndex: number, toIndex: number): Promise<void> {
if (this.status !== "ready") return;
if (this.getHistoryMode?.() === true) return;
const length = this.commands.length;
if (fromIndex < 0 || fromIndex >= length) return;
if (toIndex < 0 || toIndex >= length) return;
if (fromIndex === toIndex) return;
this.clearConflictForMutation();
const next = [...this.commands];
const [picked] = next.splice(fromIndex, 1);
if (picked === undefined) return;
next.splice(toIndex, 0, picked);
this.commands = next;
await this.persist();
this.scheduleSync();
}
/**
* forceSync re-runs the auto-sync without requiring a mutation.
* Used by the order tab's retry-on-error affordance.
*/
forceSync(): void {
this.scheduleSync();
}
/**
* markPaused projects an incoming `game.paused` push event into
* the store: the order tab shows the pause banner, the auto-sync
* loop short-circuits, and any submitting rows revert to `valid`
* (the matching engine state is still the old one). The layout
* calls this from the `game.paused` subscription. `reason`
* carries the raw runtime status published by lobby
* (`engine_unreachable` / `generation_failed`); the UI ignores
* it today but the payload is preserved for future copy
* differentiation.
*/
markPaused(opts: { reason: string; message?: string }): void {
if (this.status !== "ready") return;
this.revertSubmittingToValidInternal();
this.pausedBanner = {
code: "game_paused",
message: opts.message ?? "Game paused. Orders are not accepted until it resumes.",
reason: opts.reason,
};
this.syncStatus = "paused";
this.syncError = null;
}
/**
* resetForNewTurn drops the local draft, clears every Phase 25
* banner, and hydrates from the server for the supplied turn.
* The layout calls this from the `game.turn.ready` subscription
* when the prior `syncStatus` was `conflict` or `paused`. The
* effect mirrors a fresh boot: cache wipe → fetch → seed.
*/
async resetForNewTurn(opts: {
client: GalaxyClient;
turn: number;
}): Promise<void> {
if (this.status !== "ready") return;
this.commands = [];
this.statuses = {};
this.updatedAt = 0;
this.conflictBanner = null;
this.pausedBanner = null;
this.syncStatus = "idle";
this.syncError = null;
await this.persist();
await this.hydrateFromServer({ client: opts.client, turn: opts.turn });
}
dispose(): void {
this.destroyed = true;
this.cache = null;
this.client = null;
this.getCurrentTurn = null;
this.getHistoryMode = null;
if (this.queueStarted) {
this.queue.stop();
this.queueStarted = false;
}
}
private scheduleSync(): void {
if (this.client === null) return;
// Same UUID guard as `hydrateFromServer` — placeholder game
// ids in test fixtures must not blow up the auto-sync path.
if (!isUuid(this.gameId)) return;
// Conflict / paused states are sticky: the order tab is
// waiting for the next `game.turn.ready` (conflict) or for
// the admin to resume (paused). Local mutations clear the
// conflict; the layout's `markPaused`/`resetForNewTurn` clear
// the pause. Trying to send mid-state would re-elicit the
// same gateway reply on every keystroke and overwrite the
// banner with the same message.
if (this.syncStatus === "conflict" || this.syncStatus === "paused") {
return;
}
if (this.syncing !== null) {
this.pending = true;
return;
}
this.syncing = this.runSync().finally(() => {
this.syncing = null;
});
}
private async runSync(): Promise<void> {
while (true) {
this.pending = false;
const client = this.client;
if (client === null || this.destroyed) return;
// Capture the snapshot up-front: the in-flight request
// reflects the draft as it was when the mutation landed,
// even if the user adds another command before the
// gateway responds.
const snapshot: OrderCommand[] = $state.snapshot(
this.commands,
) as OrderCommand[];
// Auto-sync sends every command the player still has in
// the draft except the locally-invalid ones (we can't
// expect the server to accept a name that fails our own
// validator) and the Phase 12 placeholder. `applied` and
// `rejected` entries are re-sent so the server's stored
// view always mirrors the local one — re-applying an
// already-applied command is idempotent at the engine
// level (the rename ends at the same name).
const submittable = snapshot.filter((cmd) => {
const status = this.statuses[cmd.id];
return status !== "invalid" && status !== "draft";
});
const submittingIds = submittable.map((cmd) => cmd.id);
this.markSubmittingInternal(submittingIds);
this.syncStatus = "syncing";
this.syncError = null;
const outcome = await this.queue.send(() =>
submitOrder(client, this.gameId, submittable, {
updatedAt: this.updatedAt,
}),
);
if (this.destroyed) return;
switch (outcome.kind) {
case "success": {
this.applyResultsInternal(
outcome.result.results,
outcome.result.updatedAt,
);
// Even with `result.ok === true` an individual
// command may have been rejected by the engine
// (e.g. validation passed transcoders but failed
// the in-game rule). Surface that as an error in
// the sync bar so the player notices and can fix
// or remove the offending command.
const anyRejected = Array.from(
outcome.result.results.values(),
).some((s) => s === "rejected");
this.syncStatus = anyRejected ? "error" : "synced";
this.syncError = anyRejected
? "engine rejected one or more commands"
: null;
break;
}
case "rejected": {
this.markRejectedInternal(submittingIds);
this.syncStatus = "error";
this.syncError = outcome.failure.message;
break;
}
case "conflict": {
this.markConflictInternal(submittingIds);
this.conflictBanner = {
turn: this.getCurrentTurn?.() ?? null,
code: outcome.code,
message: outcome.message,
};
this.syncStatus = "conflict";
this.syncError = null;
// Stickiness: conflict overrides any pending
// mutations until the next `game.turn.ready` or a
// local edit clears the banner.
return;
}
case "paused": {
this.revertSubmittingToValidInternal();
this.pausedBanner = {
code: outcome.code,
message: outcome.message,
reason: outcome.code,
};
this.syncStatus = "paused";
this.syncError = null;
return;
}
case "offline": {
this.revertSubmittingToValidInternal();
this.syncStatus = "offline";
this.syncError = null;
return;
}
case "failed": {
this.revertSubmittingToValidInternal();
this.syncStatus = "error";
this.syncError = outcome.reason;
break;
}
}
if (!this.pending) return;
}
}
private startQueue(opts?: OrderQueueStartOptions): void {
if (this.queueStarted) return;
this.queue.start({
onOnline: () => {
if (this.destroyed) return;
if (this.syncStatus === "offline") {
this.scheduleSync();
}
},
onlineProbe: opts?.onlineProbe,
addEventListener: opts?.addEventListener,
removeEventListener: opts?.removeEventListener,
});
this.queueStarted = true;
}
private markSubmittingInternal(ids: string[]): void {
const next = { ...this.statuses };
for (const id of ids) {
// `applied` rows stay applied while the wire request is in
// flight — re-sending an already-applied command is a
// no-op idempotent operation, and flipping the badge back
// to `submitting` would flicker the inspector overlay.
if (next[id] === "valid" || next[id] === "rejected") {
next[id] = "submitting";
}
}
this.statuses = next;
}
private applyResultsInternal(
results: Map<string, CommandStatus>,
updatedAt: number,
): void {
const liveIds = new Set(this.commands.map((cmd) => cmd.id));
const next = { ...this.statuses };
for (const [id, status] of results.entries()) {
// Drop verdicts for commands the user removed while the
// request was in flight — they are no longer in the
// draft, so re-introducing a stale `applied` row would
// confuse the order tab and the overlay.
if (!liveIds.has(id)) continue;
next[id] = status;
}
this.statuses = next;
this.updatedAt = updatedAt;
}
private markRejectedInternal(ids: string[]): void {
const next = { ...this.statuses };
for (const id of ids) {
next[id] = "rejected";
}
this.statuses = next;
}
private markConflictInternal(ids: string[]): void {
const next = { ...this.statuses };
for (const id of ids) {
next[id] = "conflict";
}
this.statuses = next;
}
/**
* clearConflictForMutation drops the conflict banner and
* re-validates every `conflict`-marked command back to its
* pre-submit status. Called from every mutation (`add`,
* `remove`, `move`) so the user-driven "Edit and resubmit" flow
* works without an extra dismiss step.
*/
private clearConflictForMutation(): void {
if (this.syncStatus !== "conflict" && this.conflictBanner === null) {
return;
}
const next = { ...this.statuses };
let mutated = false;
for (const cmd of this.commands) {
if (next[cmd.id] === "conflict") {
next[cmd.id] = validateCommand(cmd);
mutated = true;
}
}
if (mutated) {
this.statuses = next;
}
this.conflictBanner = null;
if (this.syncStatus === "conflict") {
this.syncStatus = "idle";
this.syncError = null;
}
}
private revertSubmittingToValidInternal(): void {
const next = { ...this.statuses };
for (const cmd of this.commands) {
if (next[cmd.id] === "submitting") {
next[cmd.id] = validateCommand(cmd);
}
}
this.statuses = next;
}
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.
// IndexedDB's structured clone refuses to clone proxies, so the
// snapshot must be taken before the put.
const snapshot = $state.snapshot(this.commands) as OrderCommand[];
await this.cache.put(NAMESPACE, draftKey(this.gameId), snapshot);
}
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function isUuid(value: string): boolean {
return UUID_RE.test(value);
}
function validateCommand(cmd: OrderCommand): CommandStatus {
switch (cmd.kind) {
case "planetRename":
return validateEntityName(cmd.name).ok ? "valid" : "invalid";
case "setProductionType":
// Mirrors the engine's `subject=Production` validator
// (`game/internal/router/validator.go`): SCIENCE and SHIP
// require a non-empty entity-name-valid subject; the other
// six production types accept any subject (typically empty)
// because the engine only consults the subject for those
// two cases.
if (
cmd.productionType === "SCIENCE" ||
cmd.productionType === "SHIP"
) {
return validateEntityName(cmd.subject).ok ? "valid" : "invalid";
}
return "valid";
case "setCargoRoute":
// The picker pre-checks reach (and so refuses to emit a
// route to an unreachable destination) and the engine
// re-validates ownership / reach server-side. Locally we
// only refuse a self-route — the FBS validator
// (`pkg/model/order/order.go`) accepts every other
// (origin, destination, load_type) triple.
if (cmd.sourcePlanetNumber === cmd.destinationPlanetNumber) {
return "invalid";
}
return "valid";
case "removeCargoRoute":
// `removeCargoRoute` carries no destination; the only
// engine-side check is ownership of the source planet,
// which the inspector enforces by only mounting the
// component on `kind === "local"`.
return "valid";
case "createShipClass":
// Mirrors `pkg/calc/validator.go.ValidateShipTypeValues`
// plus the entity-name rules. The duplicate-name check is
// the designer's responsibility (it sees the live overlay
// list); here the validator runs without `existingNames`
// so a draft that was valid at creation time does not flip
// to invalid just because another `createShipClass` for
// the same name landed in the draft afterwards — both
// rows ride out the wire and the engine arbitrates.
return validateShipClass({
name: cmd.name,
drive: cmd.drive,
armament: cmd.armament,
weapons: cmd.weapons,
shields: cmd.shields,
cargo: cmd.cargo,
}).ok
? "valid"
: "invalid";
case "removeShipClass":
// `removeShipClass` carries only the name; the engine
// checks that the class exists and is not referenced by
// active production / ship groups. Local validation only
// guards the name shape.
return validateEntityName(cmd.name).ok ? "valid" : "invalid";
case "createScience":
// Mirrors `pkg/calc/validator.go.ValidateScienceValues`
// plus the entity-name rules. The wire shape is fractions
// (sum to 1.0); the validator runs without `existingNames`
// here for the same reason ship-class create does — a
// duplicate-name check is the designer's UX responsibility,
// not the draft store's.
return validateScience({
name: cmd.name,
drive: cmd.drive * 100,
weapons: cmd.weapons * 100,
shields: cmd.shields * 100,
cargo: cmd.cargo * 100,
}).ok
? "valid"
: "invalid";
case "removeScience":
// `removeScience` carries only the name; the engine checks
// that the science exists and is not referenced by active
// production. Local validation only guards the name shape.
return validateEntityName(cmd.name).ok ? "valid" : "invalid";
case "breakShipGroup":
// Engine rule (`controller/ship_group.go.breakGroup`):
// quantity must be at least 1 and strictly less than the
// source group size. We do not know the source size here
// (it lives on the report), so the inspector enforces the
// upper bound before emitting; locally we only refuse the
// degenerate cases — non-positive `quantity`, missing or
// equal UUIDs.
if (cmd.quantity <= 0) return "invalid";
if (!isUuid(cmd.groupId) || !isUuid(cmd.newGroupId)) return "invalid";
if (cmd.groupId === cmd.newGroupId) return "invalid";
return "valid";
case "sendShipGroup":
// Reach is enforced by the picker before the command lands
// in the draft. Locally we only refuse a degenerate
// destination (the engine uses planet number `0` as the
// "no planet" sentinel; FBS encodes as `int64`, so any
// strictly-positive number is wire-valid).
if (cmd.destinationPlanetNumber <= 0) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "loadShipGroup":
// Cargo type and quantity are pre-checked by the inspector
// against the planet stock and the group's free capacity;
// local validation only guards the wire-valid shape.
if (!isShipGroupCargo(cmd.cargo)) return "invalid";
if (cmd.quantity <= 0) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "unloadShipGroup":
if (cmd.quantity <= 0) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "upgradeShipGroup":
// Engine rule
// (`controller/ship_group_upgrade.go.shipGroupUpgrade:56`):
// `tech === "ALL"` requires `level === 0`; per-block tech
// requires a strictly positive level. The inspector also
// caps the level to the player's race tech, but the
// engine re-validates server-side.
if (!isShipGroupUpgradeTech(cmd.tech)) return "invalid";
if (cmd.tech === "ALL") {
if (cmd.level !== 0) return "invalid";
} else if (cmd.level <= 0) {
return "invalid";
}
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "dismantleShipGroup":
return isUuid(cmd.groupId) ? "valid" : "invalid";
case "transferShipGroup":
// `acceptor` is a race name; race names follow the same
// entity-name rules as planet/fleet names. The inspector
// restricts the picker to `GameReport.otherRaces`, so a
// locally-valid name is always a real race.
if (!validateEntityName(cmd.acceptor).ok) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "joinFleetShipGroup":
if (!validateEntityName(cmd.name).ok) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "setDiplomaticStance":
// `acceptor` is the opponent's race name; race names follow
// the same entity-name rules as planet/fleet names. The
// races-table view restricts the per-row picker to live
// `GameReport.races[]` entries, so a locally-valid name is
// always a real race. `relation` must be one of the two
// wire-stable values (`WAR` or `PEACE`); the FBS
// `UNKNOWN = 0` sentinel is never emitted.
if (!validateEntityName(cmd.acceptor).ok) return "invalid";
if (!isRelation(cmd.relation)) return "invalid";
return "valid";
case "setVoteRecipient":
// `acceptor` is the race the local player votes for. The
// engine accepts a self-vote as the neutral default
// (`controller/race.go`), so the table picker may include
// the local race as a valid choice. Local validation only
// guards the name shape.
if (!validateEntityName(cmd.acceptor).ok) return "invalid";
return "valid";
case "placeholder":
// Phase 12 placeholder entries are content-free and never
// transition out of `draft` — they are not submittable.
return "draft";
}
}