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>
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 underlyingGameStateStoresnapshot 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:
- One instance per game, created in
../frontend/src/routes/games/[id]/+layout.svelte. - Exposed through the
ORDER_DRAFT_CONTEXT_KEYSvelte 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.
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:
SidebarashistoryMode. The sidebar forwards it to itsTabBarashideOrder. The Order entry is filtered out of the tab list when true. If a?sidebar=orderURL seed lands while the flag is true, the sidebar falls back toinspector. If the active tab isorderwhen the flag flips on, an effect resets it toinspector.BottomTabsashideOrder. The mobile bottom-tabOrderbutton 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. DrivesOrderDraftStoredirectly withIDBCacheoverfake-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.