# Order composer This doc covers the local order draft: the per-game in-memory list of commands the player has composed but not yet submitted, the `Cache`-backed persistence that survives reloads, and the `historyMode` flag that hides the composer when the user is browsing past turns. The user-facing spec — the IA section's sidebar description, the `Order` tab placement, the per-command status display — lives in [`../PLAN.md`](../PLAN.md), section `Information Architecture and Navigation`. This doc is the source of truth for how those rules are implemented. ## Draft replaces server order The client's view of "the player's intent for the next turn" lives entirely in the local draft. The gateway exposes a turn-scoped `user.games.order` endpoint that the submit pipeline drains the draft into; until the player explicitly submits, the server has no view of pending commands. The composer never reads back the server's idea of an order — it reads its own draft and renders it. The motivation is operational: a draft can be edited, reordered, or partially removed without round-trips, and a half-composed order during a connectivity hiccup keeps every line the player typed. A remote-first composer that reflects the gateway's pending-orders queue would force a sync on every keystroke. Phase 14 lands the submit pipeline with batch semantics: every entry the user has marked `valid` is collected into one signed `user.games.order` request. The engine validates and stores the order, returning per-command `cmdApplied` / `cmdErrorCode` in the response body. The gateway re-encodes that JSON into the FBS `UserGamesOrderResponse` envelope (with `commands: [CommandItem]` populated), and `submitOrder` rejoins the verdict to each draft entry by `cmdId`. Successfully applied entries stay visible in the draft (the player keeps composing until turn cutoff); rejected entries stay until the player edits or removes them. Phase 25 layers a transport-level policy on top of this baseline without changing the batch semantics. The submit pipeline now goes through `OrderQueue` (see [`sync-protocol.md`](sync-protocol.md)): the queue holds the submit while the browser is offline, classifies `turn_already_closed` and `game_paused` server replies into matching banners on the order tab, and exits the loop on the sticky states so a stream of mutations does not re-elicit the same gateway reply. Recovery from a `conflict` or `paused` banner happens on the next `game.turn.ready` push frame via `OrderDraftStore.resetForNewTurn`, which clears the local draft and re-hydrates from the server for the new turn. ## Local-validation invariant Every command in the draft is *locally valid as of submission time*. A command may live in the draft as `draft` (not yet validated) or `valid`; `invalid` is allowed during composition but the submit pipeline refuses to drain a draft that contains any `invalid` entries. The validation step is per-command and pure — it consults the current `GameStateStore` snapshot only, never the network. Phase 14's `planetRename` is the first variant that exercises the `draft → valid | invalid` transition. The validator (`lib/util/entity-name.ts`) is a TS port of `pkg/util/string.go.ValidateTypeName`, exercised on every render in the inline editor and re-run by the store on every `add`. The submit pipeline filters the draft to `valid` entries only — any `invalid` row blocks the Submit button. ## Command status state machine ```text draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied ╲ │ │ ╲ ╲──validate──▶ invalid │ ╲──nack──▶ rejected │ ╲────turn_already_closed──▶ conflict ``` Transitions: - **`draft → valid` / `draft → invalid`**: local validation. May re-run when the underlying `GameStateStore` snapshot changes (Phase 14+). - **`valid → submitting`**: the submit pipeline picks the entry off the draft and sends it to the gateway. - **`submitting → applied` / `submitting → rejected`**: the gateway responded; the entry is no longer in flight. - **`submitting → conflict`** (Phase 25): the gateway returned `resultCode = "turn_already_closed"`. The order tab surfaces a banner above the command list. Any subsequent mutation re-validates the conflict row back to `valid` / `invalid`; a matching `game.turn.ready` push frame triggers `resetForNewTurn`, which wipes the draft entirely. See [`sync-protocol.md`](sync-protocol.md) for the full state table and recovery paths. Phase 14 lands the local validators (`draft → valid | invalid`), the submit pipeline (`valid → submitting → applied | rejected`), and the optimistic overlay that shows the player's intent on the map and inspector while the order is in flight. Statuses are runtime-only — they are not persisted alongside the commands themselves. On every `init` the store re-runs `validateEntityName` over each command and seeds `draft → valid` / `invalid`. Submitted-then-applied entries lose their `applied` status on reload but stay in the draft as `valid`; the user sees the same row, the overlay reapplies, and re-submitting is idempotent on the engine side (the rename already matches the stored value). ## Discriminated union shape `OrderCommand` is a discriminated union on the `kind` field. Phase 12 shipped the skeleton with a single content-free variant; Phase 14 added the first real one and Phase 15 added the second: ```ts interface PlaceholderCommand { readonly kind: "placeholder"; readonly id: string; readonly label: string; } interface PlanetRenameCommand { readonly kind: "planetRename"; readonly id: string; readonly planetNumber: number; readonly name: string; } interface SetProductionTypeCommand { readonly kind: "setProductionType"; readonly id: string; readonly planetNumber: number; readonly productionType: | "MAT" | "CAP" | "DRIVE" | "WEAPONS" | "SHIELDS" | "CARGO" | "SCIENCE" | "SHIP"; readonly subject: string; } type OrderCommand = | PlaceholderCommand | PlanetRenameCommand | SetProductionTypeCommand; ``` The `id` field is the canonical identifier the store uses for remove and reorder; later variants must keep `id: string` so the store API stays uniform. The whole draft round-trips through IndexedDB structured clone, so every variant must use only JSON-friendly value types. Phase 14 lands `planetRename` together with the inline editor in `lib/inspectors/planet.svelte`, the local validator (`lib/util/entity-name.ts`, parity with `pkg/util/string.go.ValidateTypeName`), and the submit pipeline. `setProductionType` is the wire-mirror of the engine's `CommandPlanetProduce` (`pkg/model/order/order.go`). The local validator runs the same `subject=Production` rule as `game/internal/router/validator.go`: `subject` is required and must satisfy `validateEntityName` when `productionType` is `SCIENCE` or `SHIP`; otherwise it is the empty string. The optimistic overlay rewrites `planet.production` using `productionDisplayFromCommand` (`api/game-state.ts`), which mirrors the engine's `Cache.PlanetProductionDisplayName` so the overlay stays byte-equal with the next server report. ### Collapse-by-target rule (Phase 15) `setProductionType` is the first variant to carry a collapse-by-target rule. `OrderDraftStore.add` enforces it: when the incoming command's `kind` is `"setProductionType"` it drops every prior `setProductionType` entry with the same `planetNumber` (and the matching keys from `statuses`) before appending. Other variants keep their append-only behaviour — each `planetRename` is a distinct user-visible action and collapsing them would lose intent. Net effect on the order tab: at most one `setProductionType` row per planet, regardless of how many times the player clicks through the inspector segments. Auto-sync still fires on every mutation; the engine accepts repeat submits idempotently. A `setProductionType` and a `planetRename` for the same planet coexist — the rules apply within a `kind`, not across. ## Store `OrderDraftStore` lives in [`../frontend/src/sync/order-draft.svelte.ts`](../frontend/src/sync/order-draft.svelte.ts). The class is a Svelte 5 runes store, so the file extension is `.svelte.ts` (the original PLAN.md artifact line listed `.ts` — the deviation is documented inline in `PLAN.md`'s Phase 12 "Decisions" subsection). Lifecycle: | Method | Effect | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | `init({ cache, gameId })` | Reads the persisted draft from `cache`; sets `commands` and flips `status` to `ready`. Errors flow into `status = "error"`. | | `add(cmd)` | Appends to the end and persists. | | `remove(id)` | Drops the matching entry and persists. A miss is a no-op. | | `move(fromIndex, toIndex)` | Reorders within the list and persists. Out-of-range and identical indices are no-ops. | | `dispose()` | Marks the store destroyed; subsequent `persist()` calls are no-ops so a fast game-switch does not write stale state into the next id. | Mutations made before `init` resolves are silently ignored — the layout always awaits `init` through `Promise.all([...])` next to `gameState.init` before exposing the store. Layout integration mirrors `GameStateStore`: - One instance per game, created in [`../frontend/src/routes/games/[id]/+layout.svelte`](../frontend/src/routes/games/[id]/+layout.svelte). - Exposed through the `ORDER_DRAFT_CONTEXT_KEY` Svelte context. - Disposed in the layout's `onDestroy`. The order tab consumes the store via `getContext(ORDER_DRAFT_CONTEXT_KEY)`; Phase 14's planet inspector will use the same key to push a new command. ## Submit pipeline `sync/submit.ts` wraps `GalaxyClient.executeCommand("user.games.order", …)`: 1. The order tab filters the draft to `valid` entries, calls `markSubmitting(ids)` so each row reads `submitting`, then posts the snapshot through `submitOrder`. 2. `submitOrder` builds the FBS `UserGamesOrder` request (game_id, `updatedAt = 0` in Phase 14, every command encoded as a `CommandItem` with the typed payload union) and signs it via the existing `executeCommand` orchestration. 3. The engine validates, stores, and answers `202 Accepted` with the stored order body — `game_id`, `updatedAt`, plus each command echoed with `cmdApplied` and (on rejection) `cmdErrorCode`. 4. The gateway re-encodes that JSON into FBS `UserGamesOrderResponse`, and the frontend parses it back into `Map`. 5. The order tab calls `applyResults` on the draft, then `gameState.refresh()` to fetch a fresh report. The optimistic overlay (`api/game-state.ts.applyOrderOverlay`) keeps the player's intent visible on the map / inspector even if the engine has not yet applied the rename — turn cutoff resolves the divergence on the next report. If the gateway answers with a non-`ok` `resultCode` (auth / transcoder / engine validation), the submit pipeline marks every in-flight entry as `rejected` and surfaces the gateway's error message inline; no refresh is issued. Network exceptions revert in-flight entries back to `valid` so the operator can retry. ## Optimistic overlay `applyOrderOverlay(report, commands, statuses)` (in `api/game-state.ts`) returns a copy of the server `GameReport` with every command in `applied` or `submitting` status projected on top. Phase 14 understands `planetRename` only; future phases extend the switch. The overlay has its own context (`RENDERED_REPORT_CONTEXT_KEY`, `lib/rendered-report.svelte.ts`) — the in-game shell layout owns the source and exposes it to the inspector tab, the mobile sheet, and the map renderer. Raw `gameState.report` stays available for debugging and for any future consumer that needs the un-overlaid snapshot (history mode is the planned reader). ## Server hydration on cache miss `OrderDraftStore` records `needsServerHydration = true` when no cache row exists for the active game (fresh install, cleared storage, switching device). After the layout boot resolves both `gameState.init` and `orderDraft.init`, it calls `orderDraft.hydrateFromServer({ client, turn })` which issues `user.games.order.get` against the gateway. A `found = false` answer leaves the draft empty; a stored order is decoded into `OrderCommand[]` and persisted to the local cache so subsequent reloads use the cached copy. An *explicitly* empty cache row (the user has removed every command they composed) does not trigger hydration — local intent always wins over the server snapshot. The "did this row exist?" distinction matters: `Cache.get` returns `undefined` on a miss and `[]` on an explicitly stored empty array. ## Persistence Cache row layout: | Namespace | Key | Value type | | -------------- | ------------------ | ---------------- | | `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | The store writes the full draft on every mutation. Phase 25 may profile the submit pipeline and batch into a microtask if write amplification becomes a problem; until then the deterministic write-on-every-mutation model is what tests assert and what the layout relies on for crash safety. The namespace is registered in [`storage.md`](storage.md). Cache implementation details live there; this doc only describes how the order composer uses the namespace. ## History mode wiring Phase 26 implements history mode: the user can step back through past turns and see the report as it was. The IA section specifies that the Order tab is hidden when history mode is active — the player is browsing an immutable snapshot, and composing commands against it would be confusing. Phase 12 wires the flag end-to-end as a prop. The layout owns the flag and passes it to: - `Sidebar` as `historyMode`. The sidebar forwards it to its `TabBar` as `hideOrder`. The Order entry is filtered out of the tab list when true. If a `?sidebar=order` URL seed lands while the flag is true, the sidebar falls back to `inspector`. If the active tab is `order` when the flag flips on, an effect resets it to `inspector`. - `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order` button is suppressed when true. Phase 26 turns the constant into a derived value driven by `GameStateStore.historyMode` (`viewedTurn < currentTurn` while `status === "ready"`). The same getter is also passed into `OrderDraftStore.bindClient` as `getHistoryMode`, which short- circuits the `add` / `remove` / `move` mutations to a no-op while the flag is true. This makes every Phase 14–22 inspector affordance that calls `orderDraft.add(...)` inert in history mode without per-component edits — the gate lives in the one chokepoint that all callers go through. The conflict / paused banners and the in-flight sync pipeline are untouched: they describe state that exists independently of the user's current view. The store itself stays alive across history-mode round-trips so the draft survives the toggle. The `RenderedReportSource` overlay (`lib/rendered-report.svelte.ts`) additionally short-circuits in history mode: when `gameState.historyMode === true` it returns the raw report so the map / inspector do not project pending renames composed for the *current* turn onto a *past* report. See [`game-state.md`](game-state.md) for the `viewTurn` / `returnToCurrent` API, the cache namespace (`game-history/{gameId}/turn/{N}`), and the visibility-refresh short-circuit; see [`navigation.md`](navigation.md) for the turn navigator and the read-only banner that surface history mode in the chrome. ## Testing Phase 12 + Phase 14 test artifacts: - [`../frontend/tests/order-draft.test.ts`](../frontend/tests/order-draft.test.ts) — Vitest unit tests for the store. Drives `OrderDraftStore` directly with `IDBCache` over `fake-indexeddb`. Covers init, add, remove, move, per-game isolation, mutations-before-init, dispose hygiene, the Phase 14 status machine (`validate` / `markSubmitting` / `applyResults` / `revertSubmittingToValid`), and the `hydrateFromServer` cache-miss fallback. - [`../frontend/tests/entity-name.test.ts`](../frontend/tests/entity-name.test.ts) — Vitest tests for the validator. Aligned with `pkg/util/string_test.go.TestValidateString` for parity. - [`../frontend/tests/submit.test.ts`](../frontend/tests/submit.test.ts) — Vitest tests for the submit pipeline. Hand-builds FBS responses to verify per-command parsing and batch-level fallback. - [`../frontend/tests/order-load.test.ts`](../frontend/tests/order-load.test.ts) — Vitest tests for `fetchOrder`. Covers the populated / not-found / negative-turn / non-ok paths. - [`../frontend/tests/order-overlay.test.ts`](../frontend/tests/order-overlay.test.ts) — Vitest tests for the pure `applyOrderOverlay` projection. - [`../frontend/tests/order-tab.test.ts`](../frontend/tests/order-tab.test.ts) — Vitest component tests for the Submit button states and the applied / rejected verdict flow. - [`../frontend/tests/inspector-planet.test.ts`](../frontend/tests/inspector-planet.test.ts) — Vitest component tests for the rename action and the inline editor's local validation. - [`../frontend/tests/e2e/order-composer.spec.ts`](../frontend/tests/e2e/order-composer.spec.ts) — Playwright spec for the Phase 12 skeleton (seed three commands, reload, persistence). - [`../frontend/tests/e2e/rename-planet.spec.ts`](../frontend/tests/e2e/rename-planet.spec.ts) — Phase 14 end-to-end: select a planet, rename, submit, observe the overlay-applied name on the inspector + map, reload, and see the rename hydrated from `user.games.order.get`. The `__galaxyDebug.seedOrderDraft(gameId, commands)` and `__galaxyDebug.clearOrderDraft(gameId)` helpers in [`../frontend/src/routes/__debug/store/+page.svelte`](../frontend/src/routes/__debug/store/+page.svelte) write directly to the cache namespace, so seeding is independent of any mounted store.