Files
galaxy-game/ui/docs/order-composer.md
T
Ilia Denisov 460591c159 ui/phase-12: order composer skeleton
OrderDraftStore persists per-game command drafts in Cache; the
sidebar Order tab renders the list with a per-row delete control.
The layout passes a `historyMode` prop through Sidebar / BottomTabs
as a constant `false`, so Phase 26 only flips the source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 23:26:58 +02:00

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

When the submit pipeline lands (Phase 25), it iterates the draft in order, sending one command per line. The gateway's per-line result rejoins the draft entry through cmdId, and the entry's CommandStatus flips to applied or rejected. Successfully applied entries stay visible until the next turn cutoff so the player can see what was committed; rejected entries stay until the player edits or removes them.

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 12 ships the skeleton without any concrete validators: the single placeholder variant is content-free and stays at draft forever. Phase 14's planetRename is the first variant that exercises the draft → valid | invalid transition.

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 12 stores the type but does not implement any transitions. Every entry remains at draft until later phases land the validators (Phase 14) and the submit pipeline (Phase 25).

Discriminated union shape

OrderCommand is a discriminated union on the kind field. Phase 12 ships a single variant:

interface PlaceholderCommand {
    readonly kind: "placeholder";
    readonly id: string;
    readonly label: string;
}
type OrderCommand = PlaceholderCommand;

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 adds the first real variant (planetRename) and updates this list.

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.

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

Two test artifacts cover the skeleton:

  • ../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, and dispose hygiene.
  • ../frontend/tests/e2e/order-composer.spec.ts — Playwright spec. Seeds three commands through __galaxyDebug.seedOrderDraft, navigates into /games/<id>/map, opens the Order tool (sidebar tab on desktop, bottom tab on mobile), asserts the rows, reloads, and asserts the rows again.

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.