659ba00ebf
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3558 lines
167 KiB
Markdown
3558 lines
167 KiB
Markdown
# 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.
|
||
|
||
The existing Fyne client in `client/` is deprecated and is not modified
|
||
or imported by the new code. The strategy and rationale behind these
|
||
choices live in the plan file at
|
||
`/Users/id/.claude/plans/buzzing-questing-fountain.md`; the architectural
|
||
overview is mirrored into `ui/README.md` as part of Phase 1.
|
||
|
||
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 breaks implementation into 36 small reviewable phases. 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 application with **one active view at a
|
||
time**. 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.
|
||
|
||
### View model
|
||
|
||
```text
|
||
ActiveView ∈ {
|
||
/login, (anonymous only)
|
||
/lobby, (auth required)
|
||
/games/:id/map, (default in-game view)
|
||
/games/:id/table/:entity, (entity ∈
|
||
planets | ship-classes |
|
||
ship-groups | fleets |
|
||
sciences | races)
|
||
/games/:id/report,
|
||
/games/:id/battle/:battleId,
|
||
/games/:id/mail,
|
||
/games/:id/designer/ship-class/:id?,
|
||
/games/:id/designer/science/:id?,
|
||
}
|
||
```
|
||
|
||
Switching between views happens through the header dropdown (desktop)
|
||
or hamburger menu (mobile). Double-tapping a row in a `table:` view
|
||
returns to `/map` with `focus=<objectId>`. 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 navigates to any other view.
|
||
|
||
### 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 (768–1024 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 route transitions
|
||
|
||
- `/login` → `/lobby` after successful confirm-email-code.
|
||
- `/lobby` → `/games/:id/map` when a game card is selected.
|
||
- Any view → `/login` immediately on session revocation push event.
|
||
- Designer views can push a transient overlay onto `/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/galaxy/gateway/v1/gatewayv1connect/edge_gateway.connect.go`
|
||
- `gateway/internal/grpcapi/server.go` rewritten around `http.Server`
|
||
+ `h2c.NewHandler` + `gatewayv1connect.NewEdgeGatewayHandler`
|
||
- new `gateway/internal/grpcapi/connect_handler.go` adapting the
|
||
existing `gatewayv1.EdgeGatewayServer` 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 `gatewayv1connect.EdgeGatewayClient` 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 +
|
||
`EdgeGatewayClient` factory.
|
||
- `ui/frontend/src/api/galaxy-client.ts` — `GalaxyClient` skeleton
|
||
with injected `Signer` and `Sha256` dependencies.
|
||
- `ui/frontend/src/proto/galaxy/gateway/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 — file-system based, no extra dispatcher.** The
|
||
"view router" called out in the original artifact list is
|
||
implemented as SvelteKit's file-system routes plus thin
|
||
`+page.svelte` wrappers that mount the matching
|
||
`lib/active-view/<name>.svelte` stub. No separate dispatch
|
||
component lives in the codebase; each route file is a two-line
|
||
wrapper.
|
||
2. **Optional designer ID segments.** Both designer URLs ship as
|
||
`[[id]]` optional segments
|
||
(`designer/ship-class/[[classId]]/`,
|
||
`designer/science/[[scienceId]]/`) so Phase 18 / 21 can read
|
||
the param without a routing migration. Phase 10 stubs ignore
|
||
the param.
|
||
3. **Battle URL — optional id.** `battle/[[battleId]]/` accepts
|
||
both the list URL (`/battle`) and a specific battle URL
|
||
(`/battle/<id>`). Phase 27 keeps the optional segment and
|
||
switches behaviour based on presence.
|
||
4. **Tablet sidebar — click toggle, not swipe.** The 768–1024 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 URL.**
|
||
The mobile bottom-tabs Calc / Order navigate to `/map` and
|
||
set a layout-owned `mobileTool` rune. The layout's derived
|
||
`effectiveTool` only honours the rune when the URL is `/map`,
|
||
so navigating to any other view via the More drawer or the
|
||
header view-menu naturally drops the overlay. The desktop
|
||
sidebar separately accepts a `?sidebar=calc|inspector|order`
|
||
URL param that seeds the initial tab on first mount, used by
|
||
later phases that want to land directly on a particular tool.
|
||
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 inherited.** The root `+layout.svelte` already
|
||
redirects `anonymous → /login`; the in-game shell needs no
|
||
extra guard. Phase 10 verified this by booting the e2e shell
|
||
spec via `__galaxyDebug.setDeviceSessionId` and observing the
|
||
post-`session.init` `authenticated` status.
|
||
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 and `?sidebar=` URL seed;
|
||
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, `?sidebar=` URL seed, 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, and the `?sidebar=order` URL seed falls back to
|
||
`inspector` while the flag is true.
|
||
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
|
||
instantiates `OrderDraftStore`, sets context, runs
|
||
`init({ cache, gameId })` next to `gameState.init` through
|
||
one `Promise.all`, disposes on destroy, passes
|
||
`historyMode={false}` down.
|
||
- `ui/frontend/src/routes/__debug/store/+page.svelte` — extended
|
||
`DebugSurface` with `seedOrderDraft` / `clearOrderDraft`.
|
||
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new
|
||
`game.sidebar.order.command_delete` key plus updated
|
||
`game.sidebar.empty.order` copy.
|
||
- `ui/docs/order-composer.md` — topic doc describing the
|
||
draft-replaces-server-order model, local-validation invariant,
|
||
command status state machine, persistence, history-mode wiring,
|
||
and test layout. Cross-references `storage.md` and
|
||
`navigation.md`.
|
||
- `ui/docs/storage.md` — namespace registry row for
|
||
`order-drafts`.
|
||
- `ui/docs/navigation.md` — describes the historyMode prop wiring
|
||
through Sidebar / BottomTabs.
|
||
- `ui/README.md` — new entry under topic docs for
|
||
`order-composer.md`.
|
||
- Vitest: `ui/frontend/tests/order-draft.test.ts`.
|
||
- Playwright: `ui/frontend/tests/e2e/order-composer.spec.ts`.
|
||
|
||
Dependencies: Phases 6, 10.
|
||
|
||
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 808–843) 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).
|
||
|
||
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.
|
||
|
||
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 14–22
|
||
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 7).
|
||
|
||
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: pending.
|
||
|
||
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: pending.
|
||
|
||
Goal: deliver the gear-icon control for hiding categories of map
|
||
content and switching between torus and no-wrap view modes. All
|
||
toggleable categories are already rendered by earlier phases; this
|
||
phase only exposes the controls.
|
||
|
||
Artifacts:
|
||
|
||
- `ui/frontend/src/lib/active-view/map-toggles.svelte` gear icon in
|
||
the map view's corner; popover (desktop) / bottom sheet (mobile)
|
||
- two sections inside the popover:
|
||
- object visibility: hyperspace groups, incoming groups, cargo
|
||
routes, reach / visibility zones, battle and bombing markers
|
||
- view options: wrap scrolling (torus / no-wrap)
|
||
- planets are always rendered and not toggleable
|
||
- `ui/frontend/src/lib/map/reach-zones.ts` implementation of reach /
|
||
visibility zone overlays, off by default (the only category not yet
|
||
rendered by earlier phases)
|
||
- toggle state persists per game in `Cache`
|
||
|
||
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:
|
||
|
||
- Vitest component tests for toggle state persistence;
|
||
- Vitest unit tests for reach-zone rendering on torus and no-wrap
|
||
fixtures;
|
||
- Playwright e2e in desktop and mobile viewports: toggle each
|
||
category and the wrap scrolling, assert visual change.
|
||
|
||
## Phase 30. Calculator Tab
|
||
|
||
Status: pending.
|
||
|
||
Goal: ship an independent calculator in the sidebar, callable from any
|
||
view, exposing the full set of `pkg/calc/` functions wired through
|
||
`Core`.
|
||
|
||
Artifacts:
|
||
|
||
- `ui/frontend/src/lib/sidebar/calculator-tab.svelte` UI with mode
|
||
selector (ship calculator, path calculator, modernization cost,
|
||
bombing power) and per-mode forms
|
||
- bridge entries in `ui/core/calc/` for any function not already
|
||
wrapped by Phase 18
|
||
- topic doc `ui/docs/calculator-ux.md` documenting modes,
|
||
layouts, and the rule that calculator inputs persist across
|
||
navigation
|
||
|
||
Dependencies: Phase 18.
|
||
|
||
Acceptance criteria:
|
||
|
||
- every calculator mode produces results identical to direct
|
||
`pkg/calc/` calls;
|
||
- inputs persist across view switches per global state-preservation
|
||
rule;
|
||
- calculator works in history mode against the snapshot's tech levels.
|
||
|
||
Targeted tests:
|
||
|
||
- Vitest snapshot tests per mode on canonical inputs;
|
||
- Playwright e2e: switch modes, confirm input persistence.
|
||
|
||
## Phase 31. Wails Desktop Wrapper
|
||
|
||
Status: pending. Re-evaluate Wails v2 vs v3 at phase start.
|
||
|
||
Goal: build a native desktop app for macOS, Windows, and Linux that
|
||
runs the same frontend bundle and replaces the WASM core with embedded
|
||
Go code.
|
||
|
||
Artifacts:
|
||
|
||
- topic doc `ui/docs/wails-version.md` recording the v2-vs-v3
|
||
decision made at phase start with rationale
|
||
- `ui/desktop/main.go` Wails entry point
|
||
- `ui/desktop/app.go` IPC bindings exposing `ui/core` API to the
|
||
WebView through a structured adapter
|
||
- `ui/desktop/keychain/` per-OS secure-storage helpers (macOS Keychain
|
||
via `Security` framework, Windows DPAPI, Linux Secret Service / file
|
||
fallback at `~/.config/galaxy/keypair` with mode `0600`)
|
||
- `ui/desktop/sqlite/` `modernc.org/sqlite` cache wired through Wails
|
||
IPC
|
||
- `ui/frontend/src/platform/core/wails.ts` `WailsCore` adapter
|
||
- `ui/frontend/src/platform/store/wails.ts` `WailsKeyStore` and
|
||
`WailsCache` adapters
|
||
- `ui/desktop/build/icon.icns` macOS app icon
|
||
- `ui/desktop/build/icon.ico` Windows app icon
|
||
- `ui/desktop/build/icon.png` Linux app icon
|
||
- `ui/Makefile` targets `desktop-mac`, `desktop-win`, `desktop-linux`
|
||
- topic doc `ui/docs/desktop-secure-storage.md` documenting the
|
||
Linux/Windows file fallback for missing keychains
|
||
|
||
Dependencies: Phase 6 (KeyStore and Cache interfaces); Phases 7
|
||
through 30 in their web form (the desktop wrapper exercises the same
|
||
TypeScript code).
|
||
|
||
Acceptance criteria:
|
||
|
||
- the macOS, Windows, and Linux binaries each launch, complete login,
|
||
and preserve the keypair across restarts on a fresh user profile;
|
||
- a single source codebase produces all three OS bundles;
|
||
- the same `Core` and `Storage` TypeScript interfaces are satisfied as
|
||
on web, with no platform-specific code outside `platform/`;
|
||
- Linux file fallback activates when Secret Service is absent and
|
||
writes with `0600` permissions.
|
||
|
||
Targeted tests:
|
||
|
||
- Go unit tests for each keychain helper, including file fallback;
|
||
- desktop e2e smoke test driven by Wails headless mode running the
|
||
Phase 7 login Playwright scenario via CDP;
|
||
- regression test: keychain absence on a Linux container without
|
||
libsecret falls back to file storage.
|
||
|
||
## Phase 32. Capacitor Mobile Wrapper
|
||
|
||
Status: pending.
|
||
|
||
Goal: build native iOS and Android apps that run the same frontend
|
||
bundle and call into a gomobile-compiled `ui/core`.
|
||
|
||
Artifacts:
|
||
|
||
- `ui/mobile-bridge/bridge.go` gomobile-friendly façade over `ui/core`
|
||
- `ui/Makefile` target `gomobile` producing `Galaxy.framework` and
|
||
`galaxy.aar`
|
||
- `ui/mobile/capacitor.config.ts` Capacitor project configuration
|
||
- `ui/mobile/plugins/galaxy-core/` custom Capacitor plugin (Swift +
|
||
Kotlin) wrapping the gomobile artifacts
|
||
- `ui/frontend/src/platform/core/capacitor.ts` `CapacitorCore` adapter
|
||
- `ui/frontend/src/platform/store/capacitor.ts` `CapacitorKeyStore`
|
||
and `CapacitorCache` using `@capacitor-community/secure-storage-plugin`
|
||
and `@capacitor-community/sqlite`
|
||
- `ui/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/` iOS app
|
||
icon set
|
||
- `ui/mobile/android/app/src/main/res/mipmap-*/` Android app icon
|
||
set
|
||
- iOS launch screen and Android splash screen
|
||
- `ui/Makefile` targets `ios` and `android`
|
||
- topic doc `ui/docs/mobile-bridge.md` describing the plugin
|
||
API, marshalling strategy, and the manual smoke procedure for this
|
||
phase
|
||
|
||
Dependencies: Phase 6; Phases 7 through 30 in their web form.
|
||
|
||
Acceptance criteria:
|
||
|
||
- both the iOS Simulator and an Android Emulator launch the app,
|
||
complete login, and preserve the keypair across restarts (validated
|
||
by manual smoke);
|
||
- the same `Core` and `Storage` TypeScript interfaces are satisfied as
|
||
on web and desktop;
|
||
- gomobile build produces deterministic outputs reproducible in CI on
|
||
a macOS runner.
|
||
|
||
Targeted tests:
|
||
|
||
- Go unit tests for the `mobile-bridge` façade;
|
||
- Capacitor plugin unit tests on iOS (XCTest) and Android (Espresso);
|
||
- manual smoke procedure: login flow on iOS Simulator and Android
|
||
Emulator, recorded in `ui/docs/mobile-bridge.md`. Full Appium
|
||
automation lands in Phase 36 as part of the acceptance pass.
|
||
|
||
## Phase 33. PWA — Service Worker, Manifest, Web Icons
|
||
|
||
Status: pending.
|
||
|
||
Goal: make the web build installable and offline-tolerant on every
|
||
browser. Native packaging icons live with their respective wrapper
|
||
phases (31 for desktop, 32 for mobile) — this phase is web-only.
|
||
|
||
Artifacts:
|
||
|
||
- `ui/frontend/src/service-worker.ts` cache-first asset strategy with
|
||
stale invalidation on app update
|
||
- `ui/frontend/static/manifest.webmanifest` PWA manifest
|
||
- `ui/frontend/static/icons/` web icon set sized per
|
||
`manifest.webmanifest` requirements
|
||
- topic doc `ui/docs/pwa-strategy.md` covering update flow and
|
||
offline scope
|
||
|
||
Dependencies: Phase 25 (offline order queue).
|
||
|
||
Acceptance criteria:
|
||
|
||
- the web app installs as a PWA on Chrome, Edge, and iOS Safari;
|
||
- the service worker survives an app update without serving stale code
|
||
on the next reload.
|
||
|
||
Targeted tests:
|
||
|
||
- Lighthouse PWA audit at score ≥ 90;
|
||
- Playwright test: install the app, take it offline, verify the cached
|
||
login route still loads;
|
||
- regression test: bumping the app version invalidates the prior
|
||
service worker.
|
||
|
||
## Phase 34. Multi-Turn Projection — Single-Turn Forecast and Range Circles
|
||
|
||
Status: pending. Long-term scope deferred but this phase ships real
|
||
features.
|
||
|
||
Goal: ship two concrete projection features (planet next-turn
|
||
forecast and ship-designer reach circles) plus the transient
|
||
map-overlay back-stack mechanism that the reach-circles feature is
|
||
the first user of.
|
||
|
||
Artifacts:
|
||
|
||
- `ui/frontend/src/lib/projection/` minimal projection engine that
|
||
computes one-turn-ahead state for a single planet using `pkg/calc/`
|
||
- planet inspector forecast section showing next-turn population,
|
||
industry, materials stockpile, and production progress
|
||
- `ui/frontend/src/lib/navigation/transient-overlay.ts` push/pop
|
||
back-stack mechanism for map overlays driven by other views, with
|
||
a back-button affordance on the map that returns to the originating
|
||
view with state preserved
|
||
- ship-designer `Preview range on map` action that pushes a transient
|
||
overlay onto the map showing concentric reach circles for 1, 2, 3,
|
||
4 turns from a chosen origin, computed from the in-progress ship
|
||
design and the player's current Drive tech via `ui/core/calc/`
|
||
- topic doc `ui/docs/multi-turn-projection.md` describing the
|
||
long-term vision (multi-turn planning mode, scenario branches) and
|
||
the phased path to it
|
||
|
||
Dependencies: Phases 17, 18.
|
||
|
||
Acceptance criteria:
|
||
|
||
- the planet inspector shows a forecast section with next-turn values
|
||
matching `pkg/calc/` outputs;
|
||
- the ship-designer `Preview range on map` button transitions to the
|
||
map with reach circles drawn from the chosen origin; back returns
|
||
to the designer with all in-progress state intact;
|
||
- the transient overlay is cleared if the user navigates to any other
|
||
view via the header dropdown.
|
||
|
||
Targeted tests:
|
||
|
||
- Vitest unit tests for the projection engine on canonical fixtures;
|
||
- Vitest unit tests for the transient-overlay push/pop logic and
|
||
state preservation;
|
||
- Playwright e2e: open a planet inspector, observe one-turn forecast;
|
||
open a ship designer, click `Preview range on map`, see reach
|
||
circles, click back, return with state intact.
|
||
|
||
## Phase 35. Polish — Accessibility, Localisation, Error UX
|
||
|
||
Status: pending.
|
||
|
||
Goal: prepare the client for technical beta with end-user-quality
|
||
polish.
|
||
|
||
Artifacts:
|
||
|
||
- `ui/frontend/src/lib/i18n/` translation bundles for English and
|
||
Russian, covering every visible string
|
||
- `ui/frontend/src/lib/error/` central error surface with stable codes
|
||
and retry / escalation guidance
|
||
- accessibility audit results recorded under `ui/docs/a11y.md`
|
||
- keyboard-only navigation paths for lobby, game view, and login
|
||
- focus rings, ARIA labels, screen-reader-only text where needed
|
||
- mobile bottom-sheet swipe-down dismissal and tap-outside dismissal,
|
||
on top of the close button shipped in Phase 13
|
||
- selected-planet visual on the map (ring or halo), wired off the
|
||
Phase 13 `SelectionStore`
|
||
|
||
Dependencies: Phase 33.
|
||
|
||
Acceptance criteria:
|
||
|
||
- WCAG 2.2 AA compliance on lobby, login, and the in-game shell per
|
||
axe-core scan;
|
||
- the entire UI is reachable by keyboard only with visible focus
|
||
rings;
|
||
- every server-side error is mapped to a translated, actionable user
|
||
message in both languages;
|
||
- locale switch persists across reloads on every platform.
|
||
|
||
Targeted tests:
|
||
|
||
- axe-core integration tests on every top-level view;
|
||
- Vitest tests for the i18n bundle structure and missing-translation
|
||
detection;
|
||
- Playwright keyboard-only navigation tests.
|
||
|
||
## Phase 36. Acceptance Pass
|
||
|
||
Status: pending.
|
||
|
||
Goal: reconcile implementation, documentation, and regression coverage
|
||
before declaring the client ready for technical beta.
|
||
|
||
Artifacts:
|
||
|
||
- updated `ui/README.md`, topic docs, and any drift in
|
||
`docs/ARCHITECTURE.md` or `docs/FUNCTIONAL.md` (mirrored to
|
||
`docs/FUNCTIONAL_ru.md`)
|
||
- final cross-platform regression run on a release-candidate build
|
||
- `ui/docs/release-checklist.md` for repeatable releases
|
||
- visual regression baselines committed under
|
||
`ui/frontend/tests/__snapshots__/`; if maintenance proves heavy,
|
||
follow-up issue to switch to self-hosted Argos
|
||
- Appium harness for iOS Simulator and Android Emulator covering the
|
||
login flow, push-event flow, and at least one full turn loop;
|
||
`.gitea/workflows/ui-release.yaml` extended with macOS-runner Appium
|
||
job (mandatory pre-release gate)
|
||
|
||
Dependencies: Phases 1 through 35.
|
||
|
||
Acceptance criteria:
|
||
|
||
- implementation matches every documented contract and live topic
|
||
doc;
|
||
- the cross-cutting regression scenarios listed below pass on web,
|
||
desktop, and mobile;
|
||
- Appium smoke passes on both iOS and Android in CI.
|
||
|
||
Targeted tests:
|
||
|
||
- run focused package tests for `ui/core` and every TypeScript
|
||
module;
|
||
- rerun cross-platform Playwright suites against release-candidate
|
||
builds;
|
||
- run Tier 2 visual regression baselines;
|
||
- run Appium smoke suites on iOS and Android.
|
||
|
||
---
|
||
|
||
## Cross-Cutting Regression Scenarios
|
||
|
||
- A fresh device generates a keypair, completes email-code login, and
|
||
successfully signs a follow-up authenticated request on every target
|
||
platform.
|
||
- A returning device resumes its session without re-login, preserves
|
||
queued orders, and continues receiving push events without gaps.
|
||
- Server-side session revocation tears down the active push stream and
|
||
forces a re-login on every target platform within one second.
|
||
- Tampering with `payload_bytes`, `payload_hash`, `request_id`,
|
||
`message_type`, or any signature byte is rejected by the verifier
|
||
in `ui/core` with a stable error code.
|
||
- Requests outside the freshness window are rejected before they
|
||
reach network, and the client surfaces a clock-skew warning when
|
||
its local clock disagrees with the server time event by more than
|
||
the freshness window.
|
||
- The map renderer holds 60 fps with a 1000-primitive fixture on
|
||
mid-range hardware on web (Chrome, Edge, Safari, Firefox), desktop
|
||
(Wails on macOS, Windows, Linux), and mobile (latest iPhone, mid-
|
||
range Android).
|
||
- The single-tool sidebar preserves state across tab switches; the
|
||
active view preserves state across view switches; designers
|
||
preserve their in-progress state when navigating to the map and
|
||
back through a transient overlay.
|
||
- Order draft is preserved across page reloads, view switches, network
|
||
drops, and history-mode entry / exit.
|
||
- Orders queued offline are flushed in order on reconnect; a turn-
|
||
cutoff conflict surfaces as a clearly failed-order banner without
|
||
retrying forever.
|
||
- History mode applies to every view; the order tab disappears in
|
||
history mode and the prior draft is restored on return to the
|
||
current turn.
|
||
- The ship-class designer's calculations match `pkg/calc/` byte-for-
|
||
byte; any drift between client mirror and server fails CI.
|
||
- Linux desktop builds without Secret Service still complete login by
|
||
falling back to the `0600` file under `~/.config/galaxy/`.
|
||
- The web service worker invalidates correctly on app update and
|
||
never serves stale code on the first load after a deploy.
|
||
- Push-event signature verification is mandatory; any verification
|
||
failure tears down the stream and reconnects with backoff.
|
||
- Locale switch persists across reloads and applies to every visible
|
||
string on every platform.
|
||
|
||
## TODO — deferred follow-ups from Phases 1-5
|
||
|
||
These items are explicit decisions to defer, not unknown work. Each
|
||
should be picked up either as a follow-up patch or folded into the
|
||
phase listed in the parenthesis when that phase lands.
|
||
|
||
- **Build `core.wasm` in CI, drop the committed artefacts** — install
|
||
TinyGo on the Gitea Actions runner (`brew install tinygo` is not
|
||
available on Linux runners, so use the official tarball or
|
||
`curl … | tar -xz` step), add `make -C ui wasm` ahead of the Vitest
|
||
step in `.gitea/workflows/ui-test.yaml`, then remove
|
||
`ui/frontend/static/core.wasm` and `ui/frontend/static/wasm_exec.js`
|
||
from the repo and re-tighten `ui/.gitignore`. Phase 5 committed the
|
||
binaries only as a stop-gap so contributors did not have to install
|
||
TinyGo. (Phase 5 cleanup, blocks before Phase 33 PWA.)
|
||
- **Restore `js.CopyBytesToGo` when TinyGo fixes the
|
||
`instanceof Uint8Array` check** — the per-element loop in
|
||
`ui/wasm/main.go::copyBytesFromJS` is a workaround for TinyGo 0.41
|
||
panicking on Uint8Arrays whose prototype chain crosses Node's
|
||
`Buffer`. Track upstream
|
||
(<https://github.com/tinygo-org/tinygo/issues>) and revert the
|
||
helper once a release is pinned. (Phase 5 follow-up.)
|
||
- **Migrate TS codegen to Connect-ES v2 BSR plugin once published** —
|
||
`ui/buf.gen.yaml` runs `protoc-gen-es` v2 locally because
|
||
`buf.build/connectrpc/es` is still on v1.6.1 and emits
|
||
v1-incompatible imports. When the v2 plugin lands on the BSR, we
|
||
can either keep the local plugin (no network dep) or move back to
|
||
the remote, depending on whether buf.build rate limits are hit in
|
||
CI. (Phase 5 follow-up; revisit when next regenerating.)
|
||
- **Rename `gateway/internal/grpcapi/` → `gateway/internal/connectapi/`**
|
||
— the package now hosts a Connect-Go listener that natively serves
|
||
Connect, gRPC, and gRPC-Web; the `grpcapi` name is historical.
|
||
Touches imports in `gateway/cmd/gateway/main.go` and a couple of
|
||
cross-package refs. Pure rename, no behaviour change. (Phase 4
|
||
cleanup; do alongside the next gateway change.)
|
||
- **Rename `GATEWAY_AUTHENTICATED_GRPC_*` env vars to drop the `GRPC`
|
||
infix** — they label the authenticated-edge tier, not the wire
|
||
protocol. Affects `gateway/internal/config/`, the integration
|
||
testenv defaults in `integration/testenv/gateway.go`, the README,
|
||
and the runbook. Coordinated with the package rename above.
|
||
(Phase 4 cleanup; not before the env vars are referenced by
|
||
external operators.)
|
||
- **Add a Docker-stack integration test for Connect end-to-end** —
|
||
Phase 4 closed with service-level Connect tests only. Once a phase
|
||
already brings up the full stack (Phase 7 onward, since auth flow
|
||
needs backend), drop a `integration/connect_call_test.go` that
|
||
exercises a unary Connect call and a server-streaming Connect call
|
||
through `testenv.Bootstrap`. (Phase 7+, fold into the phase that
|
||
needs it.)
|
||
- **Battle viewer — push event `game.battle.new`** — when a battle
|
||
involving the current player lands, emit a backend notification
|
||
intent (idempotency `battle-new:<game_id>:<turn>:<battle_id>`,
|
||
payload `{game_id, turn, battle_id}`) so the in-game shell
|
||
surfaces a toast with a deep link into the Battle Viewer.
|
||
(Phase 27 deferred; needs an engine emit-side change.)
|
||
- **Battle viewer — richer ship-class visuals** — current MVP draws
|
||
one small circle plus `<class>:<numLeft>` label per `(race,
|
||
className)` pair. Future work derives shape / scale from mass,
|
||
armament, shields, and the number of ships in the group.
|
||
(Phase 27 deferred.)
|
||
- **Battle viewer — animated re-distribution on elimination** —
|
||
current implementation hard-jumps to the new spacing on the next
|
||
frame; replace with an easing so the survivors visibly slide
|
||
along the outer ring. (Phase 27 deferred.)
|