ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
18 changed files with 1022 additions and 53 deletions
Showing only changes of commit 460591c159 - Show all commits
+90 -10
View File
@@ -1332,17 +1332,97 @@ Goal: implement the empty order composer as a persistent vertical list
that survives navigation and reloads, ready to receive commands in
later phases.
Artifacts:
Decisions taken with the project owner during implementation:
- `ui/frontend/src/lib/sidebar/order-tab.svelte` vertical command list
with empty state copy
- `ui/frontend/src/sync/order-draft.ts` draft order store backed by
`Cache`, persisting across reloads
- `ui/frontend/src/sync/order-types.ts` typed command shape
(`OrderCommand` discriminated union)
- topic doc `ui/docs/order-composer.md` describing the
draft-replaces-server-order model, the local-validation invariant,
and command status semantics
1. **Store filename uses the runes extension.** PLAN.md originally
listed `ui/frontend/src/sync/order-draft.ts`. Svelte 5 runes only
compile inside `*.svelte` / `*.svelte.ts` files; the draft state
has to be reactive so `order-tab.svelte` re-renders on
add/remove/move. The artifact ships as
`ui/frontend/src/sync/order-draft.svelte.ts`, mirroring the
Phase 11 `lib/game-state.svelte.ts` pattern.
2. **Single `placeholder` variant in the discriminated union.** The
project compactness rule rejects defining surface for the next
phase. Phase 14 owns `planetRename` end-to-end (inspector UI,
command type, submit pipeline, server-result merging) and is the
right place to add the first real variant. Phase 12 ships exactly
one variant — `{ kind: "placeholder"; id: string; label: string }`
— sufficient for the add/remove/reorder/persist tests.
3. **Reorder API is `move(fromIndex, toIndex)`.** One canonical
operation; up/down at the call site is a one-line index
arithmetic. No `moveUp`/`moveDown` aliases.
4. **Write-on-every-mutation persistence.** `add`/`remove`/`move`
each call `Cache.put` with the full draft snapshot. Phase 25 may
profile the submit pipeline and batch writes if needed; until
then deterministic writes are easier to test.
5. **Per-game scoping via Svelte context.** One `OrderDraftStore`
instance per game is created in `routes/games/[id]/+layout.svelte`
alongside `GameStateStore`, exposed through
`ORDER_DRAFT_CONTEXT_KEY`, disposed on layout destroy.
6. **`historyMode` as a prop, not a module.** Layout passes
`historyMode={false}` (a constant in Phase 12) to `Sidebar` and
`BottomTabs`; both forward to their tab-bar children which omit
the order entry when the flag is true. Phase 26 introduces the
real `lib/history-mode.ts` module and replaces the constant in
one place.
7. **Empty-state copy is `order is empty` / `приказ пуст`.** The
`coming soon` placeholder text is replaced; per-row delete
button reads `delete` / `удалить`.
8. **e2e seeding via `__galaxyDebug.seedOrderDraft`.** The existing
debug surface in `routes/__debug/store/+page.svelte` is extended
with `seedOrderDraft(gameId, commands)` and
`clearOrderDraft(gameId)` helpers that write directly to the
`order-drafts` cache namespace. The store loads the seeded draft
on the next layout mount the same way it would after a real
reload.
9. **Race / disposal hygiene mirrors `GameStateStore`.** Mutations
are gated on `status === "ready"` so calls before `init`
resolves are no-ops, and `persist` checks a `destroyed` flag so
in-flight writes after `dispose` resolve into nothing.
Artifacts (delivered):
- `ui/frontend/src/sync/order-types.ts` — `OrderCommand`
discriminated union (single `placeholder` variant) and
`CommandStatus` lifecycle type.
- `ui/frontend/src/sync/order-draft.svelte.ts` —
`OrderDraftStore` runes class with
`init` / `add` / `remove` / `move` / `dispose`, plus
`ORDER_DRAFT_CONTEXT_KEY`. Persists the full draft on every
mutation under namespace `order-drafts`, key `{gameId}/draft`.
- `ui/frontend/src/lib/sidebar/order-tab.svelte` — replaces the
Phase 10 stub. Empty state from `game.sidebar.empty.order`;
ordered list with stable `data-testid="order-command-{i}"`
rows and a per-row delete button.
- `ui/frontend/src/lib/sidebar/sidebar.svelte`,
`tab-bar.svelte`, `bottom-tabs.svelte` — `historyMode` prop on
the sidebar forwards to `hideOrder` on tab-bar / bottom-tabs;
active-tab `order` is reset to `inspector` if the flag flips
on, and the `?sidebar=order` URL seed falls back to
`inspector` while the flag is true.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
instantiates `OrderDraftStore`, sets context, runs
`init({ cache, gameId })` next to `gameState.init` through
one `Promise.all`, disposes on destroy, passes
`historyMode={false}` down.
- `ui/frontend/src/routes/__debug/store/+page.svelte` — extended
`DebugSurface` with `seedOrderDraft` / `clearOrderDraft`.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new
`game.sidebar.order.command_delete` key plus updated
`game.sidebar.empty.order` copy.
- `ui/docs/order-composer.md` — topic doc describing the
draft-replaces-server-order model, local-validation invariant,
command status state machine, persistence, history-mode wiring,
and test layout. Cross-references `storage.md` and
`navigation.md`.
- `ui/docs/storage.md` — namespace registry row for
`order-drafts`.
- `ui/docs/navigation.md` — describes the historyMode prop wiring
through Sidebar / BottomTabs.
- `ui/README.md` — new entry under topic docs for
`order-composer.md`.
- Vitest: `ui/frontend/tests/order-draft.test.ts`.
- Playwright: `ui/frontend/tests/e2e/order-composer.spec.ts`.
Dependencies: Phases 6, 10.
+3
View File
@@ -65,6 +65,7 @@ ui/
├── docs/ topic-based design notes
│ ├── auth-flow.md email-code login, session store, revocation
│ ├── i18n.md translation primitive, native-name picker, extensibility
│ ├── order-composer.md order draft model, persistence, history-mode wiring
│ ├── storage.md web KeyStore/Cache, IDB schema, baseline
│ ├── testing.md per-PR / release test tiers
│ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget
@@ -90,6 +91,8 @@ Linked topic docs:
language picker, recipe for adding a new locale.
- [`docs/storage.md`](docs/storage.md) — web KeyStore/Cache,
IndexedDB schema, browser baseline.
- [`docs/order-composer.md`](docs/order-composer.md) — local
order draft store, persistence, history-mode wiring.
- [`docs/wasm-toolchain.md`](docs/wasm-toolchain.md) — TinyGo build,
loading recipe, bundle size budget.
- [`docs/testing.md`](docs/testing.md) — Tier 1 per-PR + Tier 2
+14
View File
@@ -61,6 +61,20 @@ on mount and seeds the initial tab. Later phases that want to land
the user on a particular tool (for example, Phase 14's first
end-to-end command flow) can set it on navigation.
The Order entry is hidden when the layout's `historyMode` flag is
true. Phase 12 plumbs the flag end-to-end as a prop —
`+layout.svelte` passes a constant `false` to `Sidebar`, which
forwards `hideOrder` to its `TabBar`; the same flag goes to
`BottomTabs` so the mobile `Order` button is also suppressed. A
`?sidebar=order` URL seed that arrives while the flag is true falls
back to `inspector`, and an `$effect` on the sidebar resets
`activeTab` away from `order` if the flag flips on mid-session.
Phase 26 introduces `lib/history-mode.ts` and replaces the constant
with the live signal; the order draft survives the toggle because
`OrderDraftStore` lives one level above the sidebar in the layout
hierarchy. See [`order-composer.md`](order-composer.md) for the
draft-store side of the flow.
## Layout breakpoints
Three discrete CSS modes matched to the IA section diagrams:
+189
View File
@@ -0,0 +1,189 @@
# 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.
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.
## 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 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.
## 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 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).
## Discriminated union shape
`OrderCommand` is a discriminated union on the `kind` field. Phase
12 ships a single variant:
```ts
interface PlaceholderCommand {
readonly kind: "placeholder";
readonly id: string;
readonly label: string;
}
type OrderCommand = PlaceholderCommand;
```
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.
## 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.
## 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
Two test artifacts cover the skeleton:
- [`../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.
- [`../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.
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.
+7 -5
View File
@@ -113,13 +113,15 @@ wipes every namespace.
Namespaces in current use:
| Namespace | Key | Value type | Owner |
|-----------|----------------------|------------|-------------|
|-----------------|---------------------|------------------|-----------------------------|
| `session` | `device-session-id` | `string` | Phase 7+ |
| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) |
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) |
Phase 8 onwards will add per-feature namespaces (lobby snapshot,
game state, fixtures, etc.). The contract is namespace-strings
stay scoped to one feature; cross-feature reads through the cache
are by convention disallowed.
Later phases will add more per-feature namespaces (fixtures, lobby
snapshot, etc.). The contract is namespace-strings stay scoped to
one feature; cross-feature reads through the cache are by convention
disallowed.
## Keystore lifecycle
+2 -1
View File
@@ -118,7 +118,8 @@ const en = {
"game.sidebar.tab.order": "order",
"game.sidebar.empty.calculator": "coming soon",
"game.sidebar.empty.inspector": "select an object on the map",
"game.sidebar.empty.order": "coming soon",
"game.sidebar.empty.order": "order is empty",
"game.sidebar.order.command_delete": "delete",
"game.bottom_tabs.map": "map",
"game.bottom_tabs.calc": "calc",
"game.bottom_tabs.order": "order",
+2 -1
View File
@@ -119,7 +119,8 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.tab.order": "приказ",
"game.sidebar.empty.calculator": "скоро будет",
"game.sidebar.empty.inspector": "выберите объект на карте",
"game.sidebar.empty.order": "скоро будет",
"game.sidebar.empty.order": "приказ пуст",
"game.sidebar.order.command_delete": "удалить",
"game.bottom_tabs.map": "карта",
"game.bottom_tabs.calc": "калк",
"game.bottom_tabs.order": "приказ",
@@ -22,8 +22,14 @@ destinations beats the duplication.
gameId: string;
activeTool: MobileTool;
onSelectTool: (tool: MobileTool) => void;
hideOrder?: boolean;
};
let { gameId, activeTool, onSelectTool }: Props = $props();
let {
gameId,
activeTool,
onSelectTool,
hideOrder = false,
}: Props = $props();
let moreOpen = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
@@ -99,6 +105,7 @@ destinations beats the duplication.
<span class="icon" aria-hidden="true">🧮</span>
<span class="label">{i18n.t("game.bottom_tabs.calc")}</span>
</button>
{#if !hideOrder}
<button
type="button"
role="tab"
@@ -110,6 +117,7 @@ destinations beats the duplication.
<span class="icon" aria-hidden="true">📝</span>
<span class="label">{i18n.t("game.bottom_tabs.order")}</span>
</button>
{/if}
<button
type="button"
data-testid="bottom-tab-more"
+93 -4
View File
@@ -1,14 +1,60 @@
<!--
Phase 10 stub for the Order composer sidebar tool. Phase 12 ships
the composer skeleton; Phase 14 lands the first end-to-end command.
Order composer tool. Resolves the per-game `OrderDraftStore` from
context (set by `routes/games/[id]/+layout.svelte`) and renders the
draft as a vertical, top-to-bottom command list. Empty state shows
the i18n empty-state copy; non-empty state shows an ordered list of
rows, each with a stable `data-testid` plus a per-row delete button.
Phase 12 has no UI for adding commands — Phase 14 lands the first
end-to-end command (`planetRename`) and the inspector affordance
that pushes it into the draft. Tests exercise the skeleton through
`__galaxyDebug.seedOrderDraft` (Playwright) and via direct store
construction (Vitest).
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
function describe(cmd: { kind: string; label?: string }): string {
if (cmd.kind === "placeholder") return cmd.label ?? cmd.kind;
return cmd.kind;
}
</script>
<section class="tool" data-testid="sidebar-tool-order">
<h3>{i18n.t("game.sidebar.tab.order")}</h3>
<p>{i18n.t("game.sidebar.empty.order")}</p>
{#if draft === undefined || draft.commands.length === 0}
<p class="empty" data-testid="order-empty">
{i18n.t("game.sidebar.empty.order")}
</p>
{:else}
<ol class="commands" data-testid="order-list">
{#each draft.commands as cmd, index (cmd.id)}
<li class="command" data-testid="order-command-{index}">
<span class="index" aria-hidden="true">{index + 1}.</span>
<span class="label" data-testid="order-command-label-{index}">
{describe(cmd)}
</span>
<button
type="button"
class="delete"
data-testid="order-command-delete-{index}"
onclick={() => draft?.remove(cmd.id)}
>
{i18n.t("game.sidebar.order.command_delete")}
</button>
</li>
{/each}
</ol>
{/if}
</section>
<style>
@@ -20,8 +66,51 @@ the composer skeleton; Phase 14 lands the first end-to-end command.
margin: 0 0 0.5rem;
font-size: 1rem;
}
.tool p {
.empty {
margin: 0;
color: #888;
}
.commands {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.command {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
background: #14182a;
border: 1px solid #20253a;
border-radius: 4px;
}
.index {
min-width: 1.5rem;
color: #aab;
font-variant-numeric: tabular-nums;
}
.label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.delete {
font: inherit;
font-size: 0.85rem;
padding: 0.2rem 0.55rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.delete:hover {
color: #e8eaf6;
border-color: #6d8cff;
}
</style>
+29 -10
View File
@@ -1,14 +1,19 @@
<!--
Phase 10 sidebar with three tabs (Calculator, Inspector, Order). The
parent layout decides whether the sidebar is rendered at all (mobile
hides it, tablet collapses it behind the header toggle, desktop
keeps it always visible). State preservation across active-view
switches works for free because the layout never remounts when the
user navigates within `/games/:id/*`.
Sidebar with three tabs (Calculator, Inspector, Order). The parent
layout decides whether the sidebar is rendered at all (mobile hides
it, tablet collapses it behind the header toggle, desktop keeps it
always visible). State preservation across active-view switches
works for free because the layout never remounts when the user
navigates within `/games/:id/*`.
The optional `?sidebar=calc|calculator|inspector|order` URL param
seeds the initial tab on first mount — used by the lobby card path
when later phases want to land directly on a particular tool.
The `historyMode` prop hides the Order tab when true: the tab-bar
filters it out and any URL seed targeting `order` falls back to
`inspector`. Phase 12 wires the prop through the layout as a
constant `false`; Phase 26 flips it on for past-turn snapshots.
-->
<script lang="ts">
import { onMount } from "svelte";
@@ -23,8 +28,9 @@ when later phases want to land directly on a particular tool.
type Props = {
open: boolean;
onClose: () => void;
historyMode?: boolean;
};
let { open, onClose }: Props = $props();
let { open, onClose, historyMode = false }: Props = $props();
let activeTab: SidebarTab = $state("inspector");
@@ -36,11 +42,20 @@ when later phases want to land directly on a particular tool.
return null;
}
$effect(() => {
if (historyMode && activeTab === "order") {
activeTab = "inspector";
}
});
onMount(() => {
const seed = readUrlSeed();
if (seed !== null) {
activeTab = seed;
if (seed === null) return;
if (seed === "order" && historyMode) {
activeTab = "inspector";
return;
}
activeTab = seed;
});
</script>
@@ -51,7 +66,11 @@ when later phases want to land directly on a particular tool.
data-open={open}
>
<div class="head">
<TabBar {activeTab} onSelect={(tab) => (activeTab = tab)} />
<TabBar
{activeTab}
onSelect={(tab) => (activeTab = tab)}
hideOrder={historyMode}
/>
<button
type="button"
class="close"
+16 -6
View File
@@ -1,8 +1,14 @@
<!--
Three-button tab switcher for the Phase 10 sidebar. Each button is
labelled and tagged so component tests can target it; the parent
sidebar component owns the selected-tab state and re-renders the
matching tool panel.
Three-button tab switcher for the sidebar. Each button is labelled
and tagged so component tests can target it; the parent sidebar
component owns the selected-tab state and re-renders the matching
tool panel.
Phase 12 introduces the `hideOrder` prop: when true the Order entry
is filtered out of the tab list. The current consumer is the
`historyMode` flag forwarded from the in-game shell layout — the
flag is constant `false` in Phase 12 and Phase 26's history mode
flips it on.
-->
<script lang="ts">
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -11,14 +17,18 @@ matching tool panel.
type Props = {
activeTab: SidebarTab;
onSelect: (tab: SidebarTab) => void;
hideOrder?: boolean;
};
let { activeTab, onSelect }: Props = $props();
let { activeTab, onSelect, hideOrder = false }: Props = $props();
const tabs: ReadonlyArray<{ id: SidebarTab; key: TranslationKey }> = [
const allTabs: ReadonlyArray<{ id: SidebarTab; key: TranslationKey }> = [
{ id: "calculator", key: "game.sidebar.tab.calculator" },
{ id: "inspector", key: "game.sidebar.tab.inspector" },
{ id: "order", key: "game.sidebar.tab.order" },
];
const tabs = $derived(
hideOrder ? allTabs.filter((t) => t.id !== "order") : allTabs,
);
</script>
<div class="tab-bar" role="tablist" data-testid="sidebar-tab-bar">
@@ -6,6 +6,7 @@
setDeviceSessionId,
} from "../../../api/session";
import { loadStore } from "../../../platform/store/index";
import type { OrderCommand } from "../../../sync/order-types";
interface DebugSnapshot {
publicKey: number[];
@@ -22,6 +23,11 @@
message: number[],
signature: number[],
): Promise<boolean>;
seedOrderDraft(
gameId: string,
commands: OrderCommand[],
): Promise<void>;
clearOrderDraft(gameId: string): Promise<void>;
}
type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface };
@@ -96,6 +102,20 @@
throw new Error(`verifyWithStoredPublicKey: ${describe(err)}`);
}
},
async seedOrderDraft(gameId, commands) {
try {
await cache.put("order-drafts", `${gameId}/draft`, commands);
} catch (err) {
throw new Error(`seedOrderDraft: ${describe(err)}`);
}
},
async clearOrderDraft(gameId) {
try {
await cache.delete("order-drafts", `${gameId}/draft`);
} catch (err) {
throw new Error(`clearOrderDraft: ${describe(err)}`);
}
},
};
(window as DebugWindow).__galaxyDebug = surface;
ready = true;
@@ -34,6 +34,10 @@ the next game's snapshot is loaded fresh.
import Order from "$lib/sidebar/order-tab.svelte";
import type { MobileTool } from "$lib/sidebar/types";
import { GameStateStore, GAME_STATE_CONTEXT_KEY } from "$lib/game-state.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../../sync/order-draft.svelte";
import { session } from "$lib/session-store.svelte";
import { loadStore } from "../../../platform/store/index";
import { loadCore } from "../../../platform/core/index";
@@ -45,6 +49,9 @@ the next game's snapshot is loaded fresh.
let sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map");
// Phase 12 ships the prop wiring; Phase 26 replaces this constant
// with the real history-mode signal from `lib/history-mode.ts`.
const historyMode = false;
const gameId = $derived(page.params.id ?? "");
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname));
@@ -54,6 +61,8 @@ the next game's snapshot is loaded fresh.
const gameState = new GameStateStore();
setContext(GAME_STATE_CONTEXT_KEY, gameState);
const orderDraft = new OrderDraftStore();
setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft);
function toggleSidebar(): void {
sidebarOpen = !sidebarOpen;
@@ -85,7 +94,10 @@ the next game's snapshot is loaded fresh.
deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
await gameState.init({ client, cache, gameId });
await Promise.all([
gameState.init({ client, cache, gameId }),
orderDraft.init({ cache, gameId }),
]);
} catch (err) {
gameState.failBootstrap(describeBootstrapError(err));
}
@@ -94,6 +106,7 @@ the next game's snapshot is loaded fresh.
onDestroy(() => {
gameState.dispose();
orderDraft.dispose();
});
function describeBootstrapError(err: unknown): string {
@@ -118,12 +131,17 @@ the next game's snapshot is loaded fresh.
{@render children()}
{/if}
</main>
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} />
<Sidebar
open={sidebarOpen}
onClose={() => (sidebarOpen = false)}
{historyMode}
/>
</div>
<BottomTabs
{gameId}
activeTool={effectiveTool}
onSelectTool={(tool) => (mobileTool = tool)}
hideOrder={historyMode}
/>
</div>
+125
View File
@@ -0,0 +1,125 @@
// Per-game runes store that owns the local order draft. Mirrors the
// Phase 11 `GameStateStore` lifecycle: one instance per game, created
// in `routes/games/[id]/+layout.svelte`, exposed to descendants via
// Svelte context, disposed when the layout unmounts.
//
// Draft state is persisted into the platform `Cache` under the
// `order-drafts` namespace with a per-game key, so a reload, a
// browser restart, or a navigation through the lobby and back into
// the same game restores the previously composed list. Phase 14
// will add the submit pipeline that drains the draft to the server;
// Phase 26 will hide the order tab in history mode through a flag
// passed by the layout (the store itself remains alive across that
// transition so the draft survives history-mode round-trips).
//
// The store deliberately carries no Svelte component imports so it
// can be tested directly with a synthetic `Cache` without rendering
// any UI.
import type { Cache } from "../platform/store/index";
import type { OrderCommand } from "./order-types";
const NAMESPACE = "order-drafts";
const draftKey = (gameId: string): string => `${gameId}/draft`;
/**
* ORDER_DRAFT_CONTEXT_KEY is the Svelte context key the in-game shell
* layout uses to expose its `OrderDraftStore` instance to descendants.
* The order tab and any later command-builder UI resolve the store via
* `getContext(ORDER_DRAFT_CONTEXT_KEY)`.
*/
export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft");
type Status = "idle" | "ready" | "error";
export class OrderDraftStore {
commands: OrderCommand[] = $state([]);
status: Status = $state("idle");
error: string | null = $state(null);
private cache: Cache | null = null;
private gameId = "";
private destroyed = false;
/**
* init loads the persisted draft for `opts.gameId` from `opts.cache`
* into `commands` and flips `status` to `ready`. The call is
* idempotent on the same store instance — the layout always
* constructs a fresh store per game, so there is no need to support
* mid-life game switching here.
*/
async init(opts: { cache: Cache; gameId: string }): Promise<void> {
this.cache = opts.cache;
this.gameId = opts.gameId;
try {
const stored = await opts.cache.get<OrderCommand[]>(
NAMESPACE,
draftKey(opts.gameId),
);
if (this.destroyed) return;
this.commands = Array.isArray(stored) ? [...stored] : [];
this.status = "ready";
} catch (err) {
if (this.destroyed) return;
this.status = "error";
this.error = err instanceof Error ? err.message : "load failed";
}
}
/**
* add appends a command to the end of the draft and persists the
* updated list. Mutations made before `init` resolves are ignored —
* the layout always awaits `init` before exposing the store.
*/
async add(command: OrderCommand): Promise<void> {
if (this.status !== "ready") return;
this.commands = [...this.commands, command];
await this.persist();
}
/**
* remove drops the command with the given id from the draft and
* persists the result. A miss is a no-op.
*/
async remove(id: string): Promise<void> {
if (this.status !== "ready") return;
const next = this.commands.filter((cmd) => cmd.id !== id);
if (next.length === this.commands.length) return;
this.commands = next;
await this.persist();
}
/**
* move relocates the command at `fromIndex` to `toIndex`, shifting
* the intermediate commands. Out-of-range indices and identical
* positions are no-ops; both indices are clamped against the
* current `commands` length.
*/
async move(fromIndex: number, toIndex: number): Promise<void> {
if (this.status !== "ready") return;
const length = this.commands.length;
if (fromIndex < 0 || fromIndex >= length) return;
if (toIndex < 0 || toIndex >= length) return;
if (fromIndex === toIndex) return;
const next = [...this.commands];
const [picked] = next.splice(fromIndex, 1);
if (picked === undefined) return;
next.splice(toIndex, 0, picked);
this.commands = next;
await this.persist();
}
dispose(): void {
this.destroyed = true;
this.cache = null;
}
private async persist(): Promise<void> {
if (this.cache === null || this.destroyed) return;
// `commands` is `$state`, so individual entries are proxies.
// IndexedDB's structured clone refuses to clone proxies, so the
// snapshot must be taken before the put.
const snapshot = $state.snapshot(this.commands) as OrderCommand[];
await this.cache.put(NAMESPACE, draftKey(this.gameId), snapshot);
}
}
+59
View File
@@ -0,0 +1,59 @@
// Typed shape of a single command entry inside the local order
// draft. Phase 12 intentionally ships exactly one variant
// (`placeholder`) — Phase 14 lands the first real command
// (`planetRename`) together with the inspector UI that constructs
// it and the submit pipeline that drains the draft to the server.
//
// `OrderCommand` is a discriminated union on the `kind` field so
// later variants can extend the union without changing the array
// shape persisted in `Cache`. The whole draft round-trips through
// IndexedDB structured clone, so every variant must use only
// JSON-friendly value types (`string`, `number`, `boolean`,
// nested plain objects, and `Uint8Array`).
/**
* PlaceholderCommand is the single variant shipped with the Phase 12
* skeleton. It carries a stable `id` (used by remove and as a
* `data-testid` suffix) and a human-readable `label` rendered in the
* order tab's vertical list. The variant is deliberately content-free
* so test fixtures and the empty composer skeleton do not pre-bias
* Phase 14's first real command shape.
*/
export interface PlaceholderCommand {
readonly kind: "placeholder";
readonly id: string;
readonly label: string;
}
/**
* OrderCommand is the discriminated union of every command shape the
* local order draft can hold. The `kind` field is the discriminator;
* narrowing on it enables exhaustive `switch` statements at every
* call site. Phase 14 will widen the union with `planetRename`.
*/
export type OrderCommand = PlaceholderCommand;
/**
* CommandStatus is the lifecycle of a single command from the moment
* it lands in the draft to the moment the server resolves it. The
* skeleton stores only the type description; Phase 14 adds the
* `valid` / `invalid` transitions driven by local validation, and
* Phase 25 introduces `submitting` / `applied` / `rejected` driven
* by the submit pipeline.
*
* The state machine is:
*
* draft → valid → submitting → applied
* ↘ invalid ↘ rejected
*
* A command is `draft` until local validation has run, then `valid`
* or `invalid`. On submit the entry transitions to `submitting`,
* then to `applied` or `rejected` once the gateway responds.
*/
export type CommandStatus =
| "draft"
| "valid"
| "invalid"
| "submitting"
| "applied"
| "rejected";
@@ -0,0 +1,140 @@
// Phase 12 end-to-end coverage for the order composer skeleton. The
// shell makes no gateway calls in this spec — the boot flow seeds an
// authenticated session and a draft directly through `/__debug/store`,
// then navigates into `/games/<id>/map` and exercises the order tab.
//
// Persistence is covered by reloading the page mid-spec: the
// `OrderDraftStore` re-reads the same cache row on the next mount,
// so the rendered list survives the round-trip.
import { expect, test, type Page } from "@playwright/test";
// `window.__galaxyDebug` is owned by `routes/__debug/store/+page.svelte`
// and typed by `tests/e2e/storage-keypair-persistence.spec.ts`. The
// merged global declaration covers every helper this spec calls.
const SESSION_ID = "phase-12-order-session";
const GAME_ID = "test-order";
const SEED = [
{ kind: "placeholder" as const, id: "cmd-a", label: "first command" },
{ kind: "placeholder" as const, id: "cmd-b", label: "second command" },
{ kind: "placeholder" as const, id: "cmd-c", label: "third command" },
];
async function bootDebug(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
}
async function seedShell(page: Page): Promise<void> {
await bootDebug(page);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
await page.evaluate(
({ gameId, commands }) =>
window.__galaxyDebug!.clearOrderDraft(gameId).then(() =>
window.__galaxyDebug!.seedOrderDraft(gameId, commands),
),
{ gameId: GAME_ID, commands: SEED },
);
}
async function openOrderTool(page: Page, isMobile: boolean): Promise<void> {
if (isMobile) {
await page.getByTestId("bottom-tab-order").click();
} else {
await page.getByTestId("sidebar-tab-order").click();
}
await expect(page.getByTestId("sidebar-tool-order")).toBeVisible();
}
async function expectSeededRows(page: Page): Promise<void> {
const list = page.getByTestId("order-list");
await expect(list).toBeVisible();
for (let i = 0; i < SEED.length; i++) {
const row = page.getByTestId(`order-command-${i}`);
await expect(row).toBeVisible();
await expect(row.getByTestId(`order-command-label-${i}`)).toHaveText(
SEED[i]!.label,
);
}
await expect(page.getByTestId("order-empty")).toHaveCount(0);
}
test("seeded draft renders on the order tab and survives a reload", async ({
page,
}, testInfo) => {
const isMobile = testInfo.project.name.startsWith("chromium-mobile");
await seedShell(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toBeVisible();
await openOrderTool(page, isMobile);
await expectSeededRows(page);
await page.reload();
await expect(page.getByTestId("game-shell")).toBeVisible();
await openOrderTool(page, isMobile);
await expectSeededRows(page);
});
test("removing a command from the order tab persists the removal", async ({
page,
}, testInfo) => {
const isMobile = testInfo.project.name.startsWith("chromium-mobile");
await seedShell(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("game-shell")).toBeVisible();
await openOrderTool(page, isMobile);
await expect(page.getByTestId("order-command-1")).toBeVisible();
await page.getByTestId("order-command-delete-1").click();
// The remaining two commands shift up by one slot.
await expect(page.getByTestId("order-command-label-0")).toHaveText(
SEED[0]!.label,
);
await expect(page.getByTestId("order-command-label-1")).toHaveText(
SEED[2]!.label,
);
await expect(page.getByTestId("order-command-2")).toHaveCount(0);
await page.reload();
await expect(page.getByTestId("game-shell")).toBeVisible();
await openOrderTool(page, isMobile);
await expect(page.getByTestId("order-command-label-0")).toHaveText(
SEED[0]!.label,
);
await expect(page.getByTestId("order-command-label-1")).toHaveText(
SEED[2]!.label,
);
await expect(page.getByTestId("order-command-2")).toHaveCount(0);
});
test("empty draft renders the empty-state copy", async ({
page,
}, testInfo) => {
const isMobile = testInfo.project.name.startsWith("chromium-mobile");
await bootDebug(page);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
await page.evaluate(
(gameId) => window.__galaxyDebug!.clearOrderDraft(gameId),
GAME_ID,
);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("game-shell")).toBeVisible();
await openOrderTool(page, isMobile);
await expect(page.getByTestId("order-empty")).toBeVisible();
await expect(page.getByTestId("order-list")).toHaveCount(0);
});
@@ -13,6 +13,10 @@ interface DebugSnapshot {
deviceSessionId: string | null;
}
// Mirrors the surface mounted by `routes/__debug/store/+page.svelte`.
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`)
// reuse the global declaration below, so this interface lists every
// helper any spec calls — not only those exercised by this file.
interface DebugSurface {
ready: true;
loadSession(): Promise<DebugSnapshot>;
@@ -23,6 +27,15 @@ interface DebugSurface {
message: number[],
signature: number[],
): Promise<boolean>;
seedOrderDraft(
gameId: string,
commands: ReadonlyArray<{
kind: "placeholder";
id: string;
label: string;
}>,
): Promise<void>;
clearOrderDraft(gameId: string): Promise<void>;
}
declare global {
+178
View File
@@ -0,0 +1,178 @@
// OrderDraftStore unit tests under JSDOM with `fake-indexeddb`
// standing in for the browser's IndexedDB factory. The store is
// driven directly with a real `IDBCache` so persistence is exercised
// the same way it would be inside the in-game shell layout.
//
// Each case opens a freshly named database so state cannot leak
// across tests; per-game isolation is verified explicitly by mixing
// drafts under different `gameId`s through one shared cache.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { IDBPDatabase } from "idb";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import { OrderDraftStore } from "../src/sync/order-draft.svelte";
import type { OrderCommand } from "../src/sync/order-types";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
beforeEach(async () => {
dbName = `galaxy-order-draft-test-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
});
afterEach(async () => {
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
const GAME_ID = "11111111-2222-3333-4444-555555555555";
function placeholder(id: string, label: string): OrderCommand {
return { kind: "placeholder", id, label };
}
describe("OrderDraftStore", () => {
test("init on empty cache yields ready status with no commands", async () => {
const store = new OrderDraftStore();
expect(store.status).toBe("idle");
await store.init({ cache, gameId: GAME_ID });
expect(store.status).toBe("ready");
expect(store.commands).toEqual([]);
store.dispose();
});
test("add appends commands and persists across instances", async () => {
const a = new OrderDraftStore();
await a.init({ cache, gameId: GAME_ID });
await a.add(placeholder("c1", "first"));
await a.add(placeholder("c2", "second"));
expect(a.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
a.dispose();
const b = new OrderDraftStore();
await b.init({ cache, gameId: GAME_ID });
expect(b.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
expect(b.commands[1]).toEqual(placeholder("c2", "second"));
b.dispose();
});
test("remove drops the matching command and persists the removal", async () => {
const a = new OrderDraftStore();
await a.init({ cache, gameId: GAME_ID });
await a.add(placeholder("c1", "first"));
await a.add(placeholder("c2", "second"));
await a.add(placeholder("c3", "third"));
await a.remove("c2");
expect(a.commands.map((c) => c.id)).toEqual(["c1", "c3"]);
a.dispose();
const b = new OrderDraftStore();
await b.init({ cache, gameId: GAME_ID });
expect(b.commands.map((c) => c.id)).toEqual(["c1", "c3"]);
b.dispose();
});
test("remove on a missing id is a silent no-op", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add(placeholder("c1", "first"));
await store.remove("absent");
expect(store.commands.map((c) => c.id)).toEqual(["c1"]);
store.dispose();
});
test("move reorders the commands and persists the new order", async () => {
const a = new OrderDraftStore();
await a.init({ cache, gameId: GAME_ID });
await a.add(placeholder("c1", "first"));
await a.add(placeholder("c2", "second"));
await a.add(placeholder("c3", "third"));
await a.move(0, 2);
expect(a.commands.map((c) => c.id)).toEqual(["c2", "c3", "c1"]);
a.dispose();
const b = new OrderDraftStore();
await b.init({ cache, gameId: GAME_ID });
expect(b.commands.map((c) => c.id)).toEqual(["c2", "c3", "c1"]);
b.dispose();
});
test("move with out-of-range or identical indices is a no-op", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add(placeholder("c1", "first"));
await store.add(placeholder("c2", "second"));
await store.move(1, 1);
await store.move(-1, 0);
await store.move(0, 5);
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
store.dispose();
});
test("drafts under different game ids do not bleed through one cache", async () => {
const otherGame = "99999999-9999-9999-9999-999999999999";
const a = new OrderDraftStore();
await a.init({ cache, gameId: GAME_ID });
await a.add(placeholder("a1", "from-a"));
a.dispose();
const b = new OrderDraftStore();
await b.init({ cache, gameId: otherGame });
expect(b.commands).toEqual([]);
await b.add(placeholder("b1", "from-b"));
b.dispose();
const reloadA = new OrderDraftStore();
await reloadA.init({ cache, gameId: GAME_ID });
expect(reloadA.commands.map((c) => c.id)).toEqual(["a1"]);
reloadA.dispose();
const reloadB = new OrderDraftStore();
await reloadB.init({ cache, gameId: otherGame });
expect(reloadB.commands.map((c) => c.id)).toEqual(["b1"]);
reloadB.dispose();
});
test("mutations made before init resolves are ignored", async () => {
const store = new OrderDraftStore();
await store.add(placeholder("c1", "first"));
await store.remove("c1");
await store.move(0, 1);
expect(store.status).toBe("idle");
expect(store.commands).toEqual([]);
await store.init({ cache, gameId: GAME_ID });
expect(store.commands).toEqual([]);
store.dispose();
});
test("dispose suppresses persistence side effects of in-flight mutations", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add(placeholder("c1", "first"));
store.dispose();
// Adding after dispose is a no-op because status remains
// `ready` but the cache pointer is null and the destroyed flag
// blocks the persist path.
await store.add(placeholder("c2", "second"));
const reload = new OrderDraftStore();
await reload.init({ cache, gameId: GAME_ID });
expect(reload.commands.map((c) => c.id)).toEqual(["c1"]);
reload.dispose();
});
});