2d17760a5e
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>
934 lines
33 KiB
TypeScript
934 lines
33 KiB
TypeScript
// 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 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<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 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<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";
|
||
}
|
||
}
|