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