MVP web client (Phases 1-30) is complete; reorganize planning + living docs around that. - PLAN.md kept as the staged MVP record (1-30) with a status block + pointers; removed the 31-36 stages, regression scenarios, and deferred-TODO section (moved out); fixed a stale cross-machine plan path. - ui/PLAN-finalize.md (new): active web-finalization plan in 8 stages (visual system, a11y, i18n, error UX, PWA, build hygiene, docs, owner manual-QA loop); absorbs former Phases 33 and 35. - ui/ROADMAP.md (new): post-MVP (Wails, Capacitor, realistic projection, acceptance + regression scenarios) and triaged deferred follow-ups. - ui/docs/README.md (new): grouped topic-doc index. - De-archaeologized all 20 ui/docs topic docs + ui/README.md + ui/core/README.md: stripped Phase-N build history, rewritten as current-state; deferred work now points at ROADMAP.md / PLAN-finalize.md. Docs-only; no code change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
18 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.
The submit pipeline uses 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.
A transport-level policy layers on top of the batch baseline without
changing the batch semantics. The submit pipeline goes through
OrderQueue (see 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.
The planetRename variant 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
│
╲────turn_already_closed──▶ conflict
Transitions:
draft → valid/draft → invalid: local validation. May re-run when the underlyingGameStateStoresnapshot changes.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: the gateway returnedresultCode = "turn_already_closed". The order tab surfaces a banner above the command list. Any subsequent mutation re-validates the conflict row back tovalid/invalid; a matchinggame.turn.readypush frame triggersresetForNewTurn, which wipes the draft entirely. Seesync-protocol.mdfor the full state table and recovery paths.
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 are all implemented.
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:
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. planetRename ships 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
setProductionType carries 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.
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 and the planet inspector both consume the store via
getContext(ORDER_DRAFT_CONTEXT_KEY) to push new commands.
Submit pipeline
sync/submit.ts wraps GalaxyClient.executeCommand("user.games.order", …):
- The order tab filters the draft to
validentries, callsmarkSubmitting(ids)so each row readssubmitting, then posts the snapshot throughsubmitOrder. submitOrderbuilds the FBSUserGamesOrderrequest (game_id,updatedAt, every command encoded as aCommandItemwith the typed payload union) and signs it via the existingexecuteCommandorchestration.- The engine validates, stores, and answers
202 Acceptedwith the stored order body —game_id,updatedAt, plus each command echoed withcmdAppliedand (on rejection)cmdErrorCode. - The gateway re-encodes that JSON into FBS
UserGamesOrderResponse, and the frontend parses it back intoMap<cmdId, "applied" | "rejected">. - The order tab calls
applyResultson the draft, thengameState.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.
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. 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
History mode lets the user step back through past turns and see the report as it was. 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.
The layout owns the historyMode flag 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.
historyMode is 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
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 for the viewTurn /
returnToCurrent API, the cache namespace
(game-history/{gameId}/turn/{N}), and the visibility-refresh
short-circuit; see navigation.md for the turn
navigator and the read-only banner that surface history mode in
the chrome.
Testing
../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, dispose hygiene, the status machine (validate/markSubmitting/applyResults/revertSubmittingToValid), and thehydrateFromServercache-miss fallback.../frontend/tests/entity-name.test.ts— Vitest tests for the validator. Aligned withpkg/util/string_test.go.TestValidateStringfor parity.../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— Vitest tests forfetchOrder. Covers the populated / not-found / negative-turn / non-ok paths.../frontend/tests/order-overlay.test.ts— Vitest tests for the pureapplyOrderOverlayprojection.../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— Vitest component tests for the rename action and the inline editor's local validation.../frontend/tests/e2e/order-composer.spec.ts— Playwright spec for the order composer skeleton (seed three commands, reload, persistence).../frontend/tests/e2e/rename-planet.spec.ts— End-to-end: select a planet, rename, submit, observe the overlay-applied name on the inspector + map, reload, and see the rename hydrated fromuser.games.order.get.
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.