Files
galaxy-game/ui/docs/order-composer.md
Ilia Denisov 2d17760a5e ui/phase-26: history mode (turn navigator + read-only banner)
Split GameStateStore into currentTurn (server's latest) and viewedTurn
(displayed snapshot) so history excursions don't corrupt the resume
bookmark or the live-turn bound. Add viewTurn / returnToCurrent /
historyMode rune, plus a game-history cache namespace that stores
past-turn reports for fast re-entry. OrderDraftStore.bindClient takes
a getHistoryMode getter and short-circuits add / remove / move while
the user is viewing a past turn; RenderedReportSource skips the order
overlay in the same case. Header replaces the static "turn N" with a
clickable triplet (TurnNavigator), the layout mounts HistoryBanner
under the header, and visibility-refresh is a no-op while history is
active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:13:19 +02:00

390 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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`](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 1422 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.