Files
galaxy-game/ui/docs/order-composer.md
T
Ilia Denisov 2294d8b3d9
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m47s
fix(ui): tighter order card — calculator-scale font, corner-flush ✕;
stabilise report-sections e2e

Owner review on PR #58:

- shrink the order-card body to 0.8rem (matching the calculator's body
  text scale) so the order list reads as part of the sidebar's
  density, not its own larger surface;
- shrink the delete ✕ to 0.95rem and glue it flush to the card's
  top-right corner (no offset, sized to fit the corner padding-space);
- tighten the card padding to match the smaller text.

Independently — the same review asked to fix `report-sections › every
TOC anchor lands its section in view`, which had been a long-standing
e2e flake (run #366 on `development` already failed it twice before
passing on retry; my PR's run #367 simply exhausted all five retries).
The root cause is the smooth `scrollIntoView` settling slower than
Playwright's 5 s viewport wait under heavy CI load. The production
TOC already honours `prefers-reduced-motion: reduce` and swaps to an
instant scroll there; switching the Playwright config to that media
mode makes every spec deterministic without touching production code.
2026-05-26 08:28:58 +02:00

391 lines
19 KiB
Markdown

# 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.
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`](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
```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.
- **`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 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.
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).
### Visual encoding on the order tab
The order tab's row carries a `status-{status}` class that tints the
card background through the design-token subtle palette: `applied`
reads as `--color-success-subtle`, `invalid` / `rejected` / `conflict`
as `--color-danger-subtle`, and `draft` / `valid` / `submitting` as
`--color-warning-subtle` (the "not yet acknowledged by the server"
group, including the offline mode). The textual status name stays in
the DOM as a `.sr-only` node so screen readers and the existing
`order-command-status-N` testids still observe it. Card text sits at
`0.8rem` — the same scale the calculator tab uses for its body labels
— so the order list reads as part of the same sidebar density rather
than its own larger surface. Long labels wrap inside the card
(`overflow-wrap: anywhere`) instead of being truncated. The per-row
delete control is a tiny framed `✕` flush against the card's
top-right corner (no offset, sized to fit the corner padding-space)
— always visible, never hover-only, and labelled by
`game.sidebar.order.command_delete` for assistive tech.
## Discriminated union shape
`OrderCommand` is a discriminated union on the `kind` field:
```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. `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`](../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
shell always awaits `init` through `Promise.all([...])` next to
`gameState.init` before exposing the store.
Shell integration mirrors `GameStateStore`:
- One instance per game, created in the in-game shell
[`../frontend/src/lib/game/game-shell.svelte`](../frontend/src/lib/game/game-shell.svelte).
- Exposed through the `ORDER_DRAFT_CONTEXT_KEY` Svelte context.
- Disposed in the shell'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", …)`:
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`, 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.
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 shell 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`](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 in-game shell owns the `historyMode` 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 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.
`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`](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
- [`../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 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 order composer skeleton (seed three
commands, reload, persistence).
- [`../frontend/tests/e2e/rename-planet.spec.ts`](../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 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.