Files
galaxy-game/ui/docs/order-composer.md
T
Ilia Denisov 915b4372dd ui/phase-15: planet inspector production controls + order-draft collapse
Adds the second end-to-end command (`setProductionType`) with a
collapse-by-`planetNumber` rule on the order draft, the segmented
production-controls component on the planet inspector, the FBS
encoder/decoder pair for `CommandPlanetProduce`, and the
`localShipClass` projection on `GameReport`. Forecast number is
deferred and tracked in the new `ui/docs/calc-bridge.md`.
2026-05-09 15:54:30 +02:00

16 KiB

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, 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 is reserved for one extension on top of this: per-line sequencing if a future use case needs to submit commands individually rather than in one batch. The wire shape is already flexible enough — the response carries an array of results — so Phase 25 only changes the client-side iteration policy.

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

draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied
   ╲                  │                                  ╲
    ╲──validate──▶ invalid                                ╲──nack──▶ rejected

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.

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:

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. 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:

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<cmdId, "applied" | "rejected">.
  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. Cache implementation details live there; this doc only describes how the order composer uses the namespace.

History mode wiring

Phase 26 introduces a global history-mode flag. The IA section specifies that the Order tab is hidden when history mode is active — the player is browsing a past turn snapshot, and composing commands against an immutable snapshot would be confusing.

Phase 12 wires the flag end-to-end as a prop. The layout owns the flag (a constant false until Phase 26) 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.

The store itself stays alive across history-mode round-trips so the draft survives. Phase 26 will replace the constant with the real signal from lib/history-mode.ts and exercise the toggle in its own test suite.

Testing

Phase 12 + Phase 14 test artifacts:

The __galaxyDebug.seedOrderDraft(gameId, commands) and __galaxyDebug.clearOrderDraft(gameId) helpers in ../frontend/src/routes/__debug/store/+page.svelte write directly to the cache namespace, so seeding is independent of any mounted store.