ui/phase-14: rename planet end-to-end + order read-back

Wires the first end-to-end command through the full pipeline:
inspector rename action → local order draft → user.games.order
submit → optimistic overlay on map / inspector → server hydration
on cache miss via the new user.games.order.get message type.

Backend: GET /api/v1/user/games/{id}/orders forwards to engine
GET /api/v1/order. Gateway parses the engine PUT response into the
extended UserGamesOrderResponse FBS envelope and adds
executeUserGamesOrderGet for the read-back path. Frontend ports
ValidateTypeName to TS, lands the inline rename editor + Submit
button, and exposes a renderedReport context so consumers see the
overlay-applied snapshot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-09 11:50:09 +02:00
parent 381e41b325
commit f80c623a74
86 changed files with 7505 additions and 138 deletions
+144 -25
View File
@@ -25,13 +25,22 @@ 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.
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
@@ -42,10 +51,13 @@ 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.
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
@@ -65,14 +77,25 @@ Transitions:
- **`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).
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 ships a single variant:
12 shipped the skeleton with a single content-free variant; Phase
14 adds the first real one:
```ts
interface PlaceholderCommand {
@@ -80,15 +103,25 @@ interface PlaceholderCommand {
readonly id: string;
readonly label: string;
}
type OrderCommand = PlaceholderCommand;
interface PlanetRenameCommand {
readonly kind: "planetRename";
readonly id: string;
readonly planetNumber: number;
readonly name: string;
}
type OrderCommand = PlaceholderCommand | PlanetRenameCommand;
```
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.
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.
## Store
@@ -124,6 +157,70 @@ 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:
@@ -168,19 +265,41 @@ its own test suite.
## Testing
Two test artifacts cover the skeleton:
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,
and dispose hygiene.
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. 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.
— 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