docs(ui): sync docs to the app-shell; fix stale nav comments
Tests · UI / test (push) Failing after 9m28s
Tests · UI / test (push) Failing after 9m28s
Rewrite ui/docs (navigation, order-composer, auth-flow, pwa-strategy, game-state + secondary topic docs) and ui/README for the single-URL app-shell (in-memory screens/views, Back→lobby via shallow routing, sessionStorage restore + validation, return-to-lobby). ui/PLAN.md gets a Phase-10 supersede note (implemented; standalone-compatible). Fix stale code comments (session-store auth gate, report-sections spec contract). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+29
-20
@@ -18,10 +18,15 @@ authoritative in [`docs/FUNCTIONAL.md` §1](../../docs/FUNCTIONAL.md).
|
||||
- `ui/frontend/src/lib/revocation-watcher.ts` — minimal
|
||||
`SubscribeEvents` watcher that triggers `signOut("revoked")` on
|
||||
any non-aborted stream termination.
|
||||
- `ui/frontend/src/routes/login/+page.svelte` — two-step form.
|
||||
- `ui/frontend/src/routes/lobby/+page.svelte` — placeholder lobby
|
||||
that issues the first authenticated `user.account.get`.
|
||||
- `ui/frontend/src/routes/+layout.svelte` — route guard plus the
|
||||
- `ui/frontend/src/lib/screens/login-screen.svelte` — two-step form.
|
||||
- `ui/frontend/src/lib/screens/lobby-screen.svelte` — lobby that
|
||||
issues the first authenticated `user.account.get`.
|
||||
- `ui/frontend/src/routes/+page.svelte` — the state-based auth gate /
|
||||
screen dispatcher (anonymous → login, authenticated → the
|
||||
`appScreen` screen). The single-URL app-shell has no per-screen
|
||||
routes; see [`navigation.md`](navigation.md).
|
||||
- `ui/frontend/src/routes/+layout.svelte` — boot-time session init,
|
||||
the `loading` / `unsupported` interception, and the
|
||||
browser-not-supported blocker.
|
||||
|
||||
## State machine (`SessionStatus`)
|
||||
@@ -50,8 +55,9 @@ authoritative in [`docs/FUNCTIONAL.md` §1](../../docs/FUNCTIONAL.md).
|
||||
```
|
||||
|
||||
`signOut("revoked")` shares the same observable end state as
|
||||
`signOut("user")`; the reason exists only for telemetry. Both
|
||||
trigger the layout effect's `anonymous → /login` redirect.
|
||||
`signOut("user")`; the reason exists only for telemetry. Both settle
|
||||
`status` to `anonymous`, which the dispatcher renders as the login
|
||||
screen — there is no URL redirect (the app-shell stays at `/game/`).
|
||||
|
||||
## UX states and error mapping
|
||||
|
||||
@@ -67,7 +73,7 @@ those branches.
|
||||
| 200 from `send-email-code` | advance to step `code`, focus the code input |
|
||||
| `invalid_request` from `send` | stay on step `email`, surface the gateway message |
|
||||
| `service_unavailable` from `send` | stay on step `email`, surface "service is temporarily unavailable" |
|
||||
| 200 from `confirm-email-code` | persist `device_session_id`, redirect to `/lobby` |
|
||||
| 200 from `confirm-email-code` | persist `device_session_id`, settle `status` to `authenticated` (dispatcher shows the lobby) |
|
||||
| `invalid_request` from `confirm` | bounce to step `email`, message: "code expired or already used" |
|
||||
| any other error from `confirm` | stay on step `code`, surface the gateway message |
|
||||
|
||||
@@ -89,8 +95,10 @@ After `confirm-email-code` succeeds, `session.signIn` writes the
|
||||
`device_session_id` into the IDB cache (`namespace=session`,
|
||||
`key=device-session-id`). On the next page load,
|
||||
`SessionStore.init` reads it back and settles `status` to
|
||||
`authenticated`, so the layout effect routes the user straight to
|
||||
`/lobby`.
|
||||
`authenticated`, so the dispatcher renders the authenticated screen
|
||||
straight away. Which authenticated screen it is comes from the
|
||||
restored `appScreen` snapshot (lobby by default; see
|
||||
[`navigation.md`](navigation.md)), not from the URL.
|
||||
|
||||
The keypair lives next to the id in the same database (object
|
||||
store `keypair`, key `device`). Clearing site data wipes both;
|
||||
@@ -102,21 +110,22 @@ again. This is the documented re-login path — there is no paired
|
||||
|
||||
The keystore relies on WebCrypto Ed25519, which currently lands in
|
||||
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`. The client deliberately does not ship a
|
||||
JavaScript Ed25519 fallback — the design decision is modern-browser
|
||||
baseline only.
|
||||
[`storage.md`](storage.md) for the rationale). On boot the root
|
||||
layout runs a sanity probe (`crypto.subtle.generateKey` for
|
||||
`Ed25519`); if it rejects, `status` settles to `unsupported` and the
|
||||
layout renders a `browser not supported` page instead of the login
|
||||
screen. 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
|
||||
The root layout opens a long-running `SubscribeEvents` stream as
|
||||
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`.
|
||||
`session.signOut("revoked")`, `status` settles to `anonymous`, and
|
||||
the dispatcher swaps to the login screen on the next render — the
|
||||
URL stays `/game/`.
|
||||
|
||||
Session revocation closes the active client within one second: the
|
||||
gateway closes the stream the moment it observes a
|
||||
@@ -126,8 +135,8 @@ reacts on the next event-loop tick.
|
||||
## Localisation
|
||||
|
||||
The login form, the root layout's blocker page, and the lobby
|
||||
placeholder go through the i18n primitive in `src/lib/i18n/`. The
|
||||
language picker on `/login` lists every entry in
|
||||
screen go through the i18n primitive in `src/lib/i18n/`. The
|
||||
language picker on the login screen lists every entry in
|
||||
`SUPPORTED_LOCALES` by its native name and is initialised from
|
||||
`navigator.languages` (web) with `en` as the fallback. Picking a
|
||||
different language re-renders the form in place and is forwarded
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Battle Viewer UX
|
||||
|
||||
The battle viewer is a dedicated view for battles
|
||||
(`/games/<id>/battle/<battleId>`). Bombings are a separate static
|
||||
The battle viewer is a dedicated active view for battles
|
||||
(`activeView.view === "battle"`, with `battleId` and `turn`
|
||||
sub-parameters; the app-shell has no per-view URL — see
|
||||
[`navigation.md`](navigation.md)). 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.
|
||||
@@ -212,7 +214,8 @@ result is an X-shaped cross overlaid on the planet glyph.
|
||||
The stroke width is computed by `battleMarkerStrokeWidth(shots)`:
|
||||
1 shot → 1 px, 100 shots → 5 px, linearly interpolated in between
|
||||
(`width = 1 + (shots − 1) × 4 / 99`, clamped). A click on either
|
||||
line navigates to `/games/<id>/battle/<battleId>?turn=<turn>`.
|
||||
line opens the battle viewer in memory via
|
||||
`activeView.select("battle", { battleId, turn })`.
|
||||
|
||||
### Bombing marker — colored ring
|
||||
|
||||
@@ -223,10 +226,10 @@ Colour:
|
||||
- yellow (`#FFD400`) when `wiped: false`,
|
||||
- red (`#FF3030`) when `wiped: true`.
|
||||
|
||||
A click on the ring navigates to `/games/<id>/report#report-bombings`
|
||||
and scrolls the matching `report-bombing-row` (by `data-planet`)
|
||||
into view. Bombing markers never open the Battle Viewer — the two
|
||||
domains stay separate.
|
||||
A click on the ring switches to the report view
|
||||
(`activeView.select("report")`) and scrolls the matching
|
||||
`report-bombing-row` (by `data-planet`) into view. Bombing markers
|
||||
never open the Battle Viewer — the two domains stay separate.
|
||||
|
||||
## Data source
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# In-game diplomatic mail UI
|
||||
|
||||
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.
|
||||
backend. It is the `mail` active view (`activeView.view === "mail"`)
|
||||
and replaces the active view when the user opens the "diplomatic mail"
|
||||
entry in the header menu (`activeView.select("mail")`). The app-shell
|
||||
has no per-view URL — see [`navigation.md`](navigation.md).
|
||||
|
||||
## Wire surface
|
||||
|
||||
@@ -70,11 +71,12 @@ render the original directly with no toggle.
|
||||
|
||||
`diplomail.message.received` push frames are dispatched from
|
||||
`api/events.svelte.ts` via the singleton SubscribeEvents stream. The
|
||||
in-game layout (`routes/games/[id]/+layout.svelte`) parses the
|
||||
in-game shell (`lib/game/game-shell.svelte`) parses the
|
||||
verified payload, calls `mailStore.applyPushEvent(gameId)` (which
|
||||
re-fetches the inbox — the payload only carries a preview), and
|
||||
raises a toast through `lib/toast.svelte.ts` with a "view"
|
||||
deep-link to `/games/:id/mail`.
|
||||
raises a toast through `lib/toast.svelte.ts` whose "view" action
|
||||
switches to the mail view in memory (`activeView.select("mail")`) —
|
||||
no URL navigation.
|
||||
|
||||
The header view-menu's mail entry shows `mailStore.unreadCount` as
|
||||
an inline pill — the only chrome the badge needs.
|
||||
|
||||
+5
-5
@@ -93,11 +93,11 @@ reconnect.
|
||||
});
|
||||
onDestroy(off);
|
||||
```
|
||||
2. If the handler reads scoped data (per-game, per-route), register
|
||||
from a layout that owns that scope and pass the gameId via a
|
||||
closure. The handler should filter events whose payload does not
|
||||
match its scope (see `routes/games/[id]/+layout.svelte` for the
|
||||
`game.turn.ready` filter).
|
||||
2. If the handler reads scoped data (per-game), register from a
|
||||
component that owns that scope and pass the gameId via a closure.
|
||||
The handler should filter events whose payload does not match its
|
||||
scope (see `lib/game/game-shell.svelte` for the `game.turn.ready`
|
||||
filter).
|
||||
3. The payload encoding is owned by the producer side: the
|
||||
`game.turn.ready` event uses JSON `{game_id, turn}`. Document
|
||||
the schema next to the producer (e.g. `backend/README.md` §10).
|
||||
|
||||
+32
-12
@@ -6,13 +6,15 @@ inspector tabs, the order composer, and the calculator.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
`routes/games/[id]/+layout.svelte` instantiates one `GameStateStore`
|
||||
per game (the layout remounts when the user navigates to a different
|
||||
game id, so each game gets a fresh store). The layout exposes the
|
||||
instance through Svelte context under `GAME_STATE_CONTEXT_KEY`;
|
||||
descendants read it via `getContext(GAME_STATE_CONTEXT_KEY)`.
|
||||
The in-game shell (`lib/game/game-shell.svelte`) instantiates one
|
||||
`GameStateStore` per game. The shell is mounted by the single-route
|
||||
dispatcher only while `appScreen.screen === "game"`, and remounts when
|
||||
`appScreen.gameId` changes, so each game gets a fresh store. The shell
|
||||
exposes the instance through Svelte context under
|
||||
`GAME_STATE_CONTEXT_KEY`; descendants read it via
|
||||
`getContext(GAME_STATE_CONTEXT_KEY)`.
|
||||
|
||||
The layout's `onMount` builds the `GalaxyClient`, loads `Cache`
|
||||
The shell's boot effect builds the `GalaxyClient`, loads `Cache`
|
||||
through `loadStore()`, then calls `gameState.init({ client, cache,
|
||||
gameId })`. `init`:
|
||||
|
||||
@@ -21,9 +23,10 @@ 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
|
||||
(`GameSummary` carries `current_turn`); if the user is not a
|
||||
member, the store flips to `error`;
|
||||
- calls `lobby.my.games.list` (`findGame`) and finds the game
|
||||
record (`GameSummary` carries `current_turn`); if the game is not
|
||||
in the player's list, the store sets the `notFound` flag (see
|
||||
below);
|
||||
- calls `user.games.report` for the discovered turn and decodes
|
||||
the FlatBuffers response into a TS-friendly `GameReport` shape.
|
||||
|
||||
@@ -40,6 +43,23 @@ The store exposes:
|
||||
| `pendingTurn` | `number \| null` | latest server turn the user has not yet opened |
|
||||
| `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` |
|
||||
| `error` | `string \| null` | localised error message when `status === "error"` |
|
||||
| `notFound` | `boolean` | true when the game is not in the player's list (cancelled / removed / access revoked); the shell drops to the lobby |
|
||||
|
||||
## Missing or inaccessible game
|
||||
|
||||
A restored or stale game id (a `sessionStorage` snapshot pointing at a
|
||||
game that was cancelled, removed, or whose access was revoked) is a
|
||||
distinct case from a transient failure. When `findGame` returns no
|
||||
matching record, `setGame` sets the boolean `notFound` flag rather
|
||||
than synthesising an error message. After `init` resolves, the in-game
|
||||
shell reads `gameState.notFound` and, when true, calls
|
||||
`appScreen.go("lobby")` and shows a `game.events.unavailable` toast —
|
||||
the player lands back in the lobby instead of on an in-game error
|
||||
screen. A transient network failure takes the catch path instead,
|
||||
leaving `notFound` false and flipping `status` to `error` so the
|
||||
in-game error state offers a retry. `notFound` resets to false at the
|
||||
start of every `setGame` / `advanceToPending`. See
|
||||
[`navigation.md`](navigation.md) for the restore-and-validate flow.
|
||||
|
||||
## Store extensions
|
||||
|
||||
@@ -48,7 +68,7 @@ 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
|
||||
currentTurn`; the shell 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.
|
||||
@@ -161,9 +181,9 @@ without losing the live snapshot. The store keeps two turn runes:
|
||||
The derived `historyMode` rune (`status === "ready" && viewedTurn
|
||||
< currentTurn`) drives every history-aware consumer:
|
||||
|
||||
- the layout passes it to `Sidebar` / `BottomTabs` so the order
|
||||
- the shell passes it to `Sidebar` / `BottomTabs` so the order
|
||||
tab vanishes;
|
||||
- the layout passes a `getHistoryMode` getter to
|
||||
- the shell passes a `getHistoryMode` getter to
|
||||
`OrderDraftStore.bindClient` so `add` / `remove` / `move` are
|
||||
no-ops while the user is looking at a past turn;
|
||||
- `RenderedReportSource` returns the raw report (no order overlay)
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ helper is platform-agnostic by design.
|
||||
The boot locale resolves once at module load (no async init):
|
||||
an explicit stored choice wins, otherwise browser/system detection,
|
||||
otherwise `DEFAULT_LOCALE`. Callers that mutate the locale (the language
|
||||
pickers on `/login` and in the account menu) call `i18n.setLocale(next)`,
|
||||
pickers on the login screen and in the account menu) call `i18n.setLocale(next)`,
|
||||
which **persists** the choice to `localStorage` (key `galaxy-locale`) so
|
||||
it survives reloads. An unrecognised stored value is ignored and falls
|
||||
back to detection.
|
||||
|
||||
+6
-5
@@ -15,8 +15,8 @@ width.
|
||||
|
||||
| Section | Empty state | Source | Action |
|
||||
| -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- |
|
||||
| `create new game` | (always visible) | — | Navigates to `/lobby/create` |
|
||||
| `my games` | `no games yet` | `lobby.my.games.list` | Click → `/games/:id/map` |
|
||||
| `create new game` | (always visible) | — | Opens the create screen (`appScreen.go("lobby-create")`) |
|
||||
| `my games` | `no games yet` | `lobby.my.games.list` | Click → enters the game on the map view (`activeView.reset()` + `appScreen.go("game", { gameId })`) |
|
||||
| `pending invitations`| `no invitations` | `lobby.my.invites.list` | Accept (`lobby.invite.redeem`) / Decline (`lobby.invite.decline`) |
|
||||
| `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`) |
|
||||
@@ -85,9 +85,10 @@ public game (FUNCTIONAL.md §3.3). Fields:
|
||||
| `start_gap_players` | Advanced toggle | `2` | |
|
||||
| `target_engine_version` | Advanced toggle | `v1` | Falls back to `v1` if blank |
|
||||
|
||||
On success the page navigates back to `/lobby` and the new game shows
|
||||
up in `my games` once the lobby's onMount has had a chance to refresh
|
||||
the list.
|
||||
On success the create screen returns to the lobby
|
||||
(`appScreen.go("lobby")`) and the new game shows up in `my games`
|
||||
once the lobby's onMount has had a chance to refresh the list (the
|
||||
lobby screen remounts on return, so its onMount re-fires).
|
||||
|
||||
## Errors
|
||||
|
||||
|
||||
+158
-76
@@ -1,46 +1,120 @@
|
||||
# In-game shell — navigation model
|
||||
|
||||
This doc covers the chrome that wraps every in-game view: the
|
||||
responsive layout shell, the active-view router built on SvelteKit's
|
||||
file-system routes, the sidebar with three tools and its
|
||||
state-preservation rule, and the mobile bottom-tabs. The user-facing
|
||||
spec — view list, breakpoint diagrams, history-mode plans — lives in
|
||||
[`../PLAN.md`](../PLAN.md), section
|
||||
single-URL app-shell that selects screens and views from in-memory
|
||||
state, the responsive layout shell, the sidebar with three tools and
|
||||
its state-preservation rule, and the mobile bottom-tabs. The
|
||||
user-facing spec — view list, breakpoint diagrams, history-mode plans
|
||||
— lives in [`../PLAN.md`](../PLAN.md), section
|
||||
`Information Architecture and Navigation`. This doc is the source of
|
||||
truth for how those rules are implemented.
|
||||
|
||||
## Active-view model
|
||||
## App-shell: one URL, screens and views as state
|
||||
|
||||
The client renders **one active view at a time**. Every active view is
|
||||
a SvelteKit route under `routes/games/[id]/`; the route file is a
|
||||
two-line wrapper that mounts the matching content component from
|
||||
`src/lib/active-view/<name>.svelte`. The "view router" mentioned in
|
||||
the plan is the file system plus those wrappers — there is no
|
||||
separate dispatch component.
|
||||
The game UI is a **single SvelteKit route served at `/game/`**. There
|
||||
are no per-screen or per-view routes — the address bar stays `/game/`
|
||||
for the whole session. The only other routes are the dev/test-only
|
||||
`/__debug/*` surfaces. What the URL used to encode now lives in two
|
||||
rune singletons in `src/lib/app-nav.svelte.ts`:
|
||||
|
||||
| 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` |
|
||||
- **`appScreen`** — the top-level screen
|
||||
(`login` / `lobby` / `lobby-create` / `game`) plus the active
|
||||
`gameId`. It replaces the old `goto`-based redirects and the `[id]`
|
||||
route param.
|
||||
- **`activeView`** — the in-game view (`map` / `table` / `report` /
|
||||
`battle` / `mail` / `designer-science`) plus the sub-parameters the
|
||||
old route segments carried (`tableEntity`, `battleId`, `turn`,
|
||||
`scienceId`). It replaces the URL params the route wrappers read.
|
||||
|
||||
`/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. Ship-class design is folded into the sidebar ship-class
|
||||
A single-route dispatcher (`src/routes/+page.svelte`) chooses what to
|
||||
render: it gates on `session.status` (anonymous → login, authenticated
|
||||
→ the `appScreen.screen`), and for the authenticated tree mounts the
|
||||
matching screen component from `src/lib/screens/`
|
||||
(`login-screen.svelte`, `lobby-screen.svelte`,
|
||||
`lobby-create-screen.svelte`) or, for `screen === "game"`, the in-game
|
||||
shell `src/lib/game/game-shell.svelte`. The game shell in turn renders
|
||||
the active view from `activeView` (see below). Navigation is
|
||||
`appScreen.go(screen, { gameId })` and `activeView.select(view,
|
||||
params)` — never `goto`.
|
||||
|
||||
### Active-view dispatch
|
||||
|
||||
The client renders **one active view at a time**. The game shell
|
||||
(`game-shell.svelte`) holds an `{#if}` ladder keyed on
|
||||
`activeView.view` that mounts the matching content component from
|
||||
`src/lib/active-view/<name>.svelte`:
|
||||
|
||||
| `activeView.view` | sub-params | Active view component |
|
||||
| ------------------- | ----------------------- | ---------------------------------------------------------------------- |
|
||||
| `map` | — | `lib/active-view/map.svelte` |
|
||||
| `table` | `tableEntity` | `lib/active-view/table.svelte` |
|
||||
| `report` | — | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) |
|
||||
| `battle` | `battleId`, `turn` | `lib/active-view/battle.svelte` |
|
||||
| `mail` | — | `lib/active-view/mail.svelte` |
|
||||
| `designer-science` | `scienceId` | `lib/active-view/designer-science.svelte` |
|
||||
|
||||
Entering a game defaults the view to `map` (`activeView.reset()`).
|
||||
The optional `scienceId` sub-param on the science designer is absent
|
||||
for the empty new-science form and set to the science name for an
|
||||
existing one. 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`;
|
||||
other entities dispatch to their respective components).
|
||||
The `tableEntity` slug is kebab-case (`planets`, `ship-classes`,
|
||||
`ship-groups`, `fleets`, `sciences`, `races`). `table.svelte` is the
|
||||
table dispatcher: it switches by slug to the per-entity component
|
||||
(`ship-classes` → `table-ship-classes.svelte`; other entities dispatch
|
||||
to their respective components).
|
||||
|
||||
## Screen history: Back/Forward without a URL
|
||||
|
||||
Browser **Back/Forward move between screens**, not views, and they do
|
||||
so without ever changing the URL. The shell layers screen history on
|
||||
top of `appScreen` via SvelteKit shallow routing: `appScreen.go(...)`
|
||||
calls `pushState("", { screen, gameId })` for the overlay screens
|
||||
(`game`, `lobby-create`) and `replaceState(...)` for `lobby` / `login`,
|
||||
so browser **Back from a game returns to the lobby** beneath it. On
|
||||
the first authenticated render the dispatcher stamps the restored
|
||||
overlay on top of the load entry, then mirrors `page.state` back into
|
||||
the store on every popstate through `appScreen.syncFromHistory(...)`.
|
||||
The store is the source of truth; history only mirrors it.
|
||||
|
||||
In-game **view switches do not create history entries** —
|
||||
`activeView.select(...)` only mutates state and persists the snapshot.
|
||||
Back from any in-game view therefore leaves the game entirely rather
|
||||
than stepping through the views the player visited.
|
||||
|
||||
A **"return to lobby" control** lives in the in-game header
|
||||
(`lib/header/header.svelte`, `data-testid="return-to-lobby"`,
|
||||
label `game.shell.menu.return_to_lobby`); it calls
|
||||
`appScreen.go("lobby")`.
|
||||
|
||||
## Refresh and restore
|
||||
|
||||
`appScreen` / `activeView` persist a snapshot (screen, game id, view +
|
||||
sub-params) to `sessionStorage` (`galaxy-app-nav`) on every mutation
|
||||
and read it back once at construction, so a refresh restores the last
|
||||
screen and view. On a full load the dispatcher records the restored
|
||||
game id (`appScreen.restoredGameId`); the game shell's boot path then
|
||||
validates it against the player's game list. `GameStateStore.init`
|
||||
looks the game up through `listMyGames` / `findGame`, and if the game
|
||||
is gone (cancelled, removed, or access revoked) it sets the distinct
|
||||
`gameState.notFound` flag. The shell reacts by dropping to the lobby
|
||||
(`appScreen.go("lobby")`) with an `game.events.unavailable` toast
|
||||
rather than stranding the user on an in-game error. A transient
|
||||
network error keeps `notFound` false and surfaces the in-game error
|
||||
state instead. See [`game-state.md`](game-state.md) for the
|
||||
`notFound` semantics.
|
||||
|
||||
## Standalone-target compatibility
|
||||
|
||||
The single-URL app-shell is the natural fit for the planned
|
||||
standalone wrappers (Wails desktop, Capacitor / gomobile mobile —
|
||||
see [`../ROADMAP.md`](../ROADMAP.md)). Those targets load a single
|
||||
bundled `index.html` with no server, no per-route URLs, and no browser
|
||||
history to rely on; an in-memory screen/view model and shallow-routing
|
||||
history that never touches the address bar work there unchanged.
|
||||
|
||||
## Sidebar tools and state preservation
|
||||
|
||||
@@ -53,36 +127,38 @@ The desktop sidebar hosts three tools:
|
||||
| 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
|
||||
`lib/game/game-shell.svelte`, bound into
|
||||
`lib/sidebar/sidebar.svelte` via `$bindable()`. The shell owns the
|
||||
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
|
||||
URL coupling needed. The URL seed and the history-mode reset
|
||||
described below still live inside the sidebar — they mutate the
|
||||
bindable in place; the layout sees the change through the binding.
|
||||
active tab from outside the sidebar without plumbing callbacks. The
|
||||
shell instance lives for the lifetime of the `game` screen, and an
|
||||
in-game view switch is a pure `activeView` state change that never
|
||||
remounts the shell, so the rune survives every active-view switch
|
||||
automatically — it is in-memory state, with no URL coupling. The
|
||||
history-mode reset described below lives inside the sidebar — it
|
||||
mutates the bindable in place; the shell sees the change through the
|
||||
binding.
|
||||
|
||||
A `?sidebar=calc|calculator|inspector|order` URL param is read once
|
||||
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 tool state is pure in-memory rune state. There is no `?sidebar=`
|
||||
URL param (the app-shell has no per-screen URL to carry one) and no
|
||||
default-tab URL seed; the shell opens on its `inspector` default and
|
||||
external events flip the tab.
|
||||
|
||||
The Order entry is hidden when the layout's `historyMode` flag is
|
||||
true. `+layout.svelte` forwards a derived value to `Sidebar`, which
|
||||
The Order entry is hidden when the shell's `historyMode` flag is
|
||||
true. `game-shell.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.
|
||||
`BottomTabs` so the mobile `Order` button is also suppressed. An
|
||||
`$effect` on the sidebar resets `activeTab` away from `order` if the
|
||||
flag flips on mid-session.
|
||||
|
||||
The `historyMode` flag is derived from the live history signal owned
|
||||
by `GameStateStore`. The derivation lives directly in `+layout.svelte`
|
||||
by `GameStateStore`. The derivation lives directly in
|
||||
`game-shell.svelte`
|
||||
(`const historyMode = $derived(gameState.historyMode)`) — no
|
||||
separate `lib/history-mode.ts` module exists, because the layout is
|
||||
separate `lib/history-mode.ts` module exists, because the shell 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
|
||||
`OrderDraftStore` lives one level above the sidebar in the shell
|
||||
hierarchy; the same `historyMode` derivation is also fed into
|
||||
`OrderDraftStore.bindClient` so inspector-driven mutations
|
||||
(`add` / `remove` / `move`) become no-ops while the user is
|
||||
@@ -107,7 +183,7 @@ header whenever `gameState.historyMode === true`. It shows
|
||||
"Viewing turn {N} · read-only" with a "Return to current turn"
|
||||
button that delegates back to `gameState.returnToCurrent()`. Both
|
||||
the navigator and the banner read `gameState` through context, so
|
||||
the layout is the only place where the wiring lives.
|
||||
the game shell is the only place where the wiring lives.
|
||||
|
||||
## Layout breakpoints
|
||||
|
||||
@@ -134,18 +210,20 @@ raises a bottom-sheet — see [Planet selection](#planet-selection).
|
||||
|
||||
## Mobile bottom-tabs and tool overlay
|
||||
|
||||
The bottom-tabs row is `[Map, Calc, Order, More]`. Map navigates to
|
||||
`/games/:id/map` and clears any tool overlay. Calc and Order navigate
|
||||
to `/games/:id/map` too — but they also flip the layout's
|
||||
`mobileTool` state to `calc` / `order`, which the layout uses to
|
||||
swap the active-view slot for the Calculator / Order tool component.
|
||||
The bottom-tabs row is `[Map, Calc, Order, More]`. Map selects the
|
||||
map view (`activeView.select("map")`) and clears any tool overlay.
|
||||
Calc and Order select the map view too — but they also flip the
|
||||
shell's `mobileTool` state to `calc` / `order`, which the shell uses
|
||||
to swap the active-view slot for the Calculator / Order tool
|
||||
component.
|
||||
|
||||
The tool overlay only applies when the URL is `/map`. Navigating to
|
||||
any other view through the More drawer or the header view-menu makes
|
||||
the layout's derived `effectiveTool` collapse back to `map`, so the
|
||||
user always sees the URL's active view rather than a stale overlay.
|
||||
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 tool overlay only applies while the active view is the map.
|
||||
The shell's derived `effectiveTool` is gated by
|
||||
`activeView.view === "map"`: selecting any other view through the More
|
||||
drawer or the header view-menu collapses `effectiveTool` back to
|
||||
`map`, so the user always sees the active view rather than a stale
|
||||
overlay. The next time the user taps a Calc or Order bottom-tab, the
|
||||
selection switches back to the map view and re-applies the overlay.
|
||||
|
||||
The `More` button opens a drawer that mirrors the header view-menu
|
||||
content. A narrower "More" list (Mail, Battle log, Tables, History,
|
||||
@@ -155,12 +233,12 @@ a single source of truth for destinations.
|
||||
|
||||
## Transient map overlays
|
||||
|
||||
Some views can push a transient overlay onto `/map` with a back
|
||||
Some views can push a transient overlay onto the map view with a back
|
||||
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.
|
||||
user selects any other view via the header or the bottom-tabs.
|
||||
|
||||
The back-stack mechanism is not yet implemented; it is planned
|
||||
alongside its first user (multi-turn projection, range circles in the
|
||||
@@ -180,14 +258,14 @@ translating a renderer click into a planet selection. The flow:
|
||||
primitive, looks the planet up by `number` in the live
|
||||
`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
|
||||
instantiated by the game shell and exposed via Svelte context under
|
||||
`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.
|
||||
4. The layout watches the selection rune and, on the null → planet
|
||||
shell's lifetime (in-memory `activeView` switches inside the game
|
||||
screen) but does not persist across reloads — that contrast with
|
||||
the order draft is intentional.
|
||||
4. The shell watches the selection rune and, on the null → planet
|
||||
transition, flips its bound `activeTab` to `inspector` and
|
||||
`sidebarOpen` to `true`. Desktop already has the sidebar pinned;
|
||||
tablet needs the drawer to surface; mobile is unaffected by the
|
||||
@@ -201,7 +279,7 @@ translating a renderer click into a planet selection. The flow:
|
||||
state instead of holding stale rows.
|
||||
|
||||
The mobile bottom-sheet is mounted alongside `<BottomTabs />` in the
|
||||
layout. Its visibility is conditional on `effectiveTool === "map"` so
|
||||
game shell. Its visibility is conditional on `effectiveTool === "map"` so
|
||||
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
|
||||
@@ -224,8 +302,12 @@ together with the sheet's swipe-to-dismiss gesture.
|
||||
|
||||
## Auth gate
|
||||
|
||||
The root `+layout.svelte` redirects `anonymous → /login` for any
|
||||
non-`/__debug/` path; the in-game shell inherits that gate without
|
||||
any extra check. When a session is revoked while the user is in the
|
||||
shell, the same redirect fires through the existing
|
||||
revocation watcher.
|
||||
The auth gate is state-based, applied by the dispatcher
|
||||
(`src/routes/+page.svelte`): an `anonymous` session renders the login
|
||||
screen, an `authenticated` one renders the `appScreen.screen` (lobby /
|
||||
game / …). There is no `goto("/login")` redirect. When a session is
|
||||
revoked while the user is in the game shell, the revocation watcher
|
||||
flips `session.status` back to `anonymous`, and the dispatcher swaps
|
||||
the whole tree to the login screen on the next render — the URL stays
|
||||
`/game/` throughout. See [`auth-flow.md`](auth-flow.md) for the
|
||||
session state machine.
|
||||
|
||||
@@ -195,15 +195,15 @@ Lifecycle:
|
||||
| `dispose()` | Marks the store destroyed; subsequent `persist()` calls are no-ops so a fast game-switch does not write stale state into the next id. |
|
||||
|
||||
Mutations made before `init` resolves are silently ignored — the
|
||||
layout always awaits `init` through `Promise.all([...])` next to
|
||||
shell always awaits `init` through `Promise.all([...])` next to
|
||||
`gameState.init` before exposing the store.
|
||||
|
||||
Layout integration mirrors `GameStateStore`:
|
||||
Shell integration mirrors `GameStateStore`:
|
||||
|
||||
- One instance per game, created in
|
||||
[`../frontend/src/routes/games/[id]/+layout.svelte`](../frontend/src/routes/games/[id]/+layout.svelte).
|
||||
- One instance per game, created in the in-game shell
|
||||
[`../frontend/src/lib/game/game-shell.svelte`](../frontend/src/lib/game/game-shell.svelte).
|
||||
- Exposed through the `ORDER_DRAFT_CONTEXT_KEY` Svelte context.
|
||||
- Disposed in the layout's `onDestroy`.
|
||||
- Disposed in the shell's `onDestroy`.
|
||||
|
||||
The order tab and the planet inspector both consume the store via
|
||||
`getContext(ORDER_DRAFT_CONTEXT_KEY)` to push new commands.
|
||||
@@ -257,7 +257,7 @@ snapshot (history mode is the planned reader).
|
||||
|
||||
`OrderDraftStore` records `needsServerHydration = true` when no
|
||||
cache row exists for the active game (fresh install, cleared
|
||||
storage, switching device). After the layout boot resolves both
|
||||
storage, switching device). After the shell boot resolves both
|
||||
`gameState.init` and `orderDraft.init`, it calls
|
||||
`orderDraft.hydrateFromServer({ client, turn })` which issues
|
||||
`user.games.order.get` against the gateway. A `found = false`
|
||||
@@ -294,14 +294,12 @@ 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.
|
||||
|
||||
The layout owns the `historyMode` flag and passes it to:
|
||||
The in-game shell 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
|
||||
tab list when true. If a `?sidebar=order` URL seed lands while
|
||||
the flag is true, the sidebar falls back to `inspector`. If the
|
||||
active tab is `order` when the flag flips on, an effect resets
|
||||
it to `inspector`.
|
||||
tab list when true. If the active tab is `order` when the flag
|
||||
flips on, an effect resets it to `inspector`.
|
||||
- `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order`
|
||||
button is suppressed when true.
|
||||
|
||||
|
||||
+13
-3
@@ -4,12 +4,21 @@ The web client is an installable, offline-tolerant PWA. It uses
|
||||
SvelteKit's native service worker (no Workbox) so there is no extra
|
||||
build dependency and the cache logic stays explicit.
|
||||
|
||||
The single-URL app-shell (see [`navigation.md`](navigation.md)) makes
|
||||
the offline story simpler: the whole game UI lives at one route
|
||||
(`${base}/`, i.e. `/game/` under the single-origin deployment), so
|
||||
there is exactly one navigation target to precache and fall back to —
|
||||
no per-screen routes to enumerate. The service-worker scope and the
|
||||
manifest are unchanged by that refactor; both were already base-aware
|
||||
(the SW keys everything off `$service-worker`'s `base`, and the
|
||||
manifest uses relative `./` `start_url` / `scope`).
|
||||
|
||||
## Pieces
|
||||
|
||||
- [`src/service-worker.ts`](../frontend/src/service-worker.ts) — the
|
||||
worker. SvelteKit registers it automatically in the production build.
|
||||
It precaches the app shell (`/`), the build artefacts (JS/CSS +
|
||||
`core.wasm`), and the static files under a **version-keyed** cache
|
||||
It precaches the app shell (`${base}/`), the build artefacts (JS/CSS
|
||||
+ `core.wasm`), and the static files under a **version-keyed** cache
|
||||
(`galaxy-cache-<version>`, `version` from `$service-worker`). On
|
||||
`activate` it deletes every other cache, so a new deploy never serves
|
||||
stale code. Strategy: cache-first for the version-keyed build/files;
|
||||
@@ -17,7 +26,8 @@ build dependency and the cache logic stays explicit.
|
||||
shell answers navigations when fully offline. The gateway (cross-
|
||||
origin) is never intercepted — it is always live network.
|
||||
- [`static/manifest.webmanifest`](../frontend/static/manifest.webmanifest)
|
||||
— name, `standalone` display, `start_url`/`scope` `/`, dark
|
||||
— name, `standalone` display, relative `./` `start_url`/`scope` (so
|
||||
it resolves under whatever `base` the build is deployed at), dark
|
||||
`theme_color`/`background_color`, and the icon set.
|
||||
- [`static/icons/`](../frontend/static/icons/) — `192`/`512` (`any`),
|
||||
a `512` `maskable`, and a `180` apple-touch icon. They are placeholder
|
||||
|
||||
+18
-28
@@ -107,36 +107,23 @@ visible area so that scrolling down advances the highlight
|
||||
naturally. The observer is created on mount and torn down on
|
||||
unmount.
|
||||
|
||||
The in-game shell layout (`routes/games/[id]/+layout.svelte`)
|
||||
The in-game shell (`lib/game/game-shell.svelte`)
|
||||
expands `<main class="active-view-host">` to fit content rather
|
||||
than constraining it, so the document body is the actual scroll
|
||||
container — not the host. The IntersectionObserver root is `null`
|
||||
to match.
|
||||
|
||||
## Scroll save / restore
|
||||
## Scroll position
|
||||
|
||||
`routes/games/[id]/report/+page.svelte` exports a SvelteKit
|
||||
`Snapshot<{ scrollY: number }>`:
|
||||
|
||||
- `capture()` reads `window.scrollY` when SvelteKit's
|
||||
`beforeNavigate` cycle runs.
|
||||
- `restore(value)` schedules a short
|
||||
`requestAnimationFrame` poll that waits for
|
||||
`document.documentElement.scrollHeight` to grow tall enough to
|
||||
honour the saved offset, then calls `window.scrollTo(0, value)`.
|
||||
The poll caps at ~60 frames (≈ 1 second) so a never-tall-enough
|
||||
body never pins a frame loop.
|
||||
|
||||
The capture / restore pair is keyed by route, so:
|
||||
- Forward navigation from `/report` to `/map` lands `/map` at
|
||||
scrollY 0 (no snapshot for `/map` to restore from).
|
||||
- History-back from `/map` to `/report` restores the previously
|
||||
captured scrollY — the user returns to the same section.
|
||||
|
||||
The Snapshot API does not capture the active sidebar slug; the
|
||||
IntersectionObserver re-derives it from the restored scroll
|
||||
position on the next animation frame, which keeps the TOC
|
||||
highlight consistent without a second source of truth.
|
||||
The report is the `report` active view; switching to another view is
|
||||
an in-memory `activeView` state change, not a navigation, and the
|
||||
report component is remounted when the user returns to it. The
|
||||
single-URL app-shell therefore does not carry SvelteKit's route-keyed
|
||||
`Snapshot` scroll save/restore — that mechanism was tied to the old
|
||||
`/games/:id/report` route and was removed with it. A re-entered report
|
||||
opens at the top; the IntersectionObserver re-derives the active TOC
|
||||
slug from the scroll position on the next animation frame, so the
|
||||
highlight stays consistent without a separate source of truth.
|
||||
|
||||
## i18n namespace
|
||||
|
||||
@@ -169,10 +156,13 @@ couple them silently.
|
||||
/ IntersectionObserver are out of scope.
|
||||
- **Playwright** — `tests/e2e/report-sections.spec.ts` exercises
|
||||
the full integration: every TOC anchor lands its section in
|
||||
view, the snapshot mechanism preserves `window.scrollY` on
|
||||
history navigation, the back-to-map button reaches `/map`, the
|
||||
mobile `<select>` scrolls to the chosen section on a narrow
|
||||
viewport.
|
||||
view, the back-to-map button switches to the map view
|
||||
(`activeView.select("map")`), and the mobile `<select>` scrolls
|
||||
to the chosen section on a narrow viewport. The spec drives the
|
||||
app-shell through `window.__galaxyNav` (the dev-only nav surface)
|
||||
instead of `page.goto` per-view URLs. The old "scroll position
|
||||
survives a `/map` round-trip via SvelteKit `Snapshot`" case was
|
||||
dropped — see the [scroll position](#scroll-position) note.
|
||||
|
||||
Test IDs follow the pattern `report-section-<slug>` for section
|
||||
roots, `report-toc-<slug>` for TOC anchors, and per-section row
|
||||
|
||||
+2
-1
@@ -186,7 +186,8 @@ Thin orchestration layer over `KeyStore` + `Cache`:
|
||||
push-event-driven revocation path.
|
||||
|
||||
A `null` `deviceSessionId` is the signal that the session is
|
||||
unauthenticated — the root layout routes such users to `/login`.
|
||||
unauthenticated — `session.status` settles to `anonymous` and the
|
||||
dispatcher renders the login screen (the app-shell stays at `/game/`).
|
||||
|
||||
## Test layout
|
||||
|
||||
|
||||
+9
-8
@@ -128,9 +128,10 @@ report" affordance (`import.meta.env.DEV`). The flow is:
|
||||
|
||||
2. Run the UI dev server (`pnpm -C ui/frontend dev`), open the lobby,
|
||||
and use the "Load JSON…" file picker in the **Synthetic test
|
||||
reports (DEV)** section. The page navigates to
|
||||
`/games/synthetic-<uuid>/map` with the report wired into the
|
||||
in-game shell.
|
||||
reports (DEV)** section. The lobby enters a `synthetic-<uuid>` game
|
||||
on the map view (`activeView.reset()` + `appScreen.go("game", {
|
||||
gameId })`) with the report wired into the in-game shell. The
|
||||
app-shell URL stays `/game/` — see [`navigation.md`](navigation.md).
|
||||
|
||||
In synthetic mode:
|
||||
|
||||
@@ -139,16 +140,16 @@ In synthetic mode:
|
||||
- Composing orders works locally (overlay applies through
|
||||
`applyOrderOverlay` as usual), but **nothing is sent to the
|
||||
gateway** — `OrderDraftStore.scheduleSync` short-circuits because
|
||||
the synthetic id is not a UUID and the layout deliberately does
|
||||
not bind a `GalaxyClient` for this game.
|
||||
the synthetic id is not a UUID and the in-game shell deliberately
|
||||
does not bind a `GalaxyClient` for this game.
|
||||
- The order draft is persisted into the platform `Cache` under the
|
||||
same `order-drafts` namespace as real games, keyed by the
|
||||
synthetic id, so navigating back into the same synthetic session
|
||||
restores the draft. The cache is cleared with
|
||||
`__galaxyDebug.clearOrderDraft(gameId)` (DEV debug surface).
|
||||
- A page reload loses the in-memory report registry; opening the
|
||||
same synthetic id afterwards redirects to /lobby. Re-load the JSON
|
||||
to reseed.
|
||||
- A page reload loses the in-memory report registry; a restored
|
||||
synthetic game whose report is gone falls back to the lobby
|
||||
(`appScreen.go("lobby")`). Re-load the JSON to reseed.
|
||||
|
||||
The synthetic-report parity rule requires every change that extends
|
||||
`decodeReport` to also extend the legacy parser in lockstep, or to
|
||||
|
||||
Reference in New Issue
Block a user