915b4372dd
Adds the second end-to-end command (`setProductionType`) with a collapse-by-`planetNumber` rule on the order draft, the segmented production-controls component on the planet inspector, the FBS encoder/decoder pair for `CommandPlanetProduce`, and the `localShipClass` projection on `GameReport`. Forecast number is deferred and tracked in the new `ui/docs/calc-bridge.md`.
351 lines
16 KiB
Markdown
351 lines
16 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.
|
|
|
|
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 is reserved for one extension on top of this: per-line
|
|
sequencing if a future use case needs to submit commands
|
|
individually rather than in one batch. The wire shape is already
|
|
flexible enough — the response carries an array of results — so
|
|
Phase 25 only changes the client-side iteration policy.
|
|
|
|
## 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
|
|
```
|
|
|
|
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 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 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
|
|
|
|
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.
|