// 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 = $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 | 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 { this.cache = opts.cache; this.gameId = opts.gameId; try { const stored = await opts.cache.get( 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 14–22 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 { 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 { 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 14–22 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 { 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 { 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 { 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 { 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, 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 = {}; for (const cmd of this.commands) { next[cmd.id] = validateCommand(cmd); } this.statuses = next; } private async persist(): Promise { 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"; } }