docs(ui): finalize MVP plan structure and de-archaeologize topic docs

MVP web client (Phases 1-30) is complete; reorganize planning + living docs around that.

- PLAN.md kept as the staged MVP record (1-30) with a status block + pointers; removed the 31-36 stages, regression scenarios, and deferred-TODO section (moved out); fixed a stale cross-machine plan path.

- ui/PLAN-finalize.md (new): active web-finalization plan in 8 stages (visual system, a11y, i18n, error UX, PWA, build hygiene, docs, owner manual-QA loop); absorbs former Phases 33 and 35.

- ui/ROADMAP.md (new): post-MVP (Wails, Capacitor, realistic projection, acceptance + regression scenarios) and triaged deferred follow-ups.

- ui/docs/README.md (new): grouped topic-doc index.

- De-archaeologized all 20 ui/docs topic docs + ui/README.md + ui/core/README.md: stripped Phase-N build history, rewritten as current-state; deferred work now points at ROADMAP.md / PLAN-finalize.md. Docs-only; no code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-21 23:17:51 +02:00
parent 51865b8cf4
commit a89048f6c5
26 changed files with 836 additions and 929 deletions
+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.