docs(ui): finalize MVP plan structure + de-archaeologize topic docs #25

Merged
developer merged 1 commits from feature/ui-finalization-plan into development 2026-05-21 21:25:06 +00:00
26 changed files with 836 additions and 929 deletions
+130
View File
@@ -0,0 +1,130 @@
# UI Client — Finalization Plan
The MVP web client (Phases 130, [PLAN.md](PLAN.md)) is functionally
complete. This plan finalizes the **web** experience — visual
consistency, accessibility, localisation, error UX, installability, and
documentation — before the native platform wrappers in
[ROADMAP.md](ROADMAP.md). It absorbs the original Phases 33 (PWA) and 35
(Polish), which were pulled forward to here.
**This is a finalization pass, not the final word.** After it ships, the
owner exercises the whole UI by hand and brings small-nuance fixes; the
last stage (F8) is an explicit, open-ended owner-driven refinement loop.
The earlier stages are "done" when their acceptance holds; the feature
set is "final" only when the owner signs off.
Each stage ends with a runnable, reviewable artifact and is gated on the
per-stage CI rule (push, watch `go-unit` / `ui-test` to green) before
being marked done.
---
## F1 — Visual design system
Goal: replace the ad-hoc per-component styling (inline hex colors like
`#0a0e1a`, one-off spacing) with a shared design language so every view
looks like one product.
- `ui/frontend/src/lib/theme/` (or `app.css` `:root`) design tokens:
color palette (surface / border / text / accent / danger), spacing
scale, radii, typography, focus-ring style — as CSS custom properties.
- Migrate components to the tokens (calculator, inspectors, sidebar,
tables, lobby, auth) — replace literal hex/px with `var(--…)`.
- Topic doc `ui/docs/design-system.md` documenting the tokens and usage.
Acceptance: no literal theme colors left in component `<style>` blocks
(spot-checked); a single token change restyles the app coherently.
Consider the `frontend-design` skill for the palette/spacing pass.
## F2 — Accessibility (WCAG 2.2 AA)
(From Phase 35.) Goal: the whole client is usable by keyboard and
assistive tech.
- Keyboard-only paths for login, lobby, and the in-game shell; visible
focus rings (from the F1 token); ARIA labels/roles; screen-reader-only
text where needed.
- `ui/docs/a11y.md` audit results.
Acceptance: WCAG 2.2 AA on lobby, login, and the in-game shell per an
axe-core scan; full keyboard reachability with visible focus.
Tests: axe-core integration tests on every top-level view; Playwright
keyboard-only navigation.
## F3 — Localisation completeness
(From Phase 35.) Goal: every visible string is translated (en + ru).
- Audit all components for hard-coded strings; move them into the i18n
bundles; missing-key detection test; locale-switch persistence across
reloads.
Acceptance: no untranslated visible strings; missing-translation test is
green; locale persists. Tests: Vitest i18n bundle-structure +
missing-key detection.
## F4 — Error & state UX
(From Phase 35, plus the deferred Phase 13/35 items.) Goal: consistent,
actionable feedback everywhere.
- `ui/frontend/src/lib/error/` central error surface with stable codes
and retry/escalation guidance; every server-side error mapped to a
translated, actionable message (en/ru).
- Consistent empty / loading / error states across views.
- Selected-planet visual on the map (ring/halo) off the `SelectionStore`
(deferred from Phase 35).
- Mobile bottom-sheet swipe-down + tap-outside dismissal (deferred from
Phase 13/35).
Acceptance: every server error → a translated actionable message;
consistent empty/loading/error states; the selected planet is visually
marked.
## F5 — PWA (was Phase 33)
Goal: the web build is installable and offline-tolerant. Depends on F6
(wasm in CI) so the service worker caches a freshly-built artefact.
- `ui/frontend/src/service-worker.ts` cache-first assets with
stale-invalidation on app update; `manifest.webmanifest`;
`ui/frontend/static/icons/` web icon set; `ui/docs/pwa-strategy.md`.
Acceptance: installs as a PWA on Chrome, Edge, and iOS Safari; the SW
survives an app update without serving stale code. Tests: Lighthouse PWA
≥ 90; Playwright install→offline→cached-login; version-bump invalidation.
## F6 — Build hygiene: build core.wasm in CI
(From the PLAN.md TODO; timely — the binary is currently committed and
must be rebuilt by hand on every Go-bridge change, which has already
bitten us.) Goal: stop shipping the binary in git.
- Install TinyGo on the gitea Actions runner (Linux tarball /
`curl … | tar -xz`, since `brew` is macOS-only); add `make -C ui wasm`
ahead of the Vitest step in `.gitea/workflows/ui-test.yaml`.
- Remove `ui/frontend/static/core.wasm` and `wasm_exec.js` from the repo
and re-tighten `ui/.gitignore`.
Acceptance: CI builds the wasm and Vitest/Playwright pass against the
freshly built artefact; the binaries are no longer tracked.
## F7 — Documentation finalization
Goal: living docs read as current state, not build archaeology.
- De-archaeologize the 20 `ui/docs/` topic docs: strip "Phase N adds…"
history, rewrite as present-tense descriptions of how things work.
- `ui/docs/README.md` index (added during this reorganization).
- Sync `ui/README.md`, `docs/ARCHITECTURE.md`, and `docs/FUNCTIONAL.md`
(+ `_ru` mirror) with the finalized UI.
Acceptance: no stray "Phase N" references in `ui/docs/`; the index links
every topic doc; READMEs/ARCHITECTURE describe current state.
## F8 — Owner manual-QA refinement loop
Open-ended, owner-driven. The owner exercises the whole UI by hand and
files small-nuance fixes; each is folded in as it surfaces. There is no
fixed acceptance gate here — finalization is "done" when the owner signs
off and the client moves on to the ROADMAP platform wrappers.
+14 -382
View File
@@ -10,11 +10,18 @@ libraries (iOS/Android), and embedded directly in Wails (desktop). All
network I/O lives on the TypeScript side via ConnectRPC, so the Go
module is a pure compute boundary on every platform.
> **Status — MVP complete.** Phases 130 below (the full web client) are
> done. Remaining work is tracked outside this file: the active web
> finalization pass (visual system, accessibility, localisation, error
> UX, PWA, docs) in [PLAN-finalize.md](PLAN-finalize.md), and the
> deferred platform wrappers (Wails desktop, Capacitor mobile), the
> realistic multi-turn projection, and the cross-platform acceptance
> pass in [ROADMAP.md](ROADMAP.md). This file is retained as the staged
> record of how the MVP was built.
The existing Fyne client in `client/` is deprecated and is not modified
or imported by the new code. The strategy and rationale behind these
choices live in the plan file at
`/Users/id/.claude/plans/buzzing-questing-fountain.md`; the architectural
overview is mirrored into `ui/README.md` as part of Phase 1.
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
@@ -26,7 +33,9 @@ plan can be adjusted with at most one phase of rework.
## Summary
This plan breaks implementation into 36 small reviewable phases. Each
This plan staged the MVP web client as 30 small reviewable phases (130,
all complete; the finalization pass and post-MVP work live in
[PLAN-finalize.md](PLAN-finalize.md) and [ROADMAP.md](ROADMAP.md)). Each
phase has a single primary goal, clear deliverables, explicit
dependencies, acceptance criteria, and focused tests. Tests live
alongside the code added in the phase; a phase is not closed until its
@@ -3408,380 +3417,3 @@ 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.
## Phase 31. Wails Desktop Wrapper
Status: pending. Re-evaluate Wails v2 vs v3 at phase start.
Goal: build a native desktop app for macOS, Windows, and Linux that
runs the same frontend bundle and replaces the WASM core with embedded
Go code.
Artifacts:
- topic doc `ui/docs/wails-version.md` recording the v2-vs-v3
decision made at phase start with rationale
- `ui/desktop/main.go` Wails entry point
- `ui/desktop/app.go` IPC bindings exposing `ui/core` API to the
WebView through a structured adapter
- `ui/desktop/keychain/` per-OS secure-storage helpers (macOS Keychain
via `Security` framework, Windows DPAPI, Linux Secret Service / file
fallback at `~/.config/galaxy/keypair` with mode `0600`)
- `ui/desktop/sqlite/` `modernc.org/sqlite` cache wired through Wails
IPC
- `ui/frontend/src/platform/core/wails.ts` `WailsCore` adapter
- `ui/frontend/src/platform/store/wails.ts` `WailsKeyStore` and
`WailsCache` adapters
- `ui/desktop/build/icon.icns` macOS app icon
- `ui/desktop/build/icon.ico` Windows app icon
- `ui/desktop/build/icon.png` Linux app icon
- `ui/Makefile` targets `desktop-mac`, `desktop-win`, `desktop-linux`
- topic doc `ui/docs/desktop-secure-storage.md` documenting the
Linux/Windows file fallback for missing keychains
Dependencies: Phase 6 (KeyStore and Cache interfaces); Phases 7
through 30 in their web form (the desktop wrapper exercises the same
TypeScript code).
Acceptance criteria:
- the macOS, Windows, and Linux binaries each launch, complete login,
and preserve the keypair across restarts on a fresh user profile;
- a single source codebase produces all three OS bundles;
- the same `Core` and `Storage` TypeScript interfaces are satisfied as
on web, with no platform-specific code outside `platform/`;
- Linux file fallback activates when Secret Service is absent and
writes with `0600` permissions.
Targeted tests:
- Go unit tests for each keychain helper, including file fallback;
- desktop e2e smoke test driven by Wails headless mode running the
Phase 7 login Playwright scenario via CDP;
- regression test: keychain absence on a Linux container without
libsecret falls back to file storage.
## Phase 32. Capacitor Mobile Wrapper
Status: pending.
Goal: build native iOS and Android apps that run the same frontend
bundle and call into a gomobile-compiled `ui/core`.
Artifacts:
- `ui/mobile-bridge/bridge.go` gomobile-friendly façade over `ui/core`
- `ui/Makefile` target `gomobile` producing `Galaxy.framework` and
`galaxy.aar`
- `ui/mobile/capacitor.config.ts` Capacitor project configuration
- `ui/mobile/plugins/galaxy-core/` custom Capacitor plugin (Swift +
Kotlin) wrapping the gomobile artifacts
- `ui/frontend/src/platform/core/capacitor.ts` `CapacitorCore` adapter
- `ui/frontend/src/platform/store/capacitor.ts` `CapacitorKeyStore`
and `CapacitorCache` using `@capacitor-community/secure-storage-plugin`
and `@capacitor-community/sqlite`
- `ui/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/` iOS app
icon set
- `ui/mobile/android/app/src/main/res/mipmap-*/` Android app icon
set
- iOS launch screen and Android splash screen
- `ui/Makefile` targets `ios` and `android`
- topic doc `ui/docs/mobile-bridge.md` describing the plugin
API, marshalling strategy, and the manual smoke procedure for this
phase
Dependencies: Phase 6; Phases 7 through 30 in their web form.
Acceptance criteria:
- both the iOS Simulator and an Android Emulator launch the app,
complete login, and preserve the keypair across restarts (validated
by manual smoke);
- the same `Core` and `Storage` TypeScript interfaces are satisfied as
on web and desktop;
- gomobile build produces deterministic outputs reproducible in CI on
a macOS runner.
Targeted tests:
- Go unit tests for the `mobile-bridge` façade;
- Capacitor plugin unit tests on iOS (XCTest) and Android (Espresso);
- manual smoke procedure: login flow on iOS Simulator and Android
Emulator, recorded in `ui/docs/mobile-bridge.md`. Full Appium
automation lands in Phase 36 as part of the acceptance pass.
## Phase 33. PWA — Service Worker, Manifest, Web Icons
Status: pending.
Goal: make the web build installable and offline-tolerant on every
browser. Native packaging icons live with their respective wrapper
phases (31 for desktop, 32 for mobile) — this phase is web-only.
Artifacts:
- `ui/frontend/src/service-worker.ts` cache-first asset strategy with
stale invalidation on app update
- `ui/frontend/static/manifest.webmanifest` PWA manifest
- `ui/frontend/static/icons/` web icon set sized per
`manifest.webmanifest` requirements
- topic doc `ui/docs/pwa-strategy.md` covering update flow and
offline scope
Dependencies: Phase 25 (offline order queue).
Acceptance criteria:
- the web app installs as a PWA on Chrome, Edge, and iOS Safari;
- the service worker survives an app update without serving stale code
on the next reload.
Targeted tests:
- Lighthouse PWA audit at score ≥ 90;
- Playwright test: install the app, take it offline, verify the cached
login route still loads;
- regression test: bumping the app version invalidates the prior
service worker.
## Phase 34. Multi-Turn Projection — Realistic Planet Forecast
Status: pending. Long-term scope deferred but this phase ships real
features.
Goal: ship a realistic multi-turn planet projection and surface it in
the planet inspector and in the calculator's planet area. Reach circles
already shipped in Phase 30 (auto-drawn from the calculator's selected
planet); this phase no longer owns them.
The Phase 30 planet area is single-turn (MAT-only): it answers "ships
this turn / turns per ship" at the current or overridden MAT. This phase
makes it realistic and multi-turn by extracting the planet economy into
`pkg/calc` and simulating turns: population growth (`×1.08`), material /
capital / colonist supply, and the capital/colonist unpacking that
mirrors `MakeTurn` steps 09/12/14/15. CAP and COL only affect future
turns (post-production unpacking), so they become meaningful here and
are added to the calculator's planet area as supply inputs alongside
MAT.
Artifacts:
- `pkg/calc/` planet-economy extraction (single-sourced, engine
delegates): `PlanetProduction`, `ProducePopulation`,
`UnpackColonists`, `UnpackCapital`, reusing `ProduceShipsInTurn`; a
multi-turn projector `ProjectPlanetBuild` answering "K ships in M
turns" under guaranteed per-turn supply
- thin bridges in `ui/core/calc/` + `Core` typings
- planet inspector forecast section (next-turn population, industry,
materials, production progress)
- calculator planet area gains CAP and COL supply inputs and switches
its readout to the multi-turn projector
- topic doc `ui/docs/multi-turn-projection.md` (long-term vision:
multi-turn planning mode, scenario branches)
Dependencies: Phases 17, 18, 30.
Acceptance criteria:
- projector output is byte-identical to running the engine's per-turn
planet update over the same turns (Go parity);
- the planet inspector shows a forecast section matching it;
- the calculator planet area honours MAT / CAP / COL supply and shows
"K ships in M turns" consistent with the projector.
Targeted tests:
- Go parity tests for each extracted economy formula and the projector;
- Vitest for the calculator planet area with supply inputs;
- Playwright e2e: planet inspector forecast section.
## Phase 35. Polish — Accessibility, Localisation, Error UX
Status: pending.
Goal: prepare the client for technical beta with end-user-quality
polish.
Artifacts:
- `ui/frontend/src/lib/i18n/` translation bundles for English and
Russian, covering every visible string
- `ui/frontend/src/lib/error/` central error surface with stable codes
and retry / escalation guidance
- accessibility audit results recorded under `ui/docs/a11y.md`
- keyboard-only navigation paths for lobby, game view, and login
- focus rings, ARIA labels, screen-reader-only text where needed
- mobile bottom-sheet swipe-down dismissal and tap-outside dismissal,
on top of the close button shipped in Phase 13
- selected-planet visual on the map (ring or halo), wired off the
Phase 13 `SelectionStore`
Dependencies: Phase 33.
Acceptance criteria:
- WCAG 2.2 AA compliance on lobby, login, and the in-game shell per
axe-core scan;
- the entire UI is reachable by keyboard only with visible focus
rings;
- every server-side error is mapped to a translated, actionable user
message in both languages;
- locale switch persists across reloads on every platform.
Targeted tests:
- axe-core integration tests on every top-level view;
- Vitest tests for the i18n bundle structure and missing-translation
detection;
- Playwright keyboard-only navigation tests.
## Phase 36. Acceptance Pass
Status: pending.
Goal: reconcile implementation, documentation, and regression coverage
before declaring the client ready for technical beta.
Artifacts:
- updated `ui/README.md`, topic docs, and any drift in
`docs/ARCHITECTURE.md` or `docs/FUNCTIONAL.md` (mirrored to
`docs/FUNCTIONAL_ru.md`)
- final cross-platform regression run on a release-candidate build
- `ui/docs/release-checklist.md` for repeatable releases
- visual regression baselines committed under
`ui/frontend/tests/__snapshots__/`; if maintenance proves heavy,
follow-up issue to switch to self-hosted Argos
- Appium harness for iOS Simulator and Android Emulator covering the
login flow, push-event flow, and at least one full turn loop;
`.gitea/workflows/ui-release.yaml` extended with macOS-runner Appium
job (mandatory pre-release gate)
Dependencies: Phases 1 through 35.
Acceptance criteria:
- implementation matches every documented contract and live topic
doc;
- the cross-cutting regression scenarios listed below pass on web,
desktop, and mobile;
- Appium smoke passes on both iOS and Android in CI.
Targeted tests:
- run focused package tests for `ui/core` and every TypeScript
module;
- rerun cross-platform Playwright suites against release-candidate
builds;
- run Tier 2 visual regression baselines;
- run Appium smoke suites on iOS and Android.
---
## Cross-Cutting Regression Scenarios
- A fresh device generates a keypair, completes email-code login, and
successfully signs a follow-up authenticated request on every target
platform.
- A returning device resumes its session without re-login, preserves
queued orders, and continues receiving push events without gaps.
- Server-side session revocation tears down the active push stream and
forces a re-login on every target platform within one second.
- Tampering with `payload_bytes`, `payload_hash`, `request_id`,
`message_type`, or any signature byte is rejected by the verifier
in `ui/core` with a stable error code.
- Requests outside the freshness window are rejected before they
reach network, and the client surfaces a clock-skew warning when
its local clock disagrees with the server time event by more than
the freshness window.
- The map renderer holds 60 fps with a 1000-primitive fixture on
mid-range hardware on web (Chrome, Edge, Safari, Firefox), desktop
(Wails on macOS, Windows, Linux), and mobile (latest iPhone, mid-
range Android).
- The single-tool sidebar preserves state across tab switches; the
active view preserves state across view switches; designers
preserve their in-progress state when navigating to the map and
back through a transient overlay.
- Order draft is preserved across page reloads, view switches, network
drops, and history-mode entry / exit.
- Orders queued offline are flushed in order on reconnect; a turn-
cutoff conflict surfaces as a clearly failed-order banner without
retrying forever.
- History mode applies to every view; the order tab disappears in
history mode and the prior draft is restored on return to the
current turn.
- The ship-class designer's calculations match `pkg/calc/` byte-for-
byte; any drift between client mirror and server fails CI.
- Linux desktop builds without Secret Service still complete login by
falling back to the `0600` file under `~/.config/galaxy/`.
- The web service worker invalidates correctly on app update and
never serves stale code on the first load after a deploy.
- Push-event signature verification is mandatory; any verification
failure tears down the stream and reconnects with backoff.
- Locale switch persists across reloads and applies to every visible
string on every platform.
## TODO — deferred follow-ups from Phases 1-5
These items are explicit decisions to defer, not unknown work. Each
should be picked up either as a follow-up patch or folded into the
phase listed in the parenthesis when that phase lands.
- **Build `core.wasm` in CI, drop the committed artefacts** — install
TinyGo on the Gitea Actions runner (`brew install tinygo` is not
available on Linux runners, so use the official tarball or
`curl … | tar -xz` step), add `make -C ui wasm` ahead of the Vitest
step in `.gitea/workflows/ui-test.yaml`, then remove
`ui/frontend/static/core.wasm` and `ui/frontend/static/wasm_exec.js`
from the repo and re-tighten `ui/.gitignore`. Phase 5 committed the
binaries only as a stop-gap so contributors did not have to install
TinyGo. (Phase 5 cleanup, blocks before Phase 33 PWA.)
- **Restore `js.CopyBytesToGo` when TinyGo fixes the
`instanceof Uint8Array` check** — the per-element loop in
`ui/wasm/main.go::copyBytesFromJS` is a workaround for TinyGo 0.41
panicking on Uint8Arrays whose prototype chain crosses Node's
`Buffer`. Track upstream
(<https://github.com/tinygo-org/tinygo/issues>) and revert the
helper once a release is pinned. (Phase 5 follow-up.)
- **Migrate TS codegen to Connect-ES v2 BSR plugin once published** —
`ui/buf.gen.yaml` runs `protoc-gen-es` v2 locally because
`buf.build/connectrpc/es` is still on v1.6.1 and emits
v1-incompatible imports. When the v2 plugin lands on the BSR, we
can either keep the local plugin (no network dep) or move back to
the remote, depending on whether buf.build rate limits are hit in
CI. (Phase 5 follow-up; revisit when next regenerating.)
- **Rename `gateway/internal/grpcapi/` → `gateway/internal/connectapi/`**
— the package now hosts a Connect-Go listener that natively serves
Connect, gRPC, and gRPC-Web; the `grpcapi` name is historical.
Touches imports in `gateway/cmd/gateway/main.go` and a couple of
cross-package refs. Pure rename, no behaviour change. (Phase 4
cleanup; do alongside the next gateway change.)
- **Rename `GATEWAY_AUTHENTICATED_GRPC_*` env vars to drop the `GRPC`
infix** — they label the authenticated-edge tier, not the wire
protocol. Affects `gateway/internal/config/`, the integration
testenv defaults in `integration/testenv/gateway.go`, the README,
and the runbook. Coordinated with the package rename above.
(Phase 4 cleanup; not before the env vars are referenced by
external operators.)
- **Add a Docker-stack integration test for Connect end-to-end** —
Phase 4 closed with service-level Connect tests only. Once a phase
already brings up the full stack (Phase 7 onward, since auth flow
needs backend), drop a `integration/connect_call_test.go` that
exercises a unary Connect call and a server-streaming Connect call
through `testenv.Bootstrap`. (Phase 7+, fold into the phase that
needs it.)
- **Battle viewer — push event `game.battle.new`** — when a battle
involving the current player lands, emit a backend notification
intent (idempotency `battle-new:<game_id>:<turn>:<battle_id>`,
payload `{game_id, turn, battle_id}`) so the in-game shell
surfaces a toast with a deep link into the Battle Viewer.
(Phase 27 deferred; needs an engine emit-side change.)
- **Battle viewer — richer ship-class visuals** — current MVP draws
one small circle plus `<class>:<numLeft>` label per `(race,
className)` pair. Future work derives shape / scale from mass,
armament, shields, and the number of ships in the group.
(Phase 27 deferred.)
- **Battle viewer — animated re-distribution on elimination** —
current implementation hard-jumps to the new spacing on the next
frame; replace with an easing so the survivors visibly slide
along the outer ring. (Phase 27 deferred.)
+41 -53
View File
@@ -14,24 +14,22 @@ boundary on every platform.
The legacy Fyne client under `client/` is reference-only.
Nothing in `ui/` imports from it.
The full staged implementation plan lives in `PLAN.md`. The
strategic rationale (why Svelte, why PixiJS, why Go-as-WASM, why
The strategic rationale (why Svelte, why PixiJS, why Go-as-WASM, why
Wails+Capacitor) lives outside the repo at
`~/.claude/plans/buzzing-questing-fountain.md`. This README is a
quick orientation; deeper per-phase design notes earn their place
under `ui/docs/` as they are introduced.
quick orientation; deeper design notes live under `ui/docs/`.
## Targets
| Target | Wrapper | Toolchain | Phase |
| --------------- | ---------------- | ----------------------- | -------- |
| web | browser tab | Vite + WASM | 5+ |
| web-mobile | mobile browser | Vite + WASM | 5+ |
| desktop (mac) | Wails v2 | Go + Wails CLI | 31 |
| desktop (win) | Wails v2 | Go + Wails CLI | 31 |
| desktop (linux) | Wails v2 | Go + Wails CLI | 31 |
| iOS | Capacitor | gomobile + Xcode | 32+ |
| Android | Capacitor | gomobile + Gradle | 32+ |
| Target | Wrapper | Toolchain | Status |
| --------------- | ---------------- | ----------------------- | ------------------------------ |
| web | browser tab | Vite + WASM | implemented |
| web-mobile | mobile browser | Vite + WASM | implemented |
| desktop (mac) | Wails v2 | Go + Wails CLI | planned (see ROADMAP.md) |
| desktop (win) | Wails v2 | Go + Wails CLI | planned (see ROADMAP.md) |
| desktop (linux) | Wails v2 | Go + Wails CLI | planned (see ROADMAP.md) |
| iOS | Capacitor | gomobile + Xcode | planned (see ROADMAP.md) |
| Android | Capacitor | gomobile + Gradle | planned (see ROADMAP.md) |
## Layered architecture
@@ -58,7 +56,9 @@ under `ui/docs/` as they are introduced.
```text
ui/
├── PLAN.md staged implementation plan (Phases 1-36)
├── PLAN.md staged implementation plan
├── ROADMAP.md planned desktop / mobile / multi-turn features
├── PLAN-finalize.md PWA, accessibility, localisation, error UX
├── Makefile wasm / ts-protos / web / mobile / desktop targets
├── README.md this file
├── buf.gen.yaml local-plugin TS Protobuf-ES generator
@@ -71,6 +71,9 @@ ui/
│ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget
├── core/ ui/core Go module (canonical bytes, keypair)
├── wasm/ TinyGo entry point exposing Core to JS
├── mobile-bridge/ gomobile bindings (planned — see ROADMAP.md)
├── desktop/ Wails project (planned — see ROADMAP.md)
├── mobile/ Capacitor project (planned — see ROADMAP.md)
└── frontend/ SvelteKit / Vite source
├── src/api/ GalaxyClient + typed Connect client + auth + session
├── src/lib/ env config, session store, revocation watcher
@@ -98,40 +101,23 @@ Linked topic docs:
- [`docs/testing.md`](docs/testing.md) — Tier 1 per-PR + Tier 2
release test tiers.
```text
ui/
├── README.md this file
├── PLAN.md staged implementation plan
├── Makefile cross-target build placeholders
├── pnpm-workspace.yaml pnpm workspace root
├── .gitignore
├── docs/ per-phase topic docs (added per phase)
├── frontend/ TS + Svelte source, shared across targets
├── core/ Go module ui/core (Phase 3+)
├── wasm/ TinyGo entry point for core.wasm (Phase 5)
├── mobile-bridge/ gomobile bindings (Phase 32+)
├── desktop/ Wails project (Phase 31)
├── mobile/ Capacitor project (Phase 32+)
└── web/ static deploy assets (Phase 30+)
```
## Build pipeline
Every cross-target build flows through `make` at this level. All
named targets are placeholders until the named phase lands; running
`make` with no arguments prints the current placeholder map.
Every cross-target build flows through `make` at this level.
Native targets are placeholders until their platform work lands;
running `make` with no arguments prints the current placeholder map.
```text
make web Vite production build Phase 5+
make wasm TinyGo → core.wasm Phase 5
make ts-protos Connect-ES + Protobuf-ES gen Phase 5
make fbs-ts FlatBuffers TS bindings via flatc Phase 8
make gomobile gomobile bind → ios + android Phase 32+
make desktop-mac Wails build for darwin Phase 31
make desktop-win Wails build for windows Phase 31
make desktop-linux Wails build for linux Phase 31
make ios Capacitor + xcodebuild Phase 32+
make android Capacitor + gradle Phase 32+
make web Vite production build
make wasm TinyGo → core.wasm
make ts-protos Connect-ES + Protobuf-ES gen
make fbs-ts FlatBuffers TS bindings via flatc
make gomobile gomobile bind → ios + android (planned — see ROADMAP.md)
make desktop-mac Wails build for darwin (planned — see ROADMAP.md)
make desktop-win Wails build for windows (planned — see ROADMAP.md)
make desktop-linux Wails build for linux (planned — see ROADMAP.md)
make ios Capacitor + xcodebuild (planned — see ROADMAP.md)
make android Capacitor + gradle (planned — see ROADMAP.md)
make all every target above
```
@@ -159,18 +145,20 @@ hostnames — use the long-lived dev environment at
[`../tools/dev-deploy/`](../tools/dev-deploy/README.md). It is
redeployed by Gitea Actions on every merge into `development`.
## Per-phase docs
## Topic docs
Topic docs live under `ui/docs/` and are added per phase as they're
needed (testing tiers, WASM toolchain, navigation shell, renderer
internals, sync protocol, auth flow, and so on). The staged plan in
`PLAN.md` names the topic doc each phase produces.
Topic docs live under `ui/docs/` (testing tiers, WASM toolchain,
navigation shell, renderer internals, sync protocol, auth flow, and
so on).
## Cross-references
- [`PLAN.md`](./PLAN.md) — staged implementation plan with goals,
artifacts, dependencies, acceptance criteria, and targeted tests
per phase.
- [`PLAN.md`](./PLAN.md) — staged implementation plan (historical
record of completed work).
- [`ROADMAP.md`](./ROADMAP.md) — planned desktop / mobile / multi-turn
projection features.
- [`PLAN-finalize.md`](./PLAN-finalize.md) — PWA, accessibility,
localisation, error UX finalization work.
- [`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) — platform
architecture and the transport security model (§15) the client
envelope contract derives from.
@@ -178,4 +166,4 @@ internals, sync protocol, auth flow, and so on). The staged plan in
stories that drive the UI flows.
- [`../docs/TESTING.md`](../docs/TESTING.md) — project-wide testing
layers; UI-specific test tiers (Vitest, Playwright) live in
`ui/docs/testing.md` from Phase 2 onward.
`ui/docs/testing.md`.
+156
View File
@@ -0,0 +1,156 @@
# UI Client — Post-MVP Roadmap
The MVP web client (Phases 130 of [PLAN.md](PLAN.md)) is complete. The
near-term web **finalization** pass — visual system, accessibility,
localisation, error UX, PWA, docs — is tracked separately in
[PLAN-finalize.md](PLAN-finalize.md).
This roadmap holds the work deliberately deferred until after the web
client is finalized: the native platform wrappers, the realistic
multi-turn projection (a feature, not polish), the cross-platform
acceptance pass, and a set of non-blocking follow-ups. Items keep their
original PLAN.md phase numbers for continuity; their full original specs
live in the PLAN.md git history.
---
## Phase 31 — Wails Desktop Wrapper
Goal: native desktop app (macOS, Windows, Linux) running the same
frontend bundle, with `ui/core` embedded as Go instead of WASM.
Key artifacts: `ui/desktop/` Wails entry + IPC bindings; per-OS secure
storage (macOS Keychain, Windows DPAPI, Linux Secret Service with a
`0600` file fallback); `modernc.org/sqlite` cache; `WailsCore` /
`WailsKeyStore` / `WailsCache` adapters; OS icons; `ui/Makefile`
`desktop-*` targets; `ui/docs/desktop-secure-storage.md`. Re-evaluate
Wails v2 vs v3 at phase start.
Depends on: Phase 6 (KeyStore/Cache interfaces) and the web form of
Phases 730. Acceptance: all three OS binaries launch, log in, and
persist the keypair on a fresh profile from one codebase; Linux file
fallback activates without libsecret and writes `0600`.
## Phase 32 — Capacitor Mobile Wrapper
Goal: native iOS and Android apps running the same frontend bundle,
calling a gomobile-compiled `ui/core`.
Key artifacts: `ui/mobile-bridge/bridge.go` gomobile façade;
`ui/Makefile` `gomobile`/`ios`/`android` targets; Capacitor project +
custom `galaxy-core` plugin (Swift + Kotlin); `CapacitorCore` /
`CapacitorKeyStore` / `CapacitorCache` adapters (secure-storage +
sqlite community plugins); app icons + splash; `ui/docs/mobile-bridge.md`.
Depends on: Phase 6 and the web form of Phases 730. Acceptance: iOS
Simulator and Android Emulator launch, log in, and persist the keypair
across restarts (manual smoke); same `Core`/`Storage` TS interfaces as
web/desktop. Full Appium automation lands in Phase 36.
## Phase 34 — Multi-Turn Projection (realistic planet forecast)
Goal: a realistic multi-turn planet projection, surfaced in the planet
inspector and the calculator's planet area. (Reach circles already
shipped in Phase 30; this phase no longer owns them.)
The Phase 30 planet area is single-turn (MAT-only). This phase makes it
realistic and multi-turn by extracting the planet economy into `pkg/calc`
(single-sourced, engine delegates): `PlanetProduction`,
`ProducePopulation`, `UnpackColonists`, `UnpackCapital`, reusing
`ProduceShipsInTurn`; plus a `ProjectPlanetBuild` projector ("K ships in
M turns" under guaranteed per-turn supply) mirroring `MakeTurn` steps
09/12/14/15. CAP and COL only affect future turns (post-production
unpacking), so they become meaningful here and join MAT as supply inputs
in the calculator's planet area.
Key artifacts: the `pkg/calc` economy extraction + bridges + `Core`
typings; planet-inspector forecast section (next-turn population,
industry, materials, progress); calculator planet area gains CAP/COL
supply; `ui/docs/multi-turn-projection.md`.
Depends on: Phases 17, 18, 30. Acceptance: projector output byte-
identical to running the engine's per-turn planet update over the same
turns (Go parity); inspector + calculator readouts consistent with it.
## Phase 36 — Acceptance Pass
Goal: reconcile implementation, documentation, and regression coverage
before declaring the client ready for technical beta.
Key artifacts: final cross-platform regression run on a release
candidate; `ui/docs/release-checklist.md`; visual-regression baselines;
Appium harness (iOS Simulator + Android Emulator) covering login, push,
and a full turn loop, wired into a macOS-runner CI job as a pre-release
gate; a docs/README/ARCHITECTURE/FUNCTIONAL drift sweep.
Depends on: Phases 135 (incl. the finalization plan). Acceptance:
implementation matches every documented contract; the cross-cutting
regression scenarios below pass on web, desktop, and mobile; Appium
smoke passes on iOS and Android in CI.
### Cross-cutting regression scenarios
- A fresh device generates a keypair, completes email-code login, and
signs a follow-up authenticated request on every platform.
- A returning device resumes its session without re-login, preserves
queued orders, and keeps receiving push events without gaps.
- Server-side session revocation tears down the push stream and forces
re-login on every platform within one second.
- Tampering with `payload_bytes`, `payload_hash`, `request_id`,
`message_type`, or any signature byte is rejected by `ui/core` with a
stable error code.
- Requests outside the freshness window are rejected before network; a
clock-skew warning surfaces when the local clock disagrees beyond it.
- The map renderer holds 60 fps with a 1000-primitive fixture on web
(Chrome, Edge, Safari, Firefox), desktop (Wails on mac/win/linux),
and mobile (latest iPhone, mid-range Android).
- The sidebar preserves tool state across tab switches; the active view
preserves state across view switches.
- Order draft survives reloads, view switches, network drops, and
history-mode entry/exit.
- Orders queued offline flush in order on reconnect; a turn-cutoff
conflict surfaces as a failed-order banner without retrying forever.
- History mode applies to every view; the order tab disappears in
history mode and the prior draft is restored on return.
- The calculator's math matches `pkg/calc/` byte-for-byte; drift fails
CI.
- Linux desktop without Secret Service completes login via the `0600`
file fallback.
- The web service worker invalidates on app update and never serves
stale code on the first load after deploy.
- Push-event signature verification is mandatory; any failure tears
down the stream and reconnects with backoff.
- Locale switch persists across reloads and applies to every visible
string on every platform.
---
## Deferred follow-ups (non-blocking)
Explicit deferral decisions, to fold into the phase noted when it lands.
(The "build core.wasm in CI / drop the committed artefact" follow-up
moved to [PLAN-finalize.md](PLAN-finalize.md) F6, since it is timely.)
- **Restore `js.CopyBytesToGo`** when TinyGo fixes the
`instanceof Uint8Array` check — the per-element loop in
`ui/wasm/main.go::copyBytesFromJS` is a TinyGo 0.41 workaround. Track
upstream and revert once a fixed release is pinned.
- **Migrate TS codegen to the Connect-ES v2 BSR plugin** once published —
`ui/buf.gen.yaml` runs `protoc-gen-es` v2 locally because
`buf.build/connectrpc/es` still emits v1-incompatible imports.
- **Rename `gateway/internal/grpcapi/``connectapi/`** — historical
name; the package serves Connect/gRPC/gRPC-Web. Pure rename. (Fold
into the next gateway change; not a UI task.)
- **Rename `GATEWAY_AUTHENTICATED_GRPC_*` env vars** to drop the `GRPC`
infix — they label the authenticated edge tier, not the wire protocol.
(Coordinate with the rename above; not a UI task.)
- **Add a Docker-stack Connect end-to-end integration test** —
`integration/connect_call_test.go` exercising a unary + a
server-streaming Connect call through `testenv.Bootstrap`.
- **Battle viewer — push event `game.battle.new`** — emit a backend
notification intent (idempotency
`battle-new:<game_id>:<turn>:<battle_id>`) so the shell shows a toast
deep-linking into the Battle Viewer. Needs an engine emit-side change.
- **Battle viewer — richer ship-class visuals** — derive shape/scale
from mass, armament, shields, and ship count instead of the MVP
one-circle-plus-label per `(race, className)`.
+24 -23
View File
@@ -4,8 +4,9 @@
Galaxy cross-platform UI client. It carries v1 transport-envelope
canonical bytes, signature verification, and Ed25519 keypair
helpers. Network I/O and persistent storage live elsewhere on
purpose: this module compiles unchanged to WASM (Phase 5),
gomobile (Phase 32), and Wails-embedded native (Phase 31).
purpose: this module compiles unchanged to WASM (the web target today)
and is designed to also compile to gomobile (mobile) and Wails-embedded
native (desktop) — both planned, see [`../ROADMAP.md`](../ROADMAP.md).
The authoritative byte contract is defined in
[`docs/ARCHITECTURE.md` §15](../../docs/ARCHITECTURE.md). The gateway
@@ -39,8 +40,11 @@ parity and round-trip sign/verify are exercised by
```text
ui/core/
├── go.mod module galaxy/core (Go 1.26.0)
├── calc/ ship-math wrappers over `pkg/calc/ship.go`
── ship.go Phase 18 designer preview bridge
├── calc/ thin wrappers over `pkg/calc/` (no math here)
── ship.go ship geometry, mass, speed, combat
│ ├── planet.go ship build cost / per-turn production
│ ├── solve.go goal-seek inverse solvers
│ └── number.go display rounding (Ceil3)
├── canon/ canonical-bytes builders and verifiers
│ ├── canon.go length-prefix helpers
│ ├── request.go galaxy-request-v1 fields and signing input
@@ -92,20 +96,16 @@ ui/core/
### `galaxy/core/calc`
Thin Go bridge over `pkg/calc/ship.go`, surfaced via WASM to the
Phase 18 ship-class designer preview. Each function is a one-line
passthrough — no math lives here.
Thin Go bridges over `pkg/calc/`, surfaced via WASM to the ship-class
calculator and the ship-group inspector. Each function is a one-line
passthrough — no math lives here. Coverage spans ship geometry / mass /
speed / cargo, combat (attack, defence, bombing), block-upgrade and
per-turn ship-build cost, the single-target goal-seek inverse solvers,
and display rounding (`Ceil3`).
- `DriveEffective(drive, driveTech float64) float64`
- `EmptyMass(drive, weapons float64, armament uint, shields, cargo float64) (float64, bool)`
- `WeaponsBlockMass(weapons float64, armament uint) (float64, bool)`
- `FullMass(emptyMass, carryingMass float64) float64`
- `Speed(driveEffective, fullMass float64) float64`
- `CargoCapacity(cargo, cargoTech float64) float64`
- `CarryingMass(load, cargoTech float64) float64`
The full audit trail (which UI feature uses what, what is still
deferred) lives in [`ui/docs/calc-bridge.md`](../docs/calc-bridge.md).
The authoritative function surface (which UI feature uses what, parity
rules, what is still deferred) lives in
[`ui/docs/calc-bridge.md`](../docs/calc-bridge.md).
### `galaxy/core/types`
@@ -146,14 +146,15 @@ versa) is enforced from
## What this module is **not**
- Not a network client. ConnectRPC over `@connectrpc/connect-web`
on the TypeScript side is the only network surface (Phase 5+).
- Not a key store. Per-platform secure storage lives in Phase 6.
on the TypeScript side is the only network surface.
- Not a key store. Per-platform secure storage lives in the platform
`KeyStore` / `Cache` layer on the TypeScript side.
- Not a freshness gate. Server-side `±5 min` freshness checks
remain in `gateway/internal/grpcapi/freshness_replay.go`. The
client is expected to stamp its own `timestamp_ms` accurately
via `time.Now`, but does not enforce a window.
- Not a FlatBuffers codec — that lands in a later phase, so the
module today is small on purpose.
- Not a FlatBuffers codec — report decoding lives on the TypeScript
side, so this module stays small on purpose.
## Cross-references
@@ -161,5 +162,5 @@ versa) is enforced from
authoritative byte contract.
- [`../../gateway/authn`](../../gateway/authn) — server mirror of
the same canonical bytes.
- [`../PLAN.md`](../PLAN.md) Phase 3 — the staged plan that
describes how this module fits into the wider client.
- [`../PLAN.md`](../PLAN.md) — the staged plan that describes how this
module fits into the wider client.
+60
View File
@@ -0,0 +1,60 @@
# UI client — topic docs
Deeper, topic-based documentation for the Galaxy web/cross-platform UI
client, beyond what fits in [`../README.md`](../README.md). Each file
describes how one area works (current state); the staged build history
lives in [`../PLAN.md`](../PLAN.md), the active web finalization in
[`../PLAN-finalize.md`](../PLAN-finalize.md), and deferred work in
[`../ROADMAP.md`](../ROADMAP.md).
## Foundation & platform
- [navigation.md](navigation.md) — routes, the sidebar tabs, and the
state-preservation rules across view/tab switches.
- [storage.md](storage.md) — the `KeyStore` and `Cache` abstractions and
their web (IndexedDB) implementations.
- [game-state.md](game-state.md) — decoding the FlatBuffers report into
`GameReport` and the `GameState` / rendered-report stores.
- [sync-protocol.md](sync-protocol.md) — order-draft sync, turn cutoff,
conflict handling, and auto-pause.
- [events.md](events.md) — the signed push channel and event handling.
- [calc-bridge.md](calc-bridge.md) — the `pkg/calc` → WASM → TypeScript
bridge, with the live function surface and parity rules.
- [wasm-toolchain.md](wasm-toolchain.md) — building `ui/core` to
`core.wasm` with TinyGo.
- [testing.md](testing.md) — the UI test layers (Vitest + Playwright).
## Auth & lobby
- [auth-flow.md](auth-flow.md) — device keypair, email-code login, and
request signing on the client.
- [lobby.md](lobby.md) — the lobby/game-list UI and membership flows.
## Map & active views
- [renderer.md](renderer.md) — the PixiJS map renderer contract (world
model, hit-test, torus / no-wrap).
- [order-composer.md](order-composer.md) — the order tab and the
optimistic order overlay.
- [report-view.md](report-view.md) — the Reports view.
## Tools & inspectors
- [calculator-ux.md](calculator-ux.md) — the ship-class calculator
(design + goal-seek + planet build + reach circles + modernization).
- [science-designer-ux.md](science-designer-ux.md) — the science
designer.
- [ship-group-actions.md](ship-group-actions.md) — ship-group inspector
actions (move, send, upgrade, …).
- [cargo-routes-ux.md](cargo-routes-ux.md) — cargo-route composition and
reach filtering.
## Combat & comms
- [battle-viewer-ux.md](battle-viewer-ux.md) — the battle viewer.
- [diplomail-ui.md](diplomail-ui.md) — the diplomatic-mail view.
## Localisation
- [i18n.md](i18n.md) — the localisation mechanism and translation
bundles.
+16 -17
View File
@@ -96,7 +96,7 @@ The keypair lives next to the id in the same database (object
store `keypair`, key `device`). Clearing site data wipes both;
the next load generates a fresh keypair and the user must log in
again. This is the documented re-login path — there is no paired
"reissue device session" flow in Phase 7.
"reissue device session" flow.
## Browser support
@@ -105,25 +105,23 @@ Chrome ≥ 137, Firefox ≥ 130, Safari ≥ 17.4 (see
[`storage.md`](storage.md) for the rationale). On boot the layout
runs a sanity probe (`crypto.subtle.generateKey` for `Ed25519`); if
it rejects, the layout switches to a `browser not supported` page
instead of rendering `/login`. Phase 7 deliberately does not ship a
JavaScript Ed25519 fallback — see Phase 6's "modern-browser baseline,
no JS Ed25519 fallback" decision.
instead of rendering `/login`. The client deliberately does not ship a
JavaScript Ed25519 fallback — the design decision is modern-browser
baseline only.
## Revocation
The lobby layout opens a long-running `SubscribeEvents` stream as
soon as `status` becomes `authenticated`. The watcher does not
process individual events in Phase 7 — that arrives in Phase 24.
Its only contract is liveness: any non-aborted termination of the
stream is treated as a server-side session revocation, the watcher
calls `session.signOut("revoked")`, and the layout effect redirects
to `/login`.
soon as `status` becomes `authenticated`. Its only contract is
liveness: any non-aborted termination of the stream is treated as
a server-side session revocation, the watcher calls
`session.signOut("revoked")`, and the layout effect redirects to
`/login`.
This satisfies the Phase 7 acceptance bar of "session revocation
closes the active client within one second": the gateway closes
the stream the moment it observes a `session_invalidation` push
event from backend, and the watcher reacts on the next event-loop
tick.
Session revocation closes the active client within one second: the
gateway closes the stream the moment it observes a
`session_invalidation` push event from backend, and the watcher
reacts on the next event-loop tick.
## Localisation
@@ -140,8 +138,9 @@ drops JS-set `Accept-Language` headers. See
adding a new language.
The locale is **not** persisted between page reloads; detection
runs again on every visit. Phase 35's full polish pass will
revisit persistence and add message-format pluralisation.
runs again on every visit. Persistence and message-format
pluralisation are deferred to the finalization plan
(../Plan-finalize.md).
## Configuration
+7 -7
View File
@@ -1,9 +1,10 @@
# Battle Viewer UX
Phase 27 ships a dedicated viewer for battles (`/games/<id>/battle/<battleId>`).
Bombings stay where they were in Phase 23 — a static table in the
Reports view (`section-bombings.svelte`). The two domains are
deliberately not mixed in any visual surface or click target.
The battle viewer is a dedicated view for battles
(`/games/<id>/battle/<battleId>`). Bombings are a separate static
table in the Reports view (`section-bombings.svelte`). The two
domains are deliberately not mixed in any visual surface or click
target.
## Data shape
@@ -114,9 +115,8 @@ Below the scene the viewer renders a static `<ol>` text protocol —
one line per action, formatted from `BattleReportGroup.race` and
`BattleReportGroup.className`. The line for the current frame is
highlighted so a non-visual reader can follow along by scrolling
the log instead of watching the SVG. The list is always present
and never hidden, satisfying the original Phase 27 acceptance "the
same data is accessible as a static text log".
the log instead of watching the SVG. The list is always present and never hidden; the same data is
accessible as a static text log.
Each log row is also a `<button>`: a click or Enter/Space jumps
playback to that shot (pauses and seeks). The list auto-scrolls
+59 -73
View File
@@ -8,17 +8,16 @@ in Go under `pkg/calc/` and are surfaced to the UI through a
Go → WASM → TypeScript bridge mounted under `ui/core/calc/` and a
matching TS adapter in `ui/frontend/src/platform/core/`.
Phase 18 lands the **ship-math slice** of the bridge — everything
the ship-class designer needs to render its preview pane. Phase 20
extends it with `BlockUpgradeCost` so the ship-group inspector can
preview modernize cost. Phase 30 extends it with the **combat,
The bridge covers the **ship-math slice** (everything the ship-class
designer needs to render its preview pane), `BlockUpgradeCost` (for
the ship-group inspector's modernize-cost preview), and the **combat,
planet-build, and goal-seek slice** for the ship-class calculator:
`EffectiveAttack`, `EffectiveDefence`, `BombingPower`, `ShipBuildCost`,
`ProduceShipsInTurn`, and the inverse solvers from `pkg/calc/solve.go`.
Other slices (production/science forecast, the realistic multi-turn
planet projection) remain deferred to dedicated future phases. This
document is the running audit trail of what is live, what is missing,
and how each function maps to its `pkg/calc/` source.
planet projection) remain deferred. This document is the running audit
trail of what is live, what is missing, and how each function maps to
its `pkg/calc/` source.
## Live bridge surface
@@ -52,10 +51,10 @@ on the JS-side `globalThis.galaxyCore` (registered in
| `ceil3` | `calc.Ceil3(value)` (`pkg/calc/number.go`) | `number` | calculator display rounding (round up to 3 dp) |
`BombingPower` and the per-turn build loop are no longer engine-only:
Phase 30 extracted `BombingPower` from
`game/internal/model/game/group.go` and the per-iteration build math
from `controller.ProduceShip` into `pkg/calc` (`ProduceShipsInTurn`),
and the engine now delegates to both — a true refactor, not a mirror.
`BombingPower` was extracted from `game/internal/model/game/group.go`
and the per-iteration build math from `controller.ProduceShip` into
`pkg/calc` (`ProduceShipsInTurn`); the engine now delegates to both —
a true refactor, not a mirror.
The inverse solvers (`pkg/calc/solve.go`) invert the forward formulas
for single-target goal-seek and return `null` when infeasible;
`shieldsForDefence` uses bisection, the rest are analytic. Parity and
@@ -77,12 +76,12 @@ same inputs and asserts byte-equal outputs.
## Still-deferred slices
Phase 18's Go-side bridge is intentionally narrow: it covers ship
math and nothing else. Production forecasts, science, ship-build
progress, and reach (`FligthDistance`) still depend on either
inline TS arithmetic or the engine-shipped fields on `GameReport`.
See the table further down for what is missing and the per-feature
waivers below for the rationale on each deferral.
The Go-side bridge is intentionally narrow: it covers ship math and
the combat/planet-build/goal-seek slice. Production forecasts, science,
and reach (`FligthDistance`) still depend on either inline TS arithmetic
or the engine-shipped fields on `GameReport`. See the table further down
for what is missing and the per-feature waivers below for the rationale
on each deferral.
## Current `pkg/calc/` exports
@@ -91,7 +90,7 @@ waivers below for the rationale on each deferral.
| `ShipProductionCost(shipEmptyMass float64) float64` | Production units required per unit of ship empty mass (×10). |
| `PlanetProduceShipMass(L, Mat, Res float64) float64` | Ship mass produced per turn given free production `L`, material stockpile `Mat`, resources `Res`.|
| `DriveEffective`, `Speed`, `EmptyMass`, `FullMass`, … | Ship-level derivations (`pkg/calc/ship.go`). |
| `BlockUpgradeCost(blockMass, currentTech, target)` | Production cost of upgrading a single ship block (Phase 20 migrated this from `controller`). |
| `BlockUpgradeCost(blockMass, currentTech, target)` | Production cost of upgrading a single ship block (migrated from `controller`). |
| `FligthDistance(driveTech)`, `VisibilityDistance(...)` | Race-level reach formulas (`pkg/calc/race.go`). |
| `ValidateShipTypeValues`, `CheckShipTypeValueDWSC` | Ship-design validators (`pkg/calc/validator.go`). |
@@ -105,83 +104,70 @@ never been exported.
The table below tracks what UI features need from the bridge and
whether the underlying Go function exists.
| UI feature | Go formula | In `pkg/calc/`? | Surfaced to TS? |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | :-------------: | :-------------: |
| Ship-class designer preview (Phase 18) | `EmptyMass`, `FullMass`, `Speed`, `DriveEffective`, `CargoCapacity`, `CarryingMass`, `WeaponsBlockMass` (`pkg/calc/ship.go`) | yes | yes |
| Ship-group modernize cost preview (Phase 20) | `BlockUpgradeCost` (`pkg/calc/ship.go`, migrated from `game/internal/controller/ship_group_upgrade.go`) | yes | yes |
| Ship calculator combat (Phase 30) | `EffectiveAttack`, `EffectiveDefence`, `BombingPower` (`pkg/calc/ship.go`; `BombingPower` extracted from `model/game/group.go`) | yes | yes |
| Ship calculator goal-seek (Phase 30) | inverse solvers in `pkg/calc/solve.go` | yes | yes |
| Free production potential (`freeIndustry`) | `Planet.ProductionCapacity``industry*0.75 + population*0.25` (`game/internal/model/game/planet.go`) | no | no |
| Industry production output per turn | `Planet.ProduceIndustry(freeProduction)` (`planet.go`); `freeProduction/5` modulo material constraint | no | no |
| Materials production output per turn | `Planet.ProduceMaterial(freeProduction)` (`planet.go`); `freeProduction * resources` | no | no |
| Per-tech research progress (DRIVE/WEAPONS/…) | `ResearchTech` (`game/internal/model/game/science.go`); `freeProduction / 5000` per tech level | no | no |
| Custom-science progress | weighted form of `ResearchTech` driven by `Race.Sciences[].(Drive\|Weapons\|Shields\|Cargo)` (`science.go`) | no | no |
| Ship build progress / planet build rate (Phase 30)| `ProduceShipsInTurn(L, Mat, Res, mass)` (`pkg/calc/planet.go`, extracted from `controller.ProduceShip`); `ShipBuildCost` | yes | yes |
| UI feature | Go formula | In `pkg/calc/`? | Surfaced to TS? |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | :-------------: | :-------------: |
| Ship-class designer preview | `EmptyMass`, `FullMass`, `Speed`, `DriveEffective`, `CargoCapacity`, `CarryingMass`, `WeaponsBlockMass` (`pkg/calc/ship.go`) | yes | yes |
| Ship-group modernize cost preview | `BlockUpgradeCost` (`pkg/calc/ship.go`, migrated from `game/internal/controller/ship_group_upgrade.go`) | yes | yes |
| Ship calculator combat | `EffectiveAttack`, `EffectiveDefence`, `BombingPower` (`pkg/calc/ship.go`; `BombingPower` extracted from `model/game/group.go`) | yes | yes |
| Ship calculator goal-seek | inverse solvers in `pkg/calc/solve.go` | yes | yes |
| Free production potential (`freeIndustry`) | `Planet.ProductionCapacity``industry*0.75 + population*0.25` (`game/internal/model/game/planet.go`) | no | no |
| Industry production output per turn | `Planet.ProduceIndustry(freeProduction)` (`planet.go`); `freeProduction/5` modulo material constraint | no | no |
| Materials production output per turn | `Planet.ProduceMaterial(freeProduction)` (`planet.go`); `freeProduction * resources` | no | no |
| Per-tech research progress (DRIVE/WEAPONS/…) | `ResearchTech` (`game/internal/model/game/science.go`); `freeProduction / 5000` per tech level | no | no |
| Custom-science progress | weighted form of `ResearchTech` driven by `Race.Sciences[].(Drive\|Weapons\|Shields\|Cargo)` (`science.go`) | no | no |
| Ship build progress / planet build rate | `ProduceShipsInTurn(L, Mat, Res, mass)` (`pkg/calc/planet.go`, extracted from `controller.ProduceShip`); `ShipBuildCost` | yes | yes |
`partial` means the Go primitives exist in `pkg/calc/` but the
composition (and the conversion of TS-side `ReportPlanet`/
`ShipClass` to the formula inputs) is not implemented anywhere.
## Phase 15 waiver
## Production forecast waiver
Phase 15 ships the inspector's planet production controls
(segmented control + sub-pickers + collapse-by-`planetNumber`
order command) but **deliberately does not surface the per-type
forecast number**. The planning gate explicitly raised the gap as
a blocker per the plan's audit clause ("if any are missing in
`pkg/calc/`, raise as blocker") and the project owner approved
deferring the forecast to a dedicated future bridge phase. The
inspector still renders the existing `freeIndustry` row (free
production potential) — that number is computed engine-side and
ships in the report payload, so no calc-bridge access is required
for it today.
The inspector's planet production controls (segmented control +
sub-pickers + collapse-by-`planetNumber` order command) do **not**
surface the per-type forecast number. The inspector renders the
existing `freeIndustry` row (free production potential) — that number
is computed engine-side and ships in the report payload, so no
calc-bridge access is required for it. The per-type forecast number
is deferred pending promotion of the relevant formulas into
`pkg/calc/`.
Acceptance criterion 3 of Phase 15 ("forecast output number
reflects the chosen production type and matches `pkg/calc/`
outputs") is therefore intentionally not satisfied; the rewritten
Phase 15 stage text records this decision and points back at this
document.
## Reach formula waiver
## Phase 16 waiver
Phase 16 introduces ship-reach filtering for the cargo-route
destination picker. The engine formula is trivial:
Ship-reach filtering for the cargo-route destination picker uses
a trivial engine formula:
```
flightDistance = driveTech * 40
```
The Go-side reference now lives in
The Go-side reference lives in
[`pkg/calc/race.go`](../../pkg/calc/race.go) as
`FligthDistance(driveTech) float64` (alongside the matching
`VisibilityDistance` for in-space group reports — used in later
phases). The engine call sites
(`game/internal/model/game/race.go.FlightDistance`,
`VisibilityDistance` for in-space group reports). The engine call
sites (`game/internal/model/game/race.go.FlightDistance`,
`game/internal/controller/route.go.PlanetRouteSet`) still wrap the
Go formula directly; promoting them to call `pkg/calc/` is a
follow-up cleanup outside Phase 16's scope.
pending cleanup.
The original Phase 16 stage text described surfacing this through
`pkg/calc/` and `ui/core/calc/`; with the calc-bridge phase still
deferred, implementing the WASM glue for one constant-time
multiplication would be premature scaffolding. The picker
therefore computes reach inline in TypeScript using
`torusShortestDelta(planet.x, candidate.x, mapWidth)` and
`Math.hypot` against `40 * report.localPlayerDrive`, where
`localPlayerDrive` is decoded from the report's `Player` block by
matching `Player.name` to `report.race`
Implementing the WASM glue for one constant-time multiplication
would be premature scaffolding, so the picker computes reach inline
in TypeScript using `torusShortestDelta(planet.x, candidate.x,
mapWidth)` and `Math.hypot` against `40 * report.localPlayerDrive`,
where `localPlayerDrive` is decoded from the report's `Player` block
by matching `Player.name` to `report.race`
(`api/game-state.ts.findLocalPlayerDrive`).
When the calc-bridge phase ships, the inline formula is replaced
with a single call into the bridge — `calc.FligthDistance(driveTech)`
becomes the source of truth for both the picker and the
cargo-route auto-removal at turn cutoff. Until then, the UI
duplicates `flightDistance` knowingly — same precedent as the
production forecast deferral above.
When the remaining bridge work ships, the inline formula will be
replaced with a single call into the bridge —
`calc.FligthDistance(driveTech)` becomes the source of truth for
both the picker and the cargo-route auto-removal at turn cutoff.
Until then, the UI duplicates `flightDistance` knowingly — same
precedent as the production forecast deferral above.
## Planned bridge growth (follow-up phases)
## Planned bridge growth
Phase 18 set up the canonical bridge layout (Go subpackage + WASM
The canonical bridge layout is established (Go subpackage + WASM
registration + typed `Core` interface + parity tests). Future calc
work follows the same shape:
+4 -4
View File
@@ -1,8 +1,8 @@
# Ship Class Calculator — UX
Phase 30 fuses the ship-class designer and a calculator into one sidebar
tool (`lib/sidebar/calculator-tab.svelte`). It replaced the standalone
designer view/route from Phases 17/18. All numeric math lives in
The ship-class designer and calculator are fused into one sidebar
tool (`lib/sidebar/calculator-tab.svelte`). The standalone designer
view/route was replaced by this combined tool. All numeric math lives in
`pkg/calc` and is reached through the `Core` WASM bridge; the calculator
holds input state and orchestrates, it never computes.
@@ -36,7 +36,7 @@ in as a per-ship result rather than a separate mode.
3. **Planet area** — when an own planet is selected on the map, shows
its MAT (overridable) and the single-turn build rate (ships per turn,
turns per ship). The realistic multi-turn forecast with CAP/COL
supply is Phase 34.
supply is planned (see ../ROADMAP.md).
## Locks and goal-seek
+15 -16
View File
@@ -1,13 +1,12 @@
# Cargo routes UX
This document covers the cargo-route surface added in Phase 16: the
four-slot inspector subsection, the map-driven destination pick, and
the optimistic overlay that keeps the inspector and the map in lock-
step with the local order draft. The user-visible spec lives in
[`../PLAN.md`](../PLAN.md) Phase 16; the engine semantics are quoted
from [`game/rules.txt`](../../game/rules.txt) section "Грузовые
маршруты" (lines 808843); this file is the source of truth for how
the UI surfaces those rules.
This document covers the cargo-route surface: the four-slot
inspector subsection, the map-driven destination pick, and the
optimistic overlay that keeps the inspector and the map in lock-step
with the local order draft. The engine semantics are quoted from
[`game/rules.txt`](../../game/rules.txt) section "Грузовые маршруты"
(lines 808843); this file is the source of truth for how the UI
surfaces those rules.
## Engine semantics in one paragraph
@@ -44,7 +43,7 @@ section). `Remove` emits a `removeCargoRoute` command. The collapse
rule on the order draft store ensures only one entry per
`(source, loadType)` slot survives in the draft at any time, so a
sequence of `Add → Edit → Remove` collapses to the latest verb only
(matching the production-controls pattern from Phase 15).
(matching the production-controls pattern).
Disabled state: every button is disabled when the
`OrderDraftStore` or `MapPickService` context is missing (the
@@ -98,8 +97,8 @@ centre + zoom before each remount and restores them when the game
id is unchanged, so adding a route mid-pan does not jolt the view.
Arrows are drawn as a shaft plus two short arrowhead wings. Per-type
styling (placeholder Phase 16 colours; final values land in Phase
35 polish):
styling (visual refinements are deferred to the finalization plan
(../PLAN-finalize.md)):
| Load type | Stroke colour | Notes |
| --------- | ------------- | ------------------------ |
@@ -132,11 +131,11 @@ ownership of the *origin*). The picker mirrors that contract: the
`reachableSet()` in `cargo-routes.svelte` filters out only the
source planet itself.
Why inline rather than via a Go calc bridge? See the Phase 15 / 16
deferral note in [`calc-bridge.md`](./calc-bridge.md). The formula
is trivial (`tech × 40`) and the WASM glue would be premature
infrastructure; when the calc bridge phase lands the shared
`pkg/calc.FligthDistance` will replace this implementation.
Why inline rather than via a Go calc bridge? See the deferral note
in [`calc-bridge.md`](./calc-bridge.md). The formula is trivial
(`tech × 40`) and the WASM glue would be premature infrastructure;
when the calc bridge lands the shared `pkg/calc.FligthDistance` will
replace this implementation.
## Tests
+8 -8
View File
@@ -1,9 +1,9 @@
# In-game diplomatic mail UI
Phase 28 wires the in-game mail view that consumes the `diplomail`
subsystem in the backend. The route lives at `/games/:id/mail`
(registered in Phase 10) and replaces the active view when the user
opens the "diplomatic mail" entry in the header menu.
The in-game mail view consumes the `diplomail` subsystem in the
backend. The route lives at `/games/:id/mail` and replaces the
active view when the user opens the "diplomatic mail" entry in the
header menu.
## Wire surface
@@ -29,8 +29,8 @@ the gateway translation lives in
## Recipient by race name
The compose flow does **not** consult a memberships listing. The
recipient picker reads `gameState.report.races[].name` (the Phase 22
projection of `report.player[]`), and the send request carries the
recipient picker reads `gameState.report.races[].name` (projected
from `report.player[]`), and the send request carries the
chosen race name as `recipient_race_name`. The backend resolves it
against `Memberships.ListMembers(gameID, "active")` and rejects with
`forbidden` if the matching member is no longer active. This keeps
@@ -55,8 +55,8 @@ projects the union of inbox and sent into:
`read_at` and `deleted_at` are not surfaced to the user in any pane
— they only drive the badge counter and the optimistic mark-read
state. This is intentional (per Phase 28 decisions): the user-facing
spec for diplomatic mail does not promise read receipts.
state. This is intentional: the user-facing spec for diplomatic mail
does not promise read receipts.
## Translation toggle
+6 -8
View File
@@ -9,9 +9,8 @@ stops it on sign-out.
## Why a single consumer
Before Phase 24, the watcher in `lib/revocation-watcher.ts` opened a
parallel stream just to observe session revocation. Phase 24 folds
that watcher into `EventStream` so that:
The `EventStream` singleton consolidates what was previously a
separate revocation watcher in `lib/revocation-watcher.ts` so that:
- there is only **one** SubscribeEvents connection per session
(avoids doubling the gateway hub load);
@@ -19,7 +18,7 @@ that watcher into `EventStream` so that:
`Unauthenticated` ConnectError funnel through one
`session.signOut("revoked")` call site;
- per-event-type dispatch (turn-ready toasts, lobby/mail/battle
notifications later) shares the same verification path.
notifications) shares the same verification path.
## Lifecycle
@@ -67,10 +66,9 @@ exponential backoff (base 1 s, ceiling 30 s, unbounded retries).
- `reconnecting` — transient failure, backoff in flight.
- `offline``navigator.onLine === false` at the moment of failure.
The header connection-state indicator planned in `PLAN.md`
cross-cutting shell reads this rune; it is not part of Phase 24 but
the rune is wired now so a later phase can add the dot without
touching this module.
The header connection-state indicator reads this rune; the rune is
wired so a future change can add the indicator dot without touching
this module.
## Revocation semantics
+50 -58
View File
@@ -1,10 +1,8 @@
# Per-game state store
This document describes the per-game state owned by the in-game shell
layout. Phase 11 introduces the store and uses it for two consumers
(the header turn counter and the map view); later phases plug
inspector tabs, the order composer, and the calculator on top of the
same instance.
layout. The store serves the header turn counter, the map view,
inspector tabs, the order composer, and the calculator.
## Lifecycle
@@ -23,10 +21,9 @@ gameId })`. `init`:
2. calls `setGame(gameId)`, which:
- reads the per-game wrap-mode preference from `Cache`
(`game-prefs / <gameId>/wrap-mode`, default `torus`);
- calls `lobby.my.games.list` and finds the game record (the
Phase 11 wire schema extension on `GameSummary` adds
`current_turn`); if the user is not a member, the store flips
to `error`;
- calls `lobby.my.games.list` and finds the game record
(`GameSummary` carries `current_turn`); if the user is not a
member, the store flips to `error`;
- calls `user.games.report` for the discovered turn and decodes
the FlatBuffers response into a TS-friendly `GameReport` shape.
@@ -44,24 +41,21 @@ The store exposes:
| `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` |
| `error` | `string \| null` | localised error message when `status === "error"` |
## Phase boundaries
## Store extensions
- Phase 11 surfaces only the planet subset of the report. Later
phases extend `GameReport` and `decodeReport` as their slice of
the wire lands (ships, fleets, sciences, routes, battles, mail).
- Phase 26 splits `currentTurn` from the turn whose snapshot is
displayed (`viewedTurn`) and adds `viewTurn(turn)` /
`returnToCurrent()` for history navigation. The derived
`historyMode` rune flips automatically when `viewedTurn <
currentTurn`; the layout passes it to Phase 12's sidebar /
bottom-tabs wiring (which hides the order tab) and to
`OrderDraftStore.bindClient` (which gates `add` / `remove` /
`move`). See "History mode" below for the cache and refresh
rules.
- Phase 24 replaces the tab-focus refresh with push-event-driven
refreshes; the visibility listener stays as a fallback for
background tabs that miss a push.
- Phase 29 wires the wrap-mode toggle UI on top of `setWrapMode`.
`GameReport` and `decodeReport` are extended as each slice of the
wire lands (ships, fleets, sciences, routes, battles, mail).
`currentTurn` is split from `viewedTurn`, and `viewTurn(turn)` /
`returnToCurrent()` handle history navigation. The derived
`historyMode` rune flips automatically when `viewedTurn <
currentTurn`; the layout passes it to the sidebar / bottom-tabs
wiring (which hides the order tab) and to
`OrderDraftStore.bindClient` (which gates `add` / `remove` / `move`).
See "History mode" below for the cache and refresh rules.
Tab-focus refreshes are supplemented by push-event-driven refreshes;
the visibility listener stays as a fallback for background tabs that
miss a push. The wrap-mode toggle UI is wired on top of
`setWrapMode`.
## Why `current_turn` lives on `GameSummary`
@@ -79,20 +73,19 @@ Extending `GameSummary` reuses the existing lobby pipeline; the
backend already tracks `current_turn` in its runtime projection
(`backend/internal/server/handlers_user_lobby_helpers.go`
`gameSummaryToWire` reads it from `g.RuntimeSnapshot.CurrentTurn`).
The wire change touches Phase 8's already-shipped catalogue, but the
`current_turn` field defaults to zero on the FB side, so existing
The `current_turn` field defaults to zero on the FB side, so existing
tests and the dev sandbox flow continue to work unchanged.
## State binding
`map/state-binding.ts::reportToWorld(report)` translates a
`GameReport` into a renderer-ready `World`. Phase 11 emits one Point
`GameReport` into a renderer-ready `World`. It emits one Point
primitive per planet across all four kinds (local / other /
uninhabited / unidentified). Each kind gets a distinct fill colour,
fill alpha, and point radius so the four classes are
visually-distinguishable at a glance; later phases will refine the
colour palette as the visual language stabilises (Phase 35 polish).
visually-distinguishable at a glance; colour-palette refinement is
deferred to the finalization plan
([../PLAN-finalize.md](../PLAN-finalize.md)).
The planet engine number is reused as the primitive id so a hit-test
result can resolve back to a planet without an extra lookup table.
@@ -108,9 +101,9 @@ unchanged), so a no-op refresh does not flicker the canvas.
In history mode `refresh()` is a no-op — forcing a reload would
silently bump the user back onto the current turn while they are
intentionally viewing a past one. Push events (Phase 24) still
deliver new-turn notifications asynchronously while the user
explores history, so the pending-turn toast continues to work.
intentionally viewing a past one. Push events still deliver
new-turn notifications asynchronously while the user explores
history, so the pending-turn toast continues to work.
`setWrapMode(mode)` writes to `Cache` and updates the rune; the
map view's effect picks the change up and re-mounts the renderer
@@ -118,20 +111,19 @@ with the new mode.
## Map visibility toggles
Phase 29 adds a `mapToggles: MapToggles` rune that drives the
gear popover in the map view. Every flag defaults to `true`
including `unreachablePlanets` (showing every planet by default)
and `visibleHyperspace` (the fog overlay on by default). The
exhaustive shape lives in `src/lib/game-state.svelte.ts`; the
gear popover (`src/lib/active-view/map-toggles.svelte`) is a
thin view of the rune.
A `mapToggles: MapToggles` rune drives the gear popover in the map
view. Every flag defaults to `true` including `unreachablePlanets`
(showing every planet by default) and `visibleHyperspace` (the fog
overlay on by default). The exhaustive shape lives in
`src/lib/game-state.svelte.ts`; the gear popover
(`src/lib/active-view/map-toggles.svelte`) is a thin view of the
rune.
`setMapToggle(key, value)` flips one entry in place and
persists the whole blob to `Cache` under the
`game-map-toggles/{gameId}` key. The blob carries a companion
`lastResetTurn` number — the turn at which the toggles were last
reset to defaults — so the new-turn reset path (below) can detect
a stale blob even across a cross-session gap.
`setMapToggle(key, value)` flips one entry in place and persists
the whole blob to `Cache` under the `game-map-toggles/{gameId}` key.
The blob carries a companion `lastResetTurn` number — the turn at
which the toggles were last reset to defaults — so the new-turn reset
path (below) can detect a stale blob even across a cross-session gap.
### New-turn reset
@@ -156,7 +148,7 @@ The cache namespace and blob shape are documented in
## History mode
Phase 26 lets the user step backward through the report timeline
The store lets the user step backward through the report timeline
without losing the live snapshot. The store keeps two turn runes:
- `currentTurn` — the server's authoritative latest. Only
@@ -170,7 +162,7 @@ The derived `historyMode` rune (`status === "ready" && viewedTurn
< currentTurn`) drives every history-aware consumer:
- the layout passes it to `Sidebar` / `BottomTabs` so the order
tab vanishes (Phase 12 prop wiring);
tab vanishes;
- the layout passes a `getHistoryMode` getter to
`OrderDraftStore.bindClient` so `add` / `remove` / `move` are
no-ops while the user is looking at a past turn;
@@ -179,17 +171,17 @@ The derived `historyMode` rune (`status === "ready" && viewedTurn
- the new `HistoryBanner` component renders the sticky "Viewing
turn N · read-only" strip when the flag is true.
`last-viewed-turn` semantics keep their Phase 11 meaning: "the
latest turn the user was caught up on". `loadTurn` only writes the
cache row when called with `isCurrent === true` (i.e. when the
load matches `currentTurn`). Historical excursions are therefore
ephemeral: closing the tab and reopening the game resumes on the
last caught-up turn, not on the last clicked one.
`last-viewed-turn` means "the latest turn the user was caught up
on". `loadTurn` only writes the cache row when called with
`isCurrent === true` (i.e. when the load matches `currentTurn`).
Historical excursions are therefore ephemeral: closing the tab and
reopening the game resumes on the last caught-up turn, not on the
last clicked one.
Past-turn reports are cached in the `game-history` namespace
(`{gameId}/turn/{N}``GameReport`). The cache is written by
`loadTurn` on every successful historical fetch and read first by
`viewTurn(N)` before falling back to the network. Past turns are
immutable, so the cache has no TTL and no eviction in Phase 26.
The current-turn snapshot is deliberately *not* cached — it is
mutable until the next engine tick.
immutable, so the cache has no TTL and no eviction. The current-turn
snapshot is deliberately *not* cached — it is mutable until the next
engine tick.
+13 -11
View File
@@ -1,14 +1,14 @@
# i18n (UI)
The UI client ships with a minimal locale primitive used by the
phase-7 login form, the root layout, and the lobby placeholder. The
goal is just enough infrastructure to translate user-visible
strings, switch the active language at runtime, and forward the
caller's choice to the gateway. Phase 35 will swap this primitive
for a fuller solution once message-format pluralisation, lazy
loading, and translator workflows become necessary; until then,
the surface here covers every authenticated and unauthenticated
screen the client renders.
login form, the root layout, and the lobby. The goal is just
enough infrastructure to translate user-visible strings, switch
the active language at runtime, and forward the caller's choice to
the gateway. Swapping this primitive for a fuller solution with
message-format pluralisation, lazy loading, and translator
workflows is deferred to the finalization plan
(../Plan-finalize.md); until then, the surface here covers every
authenticated and unauthenticated screen the client renders.
## Surface
@@ -79,13 +79,15 @@ any preference, or `DEFAULT_LOCALE` (English) when nothing matches.
The web target calls it without arguments, in which case the helper
reads `navigator.languages` (or `navigator.language` as fallback).
Native wrappers (Wails, Capacitor) will pass their system locale
once Phase 31/32 lands; the helper is platform-agnostic by design.
once the desktop/mobile targets land (see ../ROADMAP.md); the
helper is platform-agnostic by design.
The detection runs once at module load — there is no asynchronous
init step. Callers that mutate the locale (e.g. the language picker
on `/login`) call `i18n.setLocale(next)` directly. The choice is
**not** persisted between page reloads in Phase 7; the next visit
re-runs detection. Persistence is a phase-35 concern.
**not** persisted between page reloads; the next visit re-runs
detection. Persistence is deferred to the finalization plan
(../Plan-finalize.md).
## Forwarding the locale to the gateway
+15 -19
View File
@@ -2,11 +2,9 @@
The lobby is the first authenticated view; the user lands here after
the email-code login completes (see
[`docs/auth-flow.md`](auth-flow.md)). Phase 8 introduced the live
lobby with five sections, the create-game form, and the TS-side
FlatBuffers integration the rest of the client builds on. This doc
captures the sections, the application / invite lifecycle the user
sees, and the defaults baked into the create-game form.
[`docs/auth-flow.md`](auth-flow.md)). This doc captures the
sections, the application / invite lifecycle the user sees, and
the defaults baked into the create-game form.
## Sections
@@ -23,15 +21,15 @@ width.
| `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) |
| `public games` | `no public games` | `lobby.public.games.list` | Submit application via inline race-name form (`lobby.application.submit`) |
The header preserves the device-session-id `<code>` block from the
Phase 7 placeholder (kept as a debug affordance) plus a greeting if
the gateway returns a `display_name` for the caller.
The header preserves the device-session-id `<code>` block (kept as
a debug affordance) plus a greeting if the gateway returns a
`display_name` for the caller.
`GameSummary` carries an extra `current_turn` field (Phase 11
extension) that the lobby UI does not display directly — the in-game
shell reads it from the same payload to load the matching
`user.games.report` for the map view without an additional gateway
call. See [`game-state.md`](game-state.md) for the consumer's view.
`GameSummary` carries a `current_turn` field that the lobby UI does
not display directly — the in-game shell reads it from the same
payload to load the matching `user.games.report` for the map view
without an additional gateway call. See
[`game-state.md`](game-state.md) for the consumer's view.
## Application lifecycle
@@ -104,7 +102,7 @@ and falls back to the gateway-supplied message via
The gateway encodes lobby payloads through `pkg/transcoder/lobby.go`
into FlatBuffers bytes; the browser must decode them with the same
schema. Phase 8 ships:
schema. The TS integration ships:
- `flatbuffers` runtime dependency in `ui/frontend/package.json`;
- `make -C ui fbs-ts` driving `flatc --ts` to regenerate the bindings
@@ -112,8 +110,6 @@ schema. Phase 8 ships:
- a Vitest round-trip suite (`tests/lobby-fbs.test.ts`) that catches
binding drift in CI.
Phase 7's `user.account.get` decode previously used
`JSON.parse(TextDecoder)`; that path was rewritten in Phase 8 to use
the same generated `AccountResponse` table, so the lobby greeting now
works against a real local stack as well as the mocked Playwright
fixtures.
`user.account.get` decodes through the generated `AccountResponse`
table, so the lobby greeting works against a real local stack as well
as the mocked Playwright fixtures.
+56 -65
View File
@@ -18,50 +18,45 @@ two-line wrapper that mounts the matching content component from
the plan is the file system plus those wrappers — there is no
separate dispatch component.
| URL | Active view component | Phase that fills it |
| ------------------------------------- | ---------------------------------------------- | ----------------------- |
| URL | Active view component | Phase that fills it |
| ------------------------------------------ | ---------------------------------------------- | ----------------------- |
| `/games/:id/map` | `lib/active-view/map.svelte` | Phase 11 |
| `/games/:id/table/:entity` | `lib/active-view/table.svelte` | Phase 11 / 17 / 19 / 22 |
| `/games/:id/report` | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) | Phase 23 |
| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 |
| `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 |
| `/games/:id/designer/science/:scienceId?` | `lib/active-view/designer-science.svelte` | Phase 21 |
| URL | Active view component |
| ------------------------------------------ | ---------------------------------------------------------------------- |
| `/games/:id/map` | `lib/active-view/map.svelte` |
| `/games/:id/table/:entity` | `lib/active-view/table.svelte` |
| `/games/:id/report` | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) |
| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` |
| `/games/:id/mail` | `lib/active-view/mail.svelte` |
| `/games/:id/designer/science/:scienceId?` | `lib/active-view/designer-science.svelte` |
`/games/:id` (no trailing view) redirects to `/games/:id/map`. The
optional `:scienceId?` segment on the science designer route matches
SvelteKit's `[[scienceId]]` syntax — `/designer/science` opens the
empty new-science form, `/designer/science/{name}` opens the named
science. Phase 17/18 originally added a parallel ship-class designer
route; Phase 30 removed it and folded ship-class design into the
sidebar ship-class calculator (`lib/sidebar/calculator-tab.svelte`,
see [calculator-ux.md](calculator-ux.md)), reached from the
ship-classes table and the view/bottom menus.
science. Ship-class design is folded into the sidebar ship-class
calculator (`lib/sidebar/calculator-tab.svelte`, see
[calculator-ux.md](calculator-ux.md)), reached from the ship-classes
table and the view/bottom menus.
The `entity` slug on the table route is kebab-case (`planets`,
`ship-classes`, `ship-groups`, `fleets`, `sciences`, `races`).
`table.svelte` is the active-view router: it dispatches by slug to
the per-entity component (`ship-classes``table-ship-classes.svelte`
in Phase 17; the others fall back to the Phase 10 stub copy until
their respective phases land).
the per-entity component (`ship-classes``table-ship-classes.svelte`;
other entities dispatch to their respective components).
## Sidebar tools and state preservation
The desktop sidebar hosts three tools:
| Tool | Component | Phase that fills it |
| ---------- | -------------------------------------- | -------------------- |
| Calculator | `lib/sidebar/calculator-tab.svelte` | Phase 30 |
| Inspector | `lib/sidebar/inspector-tab.svelte` | Phase 13 / 19 |
| Order | `lib/sidebar/order-tab.svelte` | Phase 12 / 14 |
| Tool | Component |
| ---------- | ----------------------------------- |
| Calculator | `lib/sidebar/calculator-tab.svelte` |
| Inspector | `lib/sidebar/inspector-tab.svelte` |
| Order | `lib/sidebar/order-tab.svelte` |
The selected-tab state is a `$state` rune in
`routes/games/[id]/+layout.svelte`, bound into
`lib/sidebar/sidebar.svelte` via `$bindable()`. The layout owns the
rune so external events — Phase 13's planet click, future similar
flows — can drive the active tab from outside the sidebar without
plumbing callbacks. The component is mounted by the layout, and
rune so external events — such as a planet click — can drive the
active tab from outside the sidebar without plumbing callbacks. The component is mounted by the layout, and
SvelteKit keeps that layout instance alive while the user navigates
between child routes (`/games/:id/map``/games/:id/report` → …),
so the rune survives every active-view switch automatically with no
@@ -70,23 +65,21 @@ described below still live inside the sidebar — they mutate the
bindable in place; the layout sees the change through the binding.
A `?sidebar=calc|calculator|inspector|order` URL param is read once
on mount and seeds the initial tab. Later phases that want to land
the user on a particular tool (for example, Phase 14's first
end-to-end command flow) can set it on navigation.
on mount and seeds the initial tab. Navigation flows that want to
land the user on a particular tool can set this param on navigation.
The Order entry is hidden when the layout's `historyMode` flag is
true. Phase 12 plumbs the flag end-to-end as a prop —
`+layout.svelte` forwards a derived value to `Sidebar`, which
true. `+layout.svelte` forwards a derived value to `Sidebar`, which
forwards `hideOrder` to its `TabBar`; the same flag goes to
`BottomTabs` so the mobile `Order` button is also suppressed. A
`?sidebar=order` URL seed that arrives while the flag is true falls
back to `inspector`, and an `$effect` on the sidebar resets
`activeTab` away from `order` if the flag flips on mid-session.
Phase 26 wires the flag to the live history signal owned by
`GameStateStore`. The derivation lives directly in `+layout.svelte`
The `historyMode` flag is derived from the live history signal owned
by `GameStateStore`. The derivation lives directly in `+layout.svelte`
(`const historyMode = $derived(gameState.historyMode)`) — no
separate `lib/history-mode.ts` module ships, because the layout is
separate `lib/history-mode.ts` module exists, because the layout is
the single consumer and the project's compactness rule rejects a
one-line indirection. The order draft survives the toggle because
`OrderDraftStore` lives one level above the sidebar in the layout
@@ -100,8 +93,7 @@ for the draft-store side of the flow and
## Header turn navigator and history banner
The header replaces the Phase 11 inline `turn N` text with a
`← Turn N →` triplet (`lib/header/turn-navigator.svelte`). The
The header shows a `← Turn N` triplet (`lib/header/turn-navigator.svelte`). The
arrows step `viewedTurn` by ±1 (disabled at boundaries `0` and
`currentTurn`); clicking the middle button opens an absolute
popover (desktop) or a fixed full-width drawer (mobile, ≤ 767.98
@@ -129,9 +121,8 @@ Three discrete CSS modes matched to the IA section diagrams:
toggle in the header right corner. Tapping the toggle slides the
sidebar in as a fixed overlay above the active view; a close
button on the sidebar dismisses it. The full swipe-from-right
gesture in the IA section is deferred to Phase 35 polish — the
click toggle satisfies the "layout switches at 768 px" acceptance
criterion on Phase 10.
gesture is deferred to the finalization plan
([../PLAN-finalize.md](../PLAN-finalize.md)).
- **< 768 px (mobile)** — the sidebar is hidden entirely and the
bottom-tabs row appears at the bottom of the viewport. The
view-menu trigger swaps to a hamburger icon (☰) that opens the
@@ -139,7 +130,7 @@ Three discrete CSS modes matched to the IA section diagrams:
On mobile the bottom tab row does not include `Inspector`. The
inspector content is reached by tapping a map object instead, which
raises a bottom-sheet — see [Planet selection](#planet-selection-phase-13).
raises a bottom-sheet — see [Planet selection](#planet-selection).
## Mobile bottom-tabs and tool overlay
@@ -157,27 +148,27 @@ The next time the user taps a Calc or Order bottom-tab, the
navigation re-routes them to `/map` and re-applies the overlay.
The `More` button opens a drawer that mirrors the header view-menu
content. The IA section's narrower "More" list (Mail, Battle log,
Tables, History, Settings, Logout) is the polish target for Phase 35
— Phase 10 keeps a single source of truth for destinations.
content. A narrower "More" list (Mail, Battle log, Tables, History,
Settings, Logout) is deferred to the finalization plan
([../PLAN-finalize.md](../PLAN-finalize.md)); the current drawer keeps
a single source of truth for destinations.
## Transient map overlays
Some views can push a transient overlay onto `/map` with a back
affordance. (Phase 30's calculator reach circles are a simpler,
always-on map extra rather than a back-stacked overlay; the transient
back-stack mechanism itself is still a Phase 34 concept.) A transient
overlay clears when the user navigates to any other view via the header
or the bottom-tabs.
affordance. (The calculator reach circles are a simpler, always-on
map extra rather than a back-stacked overlay; the transient
back-stack mechanism is planned — see
[../ROADMAP.md](../ROADMAP.md).) A transient overlay clears when the
user navigates to any other view via the header or the bottom-tabs.
Phase 10 documents this concept but does not implement the
back-stack mechanism. Phase 34 lands the back-stack alongside its
first user (multi-turn projection, range circles in the ship-class
designer).
The back-stack mechanism is not yet implemented; it is planned
alongside its first user (multi-turn projection, range circles in the
ship-class designer) in [../ROADMAP.md](../ROADMAP.md).
## Planet selection (Phase 13)
## Planet selection
The map view turns into the entry point for the inspector by
The map view is the entry point for the inspector by
translating a renderer click into a planet selection. The flow:
1. The renderer (`src/map/render.ts`) exposes `onClick(cb)` next to
@@ -190,9 +181,9 @@ translating a renderer click into a planet selection. The flow:
`GameStateStore.report`, and calls `SelectionStore.selectPlanet(number)`.
3. `SelectionStore` (`lib/selection.svelte.ts`) is a runes store
instantiated by the layout and exposed via Svelte context under
`SELECTION_CONTEXT_KEY`. It carries a discriminated union — Phase
13 only models `{ kind: "planet"; id: number }`; Phase 19 widens
it for ship groups. Selection is in-memory only: it survives the
`SELECTION_CONTEXT_KEY`. It carries a discriminated union —
`{ kind: "planet"; id: number }` for planets and widened for
ship groups. Selection is in-memory only: it survives the
layout's lifetime (active-view switches inside `/games/:id/*`)
but does not persist across reloads — that contrast with the
order draft is intentional.
@@ -211,11 +202,11 @@ translating a renderer click into a planet selection. The flow:
The mobile bottom-sheet is mounted alongside `<BottomTabs />` in the
layout. Its visibility is conditional on `effectiveTool === "map"` so
it does not stack on top of the calc / order overlays. Phase 13 ships
the minimal dismissal surface: a close button (`✕`) that calls
`SelectionStore.clear()`. Tap-outside and swipe-down dismissal from
the IA section are deferred to Phase 35 polish. A click that lands on
empty space is a no-op — selection is mutated only by an explicit
it does not stack on top of the calc / order overlays. The dismissal
surface is a close button (`✕`) that calls `SelectionStore.clear()`.
Tap-outside and swipe-down dismissal are deferred to the finalization
plan ([../PLAN-finalize.md](../PLAN-finalize.md)). A click that lands
on empty space is a no-op — selection is mutated only by an explicit
planet click or by the close button.
The planet inspector itself is a presentational component: it takes
@@ -227,9 +218,9 @@ field the FBS schema carries (`industryStockpile` for `capital`,
Fields the FBS table does not project for a given kind read as `null`
and the inspector simply omits the row.
The selected-planet visual on the map (a ring or halo) is **not**
shipped in Phase 13. It rolls into Phase 35 polish together with the
sheet's swipe-to-dismiss gesture.
The selected-planet visual on the map (a ring or halo) is deferred
to the finalization plan ([../PLAN-finalize.md](../PLAN-finalize.md))
together with the sheet's swipe-to-dismiss gesture.
## Auth gate
+64 -80
View File
@@ -25,29 +25,28 @@ during a connectivity hiccup keeps every line the player typed. A
remote-first composer that reflects the gateway's pending-orders
queue would force a sync on every keystroke.
Phase 14 lands the submit pipeline with batch semantics: every
entry the user has marked `valid` is collected into one signed
`user.games.order` request. The engine validates and stores the
order, returning per-command `cmdApplied` / `cmdErrorCode` in the
response body. The gateway re-encodes that JSON into the FBS
`UserGamesOrderResponse` envelope (with `commands: [CommandItem]`
populated), and `submitOrder` rejoins the verdict to each draft
entry by `cmdId`. Successfully applied entries stay visible in
the draft (the player keeps composing until turn cutoff);
rejected entries stay until the player edits or removes them.
The submit pipeline uses batch semantics: every entry the user has
marked `valid` is collected into one signed `user.games.order`
request. The engine validates and stores the order, returning
per-command `cmdApplied` / `cmdErrorCode` in the response body. The
gateway re-encodes that JSON into the FBS `UserGamesOrderResponse`
envelope (with `commands: [CommandItem]` populated), and `submitOrder`
rejoins the verdict to each draft entry by `cmdId`. Successfully
applied entries stay visible in the draft (the player keeps composing
until turn cutoff); rejected entries stay until the player edits or
removes them.
Phase 25 layers a transport-level policy on top of this baseline
without changing the batch semantics. The submit pipeline now
goes through `OrderQueue` (see
[`sync-protocol.md`](sync-protocol.md)): the queue holds the
submit while the browser is offline, classifies
`turn_already_closed` and `game_paused` server replies into
matching banners on the order tab, and exits the loop on the
sticky states so a stream of mutations does not re-elicit the
same gateway reply. Recovery from a `conflict` or `paused`
banner happens on the next `game.turn.ready` push frame via
`OrderDraftStore.resetForNewTurn`, which clears the local draft
and re-hydrates from the server for the new turn.
A transport-level policy layers on top of the batch baseline without
changing the batch semantics. The submit pipeline goes through
`OrderQueue` (see [`sync-protocol.md`](sync-protocol.md)): the queue
holds the submit while the browser is offline, classifies
`turn_already_closed` and `game_paused` server replies into matching
banners on the order tab, and exits the loop on the sticky states so
a stream of mutations does not re-elicit the same gateway reply.
Recovery from a `conflict` or `paused` banner happens on the next
`game.turn.ready` push frame via `OrderDraftStore.resetForNewTurn`,
which clears the local draft and re-hydrates from the server for the
new turn.
## Local-validation invariant
@@ -58,10 +57,9 @@ pipeline refuses to drain a draft that contains any `invalid`
entries. The validation step is per-command and pure — it consults
the current `GameStateStore` snapshot only, never the network.
Phase 14's `planetRename` is the first variant that exercises the
`draft → valid | invalid` transition. The validator
(`lib/util/entity-name.ts`) is a TS port of
`pkg/util/string.go.ValidateTypeName`, exercised on every render
The `planetRename` variant exercises the `draft → valid | invalid`
transition. The validator (`lib/util/entity-name.ts`) is a TS port
of `pkg/util/string.go.ValidateTypeName`, exercised on every render
in the inline editor and re-run by the store on every `add`. The
submit pipeline filters the draft to `valid` entries only — any
`invalid` row blocks the Submit button.
@@ -79,13 +77,12 @@ draft ──validate──▶ valid ──submit──▶ submitting ──ack
Transitions:
- **`draft → valid` / `draft → invalid`**: local validation. May
re-run when the underlying `GameStateStore` snapshot changes
(Phase 14+).
re-run when the underlying `GameStateStore` snapshot changes.
- **`valid → submitting`**: the submit pipeline picks the entry off
the draft and sends it to the gateway.
- **`submitting → applied` / `submitting → rejected`**: the gateway
responded; the entry is no longer in flight.
- **`submitting → conflict`** (Phase 25): the gateway returned
- **`submitting → conflict`**: the gateway returned
`resultCode = "turn_already_closed"`. The order tab surfaces a
banner above the command list. Any subsequent mutation
re-validates the conflict row back to `valid` / `invalid`; a
@@ -94,10 +91,10 @@ Transitions:
[`sync-protocol.md`](sync-protocol.md) for the full state
table and recovery paths.
Phase 14 lands the local validators (`draft → valid | invalid`),
the submit pipeline (`valid → submitting → applied | rejected`),
and the optimistic overlay that shows the player's intent on the
map and inspector while the order is in flight.
Local validators (`draft → valid | invalid`), the submit pipeline
(`valid → submitting → applied | rejected`), and the optimistic
overlay that shows the player's intent on the map and inspector while
the order is in flight are all implemented.
Statuses are runtime-only — they are not persisted alongside the
commands themselves. On every `init` the store re-runs
@@ -110,9 +107,7 @@ stored value).
## Discriminated union shape
`OrderCommand` is a discriminated union on the `kind` field. Phase
12 shipped the skeleton with a single content-free variant; Phase
14 added the first real one and Phase 15 added the second:
`OrderCommand` is a discriminated union on the `kind` field:
```ts
interface PlaceholderCommand {
@@ -148,9 +143,9 @@ The `id` field is the canonical identifier the store uses for
remove and reorder; later variants must keep `id: string` so the
store API stays uniform. The whole draft round-trips through
IndexedDB structured clone, so every variant must use only
JSON-friendly value types. Phase 14 lands `planetRename` together
with the inline editor in `lib/inspectors/planet.svelte`, the
local validator (`lib/util/entity-name.ts`, parity with
JSON-friendly value types. `planetRename` ships with the inline
editor in `lib/inspectors/planet.svelte`, the local validator
(`lib/util/entity-name.ts`, parity with
`pkg/util/string.go.ValidateTypeName`), and the submit pipeline.
`setProductionType` is the wire-mirror of the engine's
@@ -164,10 +159,10 @@ optimistic overlay rewrites `planet.production` using
mirrors the engine's `Cache.PlanetProductionDisplayName` so the
overlay stays byte-equal with the next server report.
### Collapse-by-target rule (Phase 15)
### Collapse-by-target rule
`setProductionType` is the first variant to carry a
collapse-by-target rule. `OrderDraftStore.add` enforces it:
`setProductionType` carries a collapse-by-target rule.
`OrderDraftStore.add` enforces it:
when the incoming command's `kind` is `"setProductionType"` it
drops every prior `setProductionType` entry with the same
`planetNumber` (and the matching keys from `statuses`) before
@@ -187,9 +182,7 @@ coexist — the rules apply within a `kind`, not across.
`OrderDraftStore` lives in
[`../frontend/src/sync/order-draft.svelte.ts`](../frontend/src/sync/order-draft.svelte.ts).
The class is a Svelte 5 runes store, so the file extension is
`.svelte.ts` (the original PLAN.md artifact line listed `.ts`
the deviation is documented inline in `PLAN.md`'s Phase 12
"Decisions" subsection).
`.svelte.ts`.
Lifecycle:
@@ -212,9 +205,8 @@ Layout integration mirrors `GameStateStore`:
- Exposed through the `ORDER_DRAFT_CONTEXT_KEY` Svelte context.
- Disposed in the layout's `onDestroy`.
The order tab consumes the store via
`getContext(ORDER_DRAFT_CONTEXT_KEY)`; Phase 14's planet inspector
will use the same key to push a new command.
The order tab and the planet inspector both consume the store via
`getContext(ORDER_DRAFT_CONTEXT_KEY)` to push new commands.
## Submit pipeline
@@ -224,9 +216,9 @@ will use the same key to push a new command.
`markSubmitting(ids)` so each row reads `submitting`, then
posts the snapshot through `submitOrder`.
2. `submitOrder` builds the FBS `UserGamesOrder` request (game_id,
`updatedAt = 0` in Phase 14, every command encoded as a
`CommandItem` with the typed payload union) and signs it via
the existing `executeCommand` orchestration.
`updatedAt`, every command encoded as a `CommandItem` with the
typed payload union) and signs it via the existing
`executeCommand` orchestration.
3. The engine validates, stores, and answers `202 Accepted` with
the stored order body — `game_id`, `updatedAt`, plus each
command echoed with `cmdApplied` and (on rejection)
@@ -252,8 +244,7 @@ in-flight entries back to `valid` so the operator can retry.
`applyOrderOverlay(report, commands, statuses)` (in
`api/game-state.ts`) returns a copy of the server `GameReport`
with every command in `applied` or `submitting` status projected
on top. Phase 14 understands `planetRename` only; future phases
extend the switch.
on top.
The overlay has its own context (`RENDERED_REPORT_CONTEXT_KEY`,
`lib/rendered-report.svelte.ts`) — the in-game shell layout owns
@@ -288,9 +279,7 @@ Cache row layout:
| -------------- | ------------------ | ---------------- |
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` |
The store writes the full draft on every mutation. Phase 25 may
profile the submit pipeline and batch into a microtask if write
amplification becomes a problem; until then the deterministic
The store writes the full draft on every mutation. The deterministic
write-on-every-mutation model is what tests assert and what the
layout relies on for crash safety.
@@ -300,14 +289,12 @@ order composer uses the namespace.
## History mode wiring
Phase 26 implements history mode: the user can step back through
past turns and see the report as it was. The IA section specifies
that the Order tab is hidden when history mode is active — the
player is browsing an immutable snapshot, and composing commands
History mode lets the user step back through past turns and see the
report as it was. The Order tab is hidden when history mode is active
the player is browsing an immutable snapshot, and composing commands
against it would be confusing.
Phase 12 wires the flag end-to-end as a prop. The layout owns the
flag and passes it to:
The layout owns the `historyMode` flag and passes it to:
- `Sidebar` as `historyMode`. The sidebar forwards it to its
`TabBar` as `hideOrder`. The Order entry is filtered out of the
@@ -318,17 +305,16 @@ flag and passes it to:
- `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order`
button is suppressed when true.
Phase 26 turns the constant into a derived value driven by
`GameStateStore.historyMode` (`viewedTurn < currentTurn` while
`status === "ready"`). The same getter is also passed into
`OrderDraftStore.bindClient` as `getHistoryMode`, which short-
circuits the `add` / `remove` / `move` mutations to a no-op while
the flag is true. This makes every Phase 1422 inspector affordance
that calls `orderDraft.add(...)` inert in history mode without
per-component edits — the gate lives in the one chokepoint that
all callers go through. The conflict / paused banners and the
in-flight sync pipeline are untouched: they describe state that
exists independently of the user's current view.
`historyMode` is a derived value driven by `GameStateStore.historyMode`
(`viewedTurn < currentTurn` while `status === "ready"`). The same
getter is also passed into `OrderDraftStore.bindClient` as
`getHistoryMode`, which short-circuits the `add` / `remove` / `move`
mutations to a no-op while the flag is true. This makes every
inspector affordance that calls `orderDraft.add(...)` inert in history
mode without per-component edits — the gate lives in the one
chokepoint that all callers go through. The conflict / paused banners
and the in-flight sync pipeline are untouched: they describe state
that exists independently of the user's current view.
The store itself stays alive across history-mode round-trips so
the draft survives the toggle. The `RenderedReportSource` overlay
@@ -346,13 +332,11 @@ the chrome.
## Testing
Phase 12 + Phase 14 test artifacts:
- [`../frontend/tests/order-draft.test.ts`](../frontend/tests/order-draft.test.ts)
— Vitest unit tests for the store. Drives `OrderDraftStore`
directly with `IDBCache` over `fake-indexeddb`. Covers init,
add, remove, move, per-game isolation, mutations-before-init,
dispose hygiene, the Phase 14 status machine
dispose hygiene, the status machine
(`validate` / `markSubmitting` / `applyResults` /
`revertSubmittingToValid`), and the
`hydrateFromServer` cache-miss fallback.
@@ -375,12 +359,12 @@ Phase 12 + Phase 14 test artifacts:
— Vitest component tests for the rename action and the inline
editor's local validation.
- [`../frontend/tests/e2e/order-composer.spec.ts`](../frontend/tests/e2e/order-composer.spec.ts)
— Playwright spec for the Phase 12 skeleton (seed three
— Playwright spec for the order composer skeleton (seed three
commands, reload, persistence).
- [`../frontend/tests/e2e/rename-planet.spec.ts`](../frontend/tests/e2e/rename-planet.spec.ts)
Phase 14 end-to-end: select a planet, rename, submit, observe
the overlay-applied name on the inspector + map, reload, and
see the rename hydrated from `user.games.order.get`.
End-to-end: select a planet, rename, submit, observe the
overlay-applied name on the inspector + map, reload, and see the
rename hydrated from `user.games.order.get`.
The `__galaxyDebug.seedOrderDraft(gameId, commands)` and
`__galaxyDebug.clearOrderDraft(gameId)` helpers in
+22 -23
View File
@@ -25,8 +25,7 @@ UI sits on top of. It must:
2. Support pan and zoom over a toroidal world (`'torus'` mode) and
over a bounded plane (`'no-wrap'` mode), both first-class.
3. Run the same algorithm on web, Wails, Capacitor, and PWA
targets — only the browser is supported in Phase 9, but no API
in this module assumes the platform.
targets — no API in this module assumes the platform.
4. Provide deterministic hit-test for cursor-to-primitive mapping,
with results that are unit-testable independently of Pixi.
@@ -76,11 +75,11 @@ overrides them.
## Theme
A single dark theme ships in Phase 9. The theme is a record of
default colours; primitives whose `style` omits a colour fall back
to the theme. Runtime theme switching is not implemented — Phase
35 introduces light/dark and the materialise-on-theme-change
cycle.
A single dark theme is implemented. The theme is a record of default
colours; primitives whose `style` omits a colour fall back to the
theme. Runtime theme switching is not implemented — light/dark and
the materialise-on-theme-change cycle are deferred to the
finalization plan ([../PLAN-finalize.md](../PLAN-finalize.md)).
## Hit-test
@@ -134,7 +133,7 @@ Per-primitive distance:
representation: from `(x1, y1)` to `(x1 + dx, y1 + dy)` where
`(dx, dy)` is the torus-shortest delta from end-1 to end-2.
The brute-force `O(N)` walk is fine for the Phase 9 target of
The brute-force `O(N)` walk is fine for the current target of
~1000 primitives on every pointer event. Spatial indexing is
deferred until profiling proves it necessary; PixiJS' culling and
batching handle the draw side without help.
@@ -260,10 +259,10 @@ average frame time over a scripted drag.
## Pick mode
Phase 16 introduced a generic *map-driven destination pick* the
inspector uses for cargo routes and that ship-group dispatch
(Phase 19/20) will reuse. The renderer owns the visual lifecycle;
the Svelte side wraps it in a promise-shaped service.
The renderer provides a generic *map-driven destination pick* that
the inspector uses for cargo routes and ship-group dispatch. The
renderer owns the visual lifecycle; the Svelte side wraps it in a
promise-shaped service.
Lifecycle (`RendererHandle.setPickMode(opts)`):
@@ -321,17 +320,17 @@ freshly-pushed extras layer (cargo-route overlay, pending-Send
tracks) does not silently un-hide a primitive whose id is in the
current set.
The Phase 29 map view (`src/lib/active-view/map.svelte`) computes
the set from the per-game `MapToggles` rune + the planet-cascade
rule and pushes it on every effect run; toggling a checkbox
flips visibility within one frame without a Pixi remount.
The map view (`src/lib/active-view/map.svelte`) computes the set
from the per-game `MapToggles` rune + the planet-cascade rule and
pushes it on every effect run; toggling a checkbox flips visibility
within one frame without a Pixi remount.
## Visible-hyperspace overlay (the "fog")
`RendererHandle.setVisibilityFog(circles)` draws (or removes) the
Phase 29 fog overlay used to highlight the player's visible
hyperspace. Each entry describes a circle around a LOCAL planet
where the player has scanner / visibility coverage:
fog overlay that highlights the player's visible hyperspace. Each
entry describes a circle around a LOCAL planet where the player has
scanner / visibility coverage:
- An empty list destroys the existing fog rectangles and mask.
- A non-empty list rebuilds a single viewport-level `fogLayer` (a
@@ -381,14 +380,14 @@ pixels:
- `getMapPrimitives()` returns a snapshot of every primitive in
the active world: id, kind, priority, current alpha
(post-overlay), the explicit fill / stroke colour from its
`Style` (no theme fallback), and the Phase 29 `visible` flag
mirroring the renderer's hide set.
`Style` (no theme fallback), and the `visible` flag mirroring the
renderer's hide set.
- `getMapPickState()` returns `{ active, sourcePlanetNumber,
reachableIds, hoveredId }` — the renderer's view of the
current pick session.
- `getMapCamera()` returns the current camera + viewport +
canvas-origin snapshot, used by Phase 29 e2e specs to assert
camera preservation across wrap-mode flips.
canvas-origin snapshot, used by e2e specs to assert camera
preservation across wrap-mode flips.
- `getMapFog()` returns the most recent fog input
(the list of circles last passed to `setVisibilityFog`).
Empty when the `visibleHyperspace` toggle is off.
+6 -6
View File
@@ -1,9 +1,9 @@
# Report view — Phase 23
# Report view
The Phase 23 in-game "turn report" view is a single scrollable
layout with twenty sections, one per array on the FBS `Report`
table. The route file is the standard two-line wrapper; the
orchestrator and the per-section components live under
The in-game "turn report" view is a single scrollable layout with
twenty sections, one per array on the FBS `Report` table. The route
file is the standard two-line wrapper; the orchestrator and the
per-section components live under
`ui/frontend/src/lib/active-view/report/`.
## Component layout
@@ -140,7 +140,7 @@ highlight consistent without a second source of truth.
## i18n namespace
All Phase 23 strings live under `game.report.*`:
All strings live under `game.report.*`:
- `game.report.loading` — section loading placeholder.
- `game.report.back_to_map`, `game.report.toc.title`,
`game.report.toc.mobile_label` — shell-level strings.
+9 -9
View File
@@ -6,9 +6,9 @@ planet's production is set to a science, the planet's industry
output for that turn is split between the four tech research tracks
in those proportions
(`game/internal/controller/planet/production.go.runScienceResearch`).
Phase 21 lights up the CRUD list, the designer, and the
production-picker integration. The wire and the engine validation
are unchanged from earlier phases — only the UI is new.
The CRUD list, the designer, and the production-picker integration
are provided by the UI; the wire and engine validation are handled
by the backend.
## Engine semantics in one paragraph
@@ -41,12 +41,12 @@ from `100`, and the form's Save button stays disabled until the sum
matches. A live readout under the inputs displays the running total
so the player can chase it down without trial-and-error guessing.
The strict-sum gate is a Phase 21 decision (alternatives —
auto-rebalance, raw-parts-with-engine-normalisation — were
considered and rejected): keeping the input model close to "what
gets sent on the wire" minimises surprises when the engine returns
the science exactly as typed. See `lib/util/science-validation.ts`
for the validator and the conversion helper.
The strict-sum gate was chosen over alternatives — auto-rebalance
and raw-parts-with-engine-normalisation — because keeping the input
model close to "what gets sent on the wire" minimises surprises when
the engine returns the science exactly as typed. See
`lib/util/science-validation.ts` for the validator and the
conversion helper.
## Name validation
+13 -17
View File
@@ -1,7 +1,7 @@
# Ship-group inspector actions
Phase 20 turns the read-only ship-group inspector
(`ui/frontend/src/lib/inspectors/ship-group.svelte`) into an
The ship-group inspector
(`ui/frontend/src/lib/inspectors/ship-group.svelte`) is an
interactive command source for the player's own groups in orbit.
This document is the running spec for the actions panel
(`ui/frontend/src/lib/inspectors/ship-group/actions.svelte`):
@@ -151,9 +151,8 @@ count. Block masses come from the player's
- Drive / shields / cargo block mass = the corresponding ship-
class field (raw value).
- Weapons block mass = `core.weaponsBlockMass({ weapons,
armament })` (Phase 18 bridge); returns null on the invalid
weapons/armament pairing, in which case the row contributes
zero.
armament })`; returns null on the invalid weapons/armament
pairing, in which case the row contributes zero.
For `tech === "ALL"` every block whose mass is non-zero
contributes against the player's race tech as the target. For
@@ -182,21 +181,18 @@ Per-action additional fields are documented on the
`ui/frontend/src/sync/order-types.ts` next to the JSDoc for each
variant.
## Decisions baked into Phase 20
## Design notes
- **`BlockUpgradeCost` migrated to `pkg/calc`**. The cost
formula previously lived in
`game/internal/controller/ship_group_upgrade.go`. To keep the
`ui/core/calc` bridge a wrapper around pure `pkg/calc/`
formulas, the function moved to `pkg/calc/ship.go` and the
controller now imports it (`controller/ship_group_upgrade.go`).
- **`BlockUpgradeCost` lives in `pkg/calc`**. The cost formula
lives in `pkg/calc/ship.go`; the `ui/core/calc` bridge wraps
pure `pkg/calc/` formulas, and the controller imports it
(`controller/ship_group_upgrade.go`).
- **`GameReport.otherRaces`**. The transfer-to-race picker reads
from a new `GameReport.otherRaces: string[]` field, populated
by walking `report.player[]` and excluding the local race plus
every `extinct` entry. Phase 22 (Races View) reuses the same
field.
from `GameReport.otherRaces: string[]`, populated by walking
`report.player[]` and excluding the local race plus every
`extinct` entry. The Races View reuses the same field.
- **Stationed-ship rows are clickable**. The map deliberately
hides on-planet groups; the planet inspector's stationed-ship
rows now pivot the selection to the corresponding ship-group
rows pivot the selection to the corresponding ship-group
variant so the actions panel is reachable from the standard
click flow.
+29 -28
View File
@@ -15,8 +15,9 @@ namespace.
This topic doc covers the web implementation only. The platform-
agnostic `KeyStore` and `Cache` interfaces in
`src/platform/store/index.ts` are what the rest of the client codes
against; later phases bring `WailsStore` and `CapacitorStore` adapters
that satisfy the same contracts.
against; `WailsStore` and `CapacitorStore` adapters will satisfy the
same contracts on their respective platforms (see
[../ROADMAP.md](../ROADMAP.md)).
Source-of-truth for the cross-service contract is
[`../../docs/ARCHITECTURE.md` §15 "Transport security model"](../../docs/ARCHITECTURE.md);
@@ -38,13 +39,12 @@ recently:
Browsers older than the baseline above will fail at the first
`generateKey({ name: 'Ed25519' }, ...)` call with a
`NotSupportedError`. Phase 6 deliberately does not ship a JavaScript
fallback (e.g. `@noble/ed25519`) — keeping the keystore on WebCrypto
is what gives us non-extractable storage on every supported engine.
The Phase 7 root layout runs a one-time probe on boot and switches
to a "browser not supported" page (described in
[`auth-flow.md`](auth-flow.md)) when the probe rejects, instead of
attempting the keystore generate.
`NotSupportedError`. No JavaScript fallback (e.g. `@noble/ed25519`)
is shipped — keeping the keystore on WebCrypto is what gives
non-extractable storage on every supported engine. The root layout
runs a one-time probe on boot and switches to a "browser not
supported" page (described in [`auth-flow.md`](auth-flow.md)) when
the probe rejects, instead of attempting the keystore generate.
### WebKit non-determinism note
@@ -60,8 +60,9 @@ is missing.
Tests that assert "the same key signs the same message identically"
must either pin to the Vitest path (Node WebCrypto) or be replaced
with verify-after-sign assertions. The Phase 6 Playwright spec uses
the verify path, which works on every engine in the baseline.
with verify-after-sign assertions. The Playwright spec for the
keystore uses the verify path, which works on every engine in the
baseline.
## IndexedDB schema
@@ -112,14 +113,14 @@ wipes every namespace.
Namespaces in current use:
| Namespace | Key | Value type | Owner |
|--------------------|--------------------------------|-----------------------------------------------|------------------------------------|
| `session` | `device-session-id` | `string` | Phase 7+ |
| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) |
| `game-prefs` | `{gameId}/last-viewed-turn` | `number` | Phase 11+ (`game-state.md`) |
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) |
| `game-history` | `{gameId}/turn/{N}` | `GameReport` | Phase 26+ (`game-state.md`) |
| `game-map-toggles` | `{gameId}` | `{toggles: MapToggles; lastResetTurn: number}` | Phase 29+ (`game-state.md`) |
| Namespace | Key | Value type | Owner |
|--------------------|--------------------------------|------------------------------------------------|--------------------------|
| `session` | `device-session-id` | `string` | `auth-flow.md` |
| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | `game-state.md` |
| `game-prefs` | `{gameId}/last-viewed-turn` | `number` | `game-state.md` |
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | `order-composer.md` |
| `game-history` | `{gameId}/turn/{N}` | `GameReport` | `game-state.md` |
| `game-map-toggles` | `{gameId}` | `{toggles: MapToggles; lastResetTurn: number}` | `game-state.md` |
The `game-map-toggles` blob stores the gear popover's per-game
visibility state plus a `lastResetTurn` companion field. Reading
@@ -131,10 +132,10 @@ whenever `lastResetTurn < currentTurn`, so a fresh server turn
always greets the player with every map category visible (see
`game-state.md` for the new-turn-reset contract).
Later phases will add more per-feature namespaces (fixtures, lobby
snapshot, etc.). The contract is namespace-strings stay scoped to
one feature; cross-feature reads through the cache are by convention
disallowed.
Additional per-feature namespaces will be added as needed (fixtures,
lobby snapshot, etc.). The contract is namespace-strings stay scoped
to one feature; cross-feature reads through the cache are by
convention disallowed.
## Keystore lifecycle
@@ -176,9 +177,8 @@ Thin orchestration layer over `KeyStore` + `Cache`:
- `loadDeviceSession(keyStore, cache)` returns
`{ keypair, deviceSessionId }`. The `keypair` field is always
populated (loaded if present, freshly generated if not). The
`deviceSessionId` field is `null` until Phase 7's
`confirm-email-code` handler stores the gateway-issued id via
`setDeviceSessionId`.
`deviceSessionId` field is `null` until the `confirm-email-code`
handler stores the gateway-issued id via `setDeviceSessionId`.
- `setDeviceSessionId(cache, id)` writes the id to the `session`
namespace.
- `clearDeviceSession(keyStore, cache)` wipes both the keypair and
@@ -186,7 +186,7 @@ Thin orchestration layer over `KeyStore` + `Cache`:
push-event-driven revocation path.
A `null` `deviceSessionId` is the signal that the session is
unauthenticated — Phase 7 routes such users to `/login`.
unauthenticated — the root layout routes such users to `/login`.
## Test layout
@@ -211,7 +211,8 @@ the debug entry point never attaches `window.__galaxyDebug`.
## Future: native targets
Phase 31 (Wails) and Phase 32 (Capacitor) bring native keystores —
Native desktop and mobile targets (planned in
[../ROADMAP.md](../ROADMAP.md)) will bring native keystores —
Keychain on macOS / iOS, DPAPI/Credential Locker on Windows,
libsecret on Linux, Android Keystore on Android — behind the same
`KeyStore` interface, plus SQLite-backed `Cache` adapters. The web
+6 -8
View File
@@ -1,11 +1,10 @@
# UI sync protocol
Phase 25 wires the transport-level policy that keeps the local
order draft consistent with the gateway across two failure modes
that Phase 14 punted on: transient network outages and turn
cutoffs the player did not anticipate. The wiring also reacts to
admin-initiated game pauses signalled by the new `game.paused`
push event.
The transport-level policy keeps the local order draft consistent
with the gateway across two failure modes: transient network
outages and turn cutoffs the player did not anticipate. The wiring
also reacts to admin-initiated game pauses signalled by the
`game.paused` push event.
The contract lives at three layers:
@@ -210,8 +209,7 @@ state machine deterministically.
status flip on the lobby side without an explicit push event.
The UI relies on the next `game.turn.ready` for recovery; a
dedicated `game.resumed` event would let the banner clear
immediately without waiting for the next cron tick. Not part
of this phase.
immediately without waiting for the next cron tick.
- The conflict banner shows the player-facing template
unmodified; a future revision may interpolate the explicit
cutoff timestamp once the server adds it to the error body.
+9 -10
View File
@@ -2,8 +2,7 @@
UI client test toolchain. Project-wide testing layers (service /
inter-service / system) live in [`../../docs/TESTING.md`](../../docs/TESTING.md);
this doc only covers the UI-specific tiers added in Phase 2 of
[`../PLAN.md`](../PLAN.md).
this doc covers the UI-specific tiers.
## Tier 1 — per-PR
@@ -45,17 +44,18 @@ as Gitea Actions artefacts (`playwright-report` and `playwright-traces`,
Triggered by `.gitea/workflows/ui-release.yaml` on tag push (`v*`).
Currently mirrors the Tier 1 step set; the dedicated release-only
checks land in later phases:
checks are deferred:
- **Visual regression baseline check** — Phase 33. Snapshots live in
- **Visual regression baseline check** — deferred to the
finalization plan (../Plan-finalize.md). Snapshots will live in
`ui/frontend/tests/__snapshots__/` until the project shifts to
Argos or another visual-diff service.
- **iOS smoke (Capacitor + Appium)** — Phase 32. Runs on a `macos-13`
runner once the Capacitor mobile wrapper exists.
- **iOS smoke (Capacitor + Appium)** — planned (see ../ROADMAP.md).
Runs on a `macos-13` runner once the Capacitor mobile wrapper
exists.
Both blocks are present as commented sections in
`.gitea/workflows/ui-release.yaml` with the phase number that
re-enables them.
`.gitea/workflows/ui-release.yaml`.
## Local execution
@@ -150,8 +150,7 @@ In synthetic mode:
same synthetic id afterwards redirects to /lobby. Re-load the JSON
to reseed.
The synthetic-report parity rule (see [`../PLAN.md`](../PLAN.md) §
Assumptions and Defaults) requires every UI phase that extends
The synthetic-report parity rule requires every change that extends
`decodeReport` to also extend the legacy parser in lockstep, or to
record in the parser's `README.md` that the new field cannot be
derived from legacy text. This keeps the synthetic-mode coverage in
+4 -4
View File
@@ -13,10 +13,10 @@ keystore — see [`storage.md`](storage.md) for the web implementation
Two viable Go-to-WASM toolchains exist:
| Toolchain | Bundle size (Phase 5) | Notes |
| Toolchain | Bundle size | Notes |
|---------------|------------------------------------|--------------------------------------------|
| **TinyGo** | ~903 KB (under 1 MB acceptance bar) | LLVM-based, no full GC, fast cold-start |
| Standard Go | ~2 MB (`GOOS=js GOARCH=wasm`) | Drops in without extra tooling |
| **TinyGo** | ~903 KB (under 1 MB target) | LLVM-based, no full GC, fast cold-start |
| Standard Go | ~2 MB (`GOOS=js GOARCH=wasm`) | Drops in without extra tooling |
`ui/core` was written under the TinyGo invariants documented in
`ui/core/README.md` (no `crypto/x509`, no `encoding/pem`, no
@@ -108,7 +108,7 @@ TinyGo being installed in every environment.
| Build | Date | Size |
|--------------|------------|-------|
| Phase 5 land | 2026-05-07 | 903 KB |
| Initial land | 2026-05-07 | 903 KB |
If the artefact ever crosses the 1 MB target, profile via
`tinygo build -size full` and trim before committing.