Files
galaxy-game/ui/PLAN.md
T
Ilia Denisov e31fb2c17a
Tests · UI / test (push) Failing after 9m28s
docs(ui): sync docs to the app-shell; fix stale nav comments
Rewrite ui/docs (navigation, order-composer, auth-flow, pwa-strategy,
game-state + secondary topic docs) and ui/README for the single-URL
app-shell (in-memory screens/views, Back→lobby via shallow routing,
sessionStorage restore + validation, return-to-lobby). ui/PLAN.md gets a
Phase-10 supersede note (implemented; standalone-compatible). Fix stale
code comments (session-store auth gate, report-sections spec contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:04:11 +02:00

3488 lines
169 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# UI Client Implementation Plan
This plan stages the implementation of the cross-platform UI client for
Galaxy. The client builds from a single TypeScript + Svelte codebase to
five targets: web, web-mobile, standalone PC (mac/win/linux), iOS, and
Android. A shared Go module (`ui/core`) carries envelope cryptography,
FlatBuffers codec, keypair management, and a thin bridge over `pkg/calc/`
for UI-side game math; it is compiled to WASM (web), gomobile native
libraries (iOS/Android), and embedded directly in Wails (desktop). All
network I/O lives on the TypeScript side via ConnectRPC, so the Go
module is a pure compute boundary on every platform.
> **Status — MVP complete.** Phases 130 below (the full web client) are
> done. Remaining work is tracked outside this file: the active web
> finalization pass (visual system, accessibility, localisation, error
> UX, PWA, docs) in [PLAN-finalize.md](PLAN-finalize.md), and the
> deferred platform wrappers (Wails desktop, Capacitor mobile), the
> realistic multi-turn projection, and the cross-platform acceptance
> pass in [ROADMAP.md](ROADMAP.md). This file is retained as the staged
> record of how the MVP was built.
>
> **Routing — superseded by the app-shell.** After the MVP, the
> URL-based routing the per-phase artifacts below describe was refactored
> into a single-URL **app-shell**: the game UI is one SvelteKit route at
> `/game/`, the screen and the in-game view are in-memory rune state
> (`lib/app-nav.svelte.ts`), the `routes/games/[id]/` subtree and the
> per-view `+page.svelte` wrappers were removed, the in-game layout
> became `lib/game/game-shell.svelte`, and the login / lobby /
> lobby-create screens moved under `lib/screens/`. Browser Back/Forward
> move between screens via shallow routing without changing the URL — a
> model that also suits the bundled standalone targets (Wails /
> Capacitor / gomobile) that have no URLs. The current navigation model
> is described in [docs/navigation.md](docs/navigation.md) and in the
> reframed `Information Architecture and Navigation` section and Phase 10
> decisions below; the per-phase `routes/games/[id]/…` artifact paths are
> left as the historical record of what each phase delivered at the time.
The existing Fyne client in `client/` is deprecated and is not modified
or imported by the new code. The architectural overview is mirrored into
`ui/README.md`.
Each phase ends with a runnable artifact. The visual progression is:
empty page → navigation skeleton → stubbed views → live data → real
actions. Phases are sized so that any one of them can be shipped, run,
and reviewed before the next starts; if a direction proves wrong, the
plan can be adjusted with at most one phase of rework.
---
## Summary
This plan staged the MVP web client as 30 small reviewable phases (130,
all complete; the finalization pass and post-MVP work live in
[PLAN-finalize.md](PLAN-finalize.md) and [ROADMAP.md](ROADMAP.md)). Each
phase has a single primary goal, clear deliverables, explicit
dependencies, acceptance criteria, and focused tests. Tests live
alongside the code added in the phase; a phase is not closed until its
tests are green on the targets it claims to support.
The intended v1 architecture is:
- TypeScript + Svelte 5 frontend, shared across all five build targets;
- PixiJS v8 with dual WebGPU/WebGL backend for the world map renderer;
- Go module `ui/core` as a compute-only library (canonical bytes,
sign/verify, FlatBuffers codec, keypair, thin bridge to `pkg/calc/`)
compiled to WASM, gomobile, and Wails-embedded native;
- TypeScript-side `Core` interface with three adapters (`WasmCore`,
`WailsCore`, `CapacitorCore`) selected at build time;
- `GalaxyClient` on top of `Core` performs all network I/O via
ConnectRPC (`@connectrpc/connect-web`) on every platform;
- per-platform storage: WebCrypto + IndexedDB on web, OS keychain +
SQLite on desktop, iOS Keychain / Android Keystore + SQLite on mobile,
all behind a single `KeyStore` and `Cache` TypeScript interface;
- mobile-first navigation: one active view occupies the main area at a
time; sidebar holds a single tool (calculator, inspector, or order)
with persistent state on switch.
## Assumptions and Defaults
- Target Go version follows `go.mod` of the parent module; TinyGo for
WASM must support `crypto/ed25519` and `crypto/sha256`. If TinyGo
support is insufficient, fall back to standard Go `GOOS=js
GOARCH=wasm` with a larger bundle (~2 MB).
- The gateway exposes server-streaming gRPC. Browsers cannot speak raw
gRPC; ConnectRPC support is added to the gateway so a single set of
Go handlers serves native gRPC and browser clients simultaneously.
- TypeScript-side network code uses `@connectrpc/connect-web` for unary
calls and server-streaming push events on every platform.
- Ed25519 private keys never leave the device. Loss of secure storage is
acceptable on every platform and triggers a re-login flow.
- Build pipeline is a single `pnpm` workspace at `ui/`; Make targets
wrap TinyGo, gomobile, Wails CLI, Capacitor CLI, and Vite.
- All file/directory names, code, comments, identifiers, and docs in
`ui/` are written in English. Russian appears only in i18n bundles
delivered in Phase 35.
- Pre-production migration rule from the project root applies: schema
changes are inlined into the existing init schema rather than
producing new migrations; clean rebuilds on every checkout.
- The existing `galaxy/client` Go module is deprecated in full. New
code does not import from it; this includes `client/world/`, which
is no longer the reference algorithm for the TypeScript renderer.
Existing types in `pkg/model/client/` are not migrated; UI types
are written from scratch in `ui/core/types/` as needed.
- The TypeScript map renderer is specified in `ui/docs/renderer.md`,
derived from the renderer's own requirements rather than from any
earlier Go code. Tile-based spatial indexing is intentionally
omitted in the first iteration; PixiJS native culling and
bounds-based hit testing carry the renderer until profiling
proves otherwise.
- Game math that must stay synchronised between server and client lives
in `pkg/calc/`. The UI client never duplicates calc functions; instead
a bridge layer in `ui/core/calc/` wraps selected `pkg/calc/` functions
for the `Core` API. New shared math is added to `pkg/calc/` first; gaps
are surfaced at the start of each phase that needs them.
- State preservation is a global rule: switching active view or sidebar
tab does not reset state. State resets only on explicit user
`discard` actions or logout.
- History mode is a global read-only toggle that applies to every active
view. The Order sidebar tab is hidden in history mode.
- Wails v2 is the desktop baseline. At the start of Phase 31, the
current state of Wails v3 is re-evaluated; if v3 has reached a stable
release, the migration is folded into that phase.
- CI uses Gitea Actions (workflow files under `.gitea/workflows/`,
format-compatible with GitHub Actions). Linux runners cover Tier 1
tests; a macOS runner is provisioned only when Tier 2 iOS smoke is
needed.
- **Synthetic-report parser parity is a global rule.** A DEV-only
loader on the lobby (`import.meta.env.DEV`) lets the developer feed
the UI a JSON file that mimics a server `Report`, so the map and
inspectors can be exercised against rich game states without playing
many turns end-to-end. The JSON is produced by the Go CLI in
`tools/local-dev/legacy-report/`, which converts legacy text
reports under `tools/local-dev/reports/` into the shape of
`pkg/model/report.Report` (whatever subset the UI currently
decodes). Every phase that **extends the server→UI report contract**
— adding decoding for a new `Report` field in
`ui/frontend/src/api/game-state.ts` — must, in the same PR, extend
the legacy parser to populate that field, **or** explicitly note in
the parser's `README.md` that the field cannot be derived from the
legacy text format and is left empty in synthetic JSON. The point
is to keep `tools/local-dev/legacy-report/` a faithful (and
type-checked, via `pkg/model/report` import) generator of test
inputs as the UI grows; otherwise synthetic data silently lags
behind the contract and visual tests stop covering the new
behaviour.
## Information Architecture and Navigation
The client is a single-page **app-shell** with **one active view at a
time**. It is served at a single URL (`/game/`) that never changes:
the visible screen and view are in-memory state, not routes. Navigation
is mobile-first: floating panels never overlap the map, the main area
never splits into multiple visible panels on small screens. Desktop
and mobile share the same model; on desktop, the sidebar sits beside
the active view, on mobile it lives behind a bottom-tab bar.
### Screen and view model
Two pieces of in-memory state (rune singletons in
`lib/app-nav.svelte.ts`) replace what URLs used to encode — `appScreen`
(the top-level screen plus the active game id) and `activeView` (the
in-game view plus its sub-parameters):
```text
appScreen.screen ∈ {
login, (anonymous only)
lobby, (auth required)
lobby-create, (auth required)
game, (auth required; carries appScreen.gameId)
}
activeView.view ∈ { (meaningful only while screen === game)
map, (default in-game view)
table, (+ tableEntity ∈ planets | ship-classes |
ship-groups | fleets | sciences | races)
report,
battle, (+ battleId, turn)
mail,
designer-science, (+ scienceId; absent = new-science form)
}
```
The top-level screen is chosen by the single-route dispatcher
(`routes/+page.svelte`) from `session.status` + `appScreen.screen`;
the in-game shell (`lib/game/game-shell.svelte`) renders the active
view from `activeView`. Browser Back/Forward move between screens
(Back from a game → lobby) via SvelteKit shallow routing, without
changing the URL; in-game view switches do not create history entries.
Switching between views happens through the header dropdown (desktop)
or hamburger menu (mobile), driven by `activeView.select(...)`.
Double-tapping a row in a table view returns to the map focused on the
object. Some views can push a transient map overlay with a back
affordance (for example, ship-class designer pushes a range-preview
overlay onto the map). The transient overlay clears when the user
selects any other view. The implementation is documented in
[docs/navigation.md](docs/navigation.md).
### Layout per breakpoint
Desktop (≥ 1024 px):
```text
┌──────────────────────────────────────────────────────────┐
│ Header: race · turn N · countdown · view dropdown · ⚙ │
├────────────────────────────────────────────┬─────────────┤
│ │ tabs │
│ active view │ ┌─────────┐ │
│ (map / table / │ │ Calc │ │
│ battle / mail / │ │ Inspect │ │
│ designer / report) │ │ Order │ │
│ │ └─────────┘ │
│ │ tool │
│ │ content │
│ │ │
└────────────────────────────────────────────┴─────────────┘
```
Tablet (7681024 px): same as desktop but sidebar collapses to a
swipe-from-right drawer; a tab bar of three icons sits in the header
right corner.
Mobile (< 768 px):
```text
┌──────────────────────┐
│ ☰ race · turn N · ⚙ │
├──────────────────────┤
│ │
│ active view │
│ │
│ │
│ │
├──────────────────────┤
│ ▣ 🧮 📝 ☰ │
│ Map Calc Order More│
└──────────────────────┘
```
On mobile, Inspector is not a bottom tab — tapping an object on the map
raises a bottom-sheet showing inspector content. The sheet swipes down
to dismiss. `More` opens a hamburger menu that lists Mail, Battle log,
Tables (planets, ship classes, ship groups, fleets, sciences, races),
History, Settings, Logout.
### Sidebar tools (single-tool with state preservation)
- **Calculator** — independent ship/path calculator, callable from any
view. Holds in-progress inputs across navigation.
- **Inspector** — context-sensitive details for the currently selected
map object. Empty state when nothing is selected: `select an object on
the map`.
- **Order** — the draft order being composed. Vertical list of commands,
top-to-bottom. Each command shows its local-validation result while
composing and its server result after submit. Order persists across
page reloads and across view switches.
### Map active view
PixiJS canvas with pan/zoom over the torus. A gear icon in the corner
opens a popover (desktop) or bottom sheet (mobile) with category
toggles:
| Toggleable | Default |
| - | - |
| Hyperspace groups | on |
| Incoming groups (not necessarily enemy) | on |
| Cargo routes | on |
| Reach / visibility zones | off |
| Battle and bombing markers | on |
Planets are always shown and cannot be hidden.
### Header turn counter and history mode
The turn counter is clickable. Click expands to a turn navigator
(popover desktop, bottom sheet mobile) listing recent turns with a
search field for jumping to a specific turn number. Selecting a past
turn enters history mode: every active view switches its data source to
that turn's snapshot, the Order sidebar tab disappears, and a
persistent banner reads `Viewing turn N · read-only` with a `Return to
turn current` action.
### Cross-cutting shell
- Push-event toasts surface from the top of the screen for: turn ready,
lobby state changes, invitations, session revoked, incoming attack.
- A connection-state indicator in the header shows online / reconnecting
/ offline based on push-stream state and last successful unary call.
- The account menu (top-right on desktop, last hamburger entry on
mobile) holds Settings, Sessions, Theme, Language, Logout.
### Authenticated screen transitions
All transitions are in-memory screen/view changes; the URL stays
`/game/` throughout.
- login → lobby after successful confirm-email-code (`session.status`
settles to `authenticated`).
- lobby → game (view `map`) when a game card is selected
(`appScreen.go("game", { gameId })`).
- any screen → login immediately on session revocation push event
(`session.status` settles back to `anonymous`).
- the in-game header carries a "return to lobby" control
(`appScreen.go("lobby")`); browser Back from a game does the same.
- Designer views can push a transient overlay onto the map; the back
affordance returns to the originating designer.
Per-screen behaviour (validations, exact field names, error mappings)
is derived from `docs/FUNCTIONAL.md` sections cited in the relevant
phases below. UI-specific decisions (animations, layout, microcopy)
live in per-phase topic docs under `ui/docs/`.
---
## ~~Phase 1. Workspace Skeleton~~
Status: done.
Goal: bring up the `ui/` workspace with a runnable empty
SvelteKit + Vite frontend and architectural anchors.
Artifacts:
- `ui/README.md` mirroring the architectural overview from this plan
- `ui/Makefile` with placeholder targets for every build type (`web`,
`wasm`, `gomobile`, `desktop-{mac,win,linux}`, `ios`, `android`,
`all`)
- `ui/pnpm-workspace.yaml` declaring the single-package pnpm workspace
- `ui/frontend/` Svelte 5 + SvelteKit + Vite + TypeScript project
(the SvelteKit scaffold provides `+layout.svelte`, `+page.svelte`,
`static/`, and the file-system router used by later phases)
- `ui/frontend/src/routes/+page.svelte` minimal landing page
rendering the app version string in the page footer; the version
is read at build time by Vite `define` from
`ui/frontend/package.json`
- `ui/frontend/{vitest.config.ts, tests/}` minimum Vitest harness
needed to run the smoke test below (`vitest`, `jsdom`,
`@testing-library/svelte`); the rest of the test toolchain
(Playwright, `@testing-library/jest-dom`, CI workflows) lands in
Phase 2
- `ui/.gitignore` covering `node_modules`, `dist`, `*.wasm`, build
outputs for Wails and Capacitor, Playwright artefacts
- `ui/docs/` empty directory ready for per-phase topic docs
Dependencies: none.
Acceptance criteria:
- `pnpm install && pnpm dev` from `ui/frontend` starts a dev server
that serves the landing page at a free local port;
- `make` lists every planned build target as a placeholder;
- `ui/README.md` lists the five target platforms, the layered
architecture, and points readers to per-phase topic docs under
`ui/docs/`.
Targeted tests:
- a single Vitest smoke test that mounts the landing component and
asserts the rendered version string is non-empty.
## ~~Phase 2. Testing Infrastructure~~
Status: done.
Goal: install and configure the test toolchain that every later phase
depends on, including Tier 1 (per-PR) and Tier 2 (release) targets.
Artifacts:
- `ui/frontend/package.json` dev-dependencies (added on top of the
Phase 1 minimum of `vitest`, `jsdom`, `@testing-library/svelte`):
`@testing-library/jest-dom`, `playwright`, `@playwright/test`
- `ui/frontend/vitest.config.ts` extended with `setupFiles:
["./tests/setup.ts"]` to wire `@testing-library/jest-dom` matchers
into Vitest (the JSDOM environment itself is wired in Phase 1)
- `ui/frontend/tests/setup.ts` registering `jest-dom` matchers
- `ui/frontend/tests/e2e/landing.spec.ts` placeholder Playwright test
asserting the version footer renders
- `ui/frontend/playwright.config.ts` with four projects:
`chromium-desktop`, `webkit-desktop`, `chromium-mobile-iphone-13`,
`chromium-mobile-pixel-5`; tracing and screenshots enabled on
failure; `webServer: pnpm run dev` on port 5173
- `.gitea/workflows/ui-test.yaml` running Tier 1 on every push and PR
on a Linux runner: monorepo Go service tests for `backend/`,
`gateway/`, `game/`, and every `pkg/<name>/` module (each pkg
module is enumerated explicitly because they sit as independent
go.work modules under a shared `pkg/` directory, and `./pkg/...`
does not recurse across module boundaries). All Go tests run with
`-count=1` so the cache never masks a failing run; backend tests
additionally run with `-p 1` because most backend packages spawn
their own Postgres testcontainer and parallel bootstraps starve
each other on the runner. The integration suite stays gated behind
`make -C integration integration` and lives outside Tier 1; the
deprecated `client/` Fyne client (see §74) is also excluded — its
tests, code, and documentation are frozen and CI must not run
them. Then `pnpm install --frozen-lockfile` from `ui/`,
`pnpm exec playwright install --with-deps`, `pnpm test`,
`pnpm exec playwright test`; Playwright reports and traces
uploaded as artefacts on failure
- `.gitea/workflows/ui-release.yaml` running Tier 2 on tag push (`v*`):
same Tier 1 step set today; visual-regression and macOS-runner
iOS-smoke jobs live as commented sections marked with the phase
number that re-enables them (Phase 33 and Phase 32 respectively)
- `ui/docs/testing.md` topic doc naming the two tiers, the tools
per tier, and the rule that visual regression baselines live in
`ui/frontend/tests/__snapshots__/` until shifted to Argos
Dependencies: Phase 1.
Acceptance criteria:
- a placeholder Vitest test passes locally and in CI;
- a placeholder Playwright test passes in `chromium-desktop` and
`webkit-desktop` projects locally;
- the Gitea Actions Tier 1 workflow runs end-to-end against a clean
clone of the repo on a Linux runner. Until the Gitea runner is
provisioned, the workflow is exercised locally with
`act -W .gitea/workflows/ui-test.yaml`.
Targeted tests:
- placeholder Vitest test from Phase 1 runs in CI and passes;
- placeholder Playwright test runs in CI on Linux runner and passes
in both `chromium-desktop` and `webkit-desktop` projects;
- intentional failure produces a Playwright trace artefact in CI.
## ~~Phase 3. Go Core: Canonical Bytes and Keypair~~
Status: done.
Goal: implement the canonical-bytes serializer and Ed25519 keypair
management in pure Go, with bit-for-bit parity to the gateway-side
implementation. No network, no UI.
Artifacts:
- `ui/core/go.mod` module `galaxy/core` declared in the project Go
workspace (`go.work` `use` and `replace` directives)
- `.gitea/workflows/ui-test.yaml` and `.gitea/workflows/ui-release.yaml`
extended to add `./ui/core/...` to the Tier 1 / Tier 2 `go test`
command list introduced in Phase 2
- `ui/core/canon/` canonical bytes for `galaxy-request-v1`,
`galaxy-response-v1`, and `galaxy-event-v1`, matching
`docs/ARCHITECTURE.md` §15 byte-for-byte. Server-only signers
(`Ed25519ResponseSigner`, PKCS#8 PEM loaders) intentionally stay
in `gateway/authn` — `ui/core` is verify-only on the server side
- `ui/core/keypair/` Ed25519 generate, marshal, unmarshal helpers
over opaque `[]byte` blobs; `Generate` accepts an injected
`io.Reader` so the WASM build can wire in `crypto.getRandomValues`
- `ui/core/types/` full v1 transport-envelope structs with
`SigningFields()` projection helpers; result-code and
protocol-version constants (`ProtocolVersionV1`, `ResultCodeOK`).
`TraceID` is part of the request envelope but deliberately
excluded from the request signing input (matches §15)
- `ui/core/canon/testdata/` golden JSON test vectors for the three
Phase-3 message types plus one response and one event
- `ui/core/README.md` documenting the public API and the
network-free / storage-free / no-x509 / no-PEM / no-`os` invariant
- `gateway/authn/parity_with_ui_core_test.go` (cross-module test)
proving canonical-bytes parity and bidirectional sign/verify
acceptance between `gateway/authn` and `galaxy/core`. The test
adds `require galaxy/core` to `gateway/go.mod` (test-only in
practice — gateway production binary does not link `ui/core`)
Dependencies: Phase 1.
Acceptance criteria:
- canonical-bytes output matches gateway-side output byte-for-byte
for the three Phase-3 message types (`user.account.get`,
`lobby.my.games.list`, `user.games.command`);
- a request signed by `ui/core` is accepted by the gateway's own
verifier in a unit test (`TestParityRequestSignedByUICoreAcceptedByGateway`);
- a response signed by `gateway/authn`'s `Ed25519ResponseSigner` is
accepted by `ui/core`'s verifier
(`TestParityResponseSignedByGatewayAcceptedByUICore`); the same
applies to gateway-signed events;
- tampered `payload_hash`, mismatched `request_id`, mismatched
`timestamp_ms`, and invalid signature length are rejected with
stable error codes from `ui/core/canon`. Server-side freshness
enforcement (the symmetric ±5 minutes around server time) stays
in `gateway/internal/grpcapi/freshness_replay.go` and is not
duplicated in `ui/core`.
Targeted tests:
- canonical-bytes equality tests on golden JSON fixtures
(`testdata/`) for every envelope kind;
- round-trip sign-then-verify across all three envelope kinds;
- negative tests: tampered `payload_hash`, mismatched `request_id`,
mismatched `timestamp_ms`, invalid signature lengths (too short,
too long, empty), bit-flipped signature, wrong public key,
malformed base64 public key;
- `gateway/authn` cross-module parity tests as listed under
Artifacts.
## ~~Phase 4. ConnectRPC Support in Gateway~~
Status: done. Cross-service phase — work happened in `gateway/` and
`integration/`, not `ui/`.
Goal: enable browsers to call the gateway's authenticated edge surface
through ConnectRPC, without keeping a separate gRPC server bootstrap
alive purely for test clients.
Decision (taken with the project owner before implementation): the
existing native-gRPC `grpc.NewServer` bootstrap was replaced with a
single `connectrpc.com/connect` HTTP/h2c listener, since Connect-Go
natively serves the Connect, gRPC, and gRPC-Web protocols on the same
port. No production gRPC clients existed to preserve. The package
`gateway/internal/grpcapi` keeps its name for diff-size reasons and
documents the historical labelling in its package doc.
Artifacts (delivered):
- `gateway/buf.gen.yaml` extended with `buf.build/connectrpc/go`,
generating `gateway/proto/edge/v1/edgev1connect/edge_gateway.connect.go`
- `gateway/internal/grpcapi/server.go` rewritten around `http.Server`
+ `h2c.NewHandler` + `edgev1connect.NewGatewayHandler`
- new `gateway/internal/grpcapi/connect_handler.go` adapting the
existing `edgev1.GatewayServer` decorator stack to the
Connect handler interface, including a `grpc.ServerStreamingServer`
shim around `*connect.ServerStream[GatewayEvent]` and a gRPC
`status.Error` → `*connect.Error` translation helper
- new `gateway/internal/grpcapi/connect_observability.go` Connect
interceptor recording the same metric and structured-log shape the
gRPC interceptors emitted; the rate-limit decorator now reads peer
IP from a context value populated by the interceptor instead of
`peer.FromContext`
- updated `gateway/README.md` (Transport Matrix + "Authenticated Edge
Surface"), `gateway/docs/runtime.md`, `gateway/docs/flows.md`,
`gateway/docs/runbook.md`, and `docs/ARCHITECTURE.md` §15
- migrated tests: `gateway/internal/grpcapi/server_test.go`,
`test_fixtures_test.go`, and every `*_integration_test.go` in that
package now drive a `edgev1connect.GatewayClient` over
HTTP/2 cleartext loopback
- migrated harness: `integration/testenv/grpc_client.go` →
`connect_client.go`. `SignedGatewayClient` keeps the same public
shape (`Execute`, `SubscribeEvents`, `Close`) but speaks Connect
internally; `Is*` helpers now use `connect.CodeOf`
Dependencies: Phase 3 (canonical bytes are needed for the
fixture-level signing the migrated tests use).
Acceptance criteria (met):
- unary Connect calls from outside the gateway process succeed
end-to-end against the authenticated surface — verified by the
migrated `grpcapi/server_test.go` and `command_routing_integration_test.go`
scenarios driving the Connect client over loopback h2c;
- server-streaming `SubscribeEvents` works over Connect with the
signed `gateway.server_time` bootstrap event delivered first —
verified by `TestSubscribeEventsValidEnvelopeSendsBootstrapEventAndWaitsForCancellation`;
- the unified listener still natively accepts gRPC and gRPC-Web
framing for any future native client (Connect-Go's documented
multi-protocol support);
- the Connect handler shares the same upstream business code as the
unified listener — there is exactly one decorator stack
(`grpcapi.NewServer` → `s.service`).
Targeted tests (delivered):
- Connect unary integration tests in `gateway/internal/grpcapi/`
exercising the full envelope → signature → freshness/replay →
rate-limit → routing pipeline through the new Connect transport;
- Connect streaming integration tests asserting bootstrap-event
delivery, replay rejection on stream open, and shutdown closure;
- the existing gateway test suite (`go test ./gateway/...`) stays
green.
Decision deviation note: the planned standalone
`gateway/internal/grpcapi/connect_server_test.go` was not added as a
separate file because the migrated `*_test.go` files in the same
package already cover unary happy + streaming bootstrap + protocol-
version reject through the Connect client. A duplicate file would not
add coverage. Future contributors looking for "the Connect tests" can
read any file in `gateway/internal/grpcapi/` — they all use the
Connect client now.
## ~~Phase 5. WASM Build, `WasmCore` Adapter, `GalaxyClient` Skeleton~~
Status: done.
Goal: package `ui/core` as a WASM module, expose it to TypeScript
through a typed adapter, and prove the WASM-side crypto pipeline at
unit level. End-to-end Connect round-trip is validated in Phase 7
(authenticated calls only become possible after login).
Decisions taken with the project owner before implementation:
1. **TinyGo as primary toolchain.** `core.wasm` lands at 903 KB —
well under the 1 MB acceptance bar. The `GOOS=js GOARCH=wasm`
fallback path stays documented in `ui/docs/wasm-toolchain.md`.
2. **`Core.signRequest` returns canonical bytes only.** No private
key inside WASM; Phase 6 plugs WebCrypto's non-exportable keys at
the orchestration layer. `GalaxyClient` takes a pluggable `Signer`
so Phase 5 tests pass a fixture-key signer and Phase 6 swaps in
WebCrypto without touching the orchestration.
3. **TS codegen runs locally, not against buf.build BSR.** A new
`ui/buf.gen.yaml` invokes
`frontend/node_modules/.bin/protoc-gen-es` (added as a
devDependency). This sidesteps BSR rate limiting and removes the
network dependency from the codegen step.
4. **Field naming is camelCase end-to-end.** Both the TS `Core`
interface and the Go bridge in `ui/wasm/main.go` use camelCase
field names; there is no snake-case translation layer.
Artifacts (delivered):
- `ui/wasm/main.go` TinyGo entry point on `globalThis.galaxyCore`
with four functions: `signRequest`, `verifyResponse`,
`verifyEvent`, `verifyPayloadHash`.
- `ui/Makefile` `wasm` and `ts-protos` targets.
- `ui/buf.gen.yaml` with the local Protobuf-ES plugin (single plugin —
protobuf-es v2 emits both message types and Connect service
descriptors in one file).
- `ui/frontend/src/platform/core/index.ts` — typed `Core` interface
plus a `loadCore()` resolver (Phase 5 ships only the WASM adapter).
- `ui/frontend/src/platform/core/wasm.ts` — `WasmCore` adapter for
browsers; the JSDOM test path lives next to it in
`ui/frontend/tests/setup-wasm.ts`.
- `ui/frontend/src/api/connect.ts` — typed Connect-Web transport +
`GatewayClient` factory.
- `ui/frontend/src/api/galaxy-client.ts` — `GalaxyClient` skeleton
with injected `Signer` and `Sha256` dependencies.
- `ui/frontend/src/proto/edge/v1/edge_gateway_pb.ts`
(generated) and `ui/frontend/src/proto/buf/validate/validate_pb.ts`
(generated as a transitive import via `--include-imports`).
- `ui/frontend/static/core.wasm` (903 KB) + `wasm_exec.js` (TinyGo
shim).
- Three Vitest files exercising the bridge end-to-end:
`tests/wasm-core.test.ts` (each Core method, including a sanity
`signRequest` check that the canonical bytes start with the v1
domain marker), `tests/wasm-core-canon-parity.test.ts` (byte-for-
byte parity against three request fixtures plus the response and
event signature fixtures from `ui/core/canon/testdata/`), and
`tests/galaxy-client.test.ts` (orchestration through a mock `Core`
and `createRouterTransport` from `@connectrpc/connect`).
- Topic doc `ui/docs/wasm-toolchain.md`.
- `ui/README.md` repository-layout block.
Dependencies: Phases 2, 3, 4.
Acceptance criteria (met):
- `make wasm` produces `core.wasm` deterministically under 1 MB (903
KB measured);
- `WasmCore.signRequest` produces canonical bytes byte-for-byte
identical to the gateway-side fixtures for three message types
(`request_user_account_get`, `request_user_games_command`,
`request_lobby_my_games_list`);
- `WasmCore` exposes the same `Core` TypeScript types future
`WailsCore` and `CapacitorCore` adapters will satisfy.
Targeted tests (delivered):
- Vitest unit tests for `WasmCore` calling each public method with
the WASM module loaded in JSDOM via `tests/setup-wasm.ts`;
- Vitest unit tests for `GalaxyClient` using a mock `Core` and the
in-memory `createRouterTransport`;
- Vitest tests asserting `WasmCore.signRequest` output matches the
committed gateway fixtures byte-for-byte for the three request
message types listed above.
Decision deviation note: the initial plan listed `protoc-gen-es` and
`protoc-gen-connect-es` as separate plugins. Protobuf-ES v2 generates
service descriptors in the `_pb.ts` file directly, so a single
`@bufbuild/protoc-gen-es` plugin is sufficient — `@connectrpc/connect`
v2 consumes those descriptors via `createClient`. The `connect-es`
plugin is a v1-only path and is intentionally not used here.
## ~~Phase 6. Storage Layer (Web)~~
Status: done.
Goal: persist the device session keypair securely in browsers, and
provide a generic local cache for game state. Defines the
TypeScript-side `KeyStore` and `Cache` interfaces that desktop and
mobile adapters will satisfy in later phases.
Decisions taken with the project owner before implementation:
1. **Phase 6 stops at the storage boundary.** The PLAN previously
listed a Playwright check that the gateway accepts a signed
request. Public-key registration happens through the email-code
confirm endpoint, which Phase 7 wires; building a temporary
test-only registration path was rejected as throw-away
scaffolding. The live-gateway round-trip is therefore covered by
Phase 7's existing acceptance bullet "the first authenticated
Connect call after login … succeeds end-to-end" instead, which
cannot pass unless the Phase 6 keystore persists and signs
correctly.
2. **Modern-browser baseline, no JS Ed25519 fallback.** WebCrypto
Ed25519 lands in Chrome ≥137, Firefox ≥130, Safari ≥17.4. Phase 7
surfaces a clear "browser not supported" message for older
engines instead of carrying a parallel `@noble/ed25519` code
path. The full matrix and rationale live in
`ui/docs/storage.md`.
Artifacts:
- `ui/frontend/src/platform/store/index.ts` — public `KeyStore`,
`Cache`, `DeviceKeypair` interfaces and the `loadStore()`
resolver, with no web-specific imports in any public signature
- `ui/frontend/src/platform/store/idb.ts` — shared `galaxy-ui`
IndexedDB connection (typed via `idb`'s `DBSchema`) used by both
the keystore and the cache
- `ui/frontend/src/platform/store/idb-cache.ts` — IndexedDB-backed
`Cache` keyed by compound `[namespace, key]`
- `ui/frontend/src/platform/store/webcrypto-keystore.ts` — WebCrypto
non-exportable Ed25519 key generation, structured-cloned through
IDB
- `ui/frontend/src/platform/store/web.ts` — the `loadWebStore`
factory wired into `loadStore`
- `ui/frontend/src/api/session.ts` thin layer with
`loadDeviceSession`, `setDeviceSessionId`, `clearDeviceSession`
- `ui/frontend/src/routes/__debug/store/+page.svelte` (+ `+page.ts`
with `prerender = false; ssr = false;`) — dev-only debug surface
the Phase 6 Playwright spec drives through `window.__galaxyDebug`
- topic doc `ui/docs/storage.md` describing the browser baseline,
IDB schema, keystore lifecycle, and cache contract
Dependencies: Phase 5.
Acceptance criteria:
- a freshly generated keypair survives page reloads (the loaded
handle still produces signatures verifiable under the persisted
public key); the live-gateway round-trip is covered by Phase 7;
- clearing site data removes the keypair, and the next request
triggers a re-login flow;
- `KeyStore` and `Cache` interfaces have full TypeScript types and
zero web-specific imports in their public signatures.
Targeted tests:
- Vitest unit tests for `IDBCache` with `fake-indexeddb`
(round-trip, namespace isolation, delete, clear-with-namespace,
full clear);
- Vitest unit tests for `WebCryptoKeyStore` (generate, load,
signature determinism under Node WebCrypto, signature
verifiability after a simulated reload, third-party verify of the
public key, clear, fresh-keypair-after-clear);
- Playwright e2e (`storage-keypair-persistence.spec.ts`, all four
projects): generate keypair, sign canonical bytes, capture the
signature, reload, assert the previous signature still verifies
under the public key (works on every engine in the baseline
including non-deterministic WebKit), and that
`clearDeviceSession` forces a fresh keypair on next load.
## ~~Phase 7. Auth Flow UI~~
Status: done.
Goal: implement the full email-code login flow with device session
registration and post-login redirect to a placeholder lobby.
Decisions taken with the project owner before implementation:
1. **Playwright e2e against a mocked gateway.** `page.route(...)`
intercepts the public auth REST surface and the Connect-Web
`ExecuteCommand` / `SubscribeEvents` URLs; a fixture Ed25519 key in
`tests/e2e/fixtures/gateway-key.ts` signs the forged responses so
`GalaxyClient.verifyResponse` accepts them under the matching
public key the dev server picks up via
`VITE_GATEWAY_RESPONSE_PUBLIC_KEY`. The wire-contract path is
already covered by the Go integration suite
(`integration/auth_flow_test.go`).
2. **Build-time gateway response public key delivery.** The browser
reads `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` (standard base64 of the
raw 32-byte key) on module load. A future phase may switch to a
`/api/v1/public/well-known/...` endpoint when prod distribution is
wired up; Phase 7 stops at the env-var.
3. **Minimal SubscribeEvents-based revocation watcher.** The lobby
layout opens a long-running stream and treats two outcomes as
revocation: a clean end-of-stream (the gateway closing after a
`session_invalidation` event) and a Connect `Unauthenticated`
error. Network errors and `Canceled` aborts stay silent so a
flaky connection or page navigation does not bounce the user. The
per-event dispatch path lands in Phase 24.
4. **Browser-not-supported blocker.** The root layout runs a one-time
`crypto.subtle.generateKey({name:"Ed25519"}, ...)` probe on boot
and renders a blocker page when the probe rejects. This closes
Phase 6's "no JS Ed25519 fallback" follow-up.
Artifacts (delivered):
- `ui/frontend/src/routes/login/+page.svelte` (+ `+page.ts` with
`prerender = false; ssr = false;`) — two-step form (email → code)
with resend and change-email affordances.
- `ui/frontend/src/routes/lobby/+page.svelte` (+ `+page.ts`) —
placeholder lobby that issues the first authenticated
`user.account.get` through `GalaxyClient` and surfaces the decoded
display name. The placeholder used `JSON.parse(TextDecoder)` to
read the response payload; that worked with the mocked Playwright
setup but did not match the gateway's FlatBuffers wire format. Phase
8 introduces the TS-side FlatBuffers integration and rewrites this
page to decode `AccountResponse` via the generated bindings, so the
greeting now also works against a real local stack.
- `ui/frontend/src/routes/+layout.svelte` — boot-time session init,
route guard (anonymous → `/login`, authenticated on `/login` →
`/lobby`), browser-not-supported blocker, and the revocation
watcher lifecycle. `+layout.ts` puts the whole tree into SPA mode
(`ssr = false; prerender = false;`).
- `ui/frontend/src/api/auth.ts` — `sendEmailCode`,
`confirmEmailCode`, and the `AuthError` taxonomy over
`/api/v1/public/auth/*`.
- `ui/frontend/src/lib/env.ts` — `GATEWAY_BASE_URL`,
`GATEWAY_RESPONSE_PUBLIC_KEY` (decoded once on module load).
- `ui/frontend/src/lib/session-store.svelte.ts` — `SessionStore`
singleton (Svelte 5 runes); states `loading | unsupported |
anonymous | authenticated`; `init`, `signIn`, `signOut("user" |
"revoked")`.
- `ui/frontend/src/lib/revocation-watcher.ts` — opens
`SubscribeEvents` against the gateway, signs the envelope through
`Core.signRequest`, treats clean stream end / `Unauthenticated` as
revocation.
- `ui/frontend/.env.example` — `VITE_GATEWAY_BASE_URL`,
`VITE_GATEWAY_RESPONSE_PUBLIC_KEY`.
- Topic doc `ui/docs/auth-flow.md`; cross-references from
`ui/docs/storage.md` and `ui/README.md`.
- Vitest: `tests/auth-api.test.ts`, `tests/session-store.test.ts`,
`tests/login-page.test.ts`.
- Playwright: `tests/e2e/auth-flow.spec.ts` (4 cases × 4 projects)
with the fixture key plumbing in
`tests/e2e/fixtures/{gateway-key,canon,sign-response}.ts`.
- Pre-existing `tests/e2e/landing.spec.ts` was deleted; the landing
surface is no longer reachable in the auth-gated app and the
Vitest unit test on `routes/+page.svelte` retains the version
footer assertion.
Dependencies: Phase 6.
Acceptance criteria (met):
- A fresh browser completes login end-to-end via the mocked gateway
in all four Playwright projects; the first authenticated Connect
call (`user.account.get`) succeeds end-to-end through `WasmCore` →
`GalaxyClient` → ConnectRPC and the response signature is verified
under `VITE_GATEWAY_RESPONSE_PUBLIC_KEY`. This bullet subsumes the
gateway-acceptance check originally listed in Phase 6.
- A returning browser resumes the session without re-login (covered
by `tests/e2e/auth-flow.spec.ts::"returning user lands on the
lobby without re-login"`).
- Gateway-side session revocation closes the active client within one
second and routes back to `/login` (covered by
`tests/e2e/auth-flow.spec.ts::"server-side revocation closes the
active client within one second"`).
Targeted tests (delivered):
- Vitest component tests for the login form with mocked `auth.ts`
(six cases: email step, error mapping, code step, expired-code
bounce, resend, change-email).
- Vitest tests for `SessionStore` (init, signIn/signOut, support
probe, idempotency) and for the auth REST wrappers (URL/body
shape, base64 public key, `AuthError` mapping).
- Playwright e2e suite (`auth-flow.spec.ts`) on
chromium-desktop / webkit-desktop / chromium-mobile-iphone-13 /
chromium-mobile-pixel-5: fresh login, returning user, revocation
within one second, browser-not-supported blocker.
## ~~Phase 8. Lobby UI~~
Status: done.
Goal: replace the placeholder lobby with a working list of games
allowing the user to view membership, see and act on invitations and
applications, submit applications to public games, and create new
private games. The phase also introduces the TS-side FlatBuffers
codec the rest of the client relies on for authenticated payloads.
Decisions taken with the project owner before implementation:
1. **Cross-stack catalog extension.** Phase 8 expands the lobby
command catalog beyond `lobby.my.games.list` and
`lobby.game.open-enrollment` (the only routes shipped before this
phase). Seven new authenticated message types now flow through
`gateway/internal/backendclient/lobby_commands.go`:
`lobby.public.games.list`, `lobby.my.applications.list`,
`lobby.my.invites.list`, `lobby.game.create`,
`lobby.application.submit`, `lobby.invite.redeem`,
`lobby.invite.decline`. Each carries its FlatBuffers schema in
`pkg/schema/fbs/lobby.fbs`, its Go request/response struct in
`pkg/model/lobby/lobby.go`, and its transcoder pair in
`pkg/transcoder/lobby.go`.
2. **My applications projection.** FUNCTIONAL.md §4.5 lists three
"my" projections (games, applications, invites). The original
plan text omitted applications; the lobby now renders a fourth
"my applications" section so the user sees the pending status of
any application they submit.
3. **Submit-application UX.** Per FUNCTIONAL.md §4.2, joining a
public game means submitting an application that lands in the
`pending` state until the owner approves. The button label is
`Submit application`, the inline race-name form lives on the
public-game card itself (no overlay/modal infrastructure yet —
that lands later when the in-game shell does), and a successful
submit refreshes the applications section so the user sees the
pending entry immediately.
4. **TS-side FlatBuffers integration.** The placeholder lobby in
Phase 7 used `JSON.parse(TextDecoder)` to read the
`user.account.get` payload; that was a mismatch with the gateway's
FlatBuffers transcoder and only worked under mocked tests. Phase 8
adds a `flatbuffers` runtime dep to `ui/frontend/package.json`, a
`fbs-ts` Make target in `ui/Makefile` that drives `flatc --ts`,
and checks the generated bindings into
`ui/frontend/src/proto/galaxy/fbs/{lobby,user}/`. Phase 7's
`user.account.get` decode is rewritten to use those bindings as
part of this phase, fixing the wire-format gap.
5. **Create-game form scope.** The form keeps `game_name`,
`description`, `turn_schedule` (5-field cron), and
`enrollment_ends_at` always visible; the rest (`min_players`,
`max_players`, `start_gap_hours`, `start_gap_players`,
`target_engine_version`) sit behind a `<details>` "Advanced"
toggle with TS-side defaults (2 / 8 / 24 / 2 / `v1`). The gateway
forces visibility to `private` regardless of input — public games
come exclusively through the admin surface per FUNCTIONAL.md §3.3.
Artifacts (delivered):
- `ui/frontend/src/routes/lobby/+page.svelte` — full lobby landing
page. Header preserves the Phase 7 device-session-id and greeting
affordances; below it five sections render in a single mobile-first
column: a top-level "create new game" action (§3.3), `my games`
cards routing to `/games/:id/map` (placeholder until Phase 10,
§4.5), `pending invitations` cards with Accept / Decline (§4.3),
`my applications` cards with localised pending / approved /
rejected status (§4.5), and `public games` cards with an inline
race-name form behind a `Submit application` button (§4.2).
Convention follows the Phase 7 login page — single `max-width:
32rem` cap, no `@media` queries.
- `ui/frontend/src/routes/lobby/create/+page.svelte` (+ `+page.ts`
with `ssr = false; prerender = false;`) — create-game form with
always-visible name / description / turn-schedule / enrollment-end,
Advanced fields under `<details>` for the rest, and TS-side
defaults for the advanced inputs.
- `ui/frontend/src/api/lobby.ts` — typed wrappers around
`GalaxyClient.executeCommand` for all eight lobby commands plus a
`LobbyError` class that surfaces canonical lobby error codes
(`invalid_request`, `subject_not_found`, `forbidden`, `conflict`,
`internal_error`).
- `ui/frontend/src/api/galaxy-client.ts` — `executeCommand` now
returns `{ resultCode, payloadBytes }`; `lobby.ts` uses the
result-code branch to throw `LobbyError`.
- `pkg/model/lobby/lobby.go` — seven new message-type constants and
matching request/response structs.
- `pkg/schema/fbs/lobby.fbs` — `PublicGamesListRequest`,
`PublicGamesListResponse`, `ApplicationSummary`,
`MyApplicationsListRequest`, `MyApplicationsListResponse`,
`InviteSummary`, `MyInvitesListRequest`, `MyInvitesListResponse`,
`GameCreateRequest`, `GameCreateResponse`,
`ApplicationSubmitRequest`, `ApplicationSubmitResponse`,
`InviteRedeemRequest`, `InviteRedeemResponse`,
`InviteDeclineRequest`, `InviteDeclineResponse` tables. Reused
`GameSummary` for `GameCreateResponse.game` and `MyGamesListResponse`.
- `pkg/transcoder/lobby.go` — encode/decode pairs for all new types
plus shared helpers `encodeApplicationSummary`,
`decodeApplicationSummary`, `encodeInviteSummary`,
`decodeInviteSummary`, `unixMilliFromOptional`. Reuses
`encodeGameSummary` / `decodeGameSummary` from before.
- `gateway/internal/backendclient/lobby_commands.go` — switch cases
for the seven new message types and the per-command REST helpers
(`executeLobbyPublicGames`, `executeLobbyMyApplications`,
`executeLobbyMyInvites`, `executeLobbyGameCreate`,
`executeLobbyApplicationSubmit`, `executeLobbyInviteRedeem`,
`executeLobbyInviteDecline`); the JSON wire types from backend's
user-lobby handlers are mirrored locally for non-strict decoding.
- `gateway/internal/backendclient/routes.go` — the new message types
are wired into `LobbyRoutes`.
- `ui/frontend/src/proto/galaxy/fbs/{lobby,user}/...` — generated TS
FlatBuffers bindings (regenerated from `pkg/schema/fbs/*.fbs` via
the `fbs-ts` Make target, checked into the repo like the Connect
bindings).
- `ui/Makefile` — new `fbs-ts` target.
- `ui/frontend/package.json` — `flatbuffers` runtime dep.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — full `lobby.*`
catalogue covering sections, empty states, application form,
create form, status badges, and lobby error code translations.
- Topic doc `ui/docs/lobby.md`.
- Vitest: `tests/lobby-fbs.test.ts` (binding round-trips),
`tests/lobby-api.test.ts` (wrapper unit tests over a stub client),
`tests/lobby-page.test.ts`, `tests/lobby-create.test.ts`.
- Playwright: `tests/e2e/lobby-flow.spec.ts` (3 cases × 4 projects)
with `tests/e2e/fixtures/lobby-fbs.ts` building forged FlatBuffers
payloads through the same generated bindings the production code
uses. The Phase 7 spec was migrated to the same fixture so
`user.account.get` is now FlatBuffers end-to-end.
- Phase 7 e2e specs were updated to `click → fill` the readonly
inputs (the readonly attribute is the documented Safari
autofill-suppression workaround; `fill` checks editability before
Playwright's own focus call, so a deliberate click is required).
- `pkg/transcoder/lobby_test.go` — round-trip and corruption-recover
cases for every new pair.
- `gateway/internal/backendclient/lobby_commands_test.go` — per-RPC
success / 4xx / 5xx / 503 cases against an `httptest.Server`.
Dependencies: Phase 7.
Acceptance criteria (met):
- the user can list, create, submit-application, and accept an
invitation end-to-end against a local stack — the gateway routes
every required envelope, and the FlatBuffers wire path is the same
in production and in mocked tests;
- mobile viewport renders without horizontal scroll on
`chromium-mobile-iphone-13` and `chromium-mobile-pixel-5`;
- empty states are explicit (`no games yet`, `no invitations`,
`no applications`, `no public games`).
Targeted tests (delivered):
- Vitest binding round-trips for every lobby request/response;
- Vitest API wrapper coverage for every wrapper plus the LobbyError
surfacing path;
- Vitest component tests for the lobby page (every section, empty
states, race-name validation, Accept / Decline) and the create-game
form (validation, submission, cancel);
- Playwright e2e (3 flows × 4 projects): full create-game flow to
My Games, submit-application to My Applications pending, accept
invitation removes card and adds the game to My Games. Phase 7
auth flow now also runs over the FlatBuffers wire.
## ~~Phase 9. Map Renderer with Fixture Data~~
Status: done.
Goal: stand up the PixiJS map renderer with pan/zoom, primitive
drawing, torus wrap behaviour and bounded-plane (no-wrap) mode against
a fixture dataset, before wiring it to live game state. Both modes
are first-class — no-wrap is a real user-selectable view option, not
a deferred nicety.
Artifacts:
- `ui/frontend/src/map/world.ts` data model (`Primitive` =
`Point | Circle | Line`, `Style`, single-theme bindings) over plain
float64 world coordinates; the renderer is a vector renderer and
Pixi's transform pipeline owns the world→screen mapping
- `ui/frontend/src/map/math.ts` geometry primitives:
`torusShortestDelta`, `distSqPointToSegment`, `clamp`, and
`screenToWorld`/`worldToScreen` round-trip transforms
- `ui/frontend/src/map/render.ts` PixiJS v8 scene graph driven by
`pixi-viewport@^6` for pan/zoom/pinch with WebGPU/WebGL backend
selection via `Application.init({ preference })`; torus wrap is
rendered through nine container copies at `(±W, 0) × (±H, 0)`
- `ui/frontend/src/map/hit-test.ts` brute-force hit-test pass over
the world primitives with `[-priority, distSq, kindOrder, id]`
ordering and torus-shortest distance in `'torus'` mode
- `ui/frontend/src/map/no-wrap.ts` camera clamp helpers
(`clampCameraNoWrap`, `minScaleNoWrap`, `pivotZoom`) for bounded
plane mode; `pixi-viewport`'s `clamp`/`clampZoom` plugins are
used at the renderer level with a centring hook on `'moved'` so
the viewport-larger-than-world case stays centred
- `ui/frontend/src/map/fixtures.ts` deterministic 1000-primitive
sample world used by the playground and by manual perf checks
- `ui/frontend/src/routes/__debug/map/+page.svelte` development page
rendering the fixture world with a mode switch between torus and
no-wrap, plus a `window.__galaxyMap` debug surface for tests
- topic doc `ui/docs/renderer.md` specifying the data model,
hit-test math, torus copy rule, no-wrap camera semantics, and
the deprecation status of `galaxy/client`
Dependencies: Phase 1.
Acceptance criteria:
- a 1000-primitive fixture world pans and zooms on a mid-range
laptop with WebGPU, falling back to WebGL when WebGPU is
unavailable, in both torus and no-wrap modes; the 60 fps target
is documented in `ui/docs/renderer.md` as a manual gate, not a
CI assertion (CI runners vary too much in CPU/GPU);
- hit testing returns the expected primitive on a hand-built
fixture set covering wrap copies, line slop, ring vs filled
circles, ordering, and zoom-dependent slop;
- torus wrap renders all relevant corner copies correctly across
the viewport edges;
- no-wrap mode clamps the camera at world boundaries; pivot zoom
keeps the world point under the cursor stable; viewport never
becomes larger than the world.
Targeted tests:
- Vitest unit tests for geometry primitives, torus-shortest
distance, no-wrap clamps, pivot-zoom invariants
(`tests/map-math.test.ts`, `tests/map-no-wrap.test.ts`);
- Vitest hit-test cases for every rule in the algorithm spec
(`tests/map-hit-test.test.ts`, ~22 cases);
- Playwright visual smoke test of the playground page across all
four configured projects (`chromium-desktop` forces WebGPU,
`webkit-desktop` forces WebGL, mobile projects auto-pick),
exercising mode switch torus → no-wrap and back, wheel zoom,
no-wrap clamp after a drag past the edge, and live hit-test
plumbing (`tests/e2e/playground-map.spec.ts`).
## ~~Phase 10. In-Game Shell with View-Replacement Skeleton~~
Status: done.
Goal: assemble the in-game layout shell (header, sidebar, main area)
with empty placeholder content for every view, so navigation works
end-to-end before any data is wired.
Decisions taken with the project owner during implementation:
1. **Routing — single-URL app-shell, in-memory dispatch.** The game
UI is one SvelteKit route served at `/game/`; the address bar never
changes. The "view router" called out in the original artifact list
is the in-memory dispatch in `lib/game/game-shell.svelte` — an
`{#if}` ladder over `activeView.view` that mounts the matching
`lib/active-view/<name>.svelte` stub. The top-level screen
(login / lobby / lobby-create / game) is chosen by the single-route
dispatcher `routes/+page.svelte` from `session.status` +
`appScreen.screen`. Both `appScreen` and `activeView` are rune
singletons in `lib/app-nav.svelte.ts`; there are no per-screen or
per-view file routes (only the dev/test `/__debug/*` ones remain).
Screen-level browser history (Back → lobby) is layered on top via
SvelteKit shallow routing (`pushState`/`replaceState` + `page.state`)
so the URL stays `/game/`. This single-URL model is also the natural
fit for the deferred standalone wrappers (Wails desktop, Capacitor /
gomobile mobile in [ROADMAP.md](ROADMAP.md)), which load a single
bundled `index.html` with no URLs or history. See
[docs/navigation.md](docs/navigation.md).
> This decision supersedes the original "file-system routes plus
> thin `+page.svelte` wrappers" plan. The app-shell transition was
> implemented after the MVP phases: the `routes/games/[id]/`
> subtree and the per-view route wrappers were removed, the layout
> became `lib/game/game-shell.svelte`, and the login / lobby /
> lobby-create screens moved under `lib/screens/`. The
> `lib/active-view/*` components are unchanged — only how they are
> mounted changed.
2. **In-game view sub-parameters — `activeView` state, not URL
segments.** What were optional URL segments are now optional fields
on `activeView` state: the science designer reads `scienceId`
(absent = new-science form), the battle view reads `battleId`
(empty = list) and `turn`, and the table view reads `tableEntity`.
Later phases set these through `activeView.select(view, params)`
instead of navigating a URL.
3. **Battle view — optional id.** The battle view accepts both the
list state (no `battleId`) and a specific battle (`battleId` set).
Phase 27 keeps the optional sub-param and switches behaviour based
on presence.
4. **Tablet sidebar — click toggle, not swipe.** The 7681024 px
tablet sidebar slides in from a header-button click rather
than the IA section's swipe-from-right gesture. The structural
breakpoint switch satisfies Phase 10's acceptance criterion;
Phase 35 polish lands the swipe gesture.
5. **Mobile tool overlay — `mobileTool` state, gated by active view.**
The mobile bottom-tabs Calc / Order select the map view and
set a shell-owned `mobileTool` rune. The shell's derived
`effectiveTool` only honours the rune while `activeView.view ===
"map"`, so selecting any other view via the More drawer or the
header view-menu naturally drops the overlay. The sidebar tool
state is pure in-memory rune state — there is no `?sidebar=` URL
param (the app-shell carries no per-screen URL); the sidebar opens
on its `inspector` default and external events flip the tab.
6. **Sidebar tool filenames — `*-tab.svelte`.** Phase 12 / 13 / 30
each name their final implementation
(`order-tab.svelte`, `inspector-tab.svelte`,
`calculator-tab.svelte`). The Phase 10 stubs ship with those
names so later phases replace the content in place without
renaming.
7. **Race-name and turn-counter placeholders.** The header race
name is the static `race ?` string from i18n, mirroring the
spec's static `turn ?` placeholder. Phase 11 wires both from
`user.games.report` data through `lib/header/turn-counter.svelte`.
8. **Auth gate — state-based in the dispatcher.** The single-route
dispatcher (`routes/+page.svelte`) renders the login screen for an
`anonymous` session and the authenticated screens for an
`authenticated` one; there is no `goto` redirect (the app-shell
stays at `/game/`). The in-game shell needs no extra guard. Phase 10
verified the gate by booting the e2e shell spec via
`__galaxyDebug.setDeviceSessionId` and observing the
post-`session.init` `authenticated` status. (Originally the gate was
a `goto("/login")` redirect in the root layout; the app-shell
transition replaced it with state-based rendering.)
9. **More drawer mirrors the view-menu.** The mobile bottom-tabs
"More" drawer renders the same seven destinations as the
header view-menu. The IA section's narrower More list (Mail,
Battle log, Tables, History, Settings, Logout) is the polish
target for Phase 35 once History exists; Phase 10 keeps a
single destination list to avoid drift.
Artifacts (delivered):
- `ui/frontend/src/routes/games/[id]/+layout.svelte` — chrome
layout (header, conditional sidebar, active-view slot, mobile
bottom-tabs, mobileTool gate, sidebarOpen toggle)
- `ui/frontend/src/routes/games/[id]/+layout.ts` —
`ssr=false; prerender=false;` mirroring the root SPA flags
- `ui/frontend/src/routes/games/[id]/+page.ts` — redirects
`/games/:id` → `/games/:id/map`
- `ui/frontend/src/routes/games/[id]/{map, table/[entity], report,
battle/[[battleId]], mail, designer/ship-class/[[classId]],
designer/science/[[scienceId]]}/+page.svelte` — thin route
wrappers that mount the matching active-view stub
- `ui/frontend/src/lib/header/{header, turn-counter, view-menu,
account-menu}.svelte` — header composition with race
placeholder, turn counter (static `?`), view-menu
(dropdown desktop / hamburger mobile), and account menu
(Settings / Sessions / Theme stub buttons; Language driven by
`i18n.setLocale`; Logout calls `session.signOut("user")`)
- `ui/frontend/src/lib/sidebar/{sidebar, tab-bar, calculator-tab,
inspector-tab, order-tab, bottom-tabs}.svelte` — three-tab
sidebar with `inspector` default (the app-shell transition later
dropped the original `?sidebar=` URL seed — there is no per-screen
URL to carry it); mobile-only bottom-tabs with
`[Map, Calc, Order, More]` plus a More drawer duplicating the
view-menu destinations
- `ui/frontend/src/lib/sidebar/types.ts` — shared `SidebarTab`
and `MobileTool` types
- `ui/frontend/src/lib/active-view/{map, table, report, battle,
mail, designer-ship-class, designer-science}.svelte` — Phase 10
stubs rendering localised titles plus `coming soon` copy with
stable testids that later phases replace
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — full
`game.shell.*`, `game.view.*`, `game.sidebar.*`,
`game.bottom_tabs.*` catalogue
- Topic doc `ui/docs/navigation.md`
- Vitest: `tests/game-shell-{header,sidebar,stubs}.test.ts`
- Playwright: `tests/e2e/game-shell.spec.ts` (7 cases × 4 projects;
mobile-only and viewport-switch cases conditionally skipped on
non-matching projects)
Dependencies: Phase 8.
Acceptance criteria (met):
- entering `/games/:id/map` from the lobby renders the shell with
all navigation chrome;
- header dropdown switches to every other view; mobile hamburger
does the same;
- sidebar tabs preserve their stub state across switches;
- the responsive layout matches the breakpoint diagrams in
`Information Architecture and Navigation` (with the swipe
gesture deferred to Phase 35).
Targeted tests (delivered):
- Vitest component tests for the header (race / turn placeholders,
view-menu navigation to every IA destination, account-menu
Logout / Language wiring);
- Vitest component tests for the sidebar (default tab, switching,
empty-state copy, close button);
- Vitest component tests for every active-view stub (title,
`coming soon` copy, table-entity prop, battle-id prop);
- Playwright e2e: visit every view stub via header dropdown and
via the mobile More drawer; sidebar tab choice survives
navigation across active views; mobile bottom-tabs toggle the
Calc / Order tool overlay;
- Playwright e2e: `setViewportSize`-driven viewport switch test
validates layout transitions at 768 px and 1024 px (sidebar
visibility, sidebar-toggle / bottom-tabs visibility).
Verified on local-ci run 3 (`success`, fc371c7).
## ~~Phase 11. Map Wired to Live Game State~~
Status: done.
Goal: replace the map fixture with real planet data fetched from the
gateway for the selected game; planets only, read-only.
Decisions taken with the project owner during implementation:
1. **`current_turn` on `GameSummary`.** The user-facing
`lobby.my.games.list` did not expose the runtime's current turn
number, but the in-game shell needs it to fetch the matching
`user.games.report`. Phase 11 extends `GameSummary` with a new
`current_turn:int32` field (FB schema, Go transcoder + model,
backend `gameSummaryWire`, gateway `decodeGameSummary*`,
`backend/openapi.yaml`, TS bindings, `api/lobby.ts`). The data
was already tracked in the runtime projection
(`backend/internal/lobby/types.go RuntimeSnapshot.CurrentTurn`);
exposing it is purely a wire change. Two alternatives were
rejected: a brand-new `user.games.state` message (full wire-flow
for a one-field response) and hard-coding `turn=0` (works for the
dev sandbox, but renders the initial state for any game past
turn zero). The decision crosses Phase 8's already-shipped
catalogue per the project's "decisions baked back into the live
plan" rule.
2. **Per-game state store with context.** A `GameStateStore` lives
in `lib/game-state.svelte.ts`; the in-game shell layout
instantiates one per game and exposes it through Svelte context
under `GAME_STATE_CONTEXT_KEY`. Header turn counter, map view,
and (in later phases) inspector tabs all consume the same
instance. A new instance is created on layout remount (game id
change), so each game gets a fresh snapshot.
3. **Lobby lookup for current turn.** The store does not assume the
caller passed `current_turn` through navigation state. On
`setGame`, it calls `lobby.my.games.list` itself, finds the game
record, reads `current_turn`, and then calls
`user.games.report`. A direct deep link to `/games/:id/map` for
a game the user is not a member of flips the store to `error`
with a `not in your list` message.
4. **Refresh on tab focus.** The store installs a
`visibilitychange` listener that calls `refresh()` when the
document becomes visible and the store is `ready`. The map
view's mount effect skips a re-render when the new snapshot's
turn matches the previously-mounted turn (and the wrap mode is
unchanged), so a no-op refresh does not flicker the canvas.
5. **Wrap-mode preference.** `Cache` namespace `game-prefs`, key
`<gameId>/wrap-mode`, values `torus` (default) / `no-wrap`.
Phase 11 reads through `wrapMode`; `setWrapMode` writes back.
Phase 29 wires the toggle UI on top of these primitives.
6. **State binding.** `map/state-binding.ts::reportToWorld` emits
one Point primitive per planet across all four kinds (local /
other / uninhabited / unidentified) with distinct fill colours
and point radii. Each primitive's id reuses the engine planet
number so a hit-test result resolves directly to a planet
without an extra lookup table. Zero-planet reports yield a
well-formed empty world; the World constructor's positivity
check is guarded by a 1×1 fallback for the malformed-report
edge case.
7. **Renderer remount on snapshot change.** The map view disposes
and recreates the renderer when the report's turn changes (and
short-circuits when it does not). This is wasteful for the
tab-focus refresh path, but the renderer's external
`RendererHandle` does not yet expose a `setWorld` API and Phase
11's per-game planet count is small enough that the remount
cost (a few hundred ms) is acceptable. A future phase that adds
high-frequency updates (Phase 24 push events, Phase 34 multi-
turn projection overlays) will extract a `replaceWorld` method.
8. **e2e bootstrap reuses `__galaxyDebug`.** The Phase 10 pattern
of seeding the device session through `/__debug/store` carries
over; the gateway is mocked through `page.route` for
`lobby.my.games.list`, `user.games.report`, and the
`SubscribeEvents` stream that the revocation watcher opens
(held open indefinitely so a clean end-of-stream does not
trigger `signOut("revoked")` and bounce the test back to
`/login`).
Artifacts (delivered):
- `ui/frontend/src/api/game-state.ts` — typed wrapper for
`user.games.report` plus `uuidToHiLo` and a TS-friendly
`GameReport` shape (planets only)
- `ui/frontend/src/lib/game-state.svelte.ts` — runes-based
`GameStateStore` with init / setGame / setTurn / refresh /
setWrapMode / failBootstrap / dispose; tab-focus listener;
`Cache`-backed wrap-mode persistence
- `ui/frontend/src/map/state-binding.ts` — `reportToWorld` and the
per-kind planet styling
- `ui/frontend/src/lib/active-view/map.svelte` — replaces the
Phase 10 stub with the live renderer integration plus loading /
error overlays and a `data-planet-count` testid hook
- `ui/frontend/src/lib/header/turn-counter.svelte` — reads
`store.report.turn` through context, falls back to the static
`?` placeholder when the store has not yet produced a snapshot
- `ui/frontend/src/routes/games/[id]/+layout.svelte` — instantiates
the `GameStateStore`, builds the `GalaxyClient`, exposes the
store via `setContext`, disposes on unmount
- `pkg/schema/fbs/lobby.fbs` — `current_turn:int32` field
- `pkg/schema/fbs/lobby/GameSummary.go` (regenerated)
- `pkg/transcoder/lobby.go` — encode/decode `current_turn`
- `pkg/transcoder/lobby_test.go` — non-zero `current_turn` in the
round-trip fixture
- `pkg/model/lobby/lobby.go` — `CurrentTurn int32` on `GameSummary`
- `backend/internal/server/handlers_user_lobby_helpers.go` —
`gameSummaryWire.CurrentTurn` + `gameSummaryToWire` reads it
from `RuntimeSnapshot.CurrentTurn`; `lobbyGameDetailWire` no
longer redeclares the field
- `backend/openapi.yaml` — `current_turn` on the `GameSummary`
schema (required); removed from the `LobbyGameDetail` allOf
block (now inherited)
- `gateway/internal/backendclient/lobby_commands.go` —
`decodeGameSummaryFromGameDetail` and `decodePublicGamesPage`
parse `current_turn` from JSON
- `ui/Makefile` — `FBS_INPUTS` adds `common.fbs` (so the
`common/uuid.ts` directory is generated) and `report.fbs`
- `ui/frontend/src/proto/galaxy/fbs/{common,report}/...` —
regenerated TS bindings
- `ui/frontend/src/api/lobby.ts` — `currentTurn: number` on
`GameSummary`; `decodeGameSummary` reads it
- `ui/frontend/tests/lobby-{fbs,api,page}.test.ts` and
`tests/e2e/fixtures/lobby-fbs.ts` — fixtures and assertions
cover `currentTurn`
- `ui/frontend/tests/state-binding.test.ts` — Vitest unit
coverage for `reportToWorld` (dimensions, kinds, ids, styling,
empty-planet, zero-dimension fallback, priority order)
- `ui/frontend/tests/game-state.test.ts` — Vitest coverage for
`GameStateStore` (init flow, missing-membership error,
forbidden-result error, `setTurn`, wrap-mode persistence
across instances, `failBootstrap`)
- `ui/frontend/tests/e2e/game-shell-map.spec.ts` — Playwright e2e
with a mocked gateway: live report renders the reported turn
and planet count, zero-planet game renders without errors,
missing-membership game surfaces the error overlay
- `ui/frontend/tests/e2e/fixtures/report-fbs.ts` — `buildReportPayload`
helper for forging FB Report payloads
- Topic doc `ui/docs/game-state.md`
- `ui/docs/lobby.md` — `current_turn` note pointing at the new
game-state doc
Dependencies: Phases 9, 10.
Acceptance criteria (met):
- entering `/games/:id/map` for a game with real planets renders them
on the map;
- the turn counter in the header reflects the actual turn number;
- map state refreshes on tab focus;
- view mode (torus / no-wrap) honours the per-game preference if set,
defaults to torus otherwise.
Targeted tests (delivered):
- Vitest: `tests/state-binding.test.ts` covers the report→world
translation across every planet kind plus malformed-dimension
guards; `tests/game-state.test.ts` covers the store lifecycle
end-to-end with a stubbed `listMyGames` and a fake `GalaxyClient`;
- Playwright e2e: `tests/e2e/game-shell-map.spec.ts` exercises the
live data path with a mocked gateway across all four projects,
including the zero-planet regression and the
missing-membership error path;
- per-game wrap-scrolling preference round-trips through `Cache`
in `game-state.test.ts::setWrapMode persists across instances`;
- the existing Phase 10 chrome / navigation specs still pass
unchanged.
Verified on local-ci run 4 (`success`, ce7a66b).
## ~~Phase 12. Order Composer Skeleton~~
Status: done.
Goal: implement the empty order composer as a persistent vertical list
that survives navigation and reloads, ready to receive commands in
later phases.
Decisions taken with the project owner during implementation:
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 superseded the
"introduce `lib/history-mode.ts`" half of this decision: the
single derivation `historyMode = $derived(gameState.historyMode)`
lives directly in `+layout.svelte`, the rune split between
`currentTurn` and `viewedTurn` lives in `GameStateStore`, and
no separate module is introduced. See Phase 26 decisions for
the rationale.
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 while it is selected.
- `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.
Acceptance criteria:
- programmatically adding a stub command shows it in the order tab;
- closing and reopening the browser preserves the draft;
- removing a command from the order tab persists the removal;
- the order tab is hidden in history mode (Phase 26) — wired here as a
prop so later phases can flip it.
Targeted tests:
- Vitest unit tests for `order-draft` covering add, remove, reorder,
persistence;
- Playwright e2e: programmatically add three stub commands, reload,
assert all three persist.
Verified on local-ci run 7 (`success`, 460591c).
## ~~Phase 13. Inspector — Planet (Read-Only)~~
Status: done.
Goal: show planet details in the inspector when a planet is clicked
on the map; read-only, no actions yet.
Artifacts:
- `ui/frontend/src/lib/sidebar/inspector-tab.svelte` empty state
(`select an object on the map`) and routing per selected-object
kind. The tab reads the selection and game-state stores from
context and hands a resolved `ReportPlanet` to the planet inspector
component.
- `ui/frontend/src/lib/inspectors/planet.svelte` read-only display of
every planet field carried by the FBS report and documented in
the `rules.txt` planet section: name, coordinates, size, population,
colonists, industry, industry stockpile (`capital`, `$`), materials
stockpile (`material`, `M`), natural resources, current production
type, free production potential. Per-kind nullable fields collapse
silently — uninhabited and unidentified planets render the smaller
field set the engine carries for them.
- `ui/frontend/src/lib/inspectors/planet-sheet.svelte` mobile-only
bottom-sheet that wraps the same planet component for the < 768 px
breakpoint. Visibility is gated on `effectiveTool === "map"` so the
sheet does not stack with the calc / order overlays.
- `ui/frontend/src/lib/active-view/map.svelte` registers a click
handler against the new `RendererHandle.onClick` (built on
`pixi-viewport`'s `clicked` event), translates the hit into a
planet, and calls `SelectionStore.selectPlanet(number)`.
- `ui/frontend/src/lib/selection.svelte.ts` runes store with the
selected-object union (`{ kind: "planet"; id: number } | null`),
exposed via `setContext` from the in-game shell layout. Lifetime
matches the layout instance — selection survives every active-view
switch but does not persist across reloads.
- `ui/frontend/src/api/game-state.ts` projection extended to surface
every planet field needed by the inspector (`industryStockpile`,
`materialsStockpile`, `industry`, `population`, `colonists`,
`production`, `freeIndustry`, plus the existing `owner`).
- `ui/frontend/src/routes/games/[id]/+layout.svelte` lifts
`activeTab` into a layout-level rune bound into the sidebar, owns
the `SelectionStore`, mounts the bottom-sheet, and runs the
reveal `$effect` that flips the sidebar to the inspector tab and
opens the tablet drawer when a new selection lands.
Dependencies: Phase 11.
Acceptance criteria:
- clicking any visible planet on the map shows its details in the
inspector tab on desktop and tablet (drawer auto-opens), and in a
bottom-sheet on mobile;
- selection state persists across view switches inside `/games/:id/*`
(per global state-preservation rule); reload starts fresh;
- a click on empty map area is a no-op — selection is cleared only
by the explicit close button (``) on the mobile sheet;
- empty inspector renders the empty-state message when no planet is
selected;
- mobile dismissal is the close button only; swipe-to-dismiss and
tap-outside-to-dismiss are deferred to Phase 35;
- a selection that no longer matches a visible planet (visibility
lost between turns) collapses to the empty state instead of
showing stale rows;
- selected-planet visual feedback on the map (ring / halo) is
intentionally out of scope and rolls into Phase 35.
Targeted tests:
- Vitest unit (`tests/selection-store.test.ts`) for the runes store;
- Vitest component (`tests/inspector-planet.test.ts`) for per-kind
field rendering against synthetic `ReportPlanet` fixtures;
- Vitest component (`tests/game-shell-sidebar.test.ts`) extended for
the selection-driven inspector content and the missing-planet
fallback;
- Playwright e2e (`tests/e2e/game-shell-inspector.spec.ts`) clicks a
seeded planet on `chromium-desktop` and asserts the sidebar
inspector content, and on `chromium-mobile-iphone-13` asserts the
bottom-sheet appears and the close button clears it.
## ~~Phase 14. First End-to-End Command — Rename Planet~~
Status: done.
Goal: prove the entire pipeline (inspector → composer → submit →
server → state refresh) by wiring up exactly one action: renaming a
planet.
Decisions taken with the project owner during implementation:
1. **Optimistic overlay over `user.games.order`.** The plan's
acceptance criterion ("name change within one second") is
inconsistent with the engine's order endpoint, which only
validates and stores; rename takes effect at turn cutoff.
Phase 14 keeps `user.games.order` for the wire path and adds a
pure projection `applyOrderOverlay(report, commands, statuses)`
in `api/game-state.ts`. Inspector, mobile sheet, and map
renderer read a derived `renderedReport` (context key
`RENDERED_REPORT_CONTEXT_KEY`) that swaps planet names in for
every applied or in-flight rename. Raw `gameState.report`
stays available for debugging / history mode.
2. **Read-back endpoint `user.games.order.get`.** Without a
server snapshot of stored orders the optimistic overlay would
not survive a cache wipe. Phase 14 adds the new authenticated
message type with a backend route
`GET /api/v1/user/games/{game_id}/orders?turn=N` (pass-through
to the engine's existing `GET /api/v1/order`). The frontend
calls it from `OrderDraftStore.hydrateFromServer` only when
the local cache row is *absent* — an explicitly empty cache
row honours the user's empty draft. The `turn` query is
required (the frontend always knows the current turn from the
lobby record).
3. **Per-command results from real engine response.** The engine
now answers `PUT /api/v1/order` with `202 Accepted` and a
populated `UserGamesOrder` body (per-command `cmdApplied`,
`cmdErrorCode`, plus an engine-assigned `updatedAt`). The
gateway parses that JSON into the extended FBS
`UserGamesOrderResponse` envelope and the frontend reads the
per-command outcome through `submitOrder`. A defensive
batch-level fallback covers an empty `commands` array.
4. **Applied commands stay in the draft.** Per the gameplay
model, the order is the player's intent surface — submitted
commands stay until the user removes them or until turn
cutoff (Phase 24 wires the auto-clear). Statuses are
runtime-only; on reload the draft re-validates as `valid` and
the overlay re-applies.
5. **Validator parity through a TS port.** `ValidateTypeName`
from `pkg/util/string.go` is mirrored in
`ui/frontend/src/lib/util/entity-name.ts`. The inspector's
inline editor disables the confirm button until the input
passes; the draft store re-runs the validator on every `add`
and exposes per-row `valid` / `invalid` to the order tab.
6. **`updatedAt` plumbing without enforcement.** Phase 14 sends
`0` on every submit (no client-side stale-order detection
yet); the engine still writes a real timestamp, the gateway
surfaces it in the FBS response, and the draft stashes it.
Future phases can wire conditional updates without a wire
change.
Artifacts (delivered):
- `pkg/schema/fbs/order.fbs` — extended `UserGamesOrderResponse`
(`game_id`, `updated_at`, `commands`); new
`UserGamesOrderGet` / `UserGamesOrderGetResponse` tables.
- `pkg/model/order/order.go` — `MessageTypeUserGamesOrderGet` and
`UserGamesOrderGet` typed payload.
- `pkg/transcoder/order.go` — `JSONToUserGamesOrder`,
`UserGamesOrderResponseToPayload`,
`UserGamesOrderGetToPayload`,
`PayloadToUserGamesOrderGet`,
`PayloadToUserGamesOrderResponse`,
`UserGamesOrderGetResponseToPayload`,
`PayloadToUserGamesOrderGetResponse`. Replaces the old
`EmptyUserGamesOrderResponsePayload` helper.
- `backend/internal/server/handlers_user_games.go` — new
`GetOrders` handler. `engineclient.GetOrder` forwards to the
engine's `GET /api/v1/order` with the player rebound.
`backend/openapi.yaml` documents the new GET operation;
`contract_test.go` extended with a `queryParamStubs` map for
required query parameters.
- `gateway/internal/backendclient/games_commands.go` — updated
`executeUserGamesOrder` (parses real engine JSON via
`JSONToUserGamesOrder`); new `executeUserGamesOrderGet` and
`projectUserGamesOrderGetResponse`.
`gateway/internal/backendclient/routes.go` registers the new
message type.
- `ui/Makefile` — `order.fbs` joins `FBS_INPUTS`; regenerated TS
bindings under `ui/frontend/src/proto/galaxy/fbs/order/`.
- `ui/frontend/src/sync/order-types.ts` — `PlanetRenameCommand`
variant added to the discriminated union.
- `ui/frontend/src/sync/submit.ts` — `submitOrder` posts the FBS
request and parses per-command verdicts.
- `ui/frontend/src/sync/order-load.ts` — `fetchOrder` issues
`user.games.order.get`.
- `ui/frontend/src/sync/order-draft.svelte.ts` — extended with
per-command `statuses`, `validate` / `markSubmitting` /
`applyResults` / `markRejected` / `revertSubmittingToValid` /
`hydrateFromServer`, and the `needsServerHydration` flag.
- `ui/frontend/src/lib/util/entity-name.ts` — TS port of
`ValidateTypeName`.
- `ui/frontend/src/api/game-state.ts` — pure
`applyOrderOverlay(report, commands, statuses)` projection
plus the `currentTurn` rune on `GameStateStore`.
- `ui/frontend/src/lib/rendered-report.svelte.ts` — derives the
overlay-applied report and exposes it through
`RENDERED_REPORT_CONTEXT_KEY`.
- `ui/frontend/src/lib/galaxy-client-context.svelte.ts` —
`GalaxyClientHolder` so command-driven UI can resolve the
per-game `GalaxyClient` via context.
- `ui/frontend/src/lib/inspectors/planet.svelte` — Rename action
+ inline editor with `validateEntityName`-driven feedback.
- `ui/frontend/src/lib/sidebar/order-tab.svelte` — per-row
status, Submit button with disabled-state matrix, refresh on
success, surfaces batch errors inline.
- `ui/frontend/src/lib/sidebar/inspector-tab.svelte` and
`ui/frontend/src/lib/active-view/map.svelte` — switched to
`renderedReport`.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` — wires the
rendered report and galaxy-client contexts; runs
`orderDraft.hydrateFromServer(...)` after the boot
`Promise.all` resolves when `needsServerHydration`.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — keys for
rename action / editor / order statuses / submit copy.
- Tests: `entity-name.test.ts`, `submit.test.ts`,
`order-load.test.ts`, `order-overlay.test.ts`,
`order-tab.test.ts`, extended `order-draft.test.ts` and
`inspector-planet.test.ts`. New Playwright spec
`tests/e2e/rename-planet.spec.ts`.
- Documentation: `docs/ARCHITECTURE.md` §9, `docs/FUNCTIONAL.md`
§6.2 (and `docs/FUNCTIONAL_ru.md` mirror), `ui/docs/order-composer.md`
with the new "Submit pipeline", "Optimistic overlay", and
"Server hydration on cache miss" sections.
Dependencies: Phases 12, 13.
Acceptance criteria:
- the user can select a planet, click `Rename`, type a new name, see
the command appear in the order tab, click `Submit`, and observe the
planet's name change everywhere within one second (overlay applies
immediately on the inspector / mobile sheet / map; server-side state
catches up at turn cutoff);
- attempting an empty or invalid name is blocked locally (Submit
button disabled, inline error message under the input);
- a server-side rejection is surfaced as `rejected` status on every
in-flight row, with the gateway's error message inline.
Targeted tests:
- Vitest unit tests for `submitOrder`, `fetchOrder`,
`applyOrderOverlay`, `validateEntityName`, and the extended
`OrderDraftStore`.
- Vitest component tests for the inline rename editor and the
Submit button states.
- Playwright e2e: rename a seeded planet, reload, confirm the new
name persists; rejected path keeps the old name.
Verified on local-ci run 11 (`success`, f80c623).
## ~~Phase 15. Inspector — Planet Production Controls~~
Status: done.
Goal: let the user switch a planet's production type to industry,
materials, research a tech field, or build a ship class; each change
appends a command to the order draft. Repeated changes for the same
planet collapse to the latest choice.
Decisions taken with the project owner during implementation:
1. **Forecast is deferred and raised as a blocker.** The plan's audit
clause discovered that `pkg/calc/` only carries the two ship-side
functions (`ShipProductionCost`, `PlanetProduceShipMass`); every
other forecast formula (industry, materials, per-tech research,
production capacity) lives inside
`game/internal/model/game/planet.go` and is not exported.
`ui/core/calc/` and `ui/docs/calc-bridge.md` did not exist at all.
Phase 15 creates `ui/docs/calc-bridge.md` documenting the gap and
waives the forecast deliverable until a dedicated future phase
builds the real Go → WASM → TS bridge. The inspector continues to
show only the existing `freeIndustry` (free production potential)
number, which is computed engine-side and ships in the report
payload.
2. **Sub-pickers expose only what the game data already supports.**
"Research" sub-row shows the four implicit tech fields
(DRIVE / WEAPONS / SHIELDS / CARGO); custom `LocalScience`
entries are deferred until the science designer phase introduces
them. "Build Ship" sub-row shows `LocalShipClass` entries; the
`GameReport` projection is extended with a minimal
`ShipClassSummary { name }` so the e2e spec can seed one ship
class and exercise the SHIP branch end-to-end. Empty
`LocalShipClass` collapses to a localised "no ship classes
designed yet" placeholder.
3. **Re-clicks always emit a command.** The collapse-by-`planetNumber`
rule keeps at most one `setProductionType` per planet in the
draft. A click that lands on the segment matching `report.production`
still emits a command; the engine accepts repeat submits
idempotently. Avoids a fragile reverse-mapping from
`report.production` display strings (`"Drive"`, ship-class name,
science name) back to the FBS enum.
4. **Inspector layout split.** `ui/frontend/src/lib/inspectors/planet/
production.svelte` is the new component; the parent
`inspectors/planet.svelte` mounts it for `kind === "local"`
planets and drops the static read-only "current production" row
on that branch (the row stays for non-local planets). The mobile
sheet (`planet-sheet.svelte`) and the sidebar
(`sidebar/inspector-tab.svelte`) both forward
`localShipClass` from the rendered-report context.
Artifacts (delivered):
- `ui/frontend/src/sync/order-types.ts` — `SetProductionTypeCommand`
variant + `ProductionType` literal union + `PRODUCTION_TYPE_VALUES`
/ `isProductionType` helpers.
- `ui/frontend/src/sync/order-draft.svelte.ts` — `validateCommand`
branch (mirrors the engine's `subject=Production` rule); `add`
enforces collapse-by-`planetNumber` for the new variant only.
- `ui/frontend/src/sync/submit.ts` — encodes
`CommandPlanetProduce` via the new `productionTypeToFBS` helper.
- `ui/frontend/src/sync/order-load.ts` — decodes
`CommandPlanetProduce` via `productionTypeFromFBS` and skips
`PlanetProduction.UNKNOWN` rows.
- `ui/frontend/src/api/game-state.ts` — `applyOrderOverlay` rewrites
`planet.production` for `setProductionType` (helper
`productionDisplayFromCommand` mirrors
`Cache.PlanetProductionDisplayName`); new `ShipClassSummary` type
and `GameReport.localShipClass` projection (decoded from
`report.localShipClass`).
- `ui/frontend/src/lib/inspectors/planet/production.svelte` — new
segmented control with Research / Build-Ship sub-rows.
- `ui/frontend/src/lib/inspectors/planet.svelte` — accepts
`localShipClass` prop, mounts `<Production />` for local planets,
drops the static production row on that branch only.
- `ui/frontend/src/lib/inspectors/planet-sheet.svelte` and
`ui/frontend/src/lib/sidebar/inspector-tab.svelte` — forward
`localShipClass` from the rendered report context.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` — derives
`localShipClass` and passes it to the mobile sheet.
- `ui/frontend/src/lib/sidebar/order-tab.svelte` — new label branch
for `setProductionType` using the new locale key.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — production-control
copy plus the new order-tab label.
- `ui/frontend/tests/e2e/fixtures/report-fbs.ts` — extended with a
`localShipClass` fixture vector.
- `ui/frontend/tests/e2e/fixtures/order-fbs.ts` — discriminated
fixture union supporting both `planetRename` and
`setProductionType` payloads.
- `ui/docs/calc-bridge.md` (new) — calc-bridge gap analysis and the
Phase 15 waiver.
- `ui/docs/order-composer.md` — updated discriminated-union
reference + new "Collapse-by-target rule" section.
- Tests: extended `order-draft.test.ts`, `submit.test.ts`,
`order-load.test.ts`, `order-overlay.test.ts`,
`game-state.test.ts`, `inspector-planet.test.ts`; new
`inspector-planet-production.test.ts` Vitest component spec; new
`tests/e2e/planet-production.spec.ts` Playwright spec.
Dependencies: Phase 14.
Acceptance criteria:
- changing production type adds exactly one `setProductionType`
command to the order draft, with the engine wire shape
(`CommandPlanetProduce` + `subject` rule for `SCIENCE` / `SHIP`);
- repeated changes for the same planet collapse to the latest choice
(no duplicate `setProductionType` commands per planet); other
variants (e.g. `planetRename`) keep their append-only behaviour;
- forecast output number is intentionally **not** rendered in this
phase (waived per decision 1; tracked in `ui/docs/calc-bridge.md`).
Targeted tests:
- Vitest unit tests for the collapse-by-`planetNumber` logic in
`OrderDraftStore.add` and the `setProductionType` branch of
`validateCommand`;
- Vitest unit tests for the FBS encoder / decoder round-trip and the
`productionDisplayFromCommand` helper;
- Vitest component tests for the segmented control's segment
emission, sub-row reveal, empty-classes placeholder, and active-
highlight derivation;
- Playwright e2e: switch production three times across all four
segments, confirm the order tab carries exactly one row at every
step, gateway records the latest choice (`SHIP` + class name),
reload preserves the row through `user.games.order.get`.
Verified on local-ci run 16 (`success`, 4273102).
## ~~Phase 16. Inspector — Cargo Routes~~
Status: done. Verified on local-ci run 17
([gitea actions](http://localhost:3000/galaxy/galaxy/actions/runs/17),
commit `7c8b5ae`).
Goal: configure up to four cargo routes per planet (colonists,
industry, materials, empty) through the inspector, with the
destination picked directly on the map. Phase 16 also lands the
generic map-pick foundation (Pass A) the inspector consumes; Phase
19/20 (ship-group dispatch) reuses the same renderer surface.
Artifacts (Pass A — renderer foundation):
- `ui/frontend/src/map/pick-mode.ts` carries the `PickModeOptions` /
`PickModeHandle` types and the pure `computePickOverlay` helper.
- `ui/frontend/src/map/render.ts` extends `RendererHandle` with
`setPickMode` / `isPickModeActive` / `getPickState`,
`onPointerMove` / `onHoverChange`, and the
`getPrimitiveAlpha(id)` debug accessor. The standard `onClick`
consumers are gated on the `pickModeActive` flag so the
destination click does not also trigger planet selection.
- `ui/frontend/src/map/hit-test.ts` widens point matching to
`(pointRadiusPx + slopPx) / camera.scale` so hover and click
zones match the visible disc; default radius shared via
`DEFAULT_POINT_RADIUS_PX = 3`.
- `ui/frontend/src/lib/map-pick.svelte.ts` defines the Svelte
`MapPickService` (promise-shaped `pick(...)` plus reactive
`active`); `lib/active-view/map.svelte` constructs the service
and binds a renderer-side resolver that resolves
`sourcePlanetNumber` against the current report.
- `ui/frontend/src/lib/debug-surface.svelte.ts` registers
`getMapPrimitives()` and `getMapPickState()` providers; the
DEV-only `__galaxyDebug` surface in
`routes/__debug/store/+page.svelte` exposes them so e2e specs
can assert the renderer's state without scraping pixels.
Artifacts (Pass B — feature):
- `ui/frontend/src/sync/order-types.ts` extends with
`CargoLoadType`, `SetCargoRouteCommand`, and
`RemoveCargoRouteCommand`. `CARGO_LOAD_TYPE_VALUES` is the
priority order (`COL`, `CAP`, `MAT`, `EMP`).
- `ui/frontend/src/sync/order-draft.svelte.ts` collapses both
variants by `(sourcePlanetNumber, loadType)`; the newer entry
supersedes any prior `set` or `remove` for the same slot.
- `ui/frontend/src/sync/submit.ts` and
`ui/frontend/src/sync/order-load.ts` round-trip the two new
variants through `CommandPlanetRouteSet` and
`CommandPlanetRouteRemove`. UNKNOWN load-type values drop with
a `console.warn`.
- `ui/frontend/src/api/game-state.ts` extends `GameReport` with
`routes: ReportRoute[]` (decoded from `report.route()` in
`CARGO_LOAD_TYPE_VALUES` order) and `localPlayerDrive: number`
(looked up via `findLocalPlayerDrive`). `applyOrderOverlay`
upserts / drops route entries for valid / submitting / applied
cargo-route commands.
- `ui/frontend/src/lib/inspectors/planet/cargo-routes.svelte` is
the four-slot subsection. `Add` / `Edit` call
`MapPickService.pick(...)`; `Remove` emits
`removeCargoRoute`.
- `ui/frontend/src/map/cargo-routes.ts` builds the `LinePrim`
arrows (shaft + two arrowhead wings) per
`(source, loadType, destination)` triple. Per-type style and
priority (`COL=8` … `EMP=5`); ids prefixed with `0x80000000`
to avoid colliding with planet numbers.
- `ui/frontend/src/map/state-binding.ts` appends
`buildCargoRouteLines(report)` to the world primitives.
- `ui/frontend/src/lib/active-view/map.svelte` adds a
routes-content fingerprint to the same-snapshot guard and
preserves camera centre + zoom across route-driven remounts
inside the same game id.
- Topic doc `ui/docs/cargo-routes-ux.md` quotes
[`rules.txt`](../game/rules.txt) (lines 808843) and maps
semantics to UI; `ui/docs/renderer.md` documents the pick-mode
contract; `ui/docs/calc-bridge.md` records the Phase 16 reach
waiver (inline TS rather than a calc bridge for one
constant-time multiplication).
Dependencies: Phase 15.
Acceptance criteria:
- the user can add, edit, and remove cargo routes through the
inspector;
- the destination picker happens on the map: out-of-reach planets
fade to `α=0.3`, the source gains an anchor ring, the cursor
draws a live line to the source, and hover over a reachable
planet outlines it. Clicks on non-reachable space are no-ops; a
click on a reachable planet emits `setCargoRoute`; Escape
cancels;
- the four route types are mutually exclusive — only one route per
type per source planet;
- configured routes are rendered as arrows on the map between
source and destination planets, distinguishable per cargo type
(placeholder colour palette; final values land in Phase 35
polish);
- the optimistic overlay surfaces draft routes immediately on the
map; the camera survives the routes-fingerprint remount so the
view does not jolt mid-edit.
Targeted tests:
- Vitest: `tests/map-hit-test.test.ts` (regenerated for the
visible-radius formula), `tests/map-pick-mode.test.ts`
(`computePickOverlay` lifecycle),
`tests/map-cargo-routes.test.ts`,
`tests/inspector-planet-cargo-routes.test.ts`,
`tests/state-binding.test.ts` extension,
`tests/order-draft.test.ts` extension,
`tests/submit.test.ts` and `tests/order-load.test.ts`
extensions, `tests/order-overlay.test.ts` extension.
- Playwright e2e `tests/e2e/cargo-routes.spec.ts`: open
inspector, trigger `Add`, assert dim state via
`__galaxyDebug.getMapPickState()`, click a reachable planet,
assert `setCargoRoute` shipped + arrow visible via
`__galaxyDebug.getMapPrimitives()`. Add a CAP route to confirm
slots coexist; Remove COL → arrow gone; reload → restored from
`user.games.order.get`.
Decisions baked into Phase 16 (vs. the original stage description):
- The destination picker is map-driven, not list-based. The
acceptance criterion "disables planets outside reach with a
tooltip" is replaced by "fades planets outside reach to
`α=0.3` and forbids picking them"; the rendered map is the
player's spatial reference, so a list duplicates information
the planet already conveys.
- Reach is computed inline in TypeScript, not via a `pkg/calc/`
Go bridge (`ui/docs/calc-bridge.md` Phase 16 waiver).
- Wrap-mode is treated as a per-game property set at map load;
the camera-preservation refactor only fires when the
routes-fingerprint changes inside the same game id.
## ~~Phase 17. Ship Classes — CRUD Without Calc~~
Status: done (local-ci run 20). Note: Phase 30 removed the standalone
designer view/route described below and folded ship-class create/view
into the sidebar calculator; the table's row-activate and "new" button
now open the calculator. The table itself and the validator are
unchanged.
Goal: list, view, create, and delete ship classes through a
dedicated table view and a designer view; numeric calculations are
stubbed pending Phase 18.
Per `game/rules.txt`, ship classes are designed once and cannot be
modified after creation — values are baked into existing ships at
build time. The future "upgrade" command (Phase 19/20,
`CommandShipGroupUpgrade`) raises an existing ship group's tech
levels but does not edit the class blueprint. Phase 17 therefore
exposes only Create and Delete; an "edit" affordance is
deliberately absent and the designer renders an existing class
read-only.
Artifacts:
- `ui/frontend/src/lib/active-view/table-ship-classes.svelte`
table of ship classes with sort and filter, plus per-row Delete
affordance (the existing `routes/games/[id]/table/[entity]/+page.svelte`
already wires this active view through the `[entity]` parameter,
so no new route file lands).
- `ui/frontend/src/lib/active-view/designer-ship-class.svelte`
rewritten from the Phase 10 stub: empty form for the Create flow
(name plus the five fields Drive, Armament, Weapons, Shields,
Cargo) and read-only view + Delete affordance for an existing
class. Validation rules from [`rules.txt`](../game/rules.txt) live
in `lib/util/ship-class-validation.ts` (TS port of
`pkg/calc/validator.go.ValidateShipTypeValues`): each of drive /
weapons / shields / cargo is 0 or ≥ 1; armament is a non-negative
integer; armament and weapons are both zero or both nonzero;
not all five values may be zero. The existing
`routes/games/[id]/designer/ship-class/[[classId]]/+page.svelte`
is already wired and consumes the optional `classId` URL segment
through `page.params`.
- `ui/frontend/src/sync/order-types.ts` extends with
`CreateShipClassCommand` and `RemoveShipClassCommand` variants
(mapped to `CommandShipClassCreate` and `CommandShipClassRemove`
on the wire by `sync/submit.ts` and `sync/order-load.ts`).
- `ui/frontend/src/api/game-state.ts` widens `ShipClassSummary`
to carry the full attribute set; `applyOrderOverlay` projects
pending Save / Delete actions onto `localShipClass` so the table
reflects the player's intent before auto-sync lands.
Dependencies: Phase 14.
Acceptance criteria:
- the user can create, list, view, and delete ship classes;
- field validation matches [`rules.txt`](../game/rules.txt)
constraints with disabled Submit + tooltip when invalid;
- double-tapping a row in the ship-classes table opens its
designer (read-only view of the existing class);
- pending Save / Delete actions surface in the order tab and
reflect on the table immediately through the rendered-report
overlay, before the auto-sync round-trip completes.
Targeted tests:
- Vitest component tests for designer field validation
(`tests/designer-ship-class.test.ts`) and the table
(`tests/table-ship-classes.test.ts`); Vitest unit tests for the
validator (`tests/ship-class-validation.test.ts`);
- Playwright e2e (`tests/e2e/ship-classes.spec.ts`): create a
class, list it, delete it; rejected-submit kept; field-validation
kept (Save disabled with localised tooltip).
## ~~Phase 18. Ship Classes — Calc Bridge~~
Status: done. Note: the live mass/speed/range preview built here moved
into the Phase 30 calculator when the standalone designer was removed;
the `ui/core/calc` ship bridge wrapped here is reused unchanged and
extended by Phase 30 (combat, planet build, solvers).
Goal: wire `pkg/calc/` ship math into the designer for live mass,
speed, range, and cargo capacity previews.
Artifacts:
- `ui/core/calc/ship.go` thin Go bridge wrapping seven functions
from `pkg/calc/ship.go` — `DriveEffective`, `EmptyMass`,
`WeaponsBlockMass`, `FullMass`, `Speed`, `CargoCapacity`,
`CarryingMass` — each as a one-line passthrough; the seventh
function (`CarryingMass`) was added during stage implementation
to let the preview compose `full-load mass` from `CargoCapacity`
without injecting math into TS;
- `ui/wasm/main.go` registers the seven wrappers under
`globalThis.galaxyCore`; `ui/frontend/src/platform/core/index.ts`
extends `Core` with the matching typed methods (`emptyMass` and
`weaponsBlockMass` return `number | null`, mirroring the Go
`(_, false)` validator path);
- `ui/frontend/src/api/game-state.ts` extends `GameReport` with
`localPlayerWeapons`, `localPlayerShields`, `localPlayerCargo`
alongside the existing `localPlayerDrive`. The decoder reads
all four from the `Player` row in the report's player block.
Phases 19-21 reuse these fields without re-extending the report;
- `ui/frontend/src/lib/core-context.svelte.ts` exposes a
`CoreHolder` through `CORE_CONTEXT_KEY`. The in-game layout
(`routes/games/[id]/+layout.svelte`) populates the holder after
`loadCore()` resolves, so the designer reads `Core` from context
without re-booting WASM;
- live-updating preview pane in the ship-class designer showing
empty mass, full-load mass, max speed (at empty), range at full
load, and cargo capacity per ship at the player's current tech
levels; the pane only renders when the form passes validation
*and* `Core` is ready;
- audit step recorded in `ui/docs/calc-bridge.md`: live surface
table lists every wired function against its `pkg/calc/` source.
Dependencies: Phases 5 (Core skeleton), 17.
Acceptance criteria:
- changing any field in the designer updates the preview within one
frame on a mid-range laptop;
- preview values are byte-for-byte identical to direct `pkg/calc/`
calls on shared fixtures;
- the bridge contains zero math beyond marshalling adapters.
Targeted tests:
- Go parity tests in `ui/core/calc/ship_test.go` against `pkg/calc/`
outputs on shared fixtures, plus a composition test that exercises
the exact preview pipeline (empty → cargo capacity → carrying mass
→ full-load mass → speed at empty + at full);
- Vitest coverage in `ui/frontend/tests/designer-ship-class.test.ts`
asserts preview hidden until validation passes, hidden when no
`Core` is available, renders five rows with computed values once
the form is valid, and reactively refreshes on subsequent edits.
## ~~Phase 19. Inspector — Ship Group (Read-Only)~~
Status: done (local-ci run 24).
Goal: render ship groups on the map and display group details in the
inspector when a group is selected; read-only, no actions yet.
Artifacts:
- `ui/frontend/src/map/ship-groups.ts` renders ship groups on the map:
own and visible foreign groups stationed on planets, groups in
hyperspace at their current coordinates, and incoming groups with a
distinct visual style and an ETA label
- `ui/frontend/src/map/state-binding.ts` extends to feed groups into
the renderer alongside planets
- `ui/frontend/src/lib/inspectors/ship-group.svelte` read-only display
of group fields: class, count, tech levels of components, location
(planet or hyperspace coordinates), cargo type and amount, fleet
membership
- map click handler that selects a group and switches sidebar to
Inspector (or raises bottom-sheet on mobile)
- selection store extended to support `ShipGroup` selections
Dependencies: Phases 11, 13.
Acceptance criteria:
- own and visible foreign ship groups render on the map for a seeded
game in both torus and no-wrap modes;
- incoming groups are visually distinct and show ETA;
- clicking any rendered group shows its details in the inspector;
- groups in hyperspace show coordinates and remaining distance in the
inspector;
- cargo type and amount display when applicable.
Targeted tests:
- Vitest unit tests for the rendering of each group variant
(on-planet, in-hyperspace, incoming);
- Vitest component tests for the ship-group inspector with fixture
data covering planet-based, hyperspace, and cargo-loaded variants;
- Playwright e2e: click each variant from a seeded game, assert all
expected fields render.
## ~~Phase 20. Inspector — Ship Group Actions~~
Status: done (local-ci run 28).
Goal: enable group operations from the inspector: split, send, load,
unload, modernize, dismantle, transfer to race, add to fleet.
Artifacts:
- action panel `ui/frontend/src/lib/inspectors/ship-group/actions.svelte`
mounted by the read-only inspector for the local variant; eight
inline forms (one per action) with disabled-button tooltips that
mirror the engine's pre-conditions
(`controller/ship_group*.go`)
- `ui/frontend/src/sync/order-types.ts` extends with eight new
command variants — `breakShipGroup`, `sendShipGroup`,
`loadShipGroup`, `unloadShipGroup`, `upgradeShipGroup`,
`dismantleShipGroup`, `transferShipGroup`, `joinFleetShipGroup` —
plus `ShipGroupCargo` and `ShipGroupUpgradeTech` literal types
- `sync/submit.ts` and `sync/order-load.ts` round-trip every new
variant against the existing FBS classes in
`proto/galaxy/fbs/order/`; the `id` field on each ship-group
payload carries the *target* group UUID (the source group, or
the freshly-minted `newGroupId` when an implicit split precedes
the action)
- `Send` action drops the inspector straight into map-pick mode
on click and only mounts the form (ship count + confirm) after
the player chooses a destination — there is no destination
control inside the form. The picker is filtered by the group's
reach (`localPlayerDrive * 40`, computed inline via the existing
`torusShortestDelta` from `cargo-routes.svelte`); the player's
tech levels are already on `GameReport.localPlayer*` from
Phase 18, no extra plumbing needed
- `Modernize` cost preview through `core.blockUpgradeCost`
(Phase 20 bridge), summed over the four ship-class blocks for
the targeted ship count; preview hides when `Core` is not yet
booted or the form is invalid (see
`ui/docs/ship-group-actions.md` for the formula breakdown)
- two-step inline confirmation for `Dismantle` over a foreign
planet with colonists onboard (engine reference
`controller/ship_group.go:177-179` — `UnloadColonists` is not
called over a foreign planet, so the cargo is lost)
- state-changing-command lock: a `Send` / `Modernize` /
`Dismantle` / `Transfer` order in the draft for a given group
disables every action button on that group's inspector and
surfaces a banner pointing to the order list. Cancelling the
queued command in the order tab releases the lock. Load /
Unload / Split / JoinFleet do not lock — they stack legitimately
on the engine side. Send used to be unlocked too, but a queued
Send is the visible commitment to launch this orbit, so the
inspector treats it the same as the three destructive variants
- `pkg/calc/ship.go.BlockUpgradeCost` (migrated from
`game/internal/controller/ship_group_upgrade.go`) — the bridge
rule says `ui/core/calc/` only wraps `pkg/calc/` formulas, so
the function moved upstream and the controller now imports it
- `GameReport.otherRaces: string[]` populated by the report
decoder from `report.player[]` (non-extinct, ≠ self) — used by
the transfer-to-race picker; Phase 22's Races View reuses the
same field
- planet inspector's stationed-ship rows
(`lib/inspectors/planet/ship-groups.svelte`) become clickable
for own groups, pivoting the `SelectionStore` to the matching
`shipGroup.local` ref so the actions panel is reachable from
the standard click flow (the map deliberately hides on-planet
groups, so this is the on-planet entry point)
- topic doc `ui/docs/ship-group-actions.md` covers the action
surface, disabled-state rules, implicit-split pattern, and the
modernize cost preview formula
Dependencies: Phases 18, 19.
Acceptance criteria:
- every action either adds the corresponding command to the order draft
or is disabled with a tooltip explaining why;
- splitting a group of N into K and N-K results in two valid commands
(the implicit split + the action);
- destructive actions surface explicit confirmation dialogs;
- end-to-end execution: send a group, submit order, observe arrival
next turn.
Targeted tests:
- `pkg/calc/ship_test.go.TestBlockUpgradeCost` — formula coverage
on the migrated function;
- `ui/core/calc/ship_test.go.TestBlockUpgradeCostParity` — bridge
parity against `pkg/calc/`;
- Vitest:
- `tests/inspector-ship-group-actions.test.ts` — disabled-state
rules per action and the implicit-split pattern;
- `tests/inspector-ship-group-dismantle-confirm.test.ts` —
two-step confirm over foreign-COL groups;
- `tests/inspector-ship-group-modernize-cost.test.ts` —
preview formula matches `BlockUpgradeCost` × ship count and
hides when `Core` is null;
- `tests/sync-order-types-ship-group.test.ts` —
`validateCommand` for each new variant;
- `tests/sync-submit-ship-group.test.ts` — encoder/decoder
round-trip per new variant;
- Playwright `tests/e2e/ship-group-send.spec.ts` — synthetic
report with a 3-ship group on Earth and a reachable Mars,
drives the planet inspector → ship-group inspector pivot, then
Send 2 of 3 with map-pick destination, asserts both Break and
Send land in the order draft via the order tab.
Decisions during stage:
1. **`BlockUpgradeCost` migration**. The pre-existing copy in
`game/internal/controller/ship_group_upgrade.go` moved to
`pkg/calc/ship.go`; the controller's `GroupUpgradeCost` and
`UpgradeGroupPreference` now call `calc.BlockUpgradeCost`.
The unit test moved from `controller/ship_group_upgrade_test.go`
to `pkg/calc/ship_test.go`.
2. **`GameReport.otherRaces`** field added to
`ui/frontend/src/api/game-state.ts`; the synthetic-report
decoder populates it the same way (`api/synthetic-report.ts`).
Phase 22's Races View can read this directly without a fresh
plumbing pass — the Phase 22 stage text below is updated to
reflect that.
3. **Stationed-ship rows are clickable**. The Phase 19 stationed-
ship subsection on the planet inspector becomes interactive
for own groups (Phase 21+ table view stays a separate target).
The map renderer continues to hide on-planet groups — this is
the cheaper navigational fix.
4. **Inline forms, no modal**. Every action opens an inline
editor under the buttons row, matching the Phase 14 rename and
Phase 16 cargo-route patterns. Send reuses
`MAP_PICK_CONTEXT_KEY` (Phase 16's renderer service) for the
destination picker. Foreign-COL Dismantle uses a two-step
inline confirm (button label flips to "confirm — colonists
die") rather than a separate modal component.
5. **Implicit split for Send/Load/Unload/Modernize/Dismantle/
Transfer**. The number-of-ships input defaults to the group's
full count; when the player picks a smaller M, the inspector
prepends `breakShipGroup(id, newId, M)` and routes the action
at `newId`. JoinFleet and Split do not get a counter (JoinFleet
is whole-group atomically per the engine; Split *is* the break
command).
6. **Send is pick-first, form-second**. Click → enter map-pick
mode immediately. The form (ship count + confirm) only appears
after a destination is chosen; cancelling the picker leaves no
form behind. Removing the destination control from the form
keeps the surface to one editable field at any time.
7. **State-changing-command lock**. Any `sendShipGroup`,
`upgradeShipGroup`, `dismantleShipGroup`, or `transferShipGroup`
in the draft for a given group id disables every action button
on that group's inspector with a "command pending" tooltip and
renders a banner pointing the player at the order list.
Cancellation from the order tab releases the lock. All four
commands flip the group out of `StateInOrbit` at turn cutoff
(`StateLaunched` / `StateUpgrade` / removal / `StateTransfer`),
so any second action would race the engine's pre-condition
check anyway — the lock surfaces that commitment up-front.
8. **Pending-Send map overlay**. A queued `sendShipGroup` for an
own group still in orbit renders as a green dashed line from
the orbit planet to the destination, drawn on the same
overlay layer as cargo-route arrows. The line is wrap-aware
(uses `torusShortestDelta`) and skipped when the engine has
marked the command `rejected` or `invalid`. Removed when the
group leaves orbit (Send applied) or the player cancels the
command from the order tab. Implemented in
`ui/frontend/src/map/pending-send-routes.ts`; the overlay
fingerprint in `lib/active-view/map.svelte` is extended so the
renderer's `setExtraPrimitives` re-runs on draft changes.
9. **Yellow dashed track for own in-space groups**. The map
already drew the in-space group point in yellow (`0xfff176`);
Phase 20 adds the matching yellow dashed line from the origin
planet to the destination so the player reads "this group is
moving" even when zoomed out. Wrap-aware via the same torus
delta. Implemented in `ui/frontend/src/map/ship-groups.ts`
alongside the existing in-space point primitive.
## ~~Phase 21. Sciences — CRUD List + Designer~~
Status: done (local-ci run 30).
Goal: define and manage sciences (named mixes of tech proportions
summing to 1.0) through a table view and a designer, plus surface
them in the planet production picker.
Artifacts:
- `ui/frontend/src/lib/active-view/table-sciences.svelte` — sciences
list with sort / filter / Delete, mounted by the existing
`routes/games/[id]/table/[entity]` catch-all when `entity ===
"sciences"`.
- `ui/frontend/src/lib/active-view/designer-science.svelte` —
designer with four percent inputs (`step="0.1"`, range
`[0, 100]`), live sum readout, strict sum-equals-100 gate, and a
read-only view mode for the existing
`routes/games/[id]/designer/science/[[scienceId]]` route.
- `ui/frontend/src/sync/order-types.ts` extends with `CreateScience`
and `RemoveScience` command variants (the original plan mentioned
`UpdateScience`; the wire only carries Create + Remove, so the
decision below replaces Update with Remove).
- `ui/frontend/src/lib/util/science-validation.ts` — the TS-side
mirror of `pkg/calc/validator.go.ValidateScienceValues` plus the
entity-name rules and the percent → fraction conversion.
- `ui/frontend/src/api/game-state.ts` — adds `ScienceSummary`,
`localScience` on `GameReport`, decoder, and overlay branches for
`createScience` / `removeScience`.
- `ui/frontend/src/lib/inspectors/planet/production.svelte` — the
Research sub-row gains one button per defined science; click
emits `setProductionType("SCIENCE", "<name>")`.
- topic doc `ui/docs/science-designer-ux.md` covering the percent
input model, validation, and the planet-production-picker
integration.
Dependencies: Phase 17.
Decisions during stage:
1. `UpdateScience` was a planning error: the wire schema
(`pkg/schema/fbs/order.fbs`) only carries
`CommandScienceCreate` + `CommandScienceRemove`. Sciences are
write-once on the wire — the designer's view mode therefore has
no Save-edits affordance, and an "edit" is a Remove + Create
sequence the player drives manually. Mirrors Phase 17's
ship-class pattern.
2. The production-picker integration places science buttons inside
the existing Research sub-row, alongside the four tech buttons,
instead of adding a fifth top-level segment. A science wins
over a same-named tech display when the engine sends an
ambiguous production string (a science named `Drive` shadows
the Drive tech button).
3. Designer inputs are percentages (`step="0.1"`, `[0, 100]`) with
a strict sum-equals-100 gate (`SUM_EPSILON_PERCENT = 1e-3`),
not auto-rebalanced fractions. The user controls the sum; the
designer converts to fractions only on Save before dispatching
`createScience`.
Acceptance criteria:
- the user can create and delete sciences (no in-place edit — see
decision 1);
- proportions are entered as one-decimal percentages and the four
must sum to exactly `100` for Save to enable;
- the planet production picker (Phase 15) lists the user's sciences
in the Research sub-row and lets the user select one for research
production;
- name validation matches [`rules.txt`](../game/rules.txt)
constraints (length, allowed characters, special characters not
at start/end, no triple repeats).
Targeted tests:
- Vitest unit tests for percent-range validation, sum-equals-100
gate, and percent → fraction conversion
(`tests/science-validation.test.ts`);
- Vitest component tests for the table
(`tests/table-sciences.test.ts`) and the designer
(`tests/designer-science.test.ts`);
- Playwright e2e: create a science, set a planet's production to it
via the Research sub-row, delete it
(`tests/e2e/sciences.spec.ts`).
## ~~Phase 22. Races View — War/Peace Toggle and Votes~~
Status: done.
Goal: list other races with their visible stats, expose the war/peace
toggle, and the voting UI.
Artifacts:
- `ui/frontend/src/lib/active-view/table-races.svelte` table mounted
by the dispatcher in
`ui/frontend/src/lib/active-view/table.svelte` (same pattern as
Phase 21's sciences table). One row per non-extinct other race
carrying name, tech levels (drive / weapons / shields / cargo as
percent), total population, total production (engine `industry`),
planet count, votes received, and the local player's stance
toward that race. The richer per-race projection
(`GameReport.races: ReportOtherRace[]`) is decoded in
`ui/frontend/src/api/game-state.ts` by walking `report.player[]`
once and surfacing the row alongside the existing `otherRaces:
string[]` (which keeps backing the ship-group transfer picker from
Phase 20)
- per-row segmented `WAR | PEACE` control. The active stance is
highlighted (`aria-pressed=true` + contrast colour); the inactive
button queues `setDiplomaticStance` (engine `CommandRaceRelation`).
The displayed stance is the local player's relation toward the
named race (`rules.txt` "(R) Ваше отношение к указанной расе, но
не наоборот") — not the other way round
- voting control: a single `<select>` populated with `races[].name`,
changing it queues `setVoteRecipient` (engine `CommandRaceVote`).
Disabled when the local player is the only non-extinct race. A
read-only `myVotes` total renders next to the picker
- explanatory note in the page header: alliance grouping and the 2/3
victory check are tallied on the server at turn cutoff and are
NOT projected on the client. The report carries each race's votes
received (`Player.votes`) and the local player's outgoing vote
(`Report.vote_for`), but foreign races' outgoing votes are
intentionally private, so a client-side vote graph would be
partial. The acceptance criterion "vote counts match server state
byte-for-byte" forbids a local recomputation
Cross-stack notes:
- No backend / wire changes. `CommandRaceRelation`,
`CommandRaceVote`, `Player.relation`, `Player.votes`,
`Report.votes`, and `Report.vote_for` already carry every datum
this stage needs
- TS draft store
(`ui/frontend/src/sync/order-draft.svelte.ts`) gains two collapse
rules: `setDiplomaticStance` collapses by `acceptor` (one stance
intent per opponent); `setVoteRecipient` collapses singleton (a
single outgoing vote slot per `rules.txt:1066`)
- The optimistic overlay (`applyOrderOverlay`) flips
`races[i].relation` and `myVoteFor` immediately so the controls
reflect the queued intent without waiting for the auto-sync
round-trip. `votesReceived`, `myVotes`, and the alliance state
stay server-authoritative
Dependencies: Phase 14.
Acceptance criteria:
- the user can toggle war / peace and change vote recipient;
- the per-row stance and the "I vote for" picker reflect the
queued intent immediately (optimistic overlay) and resolve to
`applied` in the sidebar order tab after the auto-sync round-trip;
- vote counts match server state byte-for-byte (no client tally).
Targeted tests:
- Vitest component test
(`ui/frontend/tests/table-races.test.ts`) covering: render rows
from a canonical fixture, filter, sort flip, stance click +
collapse-by-acceptor, vote pick + singleton collapse, empty state;
- Playwright e2e (`ui/frontend/tests/e2e/races.spec.ts`): open the
races table, toggle one row's stance, change the vote recipient,
observe both commands as `applied` in the sidebar order tab and
verify the decoded gateway payload.
## ~~Phase 23. Reports View — Current Turn Sections~~
Status: done (local-ci run 2).
Goal: present every section of the current turn's report as readable
panels, mirroring the structure documented in [`rules.txt`](../game/rules.txt) and
`docs/FUNCTIONAL.md` §6.4.
Artifacts:
- `ui/frontend/src/routes/games/[id]/report/+page.svelte` scrollable
layout with one section per report category (galaxy summary, votes,
player status, my sciences, foreign sciences, my ship classes,
foreign ship classes, battles, bombings, approaching groups, my
planets, ships in production, cargo routes, foreign planets,
uninhabited planets, unknown planets, my fleets, my ship groups,
foreign ship groups, unidentified groups)
- per-section anchor navigation in a sticky sidebar for quick jumping
- a `back to map` action visible at all times
Dependencies: Phase 11.
Acceptance criteria:
- every report section renders for a seeded game with non-empty data;
- empty sections render explicit empty-state copy;
- scroll position resets when switching to another view and is
restored on return.
Targeted tests:
- Vitest component tests for one representative section per data shape
(table, list, sub-table);
- Playwright e2e: open the report, scroll to each section via anchor
navigation, assert content present.
Decisions during stage:
1. **Component decomposition.** The orchestrator
`lib/active-view/report.svelte` is one file; each of the twenty
sections is its own component under
`lib/active-view/report/section-<slug>.svelte`. Six distinct data
shapes (kv-list, races-style grid, planets-style grid, sub-table-
per-race, raw UUID list, fleet/group grids) sit too unevenly in one
monolith; per-section components also map directly onto the Vitest
targeted-test seam. No shared `<Section>` abstraction was extracted
— CLAUDE.md "wait for the third real caller" still holds with one
shape per section. Shared formatters live in `report/format.ts`.
2. **`races` vs `players`.** A parallel
`GameReport.players: ReportPlayer[]` was added (full roster, self
row included, extinct rows kept with `extinct: true`). The Phase 22
`races[]` (non-extinct, self excluded) stays untouched so no Phase
22 surface had to change. Extinct races are shown in Player Status
with a `RIP` marker; the orchestrator highlights the local row.
3. **Scroll save / restore.** Wired through SvelteKit's `Snapshot`
API on `routes/games/[id]/report/+page.svelte`. Captures
`window.scrollY` (the in-game shell layout expands its
`active-view-host` to fit content, so the document body is the real
scroll container) and restores via a `requestAnimationFrame` poll
that waits for `documentElement.scrollHeight` to catch up before
calling `window.scrollTo`. The earlier plan to track the host's
`scrollTop` did not survive contact with the layout's
no-explicit-height contract; the change is contained to the route
file. No new context plumbing was introduced.
4. **Active-section highlight.** `IntersectionObserver` rooted on the
viewport (`root: null`) with `rootMargin: "-30% 0px -60% 0px"`
tracks which section sits in the upper third of the visible area
and updates the TOC. Cheaper than a scroll handler and degrades
gracefully where IO is not available.
5. **Mobile TOC.** A sticky `<select>` at the top of the report body
replaces the desktop anchor sidebar on viewports below 768 px. No
new overlay primitive is introduced; the existing layout-owned
bottom-tab bar stays unobstructed. Picking an option scrolls the
chosen section into view.
6. **Battles section.** Battle UUIDs render as inactive monospace
`<span>` rows until Phase 27 lights up `/games/:id/battle/:battleId`.
The earlier plan to link them now was reverted: a dead link is a
worse experience than a plain identifier, and the rewire when
Phase 27 lands is one line.
7. **Foreign sciences / ship classes layout.** One sub-table per race
with a `{race} sciences` / `{race} ship classes` sub-header. The
`(race, name)` decoder sort produces stable groups; cross-race
sorting is intentionally avoided (it would be semantically
meaningless across races).
8. **Bombings wiped state.** Wiped rows get a `.wiped` CSS class plus
a dedicated `report-bombing-wiped-badge` element so the boolean is
visually explicit and easy to assert in e2e.
9. **Ships in production `prodUsed` derivation (Go side).** The legacy
text reports do not carry the engine's per-turn `ProdUsed` field —
only `Cost`, `Percent`, `Free`. The legacy parser derives an
approximation as `ShipBuildCost(shipMass, material, resources) * percent`
using a new shared helper `pkg/calc.ShipBuildCost`. The engine's
`controller.ProduceShip` was refactored to call the same helper
(behavior-preserving — engine tests stay unchanged and pass). The
approximation is documented in
`tools/local-dev/legacy-report/README.md`; live engine reports come
over FBS and never flow through this parser.
10. **Legacy parser scope.** Per user direction, the parser was
extended to populate `LocalScience`, `OtherScience`,
`OthersShipClass`, `Bombing`, and `ShipProduction` from their
legacy text sections. Battles stay in the parser's Skipped list:
the legacy text carries per-battle rosters with no stable UUID,
and synthesising IDs would invent data Phase 27 would have to
drop. `OtherGroup[]`, `UnidentifiedGroup[]`, and cargo routes
remain skipped (no legacy section).
11. **i18n namespace.** All Phase 23 strings live under
`game.report.section.<slug>.*`; the duplicate-looking entries
(sciences / ship classes columns) are deliberately separate from
`game.table.*` so the two surfaces evolve independently. ≈90 new
keys, en + ru in lockstep.
## ~~Phase 24. Push Events — Turn-Ready~~
Status: done.
Goal: subscribe to the server push stream and refresh client state
when a turn-ready event arrives.
Artifacts (delivered):
- `ui/frontend/src/api/events.svelte.ts` — single
`SubscribeEvents` consumer per session. Absorbs the previous
`revocation-watcher.ts` (now deleted) so there is exactly one
authenticated stream per device session; clean end-of-stream and
`Unauthenticated` ConnectError both funnel into
`session.signOut("revoked")`. Exposes a `connectionStatus` rune
for the future header indicator.
- `ui/frontend/src/lib/toast.svelte.ts` and `toast-host.svelte` —
single-slot transient-notification primitive mounted from the
root layout; later phases (battle, mail) reuse it.
- `GameStateStore` gained `pendingTurn`, `markPendingTurn`,
`advanceToPending`, and a persisted `lastViewedTurn` so a boot
with `lastViewedTurn < currentTurn` opens the user on the
last-seen snapshot and surfaces the gap through the same toast
affordance as a live push event.
- Backend producer: `lobby.Service.OnRuntimeSnapshot` emits
`game.turn.ready` on every `current_turn` advance, addressed to
every active membership, idempotency key
`turn-ready:<game_id>:<turn>`, payload `{game_id, turn}`.
Catalog routes it through the push channel only.
- Mandatory event-signature verification through `ui/core`:
`core.verifyPayloadHash` + `core.verifyEvent` on every frame.
Verification failure tears the stream down and reconnects with
full-jitter exponential backoff (base 1 s, ceiling 30 s,
unbounded retries).
- Topic doc: `ui/docs/events.md`.
Dependencies: Phases 23, 4 (Connect streaming in gateway).
Decisions baked back in (this stage):
- **Minimum traffic on `game.turn.ready`.** The event flips
`gameState.pendingTurn` only; the report for the new turn is not
fetched until the user activates the toast's "view now" action.
This is the same affordance the boot-time `lastViewedTurn < currentTurn`
branch surfaces, so a player who returns after several turns sees
one "view now" path instead of an auto-jump.
- **Revocation-watcher folded into `events.svelte.ts`.** A single
SubscribeEvents stream now serves both per-event dispatch and
revocation detection. Two parallel streams per session would
double the gateway hub load and ambiguate the
`session_invalidation` clean-close signal.
- **Integration test scope.** Backend producer is covered by
`lobby/runtime_hooks_test.go` (testcontainers); UI consumer by
`tests/events.test.ts` and the Playwright e2e in
`tests/e2e/turn-ready.spec.ts`. A dedicated
`integration/turn_ready_flow_test.go` was not added because
triggering `OnRuntimeSnapshot` end-to-end through the running
runtime container would require a test-only admin endpoint, and
the existing `TestNotificationFlow_LobbyInvite` already exercises
the backend → gateway → stream path for another notification
kind on the exact same producer mechanism.
Acceptance criteria (met):
- a server-side turn cutoff produces a toast within one second
(Phase 24's stream propagation; the producer side ships with the
backend changes above);
- activating the toast refreshes the active view to the new turn's
data without a full page reload
(`gameState.advanceToPending` → fresh `lobby.my.games.list` +
`user.games.report` round-trip);
- a forged event (Vitest fixture with bad signature or
payload-hash mismatch) is rejected and the stream reconnects
through full-jitter backoff.
Targeted tests (delivered):
- Vitest: `tests/events.test.ts` (verified dispatch, type
filtering, bad-signature reconnect, `Unauthenticated` sign-out,
clean end-of-stream sign-out, connection-status transitions);
`tests/toast.test.ts`; extensions in `tests/game-state.test.ts`
for `pendingTurn` / `lastViewedTurn` / `advanceToPending`.
- Backend: `internal/notification/catalog_test.go` (kind +
channels); `internal/lobby/runtime_hooks_test.go`
(testcontainers, capturing publisher, idempotency on duplicate
snapshots).
- Playwright: `tests/e2e/turn-ready.spec.ts` (signed
`game.turn.ready` frame surfaces the toast, manual dismiss
clears it).
## ~~Phase 25. Sync Protocol — Turn Cutoff, Conflict, Auto-Pause~~
Status: done.
Goal: make the order draft survive transient connectivity issues
**and** the real turn-cutoff machinery, with explicit user feedback
on conflicts and on admin-pause states. The phase is intentionally
cross-module: the UI side leans on a backend turn-cutoff guard and
auto-pause that did not exist before; both land together so the
contract is end-to-end.
Decisions baked in during implementation:
- Turn-cutoff enforcement lives in `backend` (not in `game-engine`).
The scheduler flips `runtime_status` to `generation_in_progress`
before each engine tick and back to `running` after; the
user-games handlers reject every command/order in
non-running runtime states.
- A failed engine tick auto-pauses the game (`running → paused`)
through `lobby.OnRuntimeSnapshot`, and the lobby publishes a
matching `game.paused` push event. Admin resume remains the
only way out of `paused`.
- The wire-level error codes are `turn_already_closed` (cutoff
conflict) and `game_paused` (paused / starting / finished / removed).
Gateway carries them through `projectUserBackendError` unchanged.
- The UI draft store delegates to a new `OrderQueue` (single-slot
pending, single retry on reconnect via `onOnline` callback). On
`game.turn.ready` after a conflict / pause, the layout calls
`OrderDraftStore.resetForNewTurn` which wipes the draft and
re-hydrates from the server for the new turn (old commands are
preserved server-side and can be read back via
`user.games.order.get?turn=N`).
Backend artifacts:
- `backend/internal/notification/catalog.go`: new
`KindGamePaused = "game.paused"` and `catalog`/`SupportedKinds`
entries; matching `NotificationGamePaused` constant in
`backend/internal/lobby/lobby.go`; CHECK-constraint widened in
`backend/internal/postgres/migrations/00001_init.sql`.
- `backend/internal/lobby/runtime_hooks.go`:
`nextStatusFromSnapshot` flips `running → paused` on
`engine_unreachable` / `generation_failed`; new
`publishGamePaused` mirrors `publishTurnReady`, idempotency key
`paused:<game_id>:<turn>`, payload `{game_id, turn, reason}`.
- `backend/internal/runtime/scheduler.go`: `tick` wraps the engine
call with `generation_in_progress` / `running` flips and forwards
failure snapshots to lobby through
`Service.publishFailureSnapshot`.
- `backend/internal/runtime/service.go`: `CheckOrdersAccept` plus
the pure `OrdersAcceptStatus` helper used by both `Orders` and
`Commands` user-games handlers.
- `backend/internal/server/httperr/httperr.go`: new
`CodeTurnAlreadyClosed`, `CodeGamePaused`; openapi.yaml
`ErrorBody.code` enum extended.
- `backend/internal/server/handlers_user_games.go`:
`requireOrdersOpen` runs before forwarding, maps sentinels to
HTTP 409 + the matching code.
UI artifacts:
- `ui/frontend/src/sync/order-queue.svelte.ts` (new) — `OrderQueue`
class with offline detection, classification of
`turn_already_closed` / `game_paused`, dependency-injected
online probe + event listeners. Pure-function helper
`classifyResult` reused from tests.
- `ui/frontend/src/sync/order-types.ts` — `CommandStatus` gains
`conflict`.
- `ui/frontend/src/sync/order-draft.svelte.ts` — wires
`OrderQueue` through `runSync`, adds `conflict` / `paused` /
`offline` to `SyncStatus`, plus `conflictBanner` /
`pausedBanner` runes, `markPaused`, `resetForNewTurn`,
`clearConflictForMutation`, sticky-`paused` guard in
`hydrateFromServer`. `bindClient(client, { getCurrentTurn })`
lets the conflict banner interpolate the turn number.
- `ui/frontend/src/lib/sidebar/order-tab.svelte` — renders
conflict / paused banners and the new `conflict` per-row badge;
status bar carries the offline / conflict / paused copy.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new keys for
`sync.{offline,conflict,paused}`, `conflict.banner`
(with `{turn}` interpolation) plus `banner_no_turn` fallback,
`paused.banner`, `status.conflict`.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
subscribes to `game.paused`; `game.turn.ready` handler now
triggers `resetForNewTurn` when the prior `syncStatus` was
`conflict` / `paused`. `bindClient` is invoked with
`getCurrentTurn: () => gameState.currentTurn`.
- `ui/docs/sync-protocol.md` (new) — send-loop semantics, retry
budget, conflict and paused UX, recovery paths.
- `ui/docs/order-composer.md` — stale Phase 25 paragraph
replaced with a pointer to the new topic doc; state-machine
diagram extended with the `conflict` transition.
Dependencies: Phases 14, 24; backend notification / lobby /
runtime modules.
Acceptance criteria:
- submitting an order while offline queues it and submits
successfully on reconnect (one attempt on the next `online`
event, no inline retry storm);
- a turn cutoff between draft and submit produces a visible
conflict banner with the turn number; the local draft is
preserved until the next `game.turn.ready`, then the layout
wipes it and re-hydrates from the server for `turn = N+1`;
- a runtime failure during generation flips the game into
`paused`, emits `game.paused`, and the order tab shows the
pause banner; submits are blocked until the next
`game.turn.ready` clears the state;
- the order tab clearly distinguishes `draft`, `valid`,
`invalid`, `submitting`, `applied`, `rejected`, and
`conflict` states per command.
Targeted tests:
- Backend: `runtime_hooks_unit_test.go` for
`nextStatusFromSnapshot`, `orders_accept_test.go` for the
per-record decision, plus existing testcontainer-backed
`runtime_hooks_test.go` covering the published intent. Catalog
/ event tests extended with `game.paused`.
- UI Vitest: `tests/order-queue.test.ts` (classification +
offline plumbing), extended `tests/order-draft.test.ts`
(conflict marks commands, mutation clears banner, pause
blocks sync, offline holds + flushes on `online`,
`resetForNewTurn` re-hydrates), extended
`tests/order-tab.test.ts` (banner DOM + sync-status
attribute), extended `tests/events.test.ts` (`game.paused`
dispatch).
- Playwright e2e: `tests/e2e/order-sync.spec.ts` — conflict
banner on `turn_already_closed` reply and paused banner on
the signed `game.paused` frame.
## ~~Phase 26. History Mode~~
Status: done. Verified on local-ci run 6 (`success`, 2d17760).
Goal: let the user navigate to past turns and view all data as it was,
with no order composition allowed.
Decisions baked in during implementation:
1. **History state lives in `GameStateStore`, no separate module.**
The Phase 12 plan-line "introduce `lib/history-mode.ts`" is
superseded: the only consumer needs a one-line derivation
(`historyMode = $derived(gameState.historyMode)`), and the
project's compactness rule rejects an abstraction with no second
caller. The store ships two distinct turn runes — `currentTurn`
(server's authoritative latest, set by `setGame` /
`advanceToPending`) and `viewedTurn` (what the UI displays, set
by `viewTurn` / `returnToCurrent`) — plus the derived
`historyMode` rune that flips when `viewedTurn < currentTurn`.
2. **`OrderDraftStore` gates mutations at one chokepoint.**
`bindClient` gains an optional `getHistoryMode: () => boolean`
alongside the existing `getCurrentTurn`; `add` / `remove` /
`move` return early when it reports `true`. Every Phase 1422
inspector that calls `orderDraft.add(...)` becomes inert in
history mode without per-component edits.
3. **Turn navigator UX.** Header replaces the static `turn N` text
with `← turn N →`: arrows step ±1 (disabled at `0` and
`currentTurn`), the middle button opens a dropdown of every
turn `Turn #0`…`Turn #currentTurn` with the current row carrying
a badge. No free-text input. Desktop uses an absolute popover
under the header; mobile reuses `view-menu.svelte`'s fixed-
drawer pattern (no new primitive). Selecting the current row
routes through `returnToCurrent()` so the "leave history" path
has one canonical entry.
4. **History is ephemeral across reloads.** `last-viewed-turn` is
written only when `viewedTurn === currentTurn`; historical
excursions never advance the resume bookmark. Page reload exits
history mode. The visibility-refresh listener is a no-op while
`historyMode` is true so a tab-focus event cannot silently kick
the user back onto the live turn. Push events (Phase 24) continue
to deliver new-turn notifications, so the pending-turn toast
still appears.
5. **Past-turn report cache.** New `game-history/{gameId}/turn/{N}`
namespace stores past-turn reports; `viewTurn(N)` reads cache
first and falls back to the network on miss. Past turns are
immutable so the cache has no TTL and no eviction. The current
turn deliberately skips the cache (it is mutable until the next
tick).
6. **Order overlay short-circuits in history mode.**
`RenderedReportSource.report` returns the raw server snapshot
instead of running `applyOrderOverlay`: the draft is composed
against the current turn, projecting it onto a past report would
render fictional intent.
7. **`game.shell.headline` removed.** The Phase 11 i18n key that
formatted `{race} @ {game}, turn {turn}` is deleted; the header
composes `race @ game` in plain text and delegates `turn N` to
`turn-navigator.svelte`. The existing `game-shell-headline`
testid moves to the `.left` wrapper so e2e specs that match
`toContainText("turn N")` continue to find the substring inside
the navigator's button.
Artifacts (delivered):
- `ui/frontend/src/lib/game-state.svelte.ts` — `viewedTurn` rune,
derived `historyMode` rune, `viewTurn(turn)` /
`returnToCurrent()` public methods, `loadTurn(turn, { isCurrent })`
refactor that gates `last-viewed-turn` writes, `readReport` cache
layer over the `game-history` namespace, visibility-refresh
short-circuit in history mode, `initSynthetic` keeps
`currentTurn === viewedTurn`.
- `ui/frontend/src/sync/order-draft.svelte.ts` — `bindClient` accepts
`getHistoryMode`, `add` / `remove` / `move` no-op when active.
- `ui/frontend/src/lib/rendered-report.svelte.ts` — overlay short-
circuit when `gameState.historyMode === true`.
- `ui/frontend/src/lib/header/turn-navigator.svelte` (new) — header
triplet `← turn N →` + dropdown popover / drawer, reuses
`view-menu.svelte`'s outside-click / Escape pattern.
- `ui/frontend/src/lib/header/history-banner.svelte` (new) — sticky
read-only banner under the header with a `Return to current turn`
action.
- `ui/frontend/src/lib/header/header.svelte` — embeds
`<TurnNavigator />` next to the race-and-game identity span;
drops the static turn portion.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
`historyMode` derived rune, `getHistoryMode` passed to
`orderDraft.bindClient`, `<HistoryBanner />` mounted between
header and body.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new
`game.shell.history.*` and `game.shell.turn.*` keys; the now-
unused `game.shell.headline` entry is removed.
- `ui/docs/storage.md` — `game-history` namespace row; also adds
the `game-prefs/{gameId}/last-viewed-turn` row (Phase 11 doc
gap).
- `ui/docs/game-state.md` — current/viewed-turn rune table, the
new History mode section.
- `ui/docs/navigation.md` — describes the navigator, the read-only
banner, and the `historyMode` derivation wiring.
- `ui/docs/order-composer.md` — notes the mutation gate, the
overlay short-circuit, and the cross-doc references.
- Vitest: `tests/game-state.test.ts` extended with `viewTurn` /
`returnToCurrent` / `historyMode` derivation / cache hit /
visibility-refresh short-circuit / resume-from-stale-bookmark
flips; `tests/order-draft.test.ts` extended with the history-
mode gate cases; `tests/turn-navigator.test.ts` and
`tests/history-banner.test.ts` (new) cover the components in
isolation.
- Playwright: `tests/e2e/history-mode.spec.ts` (new) — drives the
full chrome flow against `/games/<id>/table/planets`. The map
view is deliberately avoided because the Pixi renderer can
monopolise the headless Chromium main thread long enough to let
the `toContainText` poll find stale "turn ?" content; the table
view exercises the same wiring without that rendering tail.
Dependencies: Phases 11, 12, 23.
Acceptance criteria:
- selecting a past turn from the navigator switches every view to that
turn's data;
- order tab disappears from the sidebar; calculator tab remains
available;
- returning to the current turn restores live data and re-shows the
order tab with the prior draft intact (state preservation);
- battle / mail stub views still render correctly while the
read-only banner is visible (Phases 27 / 28 will replace the
stubs with real implementations; the wiring is sufficient
today).
Targeted tests:
- Vitest unit tests for current/viewed turn rune split, view-turn
cache behaviour, visibility-refresh short-circuit, order-draft
history-mode gate, turn-navigator interactions, history-banner
rendering / action;
- Playwright e2e: enter history mode via arrow, navigate via
dropdown, return via banner action, confirm the order draft
survives the round-trip.
## ~~Phase 27. Battle Viewer~~
Status: done (local-ci run 14).
Goal: ship a dedicated Battle Viewer rendering radial scenes from
`BattleReport` data (planet centred, races on the outer ring, per
ship-class clusters, animated shot lines), plus battle and bombing
markers on the map. Battles and bombings stay strictly separate —
bombings remain a static table in the Reports view, only battles
get the animated viewer.
Artifacts:
- engine: `game/internal/router/handler/battle.go` for
`GET /api/v1/battle/:turn/:uuid` (handler pre-existed; Phase 27
added the tests + openapi schemas)
- engine wire: `pkg/model/report/battle.go` ships a new
`BattleSummary{id, planet, shots}`; `Report.battle` carries a
slice of these summaries so the map can place markers without
fetching every full report
- backend: `backend/internal/engineclient/client.go.FetchBattle`
and `backend/internal/server/handlers_user_games.go.Battle`
expose `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
- UI viewer: `ui/frontend/src/lib/battle-player/`
(`radial-layout.ts`, `timeline.ts`, `battle-scene.svelte`,
`playback-controls.svelte`, `battle-viewer.svelte`); SVG-based,
one frame per protocol entry, full controls (play/pause + step
back + step forward + rewind + 1x/2x/4x speed switch)
- UI route + page wrapper:
`ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte`
feeds `gameId` / `turn` / `battleId` into
`ui/frontend/src/lib/active-view/battle.svelte`, which loads the
report via `api/battle-fetch.ts` (synthetic-fixture path + real
engine fetch through the backend gateway)
- UI report link: `lib/active-view/report/section-battles.svelte`
now links every battle UUID into
`/games/{id}/battle/{uuid}?turn={turn}`
- UI map markers: `ui/frontend/src/map/battle-markers.ts` emits a
yellow X cross per battle (two `LinePrim` through the planet's
bounding-square diagonals; stroke width scales 1px..5px with
protocol length) plus a stroke-only ring per bombing (yellow when
damaged, red when wiped). Wired into `state-binding.ts`; the map
click handler dispatches battle clicks to the viewer and bombing
clicks to a scroll-into-view of the matching row in Reports.
- topic doc `ui/docs/battle-viewer-ux.md` covers playback
semantics, accessibility (the always-visible `<ol>` log), the
radial layout, and the marker click behaviour
- docs/FUNCTIONAL.md §6.5 (Battle viewer) + mirror in
docs/FUNCTIONAL_ru.md
Dependencies: Phase 23.
Acceptance criteria:
- battle and bombing markers render on the map for the seeded
current-turn report and are clickable: battle → Battle Viewer for
the corresponding UUID, bombing → scroll to its row in Reports;
- the Battle Viewer plays back any `BattleReport` end-to-end with
step back / step forward / rewind / 1x-2x-4x speeds; observers
(`inBattle === false`) are not drawn; eliminated races drop out
and survivors re-distribute on the next frame;
- the same protocol is mirrored as an always-visible text log under
the scene for accessibility;
- bombings keep their Phase 23 static table layout in Reports; no
Battle Viewer entry-point is wired from them.
Targeted tests:
- Vitest unit: radial layout (1/2/3 races) and timeline frame-
builder (initial state, shot decrement, race-elimination drop-out)
in `tests/battle-player.test.ts`
- Vitest unit: marker primitives + stroke-width formula
(1→1, 50→2.98, 100→5, 200→5) in `tests/battle-markers.test.ts`
- Go unit: engine HTTP handler validations (400 / 404 / 500) in
`game/internal/router/battle_test.go`
- Go contract: openapi freezes for the new endpoint and schemas in
`game/openapi_contract_test.go`
- Playwright e2e: click battle marker → viewer; play / step back;
click battle UUID in Reports → viewer; click bombing marker →
Reports bombings row scrolled into view.
Decisions during stage:
1. **Bombings stay a static table.** `section-bombings.svelte`
already covers the "who bombed, with what power, wiped or not"
requirement; nothing in Phase 27 touches it. Bombings explicitly
do not open the Battle Viewer.
2. **Wire change.** `Report.Battle` switched from `[]uuid.UUID` to
`[]BattleSummary{id, planet, shots}` so the map renderer can
place markers without N extra fetches and so the cross-marker
stroke can scale with protocol length.
3. **Battle marker = yellow X cross** drawn as 2 `LinePrim` through
the corners of the planet's circumscribed square; stroke width
`clamp(1 + (shots - 1) * 4 / 99, 1, 5)` px.
4. **Bombing marker = stroke-only ring** slightly larger than the
planet circle. Yellow when damaged, red when wiped. Click =
scroll to the matching row in Reports (not the viewer).
5. **Viewer URL** `/games/[id]/battle/[battleId]?turn=N`. Turn is a
query param so the same route works in history mode.
6. **SVG, not PixiJS** for the radial scene — isolated component,
no need for WebGL; PixiJS stays as the map renderer.
7. **Playback controls full set**: play / pause + step back + step
forward + rewind + 1x / 2x / 4x switch. 1x = 400 ms per frame.
8. **Observer groups (`inBattle: false`)** are filtered out of both
the scene and the text log.
9. **Cluster aggregation by `(race, className)`** so a race with
multiple groups of the same class collapses to one labelled
circle. Stable target for shot-line endpoints.
10. **Page loader switches on `synthetic-` gameId prefix** —
synthetic mode uses `api/synthetic-battle.ts` fixtures; live
games hit `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`.
BattleViewer component itself is a logically isolated prop sink.
11. **Always-visible `<ol>` text protocol** under the scene satisfies
the accessibility requirement without a separate "skip
animation" toggle.
TODO carried into Phase 27 deferred items
(see Phase 27 of this PLAN's deferred-followups list, near the
bottom):
- push event `game.battle.new` + toast deep-link;
- richer ship-class visuals derived from class characteristics;
- animated transitions when survivors re-distribute after an
elimination (currently hard-jumps).
## ~~Phase 28. Diplomatic Mail View~~
Status: done (CI gate passed on run 136 — go-unit / ui-test / integration all green at commit 6d0272b).
Decisions baked in during implementation:
1. **Transport: ConnectRPC `user.games.mail.*`.** Eight new
authenticated commands (inbox / sent / message.get / send /
broadcast / admin / read / delete) plumbed end-to-end through
the existing gateway → backend REST surface. Schemas in
`pkg/schema/fbs/diplomail.fbs`; constants in
`pkg/model/diplomail/diplomail.go`; gateway translation in
`gateway/internal/backendclient/mail_commands.go`.
2. **Recipient by race name.** The send / admin endpoints accept
an alternative `recipient_race_name` field; backend resolves it
via `Memberships.ListMembers(gameID, "active")`. The UI feeds
the picker straight off `report.races[].name` — no client-side
memberships RPC.
3. **`sender_race_name` snapshot.** New nullable column on
`diplomail_messages`, populated for `sender_kind='player'`
senders that have an active membership at send time. Drives the
per-race threading on the client.
4. **/sent returns full message detail.** Backend's bulk sent
listing now returns the same `UserMailMessageDetail` shape as
`/inbox`, one row per (message, recipient). The UI collapses
broadcasts by `message_id` into a single stand-alone item.
5. **Threading + stand-alones.** `MailStore.entries` groups
personal messages by the other party's race name. System,
admin, and outgoing broadcasts render as stand-alone items in
the same list pane.
6. **No read receipts.** `read_at` and `deleted_at` drive the
badge counter and soft-delete affordance but are never shown
to the user.
7. **Header badge.** Inline pill on the view-menu "diplomatic
mail" row, fed by `mailStore.unreadCount`. No always-visible
chrome added.
8. **Push event reuse.** A new
`eventStream.on("diplomail.message.received", …)` handler in
`routes/games/[id]/+layout.svelte` parses the verified payload,
refreshes the inbox, and raises a `toast.show` with a "view"
deep-link.
9. **Translation toggle.** Per-message Show original / Show
translation toggle inside both `thread-pane.svelte` and
`system-item-pane.svelte`; the body defaults to the cached
translation when present.
Artifacts (delivered):
- backend: `internal/postgres/migrations/00001_init.sql`,
`internal/diplomail/{types.go,store.go,service.go,admin_send.go,diplomail_e2e_test.go,README.md}`,
`internal/server/{handlers_user_mail.go,handlers_admin_diplomail.go}`,
`openapi.yaml`;
- wire: `pkg/schema/fbs/diplomail.fbs` + generated Go and TS
bindings; `pkg/model/diplomail/diplomail.go`;
- gateway: `gateway/internal/backendclient/{mail_commands.go,routes.go,mail_commands_test.go}`,
`gateway/cmd/gateway/main.go`;
- ui: `ui/frontend/src/api/diplomail.ts`,
`ui/frontend/src/lib/mail-store.svelte.ts`,
`ui/frontend/src/lib/active-view/mail.svelte` (+ subdir
`mail/{thread-list,thread-pane,system-item-pane,compose,system-titles}.svelte|.ts`),
`ui/frontend/src/lib/header/view-menu.svelte`,
`ui/frontend/src/routes/games/[id]/+layout.svelte`,
`ui/frontend/src/lib/i18n/locales/{en,ru}.ts`;
- docs: `ui/docs/diplomail-ui.md`, `docs/FUNCTIONAL.md` §11.4 +
mirror in `docs/FUNCTIONAL_ru.md`.
Original phase brief follows.
Goal: implement a mail inbox and compose flow as a dedicated view that
replaces the map.
Artifacts:
- `ui/frontend/src/routes/games/[id]/mail/+page.svelte` two-pane on
desktop (list + reading), one-pane stack on mobile
- compose form for new messages targeting any other race in the game
- inbox sorted by arrival time, with read/unread state persisted via
`Cache`
- push-event integration: new mail surfaces a toast and increments an
unread badge in the header
Dependencies: Phases 22, 24.
Acceptance criteria:
- the user can read incoming mail, compose new mail, and reply to mail
end-to-end;
- unread state persists across reloads;
- server-side delivery confirmations appear on the message thread.
Targeted tests:
- Vitest component tests for the compose form including field
validation;
- Playwright e2e: send a message between two seeded players, confirm
arrival.
## ~~Phase 29. Map Toggles~~
Status: done.
Goal: deliver the gear-icon control for hiding categories of map
content and switching between torus and no-wrap view modes. LOCAL
planets stay always-on; every other category gets a toggle that
applies within one frame via the renderer's hide-by-id facility.
Artifacts:
- `ui/frontend/src/lib/active-view/map-toggles.svelte` — gear icon
in the map view's corner; popover (desktop) / bottom sheet
(mobile). Three fieldsets:
- **Objects** — hyperspace groups, incoming groups,
unidentified groups, cargo routes, battle markers, bombing
markers (six independent checkboxes; battle and bombing have
their own toggles, not a shared one).
- **Planets** — foreign / uninhabited / unidentified kind
toggles plus a `unreachablePlanets` switch that, when off,
hides planets beyond `FlightDistance(localPlayerDrive)` of
every LOCAL planet (torus-aware).
- **View** — "visible hyperspace" checkbox + torus / no-wrap
radios. The fog overlay is named for the visible part of the
map (intelligence/scan area), since that is what the toggle
controls from the player's perspective.
- `RendererHandle.setHiddenPrimitiveIds(ids)` —
declarative hide set; flips `Graphics.visible` per copy and
threads the set into `hitTest` so click-through to deeper
primitives is correct.
- `RendererHandle.setVisibilityFog(circles)` — fog overlay
drawn via Pixi v8 `Graphics.cut()` per torus copy, below
primitives in z-order.
- `src/map/visibility.ts` — pure helpers (`computeHiddenPlanetNumbers`,
`computeHiddenIds`, `computeFogCircles`, `isCategoryVisible`,
`fingerprintHiddenPlanets`) consumed by the map view.
- `GameStateStore.mapToggles` rune + `setMapToggle` method;
single-blob persistence in cache namespace `game-map-toggles`
(key `{gameId}`, value `{toggles, lastResetTurn}`).
- New-turn reset path inside `setGame` / `advanceToPending`
drops user overrides when `lastResetTurn < currentTurn`.
Dependencies: Phases 9 (no-wrap engine), 11 (planets), 16 (cargo
routes), 19 (groups, incoming), 27 (battle markers).
Acceptance criteria:
- toggling each object visibility category hides or shows the
corresponding objects on the map within one frame;
- switching wrap scrolling switches the renderer between torus and
no-wrap mode without losing camera position when possible;
- toggle state persists across reloads per game;
- the gear popover is reachable on mobile through a comfortable tap
target (≥ 44 px).
Targeted tests:
- `tests/visibility-helpers.test.ts` — unit coverage for the
hide-set / fog computation;
- `tests/state-binding-cascade.test.ts` — `reportToWorld` emits
the `categories` + `planetDependents` maps;
- `tests/map-toggles-component.test.ts` — popover lifecycle +
store wiring;
- `tests/map-toggles-state.test.ts` — single-blob persistence +
new-turn reset path against a real fake-IndexedDB cache;
- `tests/map-hit-test.test.ts` — `hitTest` honours the
`hiddenIds` parameter;
- `tests/e2e/map-toggles.spec.ts` — cascade, fog, wrap-mode
camera preservation, reload persistence, plus render-on-demand
(an idle map does not repaint; a released drag does not coast)
across the four Playwright projects.
Decisions:
1. **"Reach zones" reinterpreted as `unreachablePlanets` filter.**
The original plan listed a "reach / visibility zones" category
rendered as concentric circles. The realised stage drops the
circle overlay and instead exposes an inverse
`unreachablePlanets` toggle that hides planets beyond the
player's `FlightDistance`. Reach is already implicit in the
reach-aware destination picker (Phase 16+), so the cleaner UX
is filtering, not adding extra rings.
2. **Visible-hyperspace overlay**. A separate `visibleHyperspace`
toggle draws a slightly lighter fog over the world outside the
union of `VisibilityDistance` circles around LOCAL planets. The
fog is a renderer-level concept (layered overpaint — fog rect
then background-coloured circles on top — instead of Pixi's
`Graphics.cut()`, which produced incorrect unions of holes), not
a primitive: it never participates in hit-test.
3. **Per-kind planet toggles + unidentified-group toggle**. The
spec's original "object visibility" list was extended:
foreign / uninhabited / unidentified planet kinds and
unidentified ship groups each get their own toggle.
4. **Battle and bombing markers are independent toggles.** The
spec text grouped them as a single line item; on review the
player wanted finer control, so each kind gets its own
checkbox.
5. **Single-blob persistence + new-turn reset.** Toggles persist
per game as one JSON blob `{toggles, lastResetTurn}` under
`game-map-toggles/{gameId}`. A new server-side turn force-
resets every flag to defaults so a hidden category cannot
silently swallow the next turn's news. History-mode
navigation (`viewTurn`) keeps the shared state.
6. **Hide-by-id renderer extension.** The wrap-mode toggle keeps
the existing remount + camera-preserve path (it has to —
torus copies need different `.visible` flags). Every
visibility flip uses the new `setHiddenPrimitiveIds` / hide-
aware `hitTest` so it applies within one frame.
7. **`pkg/calc/race.go` typo fixed**. The Go-side helper was
`FligthDistance`; the Phase 29 work renamed it to
`FlightDistance` (and the only TS call site duplicates the
formula directly, awaiting a future race-level WASM bridge).
8. **Render-on-demand + no pan inertia (fog perf, stage 1).** The
renderer originally kept Pixi's continuous auto-render loop, so
the visibility fog's layered overpaint re-rasterised every frame
and froze the whole UI on large reports in Safari (Pixi's WebGPU
backend) — even while idle. The renderer now stops the auto-render
loop (`app.stop()`) and paints on demand: a single `Ticker.shared`
flush renders only when `viewport.dirty` (camera moved) or an
internal `requestRender()` fires from a content mutation
(`setVisibilityFog` / `setHiddenPrimitiveIds` /
`setExtraPrimitives` / `applyMode` / `resize` / pick overlay);
plain hover paints nothing. The `decelerate` (drag-inertia) plugin
is removed so a released drag stops instantly and the viewport
goes idle immediately. `RendererHandle.getRenderCount()` (mirrored
on `__galaxyDebug` as `getMapRenderCount`) backs the e2e
assertions. The owner confirmed this removed the idle / whole-system
freeze, but panning a loaded map with the fog on stayed heavy in
Safari (the overpaint fill-rate was untouched) — addressed in
decision 9.
9. **Inverse stencil mask for the fog (fog perf, stage 2).** The fog's
visibility holes were previously cut by opaque background-coloured
circle overpaint — on a large report dozens of near-world-sized
opaque circles repainted every frame, the fill-rate cliff that kept
Safari's WebGPU pan heavy after stage 1. Stage 2 replaces the
overpaint with an INVERSE stencil mask: `setVisibilityFog` draws the
`FOG_COLOR` rectangle(s) into `fogLayer` and collects the visibility
circles into one `Graphics` set as
`fogLayer.setMask({ mask, inverse: true })`, so the fog shows
everywhere except the union of the circles. Per-frame cost drops to
one rectangle fill plus a stencil pass (no blended colour writes,
friendly to Apple's tile-based GPU), and the fog stays fully vector
— crisp at any zoom. `fogPaintOps` and its unit tests are unchanged
(the circle ops now feed the mask instead of an overpaint); the
rendered result is verified by a high-contrast screenshot during
development plus the existing fog / render-on-demand e2e.
## ~~Phase 30. Ship Class Calculator~~
Status: done (gitea `go-unit` + `ui-test` green; deployed to dev). UI
polish deferred to a later pass; the core functionality is in place.
Goal: replace the standalone Phase 17/18 ship-class designer with a
fused designer + calculator living in the sidebar. It shows the ship
design blocks, live derived results (mass, speed, attack, defence,
bombing), and a planet build-rate readout, and adds single-target
goal-seek — the player pins one result and the model back-solves the
single input it claims. A second mode reuses the design area to price
ship-class modernization. The standalone designer view and route are
removed; the ship-classes table and the view/bottom menus open the
calculator instead.
The original four detached modes (ship / path / modernization /
bombing) were dropped during planning: path is deferred (MVP path is
brute force) and replaced by auto reach circles on the map; bombing is
folded in as a per-ship result; ship and modernization are the two
modes. See `ui/CALCULATOR.md` history (removed) and the interview
decisions baked below.
Goal-seek claim map (one lock at a time): attack → weapons,
defence → shields, empty/loaded speed → drive, empty mass → cargo,
loaded mass → cargo load. Locking one result disables the others'
lock affordances; an unreachable target shows the locked cell in an
error state. Tech levels and the planet MAT are override inputs with a
reset lock; the player tech is the default.
Artifacts:
- `pkg/calc/` additions, single-sourced (no mirroring): `BombingPower`
extracted from `game/internal/model/game/group.go`; a pure
`ProduceShipsInTurn` extracted from `controller.ProduceShip` (the
engine now delegates to both); inverse solvers in `pkg/calc/solve.go`
(`WeaponsForAttack`, `DriveForSpeed`, `ShieldsForDefence` by
bisection, `CargoForEmptyMass`, `LoadForFullMass`)
- thin bridges in `ui/core/calc/` (combat, planet build, solvers),
registered in `ui/wasm/main.go`, typed on `Core`
(`platform/core/index.ts` + `wasm.ts`)
- `ui/frontend/src/lib/calculator/calc-model.ts` pure orchestration
(forward results + single-target goal-seek + planet build)
- `ui/frontend/src/lib/calculator/ship-design-area.svelte` reusable
design block (5 blocks + 4 techs, override locks, computed-block
read-only) — earmarked for the future ship-group upgrade flow
- `ui/frontend/src/lib/sidebar/calculator-tab.svelte` shell (mode
selector, name combobox + Create / Delete, the calc and planet areas
inline)
- `ui/frontend/src/map/reach-circles.ts` + `lib/calculator/reach.svelte.ts`
shared store: the calculator publishes the selected planet origin and
loaded speed, the map draws 13 reach circles
- `lib/calculator/load-request.svelte.ts` shared store: the table /
menus ask the layout to open the calculator on a class
- topic doc `ui/docs/calculator-ux.md`; `ui/docs/calc-bridge.md`
extended with the new wired functions
Dependencies: Phases 17, 18, 19/20 (selection store), 29 (map modes).
Acceptance criteria:
- every result is byte-identical to direct `pkg/calc/` calls on shared
fixtures (Go parity tests);
- locking one result back-solves its claimed input; a second lock is
disabled; an unreachable target shows the error state;
- Create reuses the existing ship-class command flow and validator;
selecting an existing class loads it as a template;
- inputs persist across view switches per the global state-preservation
rule; the calculator works in history mode against the snapshot's
tech levels;
- selecting an own planet draws the reach circles; clearing the
selection or an invalid design removes them;
- the standalone designer view/route no longer exists.
Targeted tests:
- Go: `pkg/calc` unit tests + engine parity (`ProduceShip`,
`BombingPower`); `ui/core/calc` bridge parity; solver round-trips;
- Vitest: `calc-model` (forward, goal-seek per claim, infeasible),
`calculator-tab` (results, goal-seek, Create, planet area),
`reach-circles` math;
- Playwright e2e: create / list / delete via the table + calculator,
Create-disabled-while-invalid (`tests/e2e/ship-classes.spec.ts`).
Note: the WASM artefact `ui/frontend/static/core.wasm` must be rebuilt
(`make wasm`, needs TinyGo) for the new bridge functions to be present
at runtime and in the Playwright suite; Vitest injects a fake `Core`
and does not need the rebuild.