f5ac9fac59
Locks in the synthetic-report parity rule as a global "Assumptions and Defaults" entry in ui/PLAN.md: every phase that extends the server->UI report contract must also extend the legacy parser in the same PR (or document in tools/local-dev/legacy-report/README.md why the new field cannot be derived from legacy text). The Go side already enforces shape compatibility via the pkg/model/report import; this rule extends that mechanical guard to "did we remember to wire the new field through". ui/docs/testing.md grows a "Synthetic reports for visual testing" section with the full conversion -> load -> compose loop and the two operational gotchas (no network on synthetic ids, page reload clears the in-memory map). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2915 lines
132 KiB
Markdown
2915 lines
132 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 introduces the
|
||
real `lib/history-mode.ts` module and replaces the constant in
|
||
one place.
|
||
7. **Empty-state copy is `order is empty` / `приказ пуст`.** The
|
||
`coming soon` placeholder text is replaced; per-row delete
|
||
button reads `delete` / `удалить`.
|
||
8. **e2e seeding via `__galaxyDebug.seedOrderDraft`.** The existing
|
||
debug surface in `routes/__debug/store/+page.svelte` is extended
|
||
with `seedOrderDraft(gameId, commands)` and
|
||
`clearOrderDraft(gameId)` helpers that write directly to the
|
||
`order-drafts` cache namespace. The store loads the seeded draft
|
||
on the next layout mount the same way it would after a real
|
||
reload.
|
||
9. **Race / disposal hygiene mirrors `GameStateStore`.** Mutations
|
||
are gated on `status === "ready"` so calls before `init`
|
||
resolves are no-ops, and `persist` checks a `destroyed` flag so
|
||
in-flight writes after `dispose` resolve into nothing.
|
||
|
||
Artifacts (delivered):
|
||
|
||
- `ui/frontend/src/sync/order-types.ts` — `OrderCommand`
|
||
discriminated union (single `placeholder` variant) and
|
||
`CommandStatus` lifecycle type.
|
||
- `ui/frontend/src/sync/order-draft.svelte.ts` —
|
||
`OrderDraftStore` runes class with
|
||
`init` / `add` / `remove` / `move` / `dispose`, plus
|
||
`ORDER_DRAFT_CONTEXT_KEY`. Persists the full draft on every
|
||
mutation under namespace `order-drafts`, key `{gameId}/draft`.
|
||
- `ui/frontend/src/lib/sidebar/order-tab.svelte` — replaces the
|
||
Phase 10 stub. Empty state from `game.sidebar.empty.order`;
|
||
ordered list with stable `data-testid="order-command-{i}"`
|
||
rows and a per-row delete button.
|
||
- `ui/frontend/src/lib/sidebar/sidebar.svelte`,
|
||
`tab-bar.svelte`, `bottom-tabs.svelte` — `historyMode` prop on
|
||
the sidebar forwards to `hideOrder` on tab-bar / bottom-tabs;
|
||
active-tab `order` is reset to `inspector` if the flag flips
|
||
on, and the `?sidebar=order` URL seed falls back to
|
||
`inspector` while the flag is true.
|
||
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
|
||
instantiates `OrderDraftStore`, sets context, runs
|
||
`init({ cache, gameId })` next to `gameState.init` through
|
||
one `Promise.all`, disposes on destroy, passes
|
||
`historyMode={false}` down.
|
||
- `ui/frontend/src/routes/__debug/store/+page.svelte` — extended
|
||
`DebugSurface` with `seedOrderDraft` / `clearOrderDraft`.
|
||
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new
|
||
`game.sidebar.order.command_delete` key plus updated
|
||
`game.sidebar.empty.order` copy.
|
||
- `ui/docs/order-composer.md` — topic doc describing the
|
||
draft-replaces-server-order model, local-validation invariant,
|
||
command status state machine, persistence, history-mode wiring,
|
||
and test layout. Cross-references `storage.md` and
|
||
`navigation.md`.
|
||
- `ui/docs/storage.md` — namespace registry row for
|
||
`order-drafts`.
|
||
- `ui/docs/navigation.md` — describes the historyMode prop wiring
|
||
through Sidebar / BottomTabs.
|
||
- `ui/README.md` — new entry under topic docs for
|
||
`order-composer.md`.
|
||
- Vitest: `ui/frontend/tests/order-draft.test.ts`.
|
||
- Playwright: `ui/frontend/tests/e2e/order-composer.spec.ts`.
|
||
|
||
Dependencies: Phases 6, 10.
|
||
|
||
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: pending.
|
||
|
||
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: pending.
|
||
|
||
Goal: enable group operations from the inspector: split, send, load,
|
||
unload, modernize, dismantle, transfer to race, add to fleet.
|
||
|
||
Artifacts:
|
||
|
||
- action buttons in `ui/frontend/src/lib/inspectors/ship-group.svelte`
|
||
with disabled-state and tooltip when local validation rejects
|
||
- `ui/frontend/src/sync/order-types.ts` extends with `SplitGroup`,
|
||
`SendGroup`, `LoadCargo`, `UnloadCargo`, `Modernize`, `Dismantle`,
|
||
`TransferToRace`, `AssignToFleet` command variants
|
||
- `Send` action picks destination through a planet picker filtered by
|
||
the group's reach (uses `pkg/calc/` reach function via Core; the
|
||
player's tech levels are already on `GameReport.localPlayer*` from
|
||
Phase 18, no extra plumbing needed)
|
||
- `Modernize` cost preview using `pkg/calc/` formula via Core
|
||
- confirmation dialog for `Dismantle` over a foreign planet with
|
||
colonists onboard (special-case from [`rules.txt`](../game/rules.txt): colonists die)
|
||
|
||
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:
|
||
|
||
- Vitest unit tests for action enablement logic per action;
|
||
- Vitest component tests for the dismantle-with-colonists confirmation;
|
||
- Playwright e2e for at least one complete flow (send a group between
|
||
two planets) against a local stack.
|
||
|
||
## Phase 21. Sciences — CRUD List + Designer
|
||
|
||
Status: pending.
|
||
|
||
Goal: define and manage sciences (named mixes of tech proportions
|
||
summing to 1.0) through a table view and a designer.
|
||
|
||
Artifacts:
|
||
|
||
- `ui/frontend/src/routes/games/[id]/table/sciences/+page.svelte`
|
||
list of sciences with name and four tech proportions
|
||
- `ui/frontend/src/routes/games/[id]/designer/science/[id]/+page.svelte`
|
||
designer with four numeric inputs that auto-normalise to 1.0 and a
|
||
name field
|
||
- `ui/frontend/src/sync/order-types.ts` extends with `CreateScience`
|
||
and `UpdateScience` command variants
|
||
- topic doc `ui/docs/science-designer-ux.md` covering
|
||
auto-normalisation, validation, and the relationship to the planet
|
||
production picker (Phase 15)
|
||
|
||
Dependencies: Phase 17.
|
||
|
||
Acceptance criteria:
|
||
|
||
- the user can create, edit, and delete sciences;
|
||
- proportions auto-normalise on edit so the sum is always 1.0;
|
||
- the planet production picker (Phase 15) lists the user's sciences
|
||
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 proportion normalisation;
|
||
- Vitest unit tests for science name validation;
|
||
- Playwright e2e: create a science, set a planet to research it,
|
||
submit, confirm.
|
||
|
||
## Phase 22. Races View — War/Peace Toggle and Votes
|
||
|
||
Status: pending.
|
||
|
||
Goal: list other races with their visible stats, expose war/peace
|
||
toggle and the voting UI.
|
||
|
||
Artifacts:
|
||
|
||
- `ui/frontend/src/routes/games/[id]/table/races/+page.svelte` table
|
||
with one row per race, including name, tech levels, total
|
||
population, total production, planet count, war-or-peace from this
|
||
race's perspective, votes received
|
||
- per-row toggle for declaring war or peace (adds
|
||
`SetDiplomaticStance` command)
|
||
- voting control: a single slot for `give my votes to <race>` (adds
|
||
`SetVoteRecipient` command)
|
||
- alliance summary panel showing the current vote graph and any
|
||
alliance reaching ≥ 2/3 of total votes
|
||
|
||
Dependencies: Phase 14.
|
||
|
||
Acceptance criteria:
|
||
|
||
- the user can toggle war / peace and change vote recipient;
|
||
- the alliance summary updates after a server roundtrip;
|
||
- vote counts match server state byte-for-byte.
|
||
|
||
Targeted tests:
|
||
|
||
- Vitest component tests for the alliance summary on canonical fixtures
|
||
(chain of votes, fork, win condition);
|
||
- Playwright e2e: change diplomatic stance and vote, submit, confirm.
|
||
|
||
## Phase 23. Reports View — Current Turn Sections
|
||
|
||
Status: pending.
|
||
|
||
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.
|
||
|
||
## Phase 24. Push Events — Turn-Ready
|
||
|
||
Status: pending.
|
||
|
||
Goal: subscribe to the server push stream and refresh client state
|
||
when a turn-ready event arrives.
|
||
|
||
Artifacts:
|
||
|
||
- `ui/frontend/src/api/events.ts` push-stream subscription wired
|
||
through `GalaxyClient.subscribeEvents` and Connect server-streaming
|
||
- on `game.turn.ready` event: invalidate `(game_id, current_turn)`
|
||
cache entries and trigger a fresh report fetch
|
||
- a top-of-screen toast: `Turn N+1 is ready. View now.` with a button
|
||
that re-renders the active view against the new turn
|
||
- mandatory event signature verification through `ui/core` — any
|
||
verification failure tears down the stream and reconnects with
|
||
exponential backoff
|
||
|
||
Dependencies: Phases 23, 4 (Connect streaming in gateway).
|
||
|
||
Acceptance criteria:
|
||
|
||
- a server-side turn cutoff produces a toast within one second;
|
||
- accepting the toast refreshes the active view to the new turn's data
|
||
without a full page reload;
|
||
- a forged event (test fixture with bad signature) is rejected and the
|
||
stream reconnects.
|
||
|
||
Targeted tests:
|
||
|
||
- Vitest unit tests for `events.ts` handling subscribe, event
|
||
dispatch, error backoff;
|
||
- Playwright e2e: trigger a server turn, observe toast and refresh.
|
||
|
||
## Phase 25. Sync Protocol — Order Queue, Retry, Conflict
|
||
|
||
Status: pending.
|
||
|
||
Goal: make the order draft survive network failures and turn cutoffs
|
||
gracefully, with explicit user feedback on conflicts.
|
||
|
||
Artifacts:
|
||
|
||
- `ui/frontend/src/sync/order-queue.ts` send loop: on disconnect, hold
|
||
the most recent submit; on reconnect, retry once; on persistent
|
||
failure, surface error to the order tab
|
||
- conflict detection: if the server returns `turn_already_closed` for
|
||
a submit, mark the entire draft as `conflict` and surface a
|
||
`Turn N closed before your order was accepted. Edit and resubmit.`
|
||
banner in the order tab
|
||
- topic doc `ui/docs/sync-protocol.md` covering queue semantics,
|
||
retry budgets, and conflict UX
|
||
|
||
Dependencies: Phases 14, 24.
|
||
|
||
Acceptance criteria:
|
||
|
||
- submitting an order while offline queues it and submits successfully
|
||
on reconnect;
|
||
- a turn cutoff between draft and submit produces a visible conflict
|
||
banner with no data loss;
|
||
- the order tab clearly distinguishes `draft`, `submitting`,
|
||
`accepted`, `rejected`, `conflict` states per command.
|
||
|
||
Targeted tests:
|
||
|
||
- Vitest unit tests for `order-queue` covering all state transitions;
|
||
- Playwright e2e: simulate network drop using Playwright's offline
|
||
mode, submit an order, restore network, confirm submission;
|
||
- regression test: force a turn cutoff during submit, assert conflict
|
||
banner appears.
|
||
|
||
## Phase 26. History Mode
|
||
|
||
Status: pending.
|
||
|
||
Goal: let the user navigate to past turns and view all data as it was,
|
||
with no order composition allowed.
|
||
|
||
Artifacts:
|
||
|
||
- `ui/frontend/src/lib/header/turn-navigator.svelte` clickable turn
|
||
counter expansion: popover (desktop) / bottom-sheet (mobile) listing
|
||
recent turns and a search field for jumping to a turn number
|
||
- `ui/frontend/src/lib/history-mode.ts` global toggle wired into every
|
||
view's data source: when active, all `state-binding`, table, report,
|
||
inspector, and map sources read from the historical snapshot for the
|
||
selected turn
|
||
- `ui/frontend/src/lib/header/history-banner.svelte` persistent banner
|
||
reading `Viewing turn N · read-only` with a `Return to current turn`
|
||
action
|
||
- order tab hidden in history mode (already prepared in Phase 12)
|
||
|
||
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);
|
||
- all UI views (map, tables, report, battle, mail) work in history
|
||
mode.
|
||
|
||
Targeted tests:
|
||
|
||
- Vitest unit tests for `history-mode` toggle and per-view source
|
||
selection;
|
||
- Playwright e2e: enter history mode, navigate three views, return,
|
||
confirm the order draft survived.
|
||
|
||
## Phase 27. Battle Viewer
|
||
|
||
Status: pending.
|
||
|
||
Goal: render battles as a dedicated view with playback controls
|
||
(play, pause, step forward, step backward, rewind), driven by the
|
||
server-side combat log; render battle and bombing markers on the map.
|
||
|
||
Artifacts:
|
||
|
||
- `ui/frontend/src/map/battle-markers.ts` renders markers on the map
|
||
for current-turn battles and bombings within visibility, clickable
|
||
to open the battle viewer
|
||
- `ui/frontend/src/routes/games/[id]/battle/[battleId]/+page.svelte`
|
||
view with the combatant list, the round-by-round log, and a player
|
||
control bar
|
||
- `ui/frontend/src/lib/battle-player/` round timeline, current-round
|
||
highlight, per-shot animation
|
||
- entry points to the viewer: marker on map, row in the report's
|
||
battles section, push-event toast when a battle this turn involved
|
||
the player
|
||
- topic doc `ui/docs/battle-viewer-ux.md` covering playback
|
||
semantics, accessibility (the combat log must be readable as text
|
||
for users who skip animations)
|
||
|
||
Dependencies: Phase 23.
|
||
|
||
Acceptance criteria:
|
||
|
||
- battle and bombing markers render on the map for the seeded
|
||
current-turn report and are clickable to open the viewer;
|
||
- the viewer plays back any battle in the seeded report including
|
||
multi-round and one-sided battles;
|
||
- step controls allow precise inspection;
|
||
- the same data is accessible as a static text log for accessibility.
|
||
|
||
Targeted tests:
|
||
|
||
- Vitest unit tests for round-state transitions;
|
||
- Vitest unit tests for marker rendering on torus and no-wrap
|
||
fixtures;
|
||
- Playwright e2e: click a battle marker on the map, play through,
|
||
step backward, return to the report.
|
||
|
||
## 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.)
|