Merge pull request 'feat(ui): single-URL game app-shell (in-memory screens/views)' (#35) from feature/ui-app-shell into development
Deploy · Dev / deploy (push) Successful in 46s
Tests · UI / test (push) Successful in 2m59s

This commit was merged in pull request #35.
This commit is contained in:
2026-05-23 20:18:09 +00:00
85 changed files with 1801 additions and 999 deletions
+135 -67
View File
@@ -18,6 +18,22 @@ module is a pure compute boundary on every platform.
> realistic multi-turn projection, and the cross-platform acceptance > realistic multi-turn projection, and the cross-platform acceptance
> pass in [ROADMAP.md](ROADMAP.md). This file is retained as the staged > pass in [ROADMAP.md](ROADMAP.md). This file is retained as the staged
> record of how the MVP was built. > record of how the MVP was built.
>
> **Routing — superseded by the app-shell.** After the MVP, the
> URL-based routing the per-phase artifacts below describe was refactored
> into a single-URL **app-shell**: the game UI is one SvelteKit route at
> `/game/`, the screen and the in-game view are in-memory rune state
> (`lib/app-nav.svelte.ts`), the `routes/games/[id]/` subtree and the
> per-view `+page.svelte` wrappers were removed, the in-game layout
> became `lib/game/game-shell.svelte`, and the login / lobby /
> lobby-create screens moved under `lib/screens/`. Browser Back/Forward
> move between screens via shallow routing without changing the URL — a
> model that also suits the bundled standalone targets (Wails /
> Capacitor / gomobile) that have no URLs. The current navigation model
> is described in [docs/navigation.md](docs/navigation.md) and in the
> reframed `Information Architecture and Navigation` section and Phase 10
> decisions below; the per-phase `routes/games/[id]/…` artifact paths are
> left as the historical record of what each phase delivered at the time.
The existing Fyne client in `client/` is deprecated and is not modified The existing Fyne client in `client/` is deprecated and is not modified
or imported by the new code. The architectural overview is mirrored into or imported by the new code. The architectural overview is mirrored into
@@ -130,38 +146,55 @@ The intended v1 architecture is:
## Information Architecture and Navigation ## Information Architecture and Navigation
The client is a single-page application with **one active view at a The client is a single-page **app-shell** with **one active view at a
time**. Navigation is mobile-first: floating panels never overlap the time**. It is served at a single URL (`/game/`) that never changes:
map, the main area never splits into multiple visible panels on small the visible screen and view are in-memory state, not routes. Navigation
screens. Desktop and mobile share the same model; on desktop, the is mobile-first: floating panels never overlap the map, the main area
sidebar sits beside the active view, on mobile it lives behind a never splits into multiple visible panels on small screens. Desktop
bottom-tab bar. and mobile share the same model; on desktop, the sidebar sits beside
the active view, on mobile it lives behind a bottom-tab bar.
### View model ### Screen and view model
Two pieces of in-memory state (rune singletons in
`lib/app-nav.svelte.ts`) replace what URLs used to encode — `appScreen`
(the top-level screen plus the active game id) and `activeView` (the
in-game view plus its sub-parameters):
```text ```text
ActiveView ∈ { appScreen.screen ∈ {
/login, (anonymous only) login, (anonymous only)
/lobby, (auth required) lobby, (auth required)
/games/:id/map, (default in-game view) lobby-create, (auth required)
/games/:id/table/:entity, (entity ∈ game, (auth required; carries appScreen.gameId)
planets | ship-classes | }
ship-groups | fleets |
sciences | races) activeView.view ∈ { (meaningful only while screen === game)
/games/:id/report, map, (default in-game view)
/games/:id/battle/:battleId, table, (+ tableEntity ∈ planets | ship-classes |
/games/:id/mail, ship-groups | fleets | sciences | races)
/games/:id/designer/ship-class/:id?, report,
/games/:id/designer/science/:id?, battle, (+ battleId, turn)
mail,
designer-science, (+ scienceId; absent = new-science form)
} }
``` ```
The top-level screen is chosen by the single-route dispatcher
(`routes/+page.svelte`) from `session.status` + `appScreen.screen`;
the in-game shell (`lib/game/game-shell.svelte`) renders the active
view from `activeView`. Browser Back/Forward move between screens
(Back from a game → lobby) via SvelteKit shallow routing, without
changing the URL; in-game view switches do not create history entries.
Switching between views happens through the header dropdown (desktop) Switching between views happens through the header dropdown (desktop)
or hamburger menu (mobile). Double-tapping a row in a `table:` view or hamburger menu (mobile), driven by `activeView.select(...)`.
returns to `/map` with `focus=<objectId>`. Some views can push a Double-tapping a row in a table view returns to the map focused on the
transient map overlay with a back affordance (for example, ship-class object. Some views can push a transient map overlay with a back
designer pushes a range-preview overlay onto the map). The transient affordance (for example, ship-class designer pushes a range-preview
overlay clears when the user navigates to any other view. overlay onto the map). The transient overlay clears when the user
selects any other view. The implementation is documented in
[docs/navigation.md](docs/navigation.md).
### Layout per breakpoint ### Layout per breakpoint
@@ -257,12 +290,20 @@ turn current` action.
- The account menu (top-right on desktop, last hamburger entry on - The account menu (top-right on desktop, last hamburger entry on
mobile) holds Settings, Sessions, Theme, Language, Logout. mobile) holds Settings, Sessions, Theme, Language, Logout.
### Authenticated route transitions ### Authenticated screen transitions
- `/login``/lobby` after successful confirm-email-code. All transitions are in-memory screen/view changes; the URL stays
- `/lobby``/games/:id/map` when a game card is selected. `/game/` throughout.
- Any view → `/login` immediately on session revocation push event.
- Designer views can push a transient overlay onto `/map`; the back - login → lobby after successful confirm-email-code (`session.status`
settles to `authenticated`).
- lobby → game (view `map`) when a game card is selected
(`appScreen.go("game", { gameId })`).
- any screen → login immediately on session revocation push event
(`session.status` settles back to `anonymous`).
- the in-game header carries a "return to lobby" control
(`appScreen.go("lobby")`); browser Back from a game does the same.
- Designer views can push a transient overlay onto the map; the back
affordance returns to the originating designer. affordance returns to the originating designer.
Per-screen behaviour (validations, exact field names, error mappings) Per-screen behaviour (validations, exact field names, error mappings)
@@ -1062,37 +1103,58 @@ end-to-end before any data is wired.
Decisions taken with the project owner during implementation: Decisions taken with the project owner during implementation:
1. **Routing — file-system based, no extra dispatcher.** The 1. **Routing — single-URL app-shell, in-memory dispatch.** The game
"view router" called out in the original artifact list is UI is one SvelteKit route served at `/game/`; the address bar never
implemented as SvelteKit's file-system routes plus thin changes. The "view router" called out in the original artifact list
`+page.svelte` wrappers that mount the matching is the in-memory dispatch in `lib/game/game-shell.svelte` — an
`lib/active-view/<name>.svelte` stub. No separate dispatch `{#if}` ladder over `activeView.view` that mounts the matching
component lives in the codebase; each route file is a two-line `lib/active-view/<name>.svelte` stub. The top-level screen
wrapper. (login / lobby / lobby-create / game) is chosen by the single-route
2. **Optional designer ID segments.** Both designer URLs ship as dispatcher `routes/+page.svelte` from `session.status` +
`[[id]]` optional segments `appScreen.screen`. Both `appScreen` and `activeView` are rune
(`designer/ship-class/[[classId]]/`, singletons in `lib/app-nav.svelte.ts`; there are no per-screen or
`designer/science/[[scienceId]]/`) so Phase 18 / 21 can read per-view file routes (only the dev/test `/__debug/*` ones remain).
the param without a routing migration. Phase 10 stubs ignore Screen-level browser history (Back → lobby) is layered on top via
the param. SvelteKit shallow routing (`pushState`/`replaceState` + `page.state`)
3. **Battle URL — optional id.** `battle/[[battleId]]/` accepts so the URL stays `/game/`. This single-URL model is also the natural
both the list URL (`/battle`) and a specific battle URL fit for the deferred standalone wrappers (Wails desktop, Capacitor /
(`/battle/<id>`). Phase 27 keeps the optional segment and gomobile mobile in [ROADMAP.md](ROADMAP.md)), which load a single
switches behaviour based on presence. bundled `index.html` with no URLs or history. See
[docs/navigation.md](docs/navigation.md).
> This decision supersedes the original "file-system routes plus
> thin `+page.svelte` wrappers" plan. The app-shell transition was
> implemented after the MVP phases: the `routes/games/[id]/`
> subtree and the per-view route wrappers were removed, the layout
> became `lib/game/game-shell.svelte`, and the login / lobby /
> lobby-create screens moved under `lib/screens/`. The
> `lib/active-view/*` components are unchanged — only how they are
> mounted changed.
2. **In-game view sub-parameters — `activeView` state, not URL
segments.** What were optional URL segments are now optional fields
on `activeView` state: the science designer reads `scienceId`
(absent = new-science form), the battle view reads `battleId`
(empty = list) and `turn`, and the table view reads `tableEntity`.
Later phases set these through `activeView.select(view, params)`
instead of navigating a URL.
3. **Battle view — optional id.** The battle view accepts both the
list state (no `battleId`) and a specific battle (`battleId` set).
Phase 27 keeps the optional sub-param and switches behaviour based
on presence.
4. **Tablet sidebar — click toggle, not swipe.** The 7681024 px 4. **Tablet sidebar — click toggle, not swipe.** The 7681024 px
tablet sidebar slides in from a header-button click rather tablet sidebar slides in from a header-button click rather
than the IA section's swipe-from-right gesture. The structural than the IA section's swipe-from-right gesture. The structural
breakpoint switch satisfies Phase 10's acceptance criterion; breakpoint switch satisfies Phase 10's acceptance criterion;
Phase 35 polish lands the swipe gesture. Phase 35 polish lands the swipe gesture.
5. **Mobile tool overlay — `mobileTool` state, gated by URL.** 5. **Mobile tool overlay — `mobileTool` state, gated by active view.**
The mobile bottom-tabs Calc / Order navigate to `/map` and The mobile bottom-tabs Calc / Order select the map view and
set a layout-owned `mobileTool` rune. The layout's derived set a shell-owned `mobileTool` rune. The shell's derived
`effectiveTool` only honours the rune when the URL is `/map`, `effectiveTool` only honours the rune while `activeView.view ===
so navigating to any other view via the More drawer or the "map"`, so selecting any other view via the More drawer or the
header view-menu naturally drops the overlay. The desktop header view-menu naturally drops the overlay. The sidebar tool
sidebar separately accepts a `?sidebar=calc|inspector|order` state is pure in-memory rune state — there is no `?sidebar=` URL
URL param that seeds the initial tab on first mount, used by param (the app-shell carries no per-screen URL); the sidebar opens
later phases that want to land directly on a particular tool. on its `inspector` default and external events flip the tab.
6. **Sidebar tool filenames — `*-tab.svelte`.** Phase 12 / 13 / 30 6. **Sidebar tool filenames — `*-tab.svelte`.** Phase 12 / 13 / 30
each name their final implementation each name their final implementation
(`order-tab.svelte`, `inspector-tab.svelte`, (`order-tab.svelte`, `inspector-tab.svelte`,
@@ -1103,11 +1165,16 @@ Decisions taken with the project owner during implementation:
name is the static `race ?` string from i18n, mirroring the name is the static `race ?` string from i18n, mirroring the
spec's static `turn ?` placeholder. Phase 11 wires both from spec's static `turn ?` placeholder. Phase 11 wires both from
`user.games.report` data through `lib/header/turn-counter.svelte`. `user.games.report` data through `lib/header/turn-counter.svelte`.
8. **Auth gate inherited.** The root `+layout.svelte` already 8. **Auth gate — state-based in the dispatcher.** The single-route
redirects `anonymous → /login`; the in-game shell needs no dispatcher (`routes/+page.svelte`) renders the login screen for an
extra guard. Phase 10 verified this by booting the e2e shell `anonymous` session and the authenticated screens for an
spec via `__galaxyDebug.setDeviceSessionId` and observing the `authenticated` one; there is no `goto` redirect (the app-shell
post-`session.init` `authenticated` status. stays at `/game/`). The in-game shell needs no extra guard. Phase 10
verified the gate by booting the e2e shell spec via
`__galaxyDebug.setDeviceSessionId` and observing the
post-`session.init` `authenticated` status. (Originally the gate was
a `goto("/login")` redirect in the root layout; the app-shell
transition replaced it with state-based rendering.)
9. **More drawer mirrors the view-menu.** The mobile bottom-tabs 9. **More drawer mirrors the view-menu.** The mobile bottom-tabs
"More" drawer renders the same seven destinations as the "More" drawer renders the same seven destinations as the
header view-menu. The IA section's narrower More list (Mail, header view-menu. The IA section's narrower More list (Mail,
@@ -1136,9 +1203,11 @@ Artifacts (delivered):
`i18n.setLocale`; Logout calls `session.signOut("user")`) `i18n.setLocale`; Logout calls `session.signOut("user")`)
- `ui/frontend/src/lib/sidebar/{sidebar, tab-bar, calculator-tab, - `ui/frontend/src/lib/sidebar/{sidebar, tab-bar, calculator-tab,
inspector-tab, order-tab, bottom-tabs}.svelte` — three-tab inspector-tab, order-tab, bottom-tabs}.svelte` — three-tab
sidebar with `inspector` default and `?sidebar=` URL seed; sidebar with `inspector` default (the app-shell transition later
mobile-only bottom-tabs with `[Map, Calc, Order, More]` plus a dropped the original `?sidebar=` URL seed — there is no per-screen
More drawer duplicating the view-menu destinations URL to carry it); mobile-only bottom-tabs with
`[Map, Calc, Order, More]` plus a More drawer duplicating the
view-menu destinations
- `ui/frontend/src/lib/sidebar/types.ts` — shared `SidebarTab` - `ui/frontend/src/lib/sidebar/types.ts` — shared `SidebarTab`
and `MobileTool` types and `MobileTool` types
- `ui/frontend/src/lib/active-view/{map, table, report, battle, - `ui/frontend/src/lib/active-view/{map, table, report, battle,
@@ -1173,7 +1242,7 @@ Targeted tests (delivered):
view-menu navigation to every IA destination, account-menu view-menu navigation to every IA destination, account-menu
Logout / Language wiring); Logout / Language wiring);
- Vitest component tests for the sidebar (default tab, switching, - Vitest component tests for the sidebar (default tab, switching,
empty-state copy, `?sidebar=` URL seed, close button); empty-state copy, close button);
- Vitest component tests for every active-view stub (title, - Vitest component tests for every active-view stub (title,
`coming soon` copy, table-entity prop, battle-id prop); `coming soon` copy, table-entity prop, battle-id prop);
- Playwright e2e: visit every view stub via header dropdown and - Playwright e2e: visit every view stub via header dropdown and
@@ -1430,8 +1499,7 @@ Artifacts (delivered):
`tab-bar.svelte`, `bottom-tabs.svelte` — `historyMode` prop on `tab-bar.svelte`, `bottom-tabs.svelte` — `historyMode` prop on
the sidebar forwards to `hideOrder` on tab-bar / bottom-tabs; the sidebar forwards to `hideOrder` on tab-bar / bottom-tabs;
active-tab `order` is reset to `inspector` if the flag flips active-tab `order` is reset to `inspector` if the flag flips
on, and the `?sidebar=order` URL seed falls back to on while it is selected.
`inspector` while the flag is true.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` — - `ui/frontend/src/routes/games/[id]/+layout.svelte` —
instantiates `OrderDraftStore`, sets context, runs instantiates `OrderDraftStore`, sets context, runs
`init({ cache, gameId })` next to `gameState.init` through `init({ cache, gameId })` next to `gameState.init` through
+13 -5
View File
@@ -53,9 +53,15 @@ quick orientation; deeper design notes live under `ui/docs/`.
+ SQLite on desktop, iOS Keychain / Android Keystore + SQLite on + SQLite on desktop, iOS Keychain / Android Keystore + SQLite on
mobile, all behind a single `KeyStore` and `Cache` TypeScript mobile, all behind a single `KeyStore` and `Cache` TypeScript
interface. interface.
- **Mobile-first navigation:** one active view occupies the main area - **Single-URL app-shell navigation:** the game UI is one route served
at a time; the sidebar holds a single tool (calculator, inspector, at `/game/`; the screen (login / lobby / game) and the in-game view
or order) with persistent state on switch. are in-memory state (`lib/app-nav.svelte.ts`), not URLs, so the
address bar never changes. Browser Back/Forward move between screens
via shallow routing without touching the URL — a model that also
suits the bundled standalone targets (Wails / Capacitor) that have no
URLs. One active view occupies the main area at a time; the sidebar
holds a single tool (calculator, inspector, or order) with persistent
state on switch. See [`docs/navigation.md`](docs/navigation.md).
## Repository layout ## Repository layout
@@ -81,16 +87,18 @@ ui/
├── mobile/ Capacitor project (planned — see ROADMAP.md) ├── mobile/ Capacitor project (planned — see ROADMAP.md)
└── frontend/ SvelteKit / Vite source └── frontend/ SvelteKit / Vite source
├── src/api/ GalaxyClient + typed Connect client + auth + session ├── src/api/ GalaxyClient + typed Connect client + auth + session
├── src/lib/ env config, session store, revocation watcher ├── src/lib/ app-shell nav + screens + game shell, env config, session store, stores
├── src/platform/core/ Core interface + WasmCore adapter ├── src/platform/core/ Core interface + WasmCore adapter
├── src/platform/store/ KeyStore/Cache interfaces + web adapter ├── src/platform/store/ KeyStore/Cache interfaces + web adapter
├── src/proto/ generated Protobuf-ES + Connect descriptors + FlatBuffers TS bindings ├── src/proto/ generated Protobuf-ES + Connect descriptors + FlatBuffers TS bindings
├── src/routes/ SvelteKit routes (/, /login, /lobby, /lobby/create) ├── src/routes/ single-URL app-shell: `/game/` dispatcher (+page.svelte) + `/__debug/*`
└── static/ core.wasm + wasm_exec.js (built by `make wasm` / CI; gitignored) └── static/ core.wasm + wasm_exec.js (built by `make wasm` / CI; gitignored)
``` ```
Linked topic docs: Linked topic docs:
- [`docs/navigation.md`](docs/navigation.md) — single-URL app-shell,
screens and views as in-memory state, screen history, sidebar tools.
- [`docs/auth-flow.md`](docs/auth-flow.md) — email-code login, - [`docs/auth-flow.md`](docs/auth-flow.md) — email-code login,
session store state machine, revocation watcher. session store state machine, revocation watcher.
- [`docs/lobby.md`](docs/lobby.md) — lobby UI sections, application - [`docs/lobby.md`](docs/lobby.md) — lobby UI sections, application
+29 -20
View File
@@ -18,10 +18,15 @@ authoritative in [`docs/FUNCTIONAL.md` §1](../../docs/FUNCTIONAL.md).
- `ui/frontend/src/lib/revocation-watcher.ts` — minimal - `ui/frontend/src/lib/revocation-watcher.ts` — minimal
`SubscribeEvents` watcher that triggers `signOut("revoked")` on `SubscribeEvents` watcher that triggers `signOut("revoked")` on
any non-aborted stream termination. any non-aborted stream termination.
- `ui/frontend/src/routes/login/+page.svelte` — two-step form. - `ui/frontend/src/lib/screens/login-screen.svelte` — two-step form.
- `ui/frontend/src/routes/lobby/+page.svelte` — placeholder lobby - `ui/frontend/src/lib/screens/lobby-screen.svelte` — lobby that
that issues the first authenticated `user.account.get`. issues the first authenticated `user.account.get`.
- `ui/frontend/src/routes/+layout.svelte`route guard plus the - `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. browser-not-supported blocker.
## State machine (`SessionStatus`) ## 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("revoked")` shares the same observable end state as
`signOut("user")`; the reason exists only for telemetry. Both `signOut("user")`; the reason exists only for telemetry. Both settle
trigger the layout effect's `anonymous → /login` redirect. `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 ## UX states and error mapping
@@ -67,7 +73,7 @@ those branches.
| 200 from `send-email-code` | advance to step `code`, focus the code input | | 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 | | `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" | | `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" | | `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 | | 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`, `device_session_id` into the IDB cache (`namespace=session`,
`key=device-session-id`). On the next page load, `key=device-session-id`). On the next page load,
`SessionStore.init` reads it back and settles `status` to `SessionStore.init` reads it back and settles `status` to
`authenticated`, so the layout effect routes the user straight to `authenticated`, so the dispatcher renders the authenticated screen
`/lobby`. 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 The keypair lives next to the id in the same database (object
store `keypair`, key `device`). Clearing site data wipes both; 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 The keystore relies on WebCrypto Ed25519, which currently lands in
Chrome ≥ 137, Firefox ≥ 130, Safari ≥ 17.4 (see Chrome ≥ 137, Firefox ≥ 130, Safari ≥ 17.4 (see
[`storage.md`](storage.md) for the rationale). On boot the layout [`storage.md`](storage.md) for the rationale). On boot the root
runs a sanity probe (`crypto.subtle.generateKey` for `Ed25519`); if layout runs a sanity probe (`crypto.subtle.generateKey` for
it rejects, the layout switches to a `browser not supported` page `Ed25519`); if it rejects, `status` settles to `unsupported` and the
instead of rendering `/login`. The client deliberately does not ship a layout renders a `browser not supported` page instead of the login
JavaScript Ed25519 fallback — the design decision is modern-browser screen. The client deliberately does not ship a JavaScript Ed25519
baseline only. fallback — the design decision is modern-browser baseline only.
## Revocation ## 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 soon as `status` becomes `authenticated`. Its only contract is
liveness: any non-aborted termination of the stream is treated as liveness: any non-aborted termination of the stream is treated as
a server-side session revocation, the watcher calls a server-side session revocation, the watcher calls
`session.signOut("revoked")`, and the layout effect redirects to `session.signOut("revoked")`, `status` settles to `anonymous`, and
`/login`. 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 Session revocation closes the active client within one second: the
gateway closes the stream the moment it observes a gateway closes the stream the moment it observes a
@@ -126,8 +135,8 @@ reacts on the next event-loop tick.
## Localisation ## Localisation
The login form, the root layout's blocker page, and the lobby The login form, the root layout's blocker page, and the lobby
placeholder go through the i18n primitive in `src/lib/i18n/`. The screen go through the i18n primitive in `src/lib/i18n/`. The
language picker on `/login` lists every entry in language picker on the login screen lists every entry in
`SUPPORTED_LOCALES` by its native name and is initialised from `SUPPORTED_LOCALES` by its native name and is initialised from
`navigator.languages` (web) with `en` as the fallback. Picking a `navigator.languages` (web) with `en` as the fallback. Picking a
different language re-renders the form in place and is forwarded different language re-renders the form in place and is forwarded
+10 -7
View File
@@ -1,7 +1,9 @@
# Battle Viewer UX # Battle Viewer UX
The battle viewer is a dedicated view for battles The battle viewer is a dedicated active view for battles
(`/games/<id>/battle/<battleId>`). Bombings are a separate static (`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 table in the Reports view (`section-bombings.svelte`). The two
domains are deliberately not mixed in any visual surface or click domains are deliberately not mixed in any visual surface or click
target. target.
@@ -212,7 +214,8 @@ result is an X-shaped cross overlaid on the planet glyph.
The stroke width is computed by `battleMarkerStrokeWidth(shots)`: The stroke width is computed by `battleMarkerStrokeWidth(shots)`:
1 shot → 1 px, 100 shots → 5 px, linearly interpolated in between 1 shot → 1 px, 100 shots → 5 px, linearly interpolated in between
(`width = 1 + (shots 1) × 4 / 99`, clamped). A click on either (`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 ### Bombing marker — colored ring
@@ -223,10 +226,10 @@ Colour:
- yellow (`#FFD400`) when `wiped: false`, - yellow (`#FFD400`) when `wiped: false`,
- red (`#FF3030`) when `wiped: true`. - red (`#FF3030`) when `wiped: true`.
A click on the ring navigates to `/games/<id>/report#report-bombings` A click on the ring switches to the report view
and scrolls the matching `report-bombing-row` (by `data-planet`) (`activeView.select("report")`) and scrolls the matching
into view. Bombing markers never open the Battle Viewer — the two `report-bombing-row` (by `data-planet`) into view. Bombing markers
domains stay separate. never open the Battle Viewer — the two domains stay separate.
## Data source ## Data source
+8 -6
View File
@@ -1,9 +1,10 @@
# In-game diplomatic mail UI # In-game diplomatic mail UI
The in-game mail view consumes the `diplomail` subsystem in the The in-game mail view consumes the `diplomail` subsystem in the
backend. The route lives at `/games/:id/mail` and replaces the backend. It is the `mail` active view (`activeView.view === "mail"`)
active view when the user opens the "diplomatic mail" entry in the and replaces the active view when the user opens the "diplomatic mail"
header menu. entry in the header menu (`activeView.select("mail")`). The app-shell
has no per-view URL — see [`navigation.md`](navigation.md).
## Wire surface ## Wire surface
@@ -70,11 +71,12 @@ render the original directly with no toggle.
`diplomail.message.received` push frames are dispatched from `diplomail.message.received` push frames are dispatched from
`api/events.svelte.ts` via the singleton SubscribeEvents stream. The `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 verified payload, calls `mailStore.applyPushEvent(gameId)` (which
re-fetches the inbox — the payload only carries a preview), and re-fetches the inbox — the payload only carries a preview), and
raises a toast through `lib/toast.svelte.ts` with a "view" raises a toast through `lib/toast.svelte.ts` whose "view" action
deep-link to `/games/:id/mail`. switches to the mail view in memory (`activeView.select("mail")`) —
no URL navigation.
The header view-menu's mail entry shows `mailStore.unreadCount` as The header view-menu's mail entry shows `mailStore.unreadCount` as
an inline pill — the only chrome the badge needs. an inline pill — the only chrome the badge needs.
+5 -5
View File
@@ -93,11 +93,11 @@ reconnect.
}); });
onDestroy(off); onDestroy(off);
``` ```
2. If the handler reads scoped data (per-game, per-route), register 2. If the handler reads scoped data (per-game), register from a
from a layout that owns that scope and pass the gameId via a component that owns that scope and pass the gameId via a closure.
closure. The handler should filter events whose payload does not The handler should filter events whose payload does not match its
match its scope (see `routes/games/[id]/+layout.svelte` for the scope (see `lib/game/game-shell.svelte` for the `game.turn.ready`
`game.turn.ready` filter). filter).
3. The payload encoding is owned by the producer side: the 3. The payload encoding is owned by the producer side: the
`game.turn.ready` event uses JSON `{game_id, turn}`. Document `game.turn.ready` event uses JSON `{game_id, turn}`. Document
the schema next to the producer (e.g. `backend/README.md` §10). the schema next to the producer (e.g. `backend/README.md` §10).
+32 -12
View File
@@ -6,13 +6,15 @@ inspector tabs, the order composer, and the calculator.
## Lifecycle ## Lifecycle
`routes/games/[id]/+layout.svelte` instantiates one `GameStateStore` The in-game shell (`lib/game/game-shell.svelte`) instantiates one
per game (the layout remounts when the user navigates to a different `GameStateStore` per game. The shell is mounted by the single-route
game id, so each game gets a fresh store). The layout exposes the dispatcher only while `appScreen.screen === "game"`, and remounts when
instance through Svelte context under `GAME_STATE_CONTEXT_KEY`; `appScreen.gameId` changes, so each game gets a fresh store. The shell
descendants read it via `getContext(GAME_STATE_CONTEXT_KEY)`. 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, through `loadStore()`, then calls `gameState.init({ client, cache,
gameId })`. `init`: gameId })`. `init`:
@@ -21,9 +23,10 @@ gameId })`. `init`:
2. calls `setGame(gameId)`, which: 2. calls `setGame(gameId)`, which:
- reads the per-game wrap-mode preference from `Cache` - reads the per-game wrap-mode preference from `Cache`
(`game-prefs / <gameId>/wrap-mode`, default `torus`); (`game-prefs / <gameId>/wrap-mode`, default `torus`);
- calls `lobby.my.games.list` and finds the game record - calls `lobby.my.games.list` (`findGame`) and finds the game
(`GameSummary` carries `current_turn`); if the user is not a record (`GameSummary` carries `current_turn`); if the game is not
member, the store flips to `error`; in the player's list, the store sets the `notFound` flag (see
below);
- calls `user.games.report` for the discovered turn and decodes - calls `user.games.report` for the discovered turn and decodes
the FlatBuffers response into a TS-friendly `GameReport` shape. 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 | | `pendingTurn` | `number \| null` | latest server turn the user has not yet opened |
| `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` | | `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` |
| `error` | `string \| null` | localised error message when `status === "error"` | | `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 ## Store extensions
@@ -48,7 +68,7 @@ wire lands (ships, fleets, sciences, routes, battles, mail).
`currentTurn` is split from `viewedTurn`, and `viewTurn(turn)` / `currentTurn` is split from `viewedTurn`, and `viewTurn(turn)` /
`returnToCurrent()` handle history navigation. The derived `returnToCurrent()` handle history navigation. The derived
`historyMode` rune flips automatically when `viewedTurn < `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 wiring (which hides the order tab) and to
`OrderDraftStore.bindClient` (which gates `add` / `remove` / `move`). `OrderDraftStore.bindClient` (which gates `add` / `remove` / `move`).
See "History mode" below for the cache and refresh rules. 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 The derived `historyMode` rune (`status === "ready" && viewedTurn
< currentTurn`) drives every history-aware consumer: < 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; tab vanishes;
- the layout passes a `getHistoryMode` getter to - the shell passes a `getHistoryMode` getter to
`OrderDraftStore.bindClient` so `add` / `remove` / `move` are `OrderDraftStore.bindClient` so `add` / `remove` / `move` are
no-ops while the user is looking at a past turn; no-ops while the user is looking at a past turn;
- `RenderedReportSource` returns the raw report (no order overlay) - `RenderedReportSource` returns the raw report (no order overlay)
+1 -1
View File
@@ -85,7 +85,7 @@ helper is platform-agnostic by design.
The boot locale resolves once at module load (no async init): The boot locale resolves once at module load (no async init):
an explicit stored choice wins, otherwise browser/system detection, an explicit stored choice wins, otherwise browser/system detection,
otherwise `DEFAULT_LOCALE`. Callers that mutate the locale (the language 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 which **persists** the choice to `localStorage` (key `galaxy-locale`) so
it survives reloads. An unrecognised stored value is ignored and falls it survives reloads. An unrecognised stored value is ignored and falls
back to detection. back to detection.
+6 -5
View File
@@ -15,8 +15,8 @@ width.
| Section | Empty state | Source | Action | | Section | Empty state | Source | Action |
| -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- | | -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- |
| `create new game` | (always visible) | — | Navigates to `/lobby/create` | | `create new game` | (always visible) | — | Opens the create screen (`appScreen.go("lobby-create")`) |
| `my games` | `no games yet` | `lobby.my.games.list` | Click → `/games/:id/map` | | `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`) | | `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`) | | `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`) | | `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` | | | `start_gap_players` | Advanced toggle | `2` | |
| `target_engine_version` | Advanced toggle | `v1` | Falls back to `v1` if blank | | `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 On success the create screen returns to the lobby
up in `my games` once the lobby's onMount has had a chance to refresh (`appScreen.go("lobby")`) and the new game shows up in `my games`
the list. 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 ## Errors
+162 -76
View File
@@ -1,46 +1,124 @@
# In-game shell — navigation model # In-game shell — navigation model
This doc covers the chrome that wraps every in-game view: the This doc covers the chrome that wraps every in-game view: the
responsive layout shell, the active-view router built on SvelteKit's single-URL app-shell that selects screens and views from in-memory
file-system routes, the sidebar with three tools and its state, the responsive layout shell, the sidebar with three tools and
state-preservation rule, and the mobile bottom-tabs. The user-facing its state-preservation rule, and the mobile bottom-tabs. The
spec — view list, breakpoint diagrams, history-mode plans — lives in user-facing spec — view list, breakpoint diagrams, history-mode plans
[`../PLAN.md`](../PLAN.md), section — lives in [`../PLAN.md`](../PLAN.md), section
`Information Architecture and Navigation`. This doc is the source of `Information Architecture and Navigation`. This doc is the source of
truth for how those rules are implemented. 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 The game UI is a **single SvelteKit route served at `/game/`**. There
a SvelteKit route under `routes/games/[id]/`; the route file is a are no per-screen or per-view routes — the address bar stays `/game/`
two-line wrapper that mounts the matching content component from for the whole session. The only other routes are the dev/test-only
`src/lib/active-view/<name>.svelte`. The "view router" mentioned in `/__debug/*` surfaces. What the URL used to encode now lives in two
the plan is the file system plus those wrappers — there is no rune singletons in `src/lib/app-nav.svelte.ts`:
separate dispatch component.
| URL | Active view component | - **`appScreen`** — the top-level screen
| ------------------------------------------ | ---------------------------------------------------------------------- | (`login` / `lobby` / `lobby-create` / `game`) plus the active
| `/games/:id/map` | `lib/active-view/map.svelte` | `gameId`. It replaces the old `goto`-based redirects and the `[id]`
| `/games/:id/table/:entity` | `lib/active-view/table.svelte` | route param.
| `/games/:id/report` | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) | - **`activeView`** — the in-game view (`map` / `table` / `report` /
| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | `battle` / `mail` / `designer-science`) plus the sub-parameters the
| `/games/:id/mail` | `lib/active-view/mail.svelte` | old route segments carried (`tableEntity`, `battleId`, `turn`,
| `/games/:id/designer/science/:scienceId?` | `lib/active-view/designer-science.svelte` | `scienceId`). It replaces the URL params the route wrappers read.
`/games/:id` (no trailing view) redirects to `/games/:id/map`. The A single-route dispatcher (`src/routes/+page.svelte`) chooses what to
optional `:scienceId?` segment on the science designer route matches render: it gates on `session.status` (anonymous → login, authenticated
SvelteKit's `[[scienceId]]` syntax — `/designer/science` opens the → the `appScreen.screen`), and for the authenticated tree mounts the
empty new-science form, `/designer/science/{name}` opens the named matching screen component from `src/lib/screens/`
science. Ship-class design is folded into the sidebar ship-class (`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 (`lib/sidebar/calculator-tab.svelte`, see
[calculator-ux.md](calculator-ux.md)), reached from the ship-classes [calculator-ux.md](calculator-ux.md)), reached from the ship-classes
table and the view/bottom menus. table and the view/bottom menus.
The `entity` slug on the table route is kebab-case (`planets`, The `tableEntity` slug is kebab-case (`planets`, `ship-classes`,
`ship-classes`, `ship-groups`, `fleets`, `sciences`, `races`). `ship-groups`, `fleets`, `sciences`, `races`). `table.svelte` is the
`table.svelte` is the active-view router: it dispatches by slug to table dispatcher: it switches by slug to the per-entity component
the per-entity component (`ship-classes``table-ship-classes.svelte`; (`ship-classes``table-ship-classes.svelte`; other entities dispatch
other entities dispatch to their respective components). 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. Re-entering a game from the lobby is not a restore: the
lobby resets `activeView` to the map before `appScreen.go("game")`, so
only an in-place refresh replays the saved view — browser Back and the
in-game return-to-lobby control both exit to the lobby. 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 ## Sidebar tools and state preservation
@@ -53,36 +131,38 @@ The desktop sidebar hosts three tools:
| Order | `lib/sidebar/order-tab.svelte` | | Order | `lib/sidebar/order-tab.svelte` |
The selected-tab state is a `$state` rune in The selected-tab state is a `$state` rune in
`routes/games/[id]/+layout.svelte`, bound into `lib/game/game-shell.svelte`, bound into
`lib/sidebar/sidebar.svelte` via `$bindable()`. The layout owns the `lib/sidebar/sidebar.svelte` via `$bindable()`. The shell owns the
rune so external events — such as a planet click — can drive 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 active tab from outside the sidebar without plumbing callbacks. The
SvelteKit keeps that layout instance alive while the user navigates shell instance lives for the lifetime of the `game` screen, and an
between child routes (`/games/:id/map``/games/:id/report` → …), in-game view switch is a pure `activeView` state change that never
so the rune survives every active-view switch automatically with no remounts the shell, so the rune survives every active-view switch
URL coupling needed. The URL seed and the history-mode reset automatically — it is in-memory state, with no URL coupling. The
described below still live inside the sidebar — they mutate the history-mode reset described below lives inside the sidebar — it
bindable in place; the layout sees the change through the binding. mutates the bindable in place; the shell sees the change through the
binding.
A `?sidebar=calc|calculator|inspector|order` URL param is read once The tool state is pure in-memory rune state. There is no `?sidebar=`
on mount and seeds the initial tab. Navigation flows that want to URL param (the app-shell has no per-screen URL to carry one) and no
land the user on a particular tool can set this param on navigation. 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 The Order entry is hidden when the shell's `historyMode` flag is
true. `+layout.svelte` forwards a derived value to `Sidebar`, which true. `game-shell.svelte` forwards a derived value to `Sidebar`, which
forwards `hideOrder` to its `TabBar`; the same flag goes to forwards `hideOrder` to its `TabBar`; the same flag goes to
`BottomTabs` so the mobile `Order` button is also suppressed. A `BottomTabs` so the mobile `Order` button is also suppressed. An
`?sidebar=order` URL seed that arrives while the flag is true falls `$effect` on the sidebar resets `activeTab` away from `order` if the
back to `inspector`, and an `$effect` on the sidebar resets flag flips on mid-session.
`activeTab` away from `order` if the flag flips on mid-session.
The `historyMode` flag is derived from the live history signal owned 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 (`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 the single consumer and the project's compactness rule rejects a
one-line indirection. The order draft survives the toggle because 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 hierarchy; the same `historyMode` derivation is also fed into
`OrderDraftStore.bindClient` so inspector-driven mutations `OrderDraftStore.bindClient` so inspector-driven mutations
(`add` / `remove` / `move`) become no-ops while the user is (`add` / `remove` / `move`) become no-ops while the user is
@@ -107,7 +187,7 @@ header whenever `gameState.historyMode === true`. It shows
"Viewing turn {N} · read-only" with a "Return to current turn" "Viewing turn {N} · read-only" with a "Return to current turn"
button that delegates back to `gameState.returnToCurrent()`. Both button that delegates back to `gameState.returnToCurrent()`. Both
the navigator and the banner read `gameState` through context, so 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 ## Layout breakpoints
@@ -134,18 +214,20 @@ raises a bottom-sheet — see [Planet selection](#planet-selection).
## Mobile bottom-tabs and tool overlay ## Mobile bottom-tabs and tool overlay
The bottom-tabs row is `[Map, Calc, Order, More]`. Map navigates to The bottom-tabs row is `[Map, Calc, Order, More]`. Map selects the
`/games/:id/map` and clears any tool overlay. Calc and Order navigate map view (`activeView.select("map")`) and clears any tool overlay.
to `/games/:id/map` too — but they also flip the layout's Calc and Order select the map view too — but they also flip the
`mobileTool` state to `calc` / `order`, which the layout uses to shell's `mobileTool` state to `calc` / `order`, which the shell uses
swap the active-view slot for the Calculator / Order tool component. to swap the active-view slot for the Calculator / Order tool
component.
The tool overlay only applies when the URL is `/map`. Navigating to The tool overlay only applies while the active view is the map.
any other view through the More drawer or the header view-menu makes The shell's derived `effectiveTool` is gated by
the layout's derived `effectiveTool` collapse back to `map`, so the `activeView.view === "map"`: selecting any other view through the More
user always sees the URL's active view rather than a stale overlay. drawer or the header view-menu collapses `effectiveTool` back to
The next time the user taps a Calc or Order bottom-tab, the `map`, so the user always sees the active view rather than a stale
navigation re-routes them to `/map` and re-applies the overlay. 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 The `More` button opens a drawer that mirrors the header view-menu
content. A narrower "More" list (Mail, Battle log, Tables, History, content. A narrower "More" list (Mail, Battle log, Tables, History,
@@ -155,12 +237,12 @@ a single source of truth for destinations.
## Transient map overlays ## 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 affordance. (The calculator reach circles are a simpler, always-on
map extra rather than a back-stacked overlay; the transient map extra rather than a back-stacked overlay; the transient
back-stack mechanism is planned — see back-stack mechanism is planned — see
[../ROADMAP.md](../ROADMAP.md).) A transient overlay clears when the [../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 The back-stack mechanism is not yet implemented; it is planned
alongside its first user (multi-turn projection, range circles in the alongside its first user (multi-turn projection, range circles in the
@@ -180,14 +262,14 @@ translating a renderer click into a planet selection. The flow:
primitive, looks the planet up by `number` in the live primitive, looks the planet up by `number` in the live
`GameStateStore.report`, and calls `SelectionStore.selectPlanet(number)`. `GameStateStore.report`, and calls `SelectionStore.selectPlanet(number)`.
3. `SelectionStore` (`lib/selection.svelte.ts`) is a runes store 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 — `SELECTION_CONTEXT_KEY`. It carries a discriminated union —
`{ kind: "planet"; id: number }` for planets and widened for `{ kind: "planet"; id: number }` for planets and widened for
ship groups. Selection is in-memory only: it survives the ship groups. Selection is in-memory only: it survives the
layout's lifetime (active-view switches inside `/games/:id/*`) shell's lifetime (in-memory `activeView` switches inside the game
but does not persist across reloads — that contrast with the screen) but does not persist across reloads — that contrast with
order draft is intentional. the order draft is intentional.
4. The layout watches the selection rune and, on the null → planet 4. The shell watches the selection rune and, on the null → planet
transition, flips its bound `activeTab` to `inspector` and transition, flips its bound `activeTab` to `inspector` and
`sidebarOpen` to `true`. Desktop already has the sidebar pinned; `sidebarOpen` to `true`. Desktop already has the sidebar pinned;
tablet needs the drawer to surface; mobile is unaffected by the tablet needs the drawer to surface; mobile is unaffected by the
@@ -201,7 +283,7 @@ translating a renderer click into a planet selection. The flow:
state instead of holding stale rows. state instead of holding stale rows.
The mobile bottom-sheet is mounted alongside `<BottomTabs />` in the 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 it does not stack on top of the calc / order overlays. The dismissal
surface is a close button (`✕`) that calls `SelectionStore.clear()`. surface is a close button (`✕`) that calls `SelectionStore.clear()`.
Tap-outside and swipe-down dismissal are deferred to the finalization Tap-outside and swipe-down dismissal are deferred to the finalization
@@ -224,8 +306,12 @@ together with the sheet's swipe-to-dismiss gesture.
## Auth gate ## Auth gate
The root `+layout.svelte` redirects `anonymous → /login` for any The auth gate is state-based, applied by the dispatcher
non-`/__debug/` path; the in-game shell inherits that gate without (`src/routes/+page.svelte`): an `anonymous` session renders the login
any extra check. When a session is revoked while the user is in the screen, an `authenticated` one renders the `appScreen.screen` (lobby /
shell, the same redirect fires through the existing game / …). There is no `goto("/login")` redirect. When a session is
revocation watcher. 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.
+9 -11
View File
@@ -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. | | `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 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. `gameState.init` before exposing the store.
Layout integration mirrors `GameStateStore`: Shell integration mirrors `GameStateStore`:
- One instance per game, created in - One instance per game, created in the in-game shell
[`../frontend/src/routes/games/[id]/+layout.svelte`](../frontend/src/routes/games/[id]/+layout.svelte). [`../frontend/src/lib/game/game-shell.svelte`](../frontend/src/lib/game/game-shell.svelte).
- Exposed through the `ORDER_DRAFT_CONTEXT_KEY` Svelte context. - 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 The order tab and the planet inspector both consume the store via
`getContext(ORDER_DRAFT_CONTEXT_KEY)` to push new commands. `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 `OrderDraftStore` records `needsServerHydration = true` when no
cache row exists for the active game (fresh install, cleared 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 `gameState.init` and `orderDraft.init`, it calls
`orderDraft.hydrateFromServer({ client, turn })` which issues `orderDraft.hydrateFromServer({ client, turn })` which issues
`user.games.order.get` against the gateway. A `found = false` `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 — the player is browsing an immutable snapshot, and composing commands
against it would be confusing. 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 - `Sidebar` as `historyMode`. The sidebar forwards it to its
`TabBar` as `hideOrder`. The Order entry is filtered out of the `TabBar` as `hideOrder`. The Order entry is filtered out of the
tab list when true. If a `?sidebar=order` URL seed lands while tab list when true. If the active tab is `order` when the flag
the flag is true, the sidebar falls back to `inspector`. If the flips on, an effect resets it to `inspector`.
active tab is `order` when the flag flips on, an effect resets
it to `inspector`.
- `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order` - `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order`
button is suppressed when true. button is suppressed when true.
+13 -3
View File
@@ -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 SvelteKit's native service worker (no Workbox) so there is no extra
build dependency and the cache logic stays explicit. 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 ## Pieces
- [`src/service-worker.ts`](../frontend/src/service-worker.ts) — the - [`src/service-worker.ts`](../frontend/src/service-worker.ts) — the
worker. SvelteKit registers it automatically in the production build. worker. SvelteKit registers it automatically in the production build.
It precaches the app shell (`/`), the build artefacts (JS/CSS + It precaches the app shell (`${base}/`), the build artefacts (JS/CSS
`core.wasm`), and the static files under a **version-keyed** cache + `core.wasm`), and the static files under a **version-keyed** cache
(`galaxy-cache-<version>`, `version` from `$service-worker`). On (`galaxy-cache-<version>`, `version` from `$service-worker`). On
`activate` it deletes every other cache, so a new deploy never serves `activate` it deletes every other cache, so a new deploy never serves
stale code. Strategy: cache-first for the version-keyed build/files; 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- shell answers navigations when fully offline. The gateway (cross-
origin) is never intercepted — it is always live network. origin) is never intercepted — it is always live network.
- [`static/manifest.webmanifest`](../frontend/static/manifest.webmanifest) - [`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. `theme_color`/`background_color`, and the icon set.
- [`static/icons/`](../frontend/static/icons/) — `192`/`512` (`any`), - [`static/icons/`](../frontend/static/icons/) — `192`/`512` (`any`),
a `512` `maskable`, and a `180` apple-touch icon. They are placeholder a `512` `maskable`, and a `180` apple-touch icon. They are placeholder
+18 -28
View File
@@ -107,36 +107,23 @@ visible area so that scrolling down advances the highlight
naturally. The observer is created on mount and torn down on naturally. The observer is created on mount and torn down on
unmount. 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 expands `<main class="active-view-host">` to fit content rather
than constraining it, so the document body is the actual scroll than constraining it, so the document body is the actual scroll
container — not the host. The IntersectionObserver root is `null` container — not the host. The IntersectionObserver root is `null`
to match. to match.
## Scroll save / restore ## Scroll position
`routes/games/[id]/report/+page.svelte` exports a SvelteKit The report is the `report` active view; switching to another view is
`Snapshot<{ scrollY: number }>`: an in-memory `activeView` state change, not a navigation, and the
report component is remounted when the user returns to it. The
- `capture()` reads `window.scrollY` when SvelteKit's single-URL app-shell therefore does not carry SvelteKit's route-keyed
`beforeNavigate` cycle runs. `Snapshot` scroll save/restore — that mechanism was tied to the old
- `restore(value)` schedules a short `/games/:id/report` route and was removed with it. A re-entered report
`requestAnimationFrame` poll that waits for opens at the top; the IntersectionObserver re-derives the active TOC
`document.documentElement.scrollHeight` to grow tall enough to slug from the scroll position on the next animation frame, so the
honour the saved offset, then calls `window.scrollTo(0, value)`. highlight stays consistent without a separate source of truth.
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.
## i18n namespace ## i18n namespace
@@ -169,10 +156,13 @@ couple them silently.
/ IntersectionObserver are out of scope. / IntersectionObserver are out of scope.
- **Playwright** — `tests/e2e/report-sections.spec.ts` exercises - **Playwright** — `tests/e2e/report-sections.spec.ts` exercises
the full integration: every TOC anchor lands its section in the full integration: every TOC anchor lands its section in
view, the snapshot mechanism preserves `window.scrollY` on view, the back-to-map button switches to the map view
history navigation, the back-to-map button reaches `/map`, the (`activeView.select("map")`), and the mobile `<select>` scrolls
mobile `<select>` scrolls to the chosen section on a narrow to the chosen section on a narrow viewport. The spec drives the
viewport. 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 Test IDs follow the pattern `report-section-<slug>` for section
roots, `report-toc-<slug>` for TOC anchors, and per-section row roots, `report-toc-<slug>` for TOC anchors, and per-section row
+2 -1
View File
@@ -186,7 +186,8 @@ Thin orchestration layer over `KeyStore` + `Cache`:
push-event-driven revocation path. push-event-driven revocation path.
A `null` `deviceSessionId` is the signal that the session is 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 ## Test layout
+9 -8
View File
@@ -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, 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 and use the "Load JSON…" file picker in the **Synthetic test
reports (DEV)** section. The page navigates to reports (DEV)** section. The lobby enters a `synthetic-<uuid>` game
`/games/synthetic-<uuid>/map` with the report wired into the on the map view (`activeView.reset()` + `appScreen.go("game", {
in-game shell. gameId })`) with the report wired into the in-game shell. The
app-shell URL stays `/game/` — see [`navigation.md`](navigation.md).
In synthetic mode: In synthetic mode:
@@ -139,16 +140,16 @@ In synthetic mode:
- Composing orders works locally (overlay applies through - Composing orders works locally (overlay applies through
`applyOrderOverlay` as usual), but **nothing is sent to the `applyOrderOverlay` as usual), but **nothing is sent to the
gateway** — `OrderDraftStore.scheduleSync` short-circuits because gateway** — `OrderDraftStore.scheduleSync` short-circuits because
the synthetic id is not a UUID and the layout deliberately does the synthetic id is not a UUID and the in-game shell deliberately
not bind a `GalaxyClient` for this game. does not bind a `GalaxyClient` for this game.
- The order draft is persisted into the platform `Cache` under the - The order draft is persisted into the platform `Cache` under the
same `order-drafts` namespace as real games, keyed by the same `order-drafts` namespace as real games, keyed by the
synthetic id, so navigating back into the same synthetic session synthetic id, so navigating back into the same synthetic session
restores the draft. The cache is cleared with restores the draft. The cache is cleared with
`__galaxyDebug.clearOrderDraft(gameId)` (DEV debug surface). `__galaxyDebug.clearOrderDraft(gameId)` (DEV debug surface).
- A page reload loses the in-memory report registry; opening the - A page reload loses the in-memory report registry; a restored
same synthetic id afterwards redirects to /lobby. Re-load the JSON synthetic game whose report is gone falls back to the lobby
to reseed. (`appScreen.go("lobby")`). Re-load the JSON to reseed.
The synthetic-report parity rule requires every change that extends The synthetic-report parity rule requires every change that extends
`decodeReport` to also extend the legacy parser in lockstep, or to `decodeReport` to also extend the legacy parser in lockstep, or to
+7 -1
View File
@@ -3,7 +3,13 @@ declare global {
const __APP_VERSION__: string; const __APP_VERSION__: string;
namespace App { namespace App {
// future-phase types added later // Shallow-routing state for the single-URL app-shell: the screen
// (and active game) live in `page.state` so browser Back/Forward
// move between screens while the address bar stays at /game/.
interface PageState {
screen?: "login" | "lobby" | "lobby-create" | "game";
gameId?: string | null;
}
} }
} }
@@ -12,9 +12,8 @@ header now — we just hand the routes down as callbacks so the
viewer keeps its prop-driven contract. viewer keeps its prop-driven contract.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { activeView } from "$lib/app-nav.svelte";
import { import {
BattleFetchError, BattleFetchError,
@@ -127,10 +126,10 @@ viewer keeps its prop-driven contract.
}); });
function backToReport() { function backToReport() {
goto(withBase(`/games/${gameId}/report`)); activeView.select("report");
} }
function backToMap() { function backToMap() {
goto(withBase(`/games/${gameId}/map`)); activeView.select("map");
} }
</script> </script>
@@ -25,10 +25,8 @@ fractions is a Phase 21 decision documented in
`ui/docs/science-designer-ux.md`. `ui/docs/science-designer-ux.md`.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { getContext, tick } from "svelte"; import { getContext, tick } from "svelte";
import { goto } from "$app/navigation"; import { activeView } from "$lib/app-nav.svelte";
import { page } from "$app/state";
import type { ScienceSummary } from "../../api/game-state"; import type { ScienceSummary } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -53,8 +51,11 @@ fractions is a Phase 21 decision documented in
ORDER_DRAFT_CONTEXT_KEY, ORDER_DRAFT_CONTEXT_KEY,
); );
const gameId = $derived(page.params.id ?? ""); // `scienceId` is the only sub-parameter the science designer needs;
const scienceId = $derived(page.params.scienceId ?? ""); // the active game id is implicit (the shell only mounts this view
// for the active game) and is read from `appScreen` where required.
let { scienceId = "" }: { scienceId?: string } = $props();
const isViewMode = $derived(scienceId !== ""); const isViewMode = $derived(scienceId !== "");
const localScience = $derived<ScienceSummary[]>( const localScience = $derived<ScienceSummary[]>(
@@ -126,7 +127,7 @@ fractions is a Phase 21 decision documented in
} }
function backToTable(): void { function backToTable(): void {
void goto(withBase(`/games/${gameId}/table/sciences`)); activeView.select("table", { tableEntity: "sciences" });
} }
async function save(): Promise<void> { async function save(): Promise<void> {
+2 -2
View File
@@ -6,7 +6,7 @@ pane, system-item pane, compose form) live under
`./mail/*.svelte`. `./mail/*.svelte`.
--> -->
<script lang="ts"> <script lang="ts">
import { page } from "$app/state"; import { appScreen } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { mailStore, type MailListEntry } from "$lib/mail-store.svelte"; import { mailStore, type MailListEntry } from "$lib/mail-store.svelte";
@@ -19,7 +19,7 @@ pane, system-item pane, compose form) live under
let selectedKey = $state<string | null>(null); let selectedKey = $state<string | null>(null);
let composeOpen = $state(false); let composeOpen = $state(false);
const gameId = $derived(page.params.id ?? ""); const gameId = $derived(appScreen.gameId ?? "");
const entries = $derived(mailStore.entries); const entries = $derived(mailStore.entries);
+35 -20
View File
@@ -1,7 +1,7 @@
<!-- <!--
Phase 11 map active view: integrates the Phase 9 renderer with the Phase 11 map active view: integrates the Phase 9 renderer with the
per-game `GameStateStore` provided through context by per-game `GameStateStore` provided through context by
`routes/games/[id]/+layout.svelte`. The view mounts the renderer `lib/game/game-shell.svelte`. The view mounts the renderer
once the store has produced a report and re-mounts when the once the store has produced a report and re-mounts when the
report's turn changes (a refresh that returns the same turn keeps report's turn changes (a refresh that returns the same turn keeps
the existing renderer instance alive). Empty-planet reports render the existing renderer instance alive). Empty-planet reports render
@@ -20,10 +20,8 @@ Phase 29 wires the wrap-mode toggle on top of the per-game `wrapMode`
preference the store already manages. preference the store already manages.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { getContext, onDestroy, onMount, untrack } from "svelte"; import { getContext, onDestroy, onMount, untrack } from "svelte";
import { goto } from "$app/navigation"; import { activeView } from "$lib/app-nav.svelte";
import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { import {
createRenderer, createRenderer,
@@ -615,6 +613,29 @@ preference the store already manages.
// through the same `hit-test` plumbing — the hitLookup map keyed // through the same `hit-test` plumbing — the hitLookup map keyed
// by primitive id resolves a hit back to either a planet or a // by primitive id resolves a hit back to either a planet or a
// ship-group selection variant. // ship-group selection variant.
// scrollToBombingRow waits for the report's bombing row for the
// given planet to mount, then scrolls it into view. The map context
// menu switches to the report view through a store mutation, so the
// section renders on a later frame; a short bounded poll bridges
// that gap without coupling the map to the report's render timing.
function scrollToBombingRow(planet: number): void {
if (typeof document === "undefined") return;
let attempts = 60;
const tick = (): void => {
const row = document.querySelector(
`[data-testid="report-bombing-row"][data-planet="${planet}"]`,
);
if (row instanceof HTMLElement) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
attempts -= 1;
if (attempts <= 0) return;
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
function handleMapClick(cursorPx: { x: number; y: number }): void { function handleMapClick(cursorPx: { x: number; y: number }): void {
if (handle === null || store?.report === undefined || store.report === null) { if (handle === null || store?.report === undefined || store.report === null) {
return; return;
@@ -634,26 +655,20 @@ preference the store already manages.
selection.selectShipGroup(target.ref); selection.selectShipGroup(target.ref);
break; break;
case "battle": { case "battle": {
const gameId = page.params.id ?? "";
const turn = store?.report?.turn ?? 0; const turn = store?.report?.turn ?? 0;
void goto( activeView.select("battle", {
withBase(`/games/${gameId}/battle/${target.battleId}?turn=${turn}`), battleId: target.battleId,
); turn,
});
break; break;
} }
case "bombing": { case "bombing": {
const gameId = page.params.id ?? ""; activeView.select("report");
void goto( // The report sections render reactively after the view
withBase(`/games/${gameId}/report#report-bombings`), // switches above, so there is no navigation promise to
).then(() => { // await; poll a bounded number of animation frames for
if (typeof document === "undefined") return; // the bombing row, then scroll it into view.
const row = document.querySelector( scrollToBombingRow(target.planet);
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
);
if (row && row.scrollIntoView) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
break; break;
} }
} }
+4 -13
View File
@@ -7,22 +7,15 @@ section is its own component under `lib/active-view/report/` — the
data shapes are too varied for one generic table, and the data shapes are too varied for one generic table, and the
component-per-section seam matches Phase 23's targeted-test contract. component-per-section seam matches Phase 23's targeted-test contract.
Active-section highlighting and scroll save/restore land here: Active-section highlighting lands here: an `IntersectionObserver`
- `IntersectionObserver` rooted on the active-view-host element rooted on the viewport watches every `<section id="report-<slug>">`
(`bind:this` in `+layout.svelte`, plumbed through and updates a local `activeSlug` rune that drives the TOC highlight.
`ACTIVE_VIEW_HOST_CONTEXT_KEY`) watches every `<section
id="report-<slug>">` and updates a local `activeSlug` rune.
- The matching `+page.svelte` exports a SvelteKit `Snapshot` that
captures and restores `host.element.scrollTop`, so navigating to
/map and back lands on the same scroll position. The save lives in
`+page.svelte` because SvelteKit binds snapshots per route.
The 20-section list lives here as a single source of truth so the The 20-section list lives here as a single source of truth so the
TOC and the body iterate the same data. TOC and the body iterate the same data.
--> -->
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { page } from "$app/state";
import ReportToc, { import ReportToc, {
type TocEntry, type TocEntry,
@@ -71,8 +64,6 @@ TOC and the body iterate the same data.
{ slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" }, { slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" },
]; ];
const gameId = $derived(page.params.id ?? "");
let activeSlug = $state<string>(ENTRIES[0]?.slug ?? ""); let activeSlug = $state<string>(ENTRIES[0]?.slug ?? "");
let bodyEl: HTMLDivElement | null = $state(null); let bodyEl: HTMLDivElement | null = $state(null);
@@ -116,7 +107,7 @@ TOC and the body iterate the same data.
</script> </script>
<div class="report-view" data-testid="active-view-report"> <div class="report-view" data-testid="active-view-report">
<ReportToc entries={ENTRIES} {activeSlug} {gameId} /> <ReportToc entries={ENTRIES} {activeSlug} />
<div class="report-body" bind:this={bodyEl}> <div class="report-body" bind:this={bodyEl}>
<SectionGalaxySummary /> <SectionGalaxySummary />
@@ -3,9 +3,10 @@ Phase 23 Report View table of contents.
Responsibilities: Responsibilities:
- "Back to map" button at the top — visible on both desktop sidebar - "Back to map" button at the top — visible on both desktop sidebar
and mobile sticky toolbar. Navigates via `$app/navigation.goto` so and mobile sticky toolbar. Switches the active view to the map
active-view-host scroll restoration plays through SvelteKit's through `activeView.select("map")`; the shell's tool gate resets
history machinery and the layout's `mobileTool` resets naturally. the `mobileTool` overlay naturally once the map is no longer the
active view.
- Desktop / tablet sidebar: a vertical list of anchor links, one per - Desktop / tablet sidebar: a vertical list of anchor links, one per
section. The active link gets `aria-current="location"` and a section. The active link gets `aria-current="location"` and a
`.active` style. Click scrolls the active-view-host (not the `.active` style. Click scrolls the active-view-host (not the
@@ -20,8 +21,7 @@ The active section is computed by the orchestrator
`activeSlug` prop. The TOC itself owns no observers. `activeSlug` prop. The TOC itself owns no observers.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths"; import { activeView } from "$lib/app-nav.svelte";
import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -33,10 +33,9 @@ The active section is computed by the orchestrator
type Props = { type Props = {
entries: readonly TocEntry[]; entries: readonly TocEntry[];
activeSlug: string; activeSlug: string;
gameId: string;
}; };
let { entries, activeSlug, gameId }: Props = $props(); let { entries, activeSlug }: Props = $props();
function scrollToSlug(slug: string): void { function scrollToSlug(slug: string): void {
const target = document.getElementById(`report-${slug}`); const target = document.getElementById(`report-${slug}`);
@@ -62,8 +61,8 @@ The active section is computed by the orchestrator
scrollToSlug(slug); scrollToSlug(slug);
} }
async function backToMap(): Promise<void> { function backToMap(): void {
await goto(withBase(`/games/${gameId}/map`)); activeView.select("map");
} }
</script> </script>
@@ -1,15 +1,14 @@
<!-- <!--
Phase 27 Report View — battles section. Each row is a link into the Phase 27 Report View — battles section. Each row opens the Battle
Battle Viewer at `/games/<id>/battle/<uuid>?turn=<turn>` where Viewer through `activeView.select("battle", { battleId, turn })`
`turn` follows the current report's turn so history-mode views land where `turn` follows the current report's turn so history-mode views
on the right battle. Phase 23 rendered the same rows as inactive land on the right battle. Phase 23 rendered the same rows as inactive
monospace `<span>`; the rewire here is the one-liner the Phase 23 monospace `<span>`; the rewire here is the one-liner the Phase 23
decision log called out. decision log called out.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { page } from "$app/state"; import { activeView } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { import {
@@ -22,8 +21,11 @@ decision log called out.
); );
const report = $derived(rendered?.report ?? null); const report = $derived(rendered?.report ?? null);
const battles = $derived(report?.battles ?? []); const battles = $derived(report?.battles ?? []);
const gameId = $derived(page.params.id ?? "");
const turn = $derived(report?.turn ?? 0); const turn = $derived(report?.turn ?? 0);
function openBattle(battleId: string): void {
activeView.select("battle", { battleId, turn });
}
</script> </script>
<section <section
@@ -46,12 +48,13 @@ decision log called out.
<span class="label"> <span class="label">
{i18n.t("game.report.section.battles.id_label")} {i18n.t("game.report.section.battles.id_label")}
</span> </span>
<a <button
type="button"
class="uuid" class="uuid"
href={withBase(`/games/${gameId}/battle/${b.id}?turn=${turn}`)} onclick={() => openBattle(b.id)}
data-testid="report-battle-row" data-testid="report-battle-row"
data-id={b.id} data-id={b.id}
>{b.id}</a> >{b.id}</button>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -90,10 +93,15 @@ decision log called out.
font-size: 0.7rem; font-size: 0.7rem;
} }
.uuid { .uuid {
padding: 0;
border: 0;
background: transparent;
font: inherit;
color: var(--color-accent); color: var(--color-accent);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
cursor: pointer;
} }
.uuid:hover { .uuid:hover {
color: var(--color-text); color: var(--color-text);
@@ -11,16 +11,14 @@ The four tech proportions are stored on the wire as fractions in
`[0, 1]` and surfaced here as percentages with one decimal so the `[0, 1]` and surfaced here as percentages with one decimal so the
table matches the designer's input units. table matches the designer's input units.
The component sits inside the active-view slot owned by The component sits inside the active-view area owned by
`routes/games/[id]/+layout.svelte`, so it inherits the per-game `lib/game/game-shell.svelte`, so it inherits the per-game
`OrderDraftStore` and `RenderedReportSource` through context. No `OrderDraftStore` and `RenderedReportSource` through context. No
data fetching is performed here — the layout is responsible. data fetching is performed here — the shell is responsible.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { activeView } from "$lib/app-nav.svelte";
import { page } from "$app/state";
import type { ScienceSummary } from "../../api/game-state"; import type { ScienceSummary } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -60,8 +58,6 @@ data fetching is performed here — the layout is responsible.
ORDER_DRAFT_CONTEXT_KEY, ORDER_DRAFT_CONTEXT_KEY,
); );
const gameId = $derived(page.params.id ?? "");
let sortColumn: SortColumn = $state("name"); let sortColumn: SortColumn = $state("name");
let sortDirection: SortDirection = $state("asc"); let sortDirection: SortDirection = $state("asc");
let filter: string = $state(""); let filter: string = $state("");
@@ -118,11 +114,11 @@ data fetching is performed here — the layout is responsible.
} }
function openDesigner(name: string): void { function openDesigner(name: string): void {
void goto(withBase(`/games/${gameId}/designer/science/${encodeURIComponent(name)}`)); activeView.select("designer-science", { scienceId: name });
} }
function newScience(): void { function newScience(): void {
void goto(withBase(`/games/${gameId}/designer/science`)); activeView.select("designer-science");
} }
async function deleteScience(name: string): Promise<void> { async function deleteScience(name: string): Promise<void> {
+223
View File
@@ -0,0 +1,223 @@
// App-shell navigation state.
//
// The game UI is a single-URL app-shell (served at `/game/`): there are no
// per-screen or per-view routes, so the address bar never changes. Two rune
// singletons hold what the URL used to encode:
//
// - `appScreen` — the top-level screen (login / lobby / lobby-create /
// game) and the active game id. It replaces the `goto`-based redirects
// and the `[id]` route param.
// - `activeView` — the in-game view (map / table / report / battle / mail /
// designer-science) and its sub-parameters. It replaces the URL params the
// old route wrappers read.
//
// Both live in this one module so they can share a single `sessionStorage`
// snapshot (persisted here) without a circular import. The snapshot is read
// once at construction to seed the initial render and rewritten on every
// mutation; `restoredGameId` lets the boot path validate a restored game
// before loading it (a cancelled/removed game falls back to lobby — see the
// dispatcher). Screen-level browser history (Back → lobby) is layered on top
// in the shell via SvelteKit shallow routing; this module is the source of
// truth, history only mirrors it.
import { pushState, replaceState } from "$app/navigation";
export type AppScreen = "login" | "lobby" | "lobby-create" | "game";
export type GameView =
| "map"
| "table"
| "report"
| "battle"
| "mail"
| "designer-science";
/** In-game view plus the sub-parameters the old route segments carried. */
export interface GameViewState {
view: GameView;
/** Table entity slug when `view === "table"` (e.g. `planets`, `sciences`). */
tableEntity?: string;
/** Selected battle when `view === "battle"`; empty string = list/none. */
battleId?: string;
/** Viewed turn for the battle view; 0 = current. */
turn?: number;
/** Science id when `view === "designer-science"`; absent = new-science form. */
scienceId?: string;
}
const STORAGE_KEY = "galaxy-app-nav";
const APP_SCREENS: readonly AppScreen[] = [
"login",
"lobby",
"lobby-create",
"game",
];
const GAME_VIEWS: readonly GameView[] = [
"map",
"table",
"report",
"battle",
"mail",
"designer-science",
];
const DEFAULT_VIEW: GameViewState = { view: "map" };
interface NavSnapshot {
screen: AppScreen;
gameId: string | null;
game: GameViewState;
}
function readSnapshot(): NavSnapshot | null {
if (typeof sessionStorage === "undefined") return null;
let raw: string | null;
try {
raw = sessionStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
if (raw === null) return null;
try {
const parsed = JSON.parse(raw) as Partial<NavSnapshot> | null;
if (parsed === null || typeof parsed !== "object") return null;
const screen = APP_SCREENS.includes(parsed.screen as AppScreen)
? (parsed.screen as AppScreen)
: "lobby";
const gameId =
typeof parsed.gameId === "string" && parsed.gameId.length > 0
? parsed.gameId
: null;
return { screen, gameId, game: sanitizeView(parsed.game) };
} catch {
return null;
}
}
function sanitizeView(value: unknown): GameViewState {
if (value === null || typeof value !== "object") return { ...DEFAULT_VIEW };
const v = value as Partial<GameViewState>;
const view = GAME_VIEWS.includes(v.view as GameView)
? (v.view as GameView)
: "map";
const out: GameViewState = { view };
if (typeof v.tableEntity === "string") out.tableEntity = v.tableEntity;
if (typeof v.battleId === "string") out.battleId = v.battleId;
if (typeof v.turn === "number" && Number.isFinite(v.turn) && v.turn >= 0) {
out.turn = Math.trunc(v.turn);
}
if (typeof v.scienceId === "string") out.scienceId = v.scienceId;
return out;
}
function persist(): void {
if (typeof sessionStorage === "undefined") return;
const snapshot: NavSnapshot = {
screen: appScreen.screen,
gameId: appScreen.gameId,
game: activeView.state,
};
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
// Storage full / disabled / private-mode quota — navigation still
// works in memory; only refresh-restore is lost.
}
}
const initial = readSnapshot();
/**
* AppScreenStore owns the top-level screen and the active game id. Anonymous
* vs authenticated gating is applied by the dispatcher on top of `screen`.
*/
class AppScreenStore {
#screen = $state<AppScreen>(initial?.screen ?? "lobby");
#gameId = $state<string | null>(initial?.gameId ?? null);
/** The game id captured from a restored snapshot, for boot-time validation. */
readonly restoredGameId: string | null = initial?.gameId ?? null;
get screen(): AppScreen {
return this.#screen;
}
get gameId(): string | null {
return this.#gameId;
}
/**
* go switches the top-level screen. Entering a game requires a `gameId`;
* leaving a game clears it. Persists the snapshot. History wiring (Back →
* lobby) is added by the shell, which observes `screen`.
*/
go(screen: AppScreen, options: { gameId?: string } = {}): void {
this.#screen = screen;
if (screen === "game") {
if (options.gameId !== undefined) this.#gameId = options.gameId;
} else {
this.#gameId = null;
}
persist();
this.#syncHistory();
}
/**
* syncFromHistory applies a screen restored from browser history (a
* Back/Forward popstate) WITHOUT pushing a new entry. An absent/unknown
* screen (the load entry beneath an overlay) falls back to lobby.
*/
syncFromHistory(screen: AppScreen | undefined, gameId: string | null): void {
const next =
screen !== undefined && APP_SCREENS.includes(screen) ? screen : "lobby";
this.#screen = next;
this.#gameId = next === "game" ? gameId : null;
persist();
}
// Mirror the screen into browser history via shallow routing (the URL is
// unchanged — the address bar stays at /game/). Overlays (game,
// lobby-create) push a new entry so browser Back returns to the lobby
// beneath; lobby/login replace in place.
#syncHistory(): void {
if (typeof window === "undefined") return;
const state: App.PageState = { screen: this.#screen, gameId: this.#gameId };
if (this.#screen === "game" || this.#screen === "lobby-create") {
pushState("", state);
} else {
replaceState("", state);
}
}
}
/**
* ActiveViewStore owns the in-game view and its sub-parameters. It is only
* meaningful while `appScreen.screen === "game"`.
*/
class ActiveViewStore {
#state = $state<GameViewState>(initial?.game ?? { ...DEFAULT_VIEW });
get state(): GameViewState {
return this.#state;
}
get view(): GameView {
return this.#state.view;
}
/** Replace the active in-game view and its sub-parameters. Persists. */
select(view: GameView, params: Omit<GameViewState, "view"> = {}): void {
this.#state = { view, ...params };
persist();
}
/** Reset to the default view (map). Used when entering a fresh game. */
reset(): void {
this.#state = { ...DEFAULT_VIEW };
persist();
}
}
export const appScreen = new AppScreenStore();
export const activeView = new ActiveViewStore();
+27
View File
@@ -118,6 +118,17 @@ export class GameStateStore {
*/ */
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES }); mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
error: string | null = $state(null); error: string | null = $state(null);
/**
* notFound is the distinct "this game is not in the player's list"
* signal, set when `findGame` returns null (cancelled, removed, or
* access revoked). It is a clean flag the app-shell reads after
* `init` to drop a restored/stale game back to the lobby with a
* toast, rather than string-matching the `error` message. Transient
* network failures keep `notFound` false (they take the catch path)
* so they still surface the in-game error state for a retry. Reset
* to false at the start of every `setGame` / `advanceToPending`.
*/
notFound = $state(false);
/** /**
* currentTurn mirrors the engine's turn number for the running * currentTurn mirrors the engine's turn number for the running
* game (lifted from the lobby record on `setGame`). Phase 14 * game (lifted from the lobby record on `setGame`). Phase 14
@@ -218,6 +229,7 @@ export class GameStateStore {
this.gameId = gameId; this.gameId = gameId;
this.status = "loading"; this.status = "loading";
this.error = null; this.error = null;
this.notFound = false;
this.report = null; this.report = null;
this.wrapMode = await readWrapMode(this.cache, gameId); this.wrapMode = await readWrapMode(this.cache, gameId);
@@ -229,6 +241,7 @@ export class GameStateStore {
if (summary === null) { if (summary === null) {
this.status = "error"; this.status = "error";
this.error = `game ${gameId} is not in your list`; this.error = `game ${gameId} is not in your list`;
this.notFound = true;
return; return;
} }
this.gameName = summary.gameName; this.gameName = summary.gameName;
@@ -306,11 +319,13 @@ export class GameStateStore {
} }
this.status = "loading"; this.status = "loading";
this.error = null; this.error = null;
this.notFound = false;
try { try {
const summary = await this.findGame(this.gameId); const summary = await this.findGame(this.gameId);
if (summary === null) { if (summary === null) {
this.status = "error"; this.status = "error";
this.error = `game ${this.gameId} is not in your list`; this.error = `game ${this.gameId} is not in your list`;
this.notFound = true;
return; return;
} }
this.gameName = summary.gameName; this.gameName = summary.gameName;
@@ -558,6 +573,18 @@ export class GameStateStore {
private installVisibilityListener(): void { private installVisibilityListener(): void {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
// Idempotent on re-init. In the single-URL app-shell a direct
// game → game switch (a push deep-link or a refresh-restore)
// re-runs `init` without unmounting the shell, so a naive
// `addEventListener` here would stack a second listener on every
// switch. Drop any previously-registered one first so exactly one
// stays live.
if (this.visibilityListener !== null) {
document.removeEventListener(
"visibilitychange",
this.visibilityListener,
);
}
const listener = (): void => { const listener = (): void => {
if (document.visibilityState === "visible" && this.status === "ready") { if (document.visibilityState === "visible" && this.status === "ready") {
void this.refresh(); void this.refresh();
@@ -1,59 +1,63 @@
<!-- <!--
Phase 10 in-game shell. Composes the header, a conditionally-visible In-game shell. Composes the header, a conditionally-visible sidebar
sidebar (Calculator / Inspector / Order tabs), the active-view slot (Calculator / Inspector / Order tabs), the active-view area selected
filled by the child route, and a mobile-only bottom-tab bar. The by `activeView`, and a mobile-only bottom-tab bar. In the single-URL
layout owns: app-shell there are no per-view routes: the active game id comes from
`appScreen.gameId` and the visible view from `activeView`, both held
in `$lib/app-nav.svelte`. The shell owns:
- `sidebarOpen` — tablet-only drawer toggle. Desktop keeps the - `sidebarOpen` — tablet-only drawer toggle. Desktop keeps the
sidebar pinned via CSS; mobile hides it entirely. sidebar pinned via CSS; mobile hides it entirely.
- `mobileTool` — mobile-only tool overlay state. The tool only - `mobileTool` — mobile-only tool overlay state. The tool only
visually overrides the active-view slot when the URL is `/map`, visually overrides the active-view area when the active view is the
so navigating to any other view through the More drawer or the map, so switching to any other view through the More drawer or the
header view-menu naturally drops the overlay even if `mobileTool` header view-menu naturally drops the overlay even if `mobileTool`
was set on a previous tap. was set on a previous tap.
- `activeTab` — current sidebar tool (`calculator` / `inspector` / - `activeTab` — current sidebar tool (`calculator` / `inspector` /
`order`). Held here, bound into the sidebar so a planet click on `order`). Held here, bound into the sidebar so a planet click on
the map can flip it to `inspector` from the outside (Phase 13). the map can flip it to `inspector` from the outside.
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the - Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
Phase 13 `SelectionStore`. All three are exposed to descendants `SelectionStore`. All three are exposed to descendants via Svelte
via Svelte context; their lifetimes match the layout instance, context; their lifetimes match the shell instance.
which itself stays mounted across active-view switches inside
`/games/:id/*`.
Phase 11 added the per-game `GameStateStore` instance owned by this The per-game `GameStateStore` constructs the `GalaxyClient`, fetches
layout: it constructs the `GalaxyClient`, fetches the matching lobby the matching lobby record to discover `current_turn`, then loads the
record to discover `current_turn`, then loads the report. The store report. The store is shared with descendants via
is shared with descendants via `setContext("gameState", ...)` so the `setContext(GAME_STATE_CONTEXT_KEY, ...)` so the header turn counter,
header turn counter, the map view, and the inspector tab all read the map view, and the inspector tab all read from the same snapshot.
from the same snapshot.
Phase 13 adds the planet inspector. The layout watches the selection The planet inspector: the shell watches the selection store and, on
store and, on the null → planet transition, flips `activeTab` to the null → planet transition, flips `activeTab` to `inspector` and
`inspector` and `sidebarOpen` to `true` so the inspector becomes `sidebarOpen` to `true` so the inspector becomes visible regardless
visible regardless of breakpoint (desktop already has the sidebar of breakpoint (desktop already has the sidebar pinned; tablet needs
pinned; tablet needs the drawer to surface). On mobile the the drawer to surface). On mobile the `<PlanetSheet />` overlay reads
`<PlanetSheet />` overlay reads the same selection and displays a the same selection and displays a read-only sheet over the map;
read-only sheet over the map; closing the sheet clears the closing the sheet clears the selection.
selection.
State preservation across active-view switches works for free The per-game bootstrap (client construction, store init, push-event
because SvelteKit keeps this layout instance mounted while children subscriptions) runs from an `$effect` keyed on `appScreen.gameId`:
swap; navigating between games unmounts and remounts the layout, so the cleanup tears the previous game's subscriptions down and the body
the next game's snapshot — and the next game's selection — start re-initialises the shared stores for the new id, so a direct
fresh. game → game switch (without leaving the shell) rebinds cleanly. The
shell unmounts when the dispatcher leaves the `game` screen, so a
return to the lobby still disposes the stores via `onDestroy`.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths"; import { onDestroy, setContext, untrack } from "svelte";
import { onDestroy, onMount, setContext, untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import Header from "$lib/header/header.svelte"; import Header from "$lib/header/header.svelte";
import HistoryBanner from "$lib/header/history-banner.svelte"; import HistoryBanner from "$lib/header/history-banner.svelte";
import Sidebar from "$lib/sidebar/sidebar.svelte"; import Sidebar from "$lib/sidebar/sidebar.svelte";
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte"; import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
import Calculator from "$lib/sidebar/calculator-tab.svelte"; import Calculator from "$lib/sidebar/calculator-tab.svelte";
import Order from "$lib/sidebar/order-tab.svelte"; import Order from "$lib/sidebar/order-tab.svelte";
import MapView from "$lib/active-view/map.svelte";
import TableView from "$lib/active-view/table.svelte";
import ReportView from "$lib/active-view/report.svelte";
import BattleView from "$lib/active-view/battle.svelte";
import MailView from "$lib/active-view/mail.svelte";
import DesignerScience from "$lib/active-view/designer-science.svelte";
import PlanetSheet from "$lib/inspectors/planet-sheet.svelte"; import PlanetSheet from "$lib/inspectors/planet-sheet.svelte";
import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte"; import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte";
import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte"; import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte";
@@ -71,7 +75,7 @@ fresh.
import { import {
ORDER_DRAFT_CONTEXT_KEY, ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore, OrderDraftStore,
} from "../../../sync/order-draft.svelte"; } from "../../sync/order-draft.svelte";
import { import {
MAP_PICK_CONTEXT_KEY, MAP_PICK_CONTEXT_KEY,
MapPickService, MapPickService,
@@ -85,30 +89,30 @@ fresh.
CoreHolder, CoreHolder,
} from "$lib/core-context.svelte"; } from "$lib/core-context.svelte";
import { session } from "$lib/session-store.svelte"; import { session } from "$lib/session-store.svelte";
import { loadStore } from "../../../platform/store/index"; import { loadStore } from "../../platform/store/index";
import { loadCore } from "../../../platform/core/index"; import { loadCore } from "../../platform/core/index";
import { createGatewayClient } from "../../../api/connect"; import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../../api/galaxy-client"; import { GalaxyClient } from "../../api/galaxy-client";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env"; import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { import {
getSyntheticReport, getSyntheticReport,
isSyntheticGameId, isSyntheticGameId,
} from "../../../api/synthetic-report"; } from "../../api/synthetic-report";
import { import {
eventStream, eventStream,
type VerifiedEvent, type VerifiedEvent,
} from "../../../api/events.svelte"; } from "../../api/events.svelte";
import { toast } from "$lib/toast.svelte"; import { toast } from "$lib/toast.svelte";
import { mailStore } from "$lib/mail-store.svelte"; import { mailStore } from "$lib/mail-store.svelte";
let { children } = $props();
let sidebarOpen = $state(false); let sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map"); let mobileTool: MobileTool = $state("map");
let activeTab: SidebarTab = $state("inspector"); let activeTab: SidebarTab = $state("inspector");
const gameId = $derived(page.params.id ?? ""); // The tool overlay (Calculator / Order) only replaces the active
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname)); // view while the map is showing; switching to any other view drops
// it, matching the previous URL-driven behaviour.
const isOnMap = $derived(activeView.view === "map");
const effectiveTool: MobileTool = $derived.by(() => const effectiveTool: MobileTool = $derived.by(() =>
isOnMap ? mobileTool : "map", isOnMap ? mobileTool : "map",
); );
@@ -363,18 +367,45 @@ fresh.
}); });
}); });
onMount(() => { function teardownSubscriptions(): void {
if (unsubTurnReady !== null) {
unsubTurnReady();
unsubTurnReady = null;
}
if (unsubGamePaused !== null) {
unsubGamePaused();
unsubGamePaused = null;
}
if (unsubMailReceived !== null) {
unsubMailReceived();
unsubMailReceived = null;
}
}
// Per-game bootstrap. The effect re-runs whenever `appScreen.gameId`
// changes: its cleanup tears the previous game's push-event
// subscriptions down, then the body rebinds the shared stores to the
// new id. The shared store instances persist across the switch
// (descendants captured them through context at construction), so a
// game → game switch re-initialises them in place rather than
// recreating them; `onDestroy` performs the terminal `dispose()`
// when the dispatcher leaves the `game` screen and unmounts the
// shell. A null id (no active game) is a no-op.
$effect(() => {
const activeGameId = appScreen.gameId;
if (activeGameId === null || activeGameId === "") return;
(async (): Promise<void> => { (async (): Promise<void> => {
// DEV-only synthetic-report path. The lobby's "Load // DEV-only synthetic-report path. The lobby's "Load
// synthetic report" affordance navigates here with a // synthetic report" affordance enters the game with a
// `synthetic-<uuid>` id and the matching report // `synthetic-<uuid>` id and the matching report
// pre-registered in an in-memory map. A page reload // pre-registered in an in-memory map. A page reload
// loses the map entry; that case redirects to /lobby // loses the map entry; that case returns to the lobby
// so the user reloads the JSON. // so the user reloads the JSON.
if (isSyntheticGameId(gameId)) { if (isSyntheticGameId(activeGameId)) {
const report = getSyntheticReport(gameId); const report = getSyntheticReport(activeGameId);
if (report === undefined) { if (report === undefined) {
await goto(withBase("/lobby")); appScreen.go("lobby");
return; return;
} }
try { try {
@@ -392,8 +423,8 @@ fresh.
]); ]);
coreHolder.set(core); coreHolder.set(core);
await Promise.all([ await Promise.all([
gameState.initSynthetic({ cache, gameId, report }), gameState.initSynthetic({ cache, gameId: activeGameId, report }),
orderDraft.init({ cache, gameId }), orderDraft.init({ cache, gameId: activeGameId }),
]); ]);
// Deliberately no `galaxyClient.set` and no // Deliberately no `galaxyClient.set` and no
// `orderDraft.bindClient`: synthetic mode never // `orderDraft.bindClient`: synthetic mode never
@@ -439,7 +470,7 @@ fresh.
// became history at the cutoff. // became history at the cutoff.
unsubTurnReady = eventStream.on("game.turn.ready", (event) => { unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
const parsed = parseTurnReadyPayload(event); const parsed = parseTurnReadyPayload(event);
if (parsed === null || parsed.gameId !== gameId) { if (parsed === null || parsed.gameId !== activeGameId) {
return; return;
} }
gameState.markPendingTurn(parsed.turn); gameState.markPendingTurn(parsed.turn);
@@ -455,7 +486,7 @@ fresh.
}); });
unsubGamePaused = eventStream.on("game.paused", (event) => { unsubGamePaused = eventStream.on("game.paused", (event) => {
const parsed = parseGamePausedPayload(event); const parsed = parseGamePausedPayload(event);
if (parsed === null || parsed.gameId !== gameId) { if (parsed === null || parsed.gameId !== activeGameId) {
return; return;
} }
orderDraft.markPaused({ reason: parsed.reason }); orderDraft.markPaused({ reason: parsed.reason });
@@ -464,7 +495,7 @@ fresh.
"diplomail.message.received", "diplomail.message.received",
(event) => { (event) => {
const parsed = parseMailReceivedPayload(event); const parsed = parseMailReceivedPayload(event);
if (parsed === null || parsed.gameId !== gameId) { if (parsed === null || parsed.gameId !== activeGameId) {
return; return;
} }
void mailStore.applyPushEvent(parsed.gameId); void mailStore.applyPushEvent(parsed.gameId);
@@ -473,17 +504,36 @@ fresh.
messageParams: { from: parsed.from }, messageParams: { from: parsed.from },
actionLabelKey: "game.events.mail_new.action", actionLabelKey: "game.events.mail_new.action",
onAction: () => { onAction: () => {
void goto(withBase(`/games/${gameId}/mail`)); activeView.select("mail");
}, },
durationMs: 8000, durationMs: 8000,
}); });
}, },
); );
await Promise.all([ await Promise.all([
gameState.init({ client, cache, gameId }), gameState.init({ client, cache, gameId: activeGameId }),
orderDraft.init({ cache, gameId }), orderDraft.init({ cache, gameId: activeGameId }),
mailStore.init({ client, cache, gameId }), mailStore.init({ client, cache, gameId: activeGameId }),
]); ]);
// A restored or stale game id may point at a game that is
// no longer in the player's list (cancelled, removed, or
// access revoked). `init` flags that distinct case via
// `gameState.notFound` (a transient network error keeps it
// false and surfaces the in-game error state instead). Drop
// to the lobby with a toast rather than stranding the user
// on the in-game "not in your list" error. Leaving the
// `game` screen unmounts the shell, so the stores are
// disposed via `onDestroy`; the rest of the bootstrap
// (client bind, server hydration) is skipped for the dead
// game.
if (gameState.notFound) {
appScreen.go("lobby");
toast.show({
messageKey: "game.events.unavailable.message",
durationMs: 8000,
});
return;
}
galaxyClient.set(client); galaxyClient.set(client);
orderDraft.bindClient(client, { orderDraft.bindClient(client, {
getCurrentTurn: () => gameState.currentTurn, getCurrentTurn: () => gameState.currentTurn,
@@ -503,21 +553,12 @@ fresh.
gameState.failBootstrap(describeBootstrapError(err)); gameState.failBootstrap(describeBootstrapError(err));
} }
})(); })();
return teardownSubscriptions;
}); });
onDestroy(() => { onDestroy(() => {
if (unsubTurnReady !== null) { teardownSubscriptions();
unsubTurnReady();
unsubTurnReady = null;
}
if (unsubGamePaused !== null) {
unsubGamePaused();
unsubGamePaused = null;
}
if (unsubMailReceived !== null) {
unsubMailReceived();
unsubMailReceived = null;
}
gameState.dispose(); gameState.dispose();
orderDraft.dispose(); orderDraft.dispose();
selection.dispose(); selection.dispose();
@@ -534,7 +575,6 @@ fresh.
{i18n.t("common.skip_to_content")} {i18n.t("common.skip_to_content")}
</a> </a>
<Header <Header
{gameId}
{sidebarOpen} {sidebarOpen}
onToggleSidebar={toggleSidebar} onToggleSidebar={toggleSidebar}
/> />
@@ -550,8 +590,22 @@ fresh.
<Calculator /> <Calculator />
{:else if effectiveTool === "order"} {:else if effectiveTool === "order"}
<Order /> <Order />
{:else} {:else if activeView.view === "map"}
{@render children()} <MapView />
{:else if activeView.view === "table"}
<TableView entity={activeView.state.tableEntity ?? ""} />
{:else if activeView.view === "report"}
<ReportView />
{:else if activeView.view === "battle"}
<BattleView
gameId={appScreen.gameId ?? ""}
turn={activeView.state.turn ?? 0}
battleId={activeView.state.battleId ?? ""}
/>
{:else if activeView.view === "mail"}
<MailView />
{:else if activeView.view === "designer-science"}
<DesignerScience scienceId={activeView.state.scienceId} />
{/if} {/if}
</main> </main>
<Sidebar <Sidebar
@@ -562,7 +616,6 @@ fresh.
/> />
</div> </div>
<BottomTabs <BottomTabs
{gameId}
activeTool={effectiveTool} activeTool={effectiveTool}
onSelectTool={(tool) => (mobileTool = tool)} onSelectTool={(tool) => (mobileTool = tool)}
hideOrder={historyMode} hideOrder={historyMode}
@@ -618,6 +671,22 @@ fresh.
overflow-y: auto; overflow-y: auto;
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
/*
Pin the shell to the viewport on mobile and take it out of
document flow. With `min-height: 100vh` the shell can overflow
the viewport by a few sub-pixels, which makes the document
scrollable; scrolling then toggles the mobile browser's dynamic
toolbar, resizing the viewport and the `position: fixed` overlays
(map-toggles menu, bottom-tab drawer, planet sheet) mid-gesture.
`position: fixed; inset: 0` keeps the viewport — and those
overlays — stable, leaving the active-view area as the single
internal scroll region.
*/
.game-shell {
position: fixed;
inset: 0;
min-height: 0;
}
.body { .body {
padding-bottom: 3.25rem; padding-bottom: 3.25rem;
} }
+25 -3
View File
@@ -18,6 +18,7 @@ absent until Phase 24 wires push-event state.
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { import {
GAME_STATE_CONTEXT_KEY, GAME_STATE_CONTEXT_KEY,
type GameStateStore, type GameStateStore,
@@ -27,11 +28,10 @@ absent until Phase 24 wires push-event state.
import TurnNavigator from "./turn-navigator.svelte"; import TurnNavigator from "./turn-navigator.svelte";
type Props = { type Props = {
gameId: string;
sidebarOpen: boolean; sidebarOpen: boolean;
onToggleSidebar: () => void; onToggleSidebar: () => void;
}; };
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props(); let { sidebarOpen, onToggleSidebar }: Props = $props();
const gameState = getContext<GameStateStore | undefined>( const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY, GAME_STATE_CONTEXT_KEY,
@@ -57,6 +57,14 @@ absent until Phase 24 wires push-event state.
<TurnNavigator /> <TurnNavigator />
</div> </div>
<div class="right"> <div class="right">
<button
type="button"
class="return-to-lobby"
data-testid="return-to-lobby"
onclick={() => appScreen.go("lobby")}
>
{i18n.t("game.shell.menu.return_to_lobby")}
</button>
<button <button
type="button" type="button"
class="sidebar-toggle" class="sidebar-toggle"
@@ -69,7 +77,7 @@ absent until Phase 24 wires push-event state.
> >
</button> </button>
<ViewMenu {gameId} /> <ViewMenu />
<AccountMenu /> <AccountMenu />
</div> </div>
</header> </header>
@@ -102,6 +110,20 @@ absent until Phase 24 wires push-event state.
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.return-to-lobby {
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.6rem;
background: transparent;
color: inherit;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
white-space: nowrap;
}
.return-to-lobby:hover {
background: var(--color-surface-hover);
}
.sidebar-toggle { .sidebar-toggle {
font: inherit; font: inherit;
font-size: 1.1rem; font-size: 1.1rem;
+16 -16
View File
@@ -7,21 +7,18 @@ itself is identical. The same component is reused for the mobile
Lists the seven IA destinations: map, tables (sub-list of six Lists the seven IA destinations: map, tables (sub-list of six
entities), report, battle, mail, ship-class designer, science entities), report, battle, mail, ship-class designer, science
designer. Closes on Escape, on outside click, and after a designer. Each entry mutates `activeView` (the single-URL app-shell
navigation. Phase 26 introduces the history-mode entry; Phase 35 has no per-view routes) and closes the menu. Closes on Escape, on
polishes microcopy. outside click, and after a selection. Phase 26 introduces the
history-mode entry; Phase 35 polishes microcopy.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { activeView, type GameView } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { mailStore } from "$lib/mail-store.svelte"; import { mailStore } from "$lib/mail-store.svelte";
import { restoreFocus } from "$lib/a11y/restore-focus"; import { restoreFocus } from "$lib/a11y/restore-focus";
type Props = { gameId: string };
let { gameId }: Props = $props();
const mailUnread = $derived(mailStore.unreadCount); const mailUnread = $derived(mailStore.unreadCount);
let open = $state(false); let open = $state(false);
@@ -40,9 +37,12 @@ polishes microcopy.
open = !open; open = !open;
} }
function go(path: string): void { function select(
view: GameView,
params: { tableEntity?: string } = {},
): void {
open = false; open = false;
void goto(withBase(path)); activeView.select(view, params);
} }
function onKeyDown(event: KeyboardEvent): void { function onKeyDown(event: KeyboardEvent): void {
@@ -93,7 +93,7 @@ polishes microcopy.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="view-menu-item-map" data-testid="view-menu-item-map"
onclick={() => go(`/games/${gameId}/map`)} onclick={() => select("map")}
> >
{i18n.t("game.view.map")} {i18n.t("game.view.map")}
</button> </button>
@@ -105,7 +105,7 @@ polishes microcopy.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="view-menu-item-table-{entry.slug}" data-testid="view-menu-item-table-{entry.slug}"
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)} onclick={() => select("table", { tableEntity: entry.slug })}
> >
{i18n.t(entry.key)} {i18n.t(entry.key)}
</button> </button>
@@ -116,7 +116,7 @@ polishes microcopy.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="view-menu-item-report" data-testid="view-menu-item-report"
onclick={() => go(`/games/${gameId}/report`)} onclick={() => select("report")}
> >
{i18n.t("game.view.report")} {i18n.t("game.view.report")}
</button> </button>
@@ -124,7 +124,7 @@ polishes microcopy.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="view-menu-item-battle" data-testid="view-menu-item-battle"
onclick={() => go(`/games/${gameId}/battle`)} onclick={() => select("battle")}
> >
{i18n.t("game.view.battle")} {i18n.t("game.view.battle")}
</button> </button>
@@ -133,7 +133,7 @@ polishes microcopy.
role="menuitem" role="menuitem"
data-testid="view-menu-item-mail" data-testid="view-menu-item-mail"
class="with-badge" class="with-badge"
onclick={() => go(`/games/${gameId}/mail`)} onclick={() => select("mail")}
> >
<span>{i18n.t("game.view.mail")}</span> <span>{i18n.t("game.view.mail")}</span>
{#if mailUnread > 0} {#if mailUnread > 0}
@@ -146,7 +146,7 @@ polishes microcopy.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="view-menu-item-designer-science" data-testid="view-menu-item-designer-science"
onclick={() => go(`/games/${gameId}/designer/science`)} onclick={() => select("designer-science")}
> >
{i18n.t("game.view.designer.science")} {i18n.t("game.view.designer.science")}
</button> </button>
+2
View File
@@ -29,6 +29,7 @@ const en = {
"game.events.turn_ready.message": "turn {turn} is ready", "game.events.turn_ready.message": "turn {turn} is ready",
"game.events.turn_ready.action": "view now", "game.events.turn_ready.action": "view now",
"game.events.signature_failed": "verification failed, reconnecting…", "game.events.signature_failed": "verification failed, reconnecting…",
"game.events.unavailable.message": "this game is no longer available",
"login.title": "sign in to Galaxy", "login.title": "sign in to Galaxy",
"login.email_label": "email", "login.email_label": "email",
@@ -118,6 +119,7 @@ const en = {
"game.shell.menu.theme_light": "light", "game.shell.menu.theme_light": "light",
"game.shell.menu.theme_dark": "dark", "game.shell.menu.theme_dark": "dark",
"game.shell.menu.language": "language", "game.shell.menu.language": "language",
"game.shell.menu.return_to_lobby": "return to lobby",
"game.shell.menu.logout": "logout", "game.shell.menu.logout": "logout",
"game.shell.coming_soon": "coming soon", "game.shell.coming_soon": "coming soon",
"game.shell.turn.label": "turn {turn}", "game.shell.turn.label": "turn {turn}",
+2
View File
@@ -30,6 +30,7 @@ const ru: Record<keyof typeof en, string> = {
"game.events.turn_ready.message": "ход {turn} готов", "game.events.turn_ready.message": "ход {turn} готов",
"game.events.turn_ready.action": "открыть", "game.events.turn_ready.action": "открыть",
"game.events.signature_failed": "подпись повреждена, переподключение…", "game.events.signature_failed": "подпись повреждена, переподключение…",
"game.events.unavailable.message": "эта игра больше недоступна",
"login.title": "вход в Galaxy", "login.title": "вход в Galaxy",
"login.email_label": "электронная почта", "login.email_label": "электронная почта",
@@ -119,6 +120,7 @@ const ru: Record<keyof typeof en, string> = {
"game.shell.menu.theme_light": "светлая", "game.shell.menu.theme_light": "светлая",
"game.shell.menu.theme_dark": "тёмная", "game.shell.menu.theme_dark": "тёмная",
"game.shell.menu.language": "язык", "game.shell.menu.language": "язык",
"game.shell.menu.return_to_lobby": "вернуться в лобби",
"game.shell.menu.logout": "выйти", "game.shell.menu.logout": "выйти",
"game.shell.coming_soon": "скоро будет", "game.shell.coming_soon": "скоро будет",
"game.shell.turn.label": "ход {turn}", "game.shell.turn.label": "ход {turn}",
@@ -1,14 +1,13 @@
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../../api/connect"; import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../../api/galaxy-client"; import { GalaxyClient } from "../../api/galaxy-client";
import { LobbyError, createGame } from "../../../api/lobby"; import { LobbyError, createGame } from "../../api/lobby";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env"; import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { loadCore } from "../../../platform/core/index"; import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte"; import { session } from "$lib/session-store.svelte";
const DEFAULT_MIN_PLAYERS = 2; const DEFAULT_MIN_PLAYERS = 2;
@@ -52,7 +51,7 @@
} }
function cancel(): void { function cancel(): void {
goto(withBase("/lobby")); appScreen.go("lobby");
} }
async function submit(): Promise<void> { async function submit(): Promise<void> {
@@ -94,7 +93,7 @@
turnSchedule: trimmedSchedule, turnSchedule: trimmedSchedule,
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION, targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
}); });
goto(withBase("/lobby")); appScreen.go("lobby");
} catch (err) { } catch (err) {
formError = describeLobbyError(err); formError = describeLobbyError(err);
} finally { } finally {
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../api/connect"; import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client"; import { GalaxyClient } from "../../api/galaxy-client";
@@ -185,11 +184,15 @@
} }
function gotoCreate(): void { function gotoCreate(): void {
goto(withBase("/lobby/create")); appScreen.go("lobby-create");
} }
function gotoGame(gameId: string): void { function gotoGame(gameId: string): void {
goto(withBase(`/games/${gameId}/map`)); // Enter a fresh game on the map view: reset the in-game view
// state first so a stale snapshot from a previous game does not
// leak into the new one, then switch the top-level screen.
activeView.reset();
appScreen.go("game", { gameId });
} }
async function onSyntheticFileChange( async function onSyntheticFileChange(
@@ -208,7 +211,8 @@
const text = await file.text(); const text = await file.text();
const json: unknown = JSON.parse(text); const json: unknown = JSON.parse(text);
const { gameId } = loadSyntheticReportFromJSON(json); const { gameId } = loadSyntheticReportFromJSON(json);
await goto(withBase(`/games/${gameId}/map`)); activeView.reset();
appScreen.go("game", { gameId });
} catch (err) { } catch (err) {
if (err instanceof SyntheticReportError) { if (err instanceof SyntheticReportError) {
syntheticError = err.message; syntheticError = err.message;
@@ -227,9 +231,8 @@
// Statuses for which the game has a navigable in-game view. // Statuses for which the game has a navigable in-game view.
// Lobby-internal statuses (draft, enrollment_open, ready_to_start, // Lobby-internal statuses (draft, enrollment_open, ready_to_start,
// starting, start_failed) and terminal ones (cancelled) stay // starting, start_failed) and terminal ones (cancelled) stay
// non-clickable; clicking them otherwise lands on a 404 because // non-clickable; entering them otherwise opens the game shell on a
// /games/:id/map only meaningfully exists once the runtime has // game whose runtime state does not exist yet.
// produced game state.
function isPlayableStatus(status: string): boolean { function isPlayableStatus(status: string): boolean {
return status === "running" || status === "paused" || status === "finished"; return status === "running" || status === "paused" || status === "finished";
} }
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths"; import { appScreen } from "$lib/app-nav.svelte";
import { goto } from "$app/navigation";
import { import {
AuthError, AuthError,
confirmEmailCode, confirmEmailCode,
@@ -89,7 +88,7 @@
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}); });
await session.signIn(result.deviceSessionId); await session.signIn(result.deviceSessionId);
void goto(withBase("/lobby"), { replaceState: true }); appScreen.go("lobby");
} catch (err) { } catch (err) {
if (err instanceof AuthError && err.code === "invalid_request") { if (err instanceof AuthError && err.code === "invalid_request") {
challengeId = null; challengeId = null;
+3 -3
View File
@@ -16,8 +16,8 @@
// asynchronously; the watcher in `lib/revocation-watcher.ts` calls // asynchronously; the watcher in `lib/revocation-watcher.ts` calls
// it without user interaction. The post-condition is the same as // it without user interaction. The post-condition is the same as
// `signOut("user")` — keypair regenerated, session id wiped, // `signOut("user")` — keypair regenerated, session id wiped,
// status returned to `anonymous` — so the layout's existing // status returned to `anonymous` — so the dispatcher's state-based
// `anonymous → /login` redirect handles both reasons uniformly. // auth gate renders the login screen for both reasons uniformly.
import type { import type {
Cache, Cache,
@@ -83,7 +83,7 @@ export class SessionStore {
* revoked public key, and returns the status to `anonymous`. The * revoked public key, and returns the status to `anonymous`. The
* `reason` is recorded in console output for telemetry but does * `reason` is recorded in console output for telemetry but does
* not change the post-state — both user-driven logout and * not change the post-state — both user-driven logout and
* gateway-driven revocation land the user back on `/login`. * gateway-driven revocation return the user to the login screen.
*/ */
async signOut(reason: "user" | "revoked"): Promise<void> { async signOut(reason: "user" | "revoked"): Promise<void> {
if (this.keyStore === null || this.cache === null) { if (this.keyStore === null || this.cache === null) {
+24 -24
View File
@@ -1,33 +1,31 @@
<!-- <!--
Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map navigates Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map switches
to `/games/:id/map` and resets the tool overlay. Calc and Order also the active view to the map and resets the tool overlay. Calc and
navigate to `/games/:id/map` — the layout's tool gate replaces the Order also switch to the map view — the shell's tool gate replaces
active view with the matching sidebar tool only when the URL is the active view with the matching sidebar tool only while the map is
`/map`, so navigating to any other view via the More drawer or the the active view, so navigating to any other view via the More drawer
header view-menu naturally drops the overlay. or the header view-menu naturally drops the overlay.
More opens a drawer with the same destination list as the header More opens a drawer with the same destination list as the header
view-menu. Phase 35 polish narrows it to the IA-spec subset view-menu, each entry mutating `activeView` directly (the single-URL
(Mail, Battle log, Tables, History, Settings, Logout) once History app-shell has no per-view routes). Phase 35 polish narrows it to the
exists; until then the convenience of one source of truth for IA-spec subset (Mail, Battle log, Tables, History, Settings, Logout)
destinations beats the duplication. once History exists; until then the convenience of one source of
truth for destinations beats the duplication.
--> -->
<script lang="ts"> <script lang="ts">
import { withBase } from "$lib/paths";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { activeView, type GameView } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { restoreFocus } from "$lib/a11y/restore-focus"; import { restoreFocus } from "$lib/a11y/restore-focus";
import type { MobileTool } from "./types"; import type { MobileTool } from "./types";
type Props = { type Props = {
gameId: string;
activeTool: MobileTool; activeTool: MobileTool;
onSelectTool: (tool: MobileTool) => void; onSelectTool: (tool: MobileTool) => void;
hideOrder?: boolean; hideOrder?: boolean;
}; };
let { let {
gameId,
activeTool, activeTool,
onSelectTool, onSelectTool,
hideOrder = false, hideOrder = false,
@@ -45,16 +43,18 @@ destinations beats the duplication.
{ slug: "races", key: "game.view.table.races" }, { slug: "races", key: "game.view.table.races" },
]; ];
async function selectTool(tool: MobileTool): Promise<void> { function selectTool(tool: MobileTool): void {
moreOpen = false; moreOpen = false;
onSelectTool(tool); onSelectTool(tool);
await goto(withBase(`/games/${gameId}/map`)); // Calc / Order surface only over the map; selecting Map simply
// drops the overlay. Either way the map must be the active view.
activeView.select("map");
} }
async function go(path: string): Promise<void> { function go(view: GameView, params: { tableEntity?: string } = {}): void {
moreOpen = false; moreOpen = false;
onSelectTool("map"); onSelectTool("map");
await goto(withBase(path)); activeView.select(view, params);
} }
function toggleMore(): void { function toggleMore(): void {
@@ -143,7 +143,7 @@ destinations beats the duplication.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="bottom-tabs-more-map" data-testid="bottom-tabs-more-map"
onclick={() => go(`/games/${gameId}/map`)} onclick={() => go("map")}
> >
{i18n.t("game.view.map")} {i18n.t("game.view.map")}
</button> </button>
@@ -155,7 +155,7 @@ destinations beats the duplication.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="bottom-tabs-more-table-{entry.slug}" data-testid="bottom-tabs-more-table-{entry.slug}"
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)} onclick={() => go("table", { tableEntity: entry.slug })}
> >
{i18n.t(entry.key)} {i18n.t(entry.key)}
</button> </button>
@@ -166,7 +166,7 @@ destinations beats the duplication.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="bottom-tabs-more-report" data-testid="bottom-tabs-more-report"
onclick={() => go(`/games/${gameId}/report`)} onclick={() => go("report")}
> >
{i18n.t("game.view.report")} {i18n.t("game.view.report")}
</button> </button>
@@ -174,7 +174,7 @@ destinations beats the duplication.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="bottom-tabs-more-battle" data-testid="bottom-tabs-more-battle"
onclick={() => go(`/games/${gameId}/battle`)} onclick={() => go("battle")}
> >
{i18n.t("game.view.battle")} {i18n.t("game.view.battle")}
</button> </button>
@@ -182,7 +182,7 @@ destinations beats the duplication.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="bottom-tabs-more-mail" data-testid="bottom-tabs-more-mail"
onclick={() => go(`/games/${gameId}/mail`)} onclick={() => go("mail")}
> >
{i18n.t("game.view.mail")} {i18n.t("game.view.mail")}
</button> </button>
@@ -190,7 +190,7 @@ destinations beats the duplication.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="bottom-tabs-more-designer-science" data-testid="bottom-tabs-more-designer-science"
onclick={() => go(`/games/${gameId}/designer/science`)} onclick={() => go("designer-science")}
> >
{i18n.t("game.view.designer.science")} {i18n.t("game.view.designer.science")}
</button> </button>
@@ -15,7 +15,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import { page } from "$app/state"; import { appScreen } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { import {
@@ -67,7 +67,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
// Reset the design when the active game changes; a no-op otherwise, so // Reset the design when the active game changes; a no-op otherwise, so
// the design persists across tab switches within a game. // the design persists across tab switches within a game.
$effect(() => { $effect(() => {
cs.ensureGame(page.params.id ?? ""); cs.ensureGame(appScreen.gameId ?? "");
}); });
const core = $derived(coreHandle?.core ?? null); const core = $derived(coreHandle?.core ?? null);
+10 -34
View File
@@ -1,29 +1,23 @@
<!-- <!--
Sidebar with three tabs (Calculator, Inspector, Order). The parent Sidebar with three tabs (Calculator, Inspector, Order). The parent
layout decides whether the sidebar is rendered at all (mobile hides shell decides whether the sidebar is rendered at all (mobile hides
it, tablet collapses it behind the header toggle, desktop keeps it it, tablet collapses it behind the header toggle, desktop keeps it
always visible). State preservation across active-view switches always visible). State preservation across active-view switches
works for free because the layout never remounts when the user works for free because the shell never remounts when the user
navigates within `/games/:id/*`. switches the active view within a game.
The optional `?sidebar=calc|calculator|inspector|order` URL param
seeds the initial tab on first mount — used by the lobby card path
when later phases want to land directly on a particular tool.
The `historyMode` prop hides the Order tab when true: the tab-bar The `historyMode` prop hides the Order tab when true: the tab-bar
filters it out and any URL seed targeting `order` falls back to filters it out and the history-mode reset falls back to `inspector`.
`inspector`. Phase 12 wires the prop through the layout as a Phase 12 wires the prop through the shell as a constant `false`;
constant `false`; Phase 26 flips it on for past-turn snapshots. Phase 26 flips it on for past-turn snapshots.
`activeTab` is a `$bindable` prop so the layout can drive it from `activeTab` is a `$bindable` prop so the shell can drive it from
external events (Phase 13 reveals the inspector tab when a planet external events (Phase 13 reveals the inspector tab when a planet
is clicked on the map). The URL seed and the history-mode reset is clicked on the map). The history-mode reset mutates the bindable
both mutate the bindable in place; the layout sees the change in place; the shell sees the change through the binding without
through the binding without extra plumbing. extra plumbing.
--> -->
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/state";
import TabBar from "./tab-bar.svelte"; import TabBar from "./tab-bar.svelte";
import Calculator from "./calculator-tab.svelte"; import Calculator from "./calculator-tab.svelte";
import Inspector from "./inspector-tab.svelte"; import Inspector from "./inspector-tab.svelte";
@@ -44,29 +38,11 @@ through the binding without extra plumbing.
activeTab = $bindable<SidebarTab>("inspector"), activeTab = $bindable<SidebarTab>("inspector"),
}: Props = $props(); }: Props = $props();
function readUrlSeed(): SidebarTab | null {
const v = page.url.searchParams.get("sidebar");
if (v === "calc" || v === "calculator") return "calculator";
if (v === "inspector") return "inspector";
if (v === "order") return "order";
return null;
}
$effect(() => { $effect(() => {
if (historyMode && activeTab === "order") { if (historyMode && activeTab === "order") {
activeTab = "inspector"; activeTab = "inspector";
} }
}); });
onMount(() => {
const seed = readUrlSeed();
if (seed === null) return;
if (seed === "order" && historyMode) {
activeTab = "inspector";
return;
}
activeTab = seed;
});
</script> </script>
<aside <aside
+1 -22
View File
@@ -2,10 +2,8 @@
import "$lib/theme/tokens.css"; import "$lib/theme/tokens.css";
import "$lib/theme/base.css"; import "$lib/theme/base.css";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { dev } from "$app/environment"; import { dev } from "$app/environment";
import { appBase, withBase } from "$lib/paths"; import { withBase } from "$lib/paths";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte"; import { session } from "$lib/session-store.svelte";
import { eventStream } from "../api/events.svelte"; import { eventStream } from "../api/events.svelte";
@@ -77,25 +75,6 @@
eventStream.stop(); eventStream.stop();
streamSessionId = null; streamSessionId = null;
} }
// page.url.pathname includes the configured base path; strip it so
// the route comparisons below stay base-agnostic.
const pathname = page.url.pathname.slice(appBase.length);
// Debug-only routes under /__debug/* run their own bootstrap
// path against the storage primitives and must bypass the
// auth guard so Phase 6's Playwright spec can drive the
// keystore directly.
if (pathname.startsWith("/__debug/")) {
return;
}
if (session.status === "anonymous" && pathname !== "/login") {
void goto(withBase("/login"), { replaceState: true });
} else if (
session.status === "authenticated" &&
(pathname === "/login" || pathname === "/")
) {
void goto(withBase("/lobby"), { replaceState: true });
}
}); });
</script> </script>
+95 -15
View File
@@ -1,18 +1,98 @@
<script lang="ts"> <script lang="ts">
// The app root renders no content of its own. The root layout's auth // Single-route screen dispatcher for the app-shell. There are no
// guard redirects "/" to /lobby (authenticated) or /login // per-screen routes: the visible screen is selected from in-memory
// (anonymous); this placeholder only shows for the brief moment // state (`session.status` for the auth gate, `appScreen.screen` for
// before that client-side redirect resolves. // the authenticated screen) rather than from the URL. The root
import { i18n } from "$lib/i18n/index.svelte"; // layout intercepts the `loading` and `unsupported` session states
// before this component renders, so here `session.status` is either
// `anonymous` (login) or `authenticated` (lobby / create / game).
import { onMount } from "svelte";
import { dev } from "$app/environment";
import { session } from "$lib/session-store.svelte";
import {
appScreen,
activeView,
type AppScreen,
type GameView,
type GameViewState,
} from "$lib/app-nav.svelte";
import LoginScreen from "$lib/screens/login-screen.svelte";
import LobbyScreen from "$lib/screens/lobby-screen.svelte";
import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte";
import GameShell from "$lib/game/game-shell.svelte";
import { pushState } from "$app/navigation";
import { page } from "$app/state";
// Dev-only navigation affordance for the Playwright e2e suite. The
// single-URL app-shell has no per-screen / per-view routes, so a
// spec can no longer drive the UI by `page.goto("/games/:id/:view")`.
// Instead the suite seeds the session, loads `/` (which lands on the
// authenticated lobby), then calls `window.__galaxyNav.enterGame(...)`
// to switch the in-memory screen and view. Guarded by `dev` so it is
// stripped from the production bundle — `import.meta.env.DEV` (and the
// SvelteKit `dev` re-export) is statically `false` there, so the
// whole `onMount` body tree-shakes away.
type ViewParams = Omit<GameViewState, "view">;
interface NavSurface {
enterGame(gameId: string, view?: GameView, params?: ViewParams): void;
select(view: GameView, params?: ViewParams): void;
go(screen: AppScreen, opts?: { gameId?: string }): void;
}
type NavWindow = typeof globalThis & { __galaxyNav?: NavSurface };
onMount(() => {
if (!dev) return;
(window as NavWindow).__galaxyNav = {
enterGame(gameId, view = "map", params = {}): void {
activeView.select(view, params);
appScreen.go("game", { gameId });
},
select(view, params = {}): void {
activeView.select(view, params);
},
go(screen, opts = {}): void {
appScreen.go(screen, opts);
},
};
});
// Screen-level browser history (Back → lobby) without changing the URL.
// On the first authenticated render, stamp a restored overlay (game /
// lobby-create) on top of the load entry so Back falls through to lobby.
let historyStamped = $state(false);
$effect(() => {
if (session.status === "authenticated" && !historyStamped) {
historyStamped = true;
if (appScreen.screen === "game" && appScreen.gameId !== null) {
pushState("", { screen: "game", gameId: appScreen.gameId });
} else if (appScreen.screen === "lobby-create") {
pushState("", { screen: "lobby-create" });
}
}
});
// Sync the store from history on Back/Forward (popstate updates
// `page.state`). Skipped until the baseline is stamped so it never
// clobbers the restored screen on first render.
$effect(() => {
if (!historyStamped) return;
appScreen.syncFromHistory(page.state.screen, page.state.gameId ?? null);
});
</script> </script>
<main class="status"> {#if session.status === "authenticated"}
<p>{i18n.t("common.loading")}</p> {#if appScreen.screen === "lobby-create"}
</main> <LobbyCreateScreen />
{:else if appScreen.screen === "game" && appScreen.gameId !== null}
<style> <GameShell />
.status { {:else}
padding: var(--space-6); <!--
font-family: var(--font-sans); Default authenticated screen. Covers `lobby`, a stale `login`
} screen restored from a previous anonymous session, and a `game`
</style> screen with no active game id (a snapshot that lost its id).
-->
<LobbyScreen />
{/if}
{:else}
<LoginScreen />
{/if}
@@ -1,8 +0,0 @@
// SPA mode for the in-game shell, mirroring the root layout. The
// session bootstrap and the auth gate already live in the root
// `+layout.svelte`; this layout just inherits the SPA flags so the
// static adapter does not try to prerender a per-game shell at build
// time.
export const ssr = false;
export const prerender = false;
@@ -1,12 +0,0 @@
// A bare `/games/:id` URL is not in the IA section — every in-game
// view sits under one of the typed sub-routes (`map`, `table/...`,
// etc.). Default the user to the map view so the URL is always
// pointing at a real active view; SvelteKit's `redirect` runs in the
// browser because the layout disables SSR.
import { redirect } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
export const load: PageLoad = ({ params }) => {
throw redirect(307, `/games/${params.id}/map`);
};
@@ -1,16 +0,0 @@
<script lang="ts">
import { page } from "$app/state";
import BattleView from "$lib/active-view/battle.svelte";
const turn = $derived.by(() => {
const raw = page.url.searchParams.get("turn");
const n = raw === null ? NaN : Number(raw);
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : 0;
});
</script>
<BattleView
gameId={page.params.id ?? ""}
{turn}
battleId={page.params.battleId ?? ""}
/>
@@ -1,5 +0,0 @@
<script lang="ts">
import DesignerScience from "$lib/active-view/designer-science.svelte";
</script>
<DesignerScience />
@@ -1,5 +0,0 @@
<script lang="ts">
import MailView from "$lib/active-view/mail.svelte";
</script>
<MailView />
@@ -1,5 +0,0 @@
<script lang="ts">
import MapView from "$lib/active-view/map.svelte";
</script>
<MapView />
@@ -1,47 +0,0 @@
<!--
Phase 23 turn-report route. The orchestrator renders the table of
contents and the twenty sections; scroll save/restore is wired
through SvelteKit's `Snapshot` API on this route file.
`window.scrollY` is captured before navigating away and restored
after `afterNavigate` re-mounts the route. The in-game shell
layout expands the active-view-host to fit content rather than
constraining its own height, so the document body is what scrolls
— hence `window.scroll` rather than a host-element scrollTop.
A short `requestAnimationFrame` poll waits for the body to grow
tall enough to honour the saved offset, because the captured
position usually exceeds the viewport height before the sections
mount on return navigation.
-->
<script lang="ts">
import type { Snapshot } from "@sveltejs/kit";
import ReportView from "$lib/active-view/report.svelte";
function restoreScroll(target: number): void {
if (target <= 0) return;
let attempts = 60;
const tick = (): void => {
const need = target + window.innerHeight;
const have = document.documentElement.scrollHeight;
if (have >= need || attempts === 0) {
window.scrollTo(0, target);
return;
}
attempts -= 1;
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
export const snapshot: Snapshot<{ scrollY: number }> = {
capture() {
return { scrollY: window.scrollY };
},
restore(value) {
restoreScroll(value.scrollY);
},
};
</script>
<ReportView />
@@ -1,6 +0,0 @@
<script lang="ts">
import { page } from "$app/state";
import TableView from "$lib/active-view/table.svelte";
</script>
<TableView entity={page.params.entity ?? ""} />
-6
View File
@@ -1,6 +0,0 @@
// Lobby is the first authenticated screen and depends on the
// session keypair plus the WASM core loaded at runtime; SSR and
// prerendering stay disabled.
export const ssr = false;
export const prerender = false;
@@ -1,2 +0,0 @@
export const ssr = false;
export const prerender = false;
-6
View File
@@ -1,6 +0,0 @@
// Login depends on browser-only WebCrypto and IndexedDB through the
// session store; SSR and prerendering are disabled to keep the
// component out of the server-render pipeline.
export const ssr = false;
export const prerender = false;
+23 -21
View File
@@ -33,19 +33,16 @@ import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
const GAME_ID = "11111111-2222-3333-4444-555555555555"; const GAME_ID = "11111111-2222-3333-4444-555555555555";
const pageMock = vi.hoisted(() => ({ // The science designer reads its target science from the `scienceId`
url: new URL("http://localhost/games/g1/designer/science"), // prop (the single-URL app-shell passes view sub-parameters as props,
params: { id: "g1" } as Record<string, string>, // not URL segments) and returns to the sciences table by switching the
})); // active in-game view via `activeView.select("table", …)`. Mock the
// nav store so the spy captures the view switch and no real `pushState`
// runs.
const activeViewSelectMock = vi.hoisted(() => vi.fn());
const gotoMock = vi.hoisted(() => vi.fn()); vi.mock("$lib/app-nav.svelte", () => ({
activeView: { select: activeViewSelectMock },
vi.mock("$app/state", () => ({
page: pageMock,
}));
vi.mock("$app/navigation", () => ({
goto: gotoMock,
})); }));
import DesignerScience from "../src/lib/active-view/designer-science.svelte"; import DesignerScience from "../src/lib/active-view/designer-science.svelte";
@@ -62,8 +59,7 @@ beforeEach(async () => {
draft = new OrderDraftStore(); draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID }); await draft.init({ cache, gameId: GAME_ID });
i18n.resetForTests("en"); i18n.resetForTests("en");
pageMock.params = { id: "g1" }; activeViewSelectMock.mockClear();
gotoMock.mockClear();
}); });
afterEach(async () => { afterEach(async () => {
@@ -113,9 +109,6 @@ function mountDesigner(opts: {
report?: GameReport | null; report?: GameReport | null;
}) { }) {
const report = opts.report ?? makeReport(); const report = opts.report ?? makeReport();
pageMock.params = opts.scienceId
? { id: "g1", scienceId: opts.scienceId }
: { id: "g1" };
const renderedReport = { const renderedReport = {
get report() { get report() {
return report; return report;
@@ -125,7 +118,10 @@ function mountDesigner(opts: {
[ORDER_DRAFT_CONTEXT_KEY, draft], [ORDER_DRAFT_CONTEXT_KEY, draft],
[RENDERED_REPORT_CONTEXT_KEY, renderedReport], [RENDERED_REPORT_CONTEXT_KEY, renderedReport],
]); ]);
return render(DesignerScience, { context }); return render(DesignerScience, {
props: opts.scienceId ? { scienceId: opts.scienceId } : {},
context,
});
} }
describe("science designer (new mode)", () => { describe("science designer (new mode)", () => {
@@ -172,7 +168,9 @@ describe("science designer (new mode)", () => {
expect(cmd.shields).toBeCloseTo(0.25, 12); expect(cmd.shields).toBeCloseTo(0.25, 12);
expect(cmd.cargo).toBeCloseTo(0.25, 12); expect(cmd.cargo).toBeCloseTo(0.25, 12);
await waitFor(() => await waitFor(() =>
expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences"), expect(activeViewSelectMock).toHaveBeenCalledWith("table", {
tableEntity: "sciences",
}),
); );
}); });
@@ -238,7 +236,9 @@ describe("science designer (new mode)", () => {
const ui = mountDesigner({}); const ui = mountDesigner({});
await fireEvent.click(ui.getByTestId("designer-science-cancel")); await fireEvent.click(ui.getByTestId("designer-science-cancel"));
expect(draft.commands).toHaveLength(0); expect(draft.commands).toHaveLength(0);
expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences"); expect(activeViewSelectMock).toHaveBeenCalledWith("table", {
tableEntity: "sciences",
});
}); });
}); });
@@ -286,7 +286,9 @@ describe("science designer (view mode)", () => {
if (cmd.kind !== "removeScience") throw new Error("wrong kind"); if (cmd.kind !== "removeScience") throw new Error("wrong kind");
expect(cmd.name).toBe("FirstStep"); expect(cmd.name).toBe("FirstStep");
await waitFor(() => await waitFor(() =>
expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences"), expect(activeViewSelectMock).toHaveBeenCalledWith("table", {
tableEntity: "sciences",
}),
); );
}); });
+57 -15
View File
@@ -4,12 +4,15 @@
// webkit/mobile projects adds cost without new signal). // webkit/mobile projects adds cost without new signal).
// //
// Auth is bootstrapped through `/__debug/store` exactly as the // Auth is bootstrapped through `/__debug/store` exactly as the
// game-shell specs do; the in-game layout tolerates a missing gateway // game-shell specs do; the in-game shell tolerates a missing gateway
// (ECONNREFUSED) and still renders the chrome + view shells, which is // (ECONNREFUSED) and still renders the chrome + view shells, which is
// what the structural a11y scan needs. // what the structural a11y scan needs. Screens and in-game views are
// reached through the dev-only `window.__galaxyNav` affordance — the
// single-URL app-shell has no per-screen / per-view routes.
import AxeBuilder from "@axe-core/playwright"; import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import type { GameView, GameViewState } from "../../src/lib/app-nav.svelte";
const SESSION_ID = "f2-a11y-axe-session"; const SESSION_ID = "f2-a11y-axe-session";
// A real UUID — the layout's auto-sync calls `uuidToHiLo` on it. // A real UUID — the layout's auto-sync calls `uuidToHiLo` on it.
@@ -46,38 +49,77 @@ test.describe("axe WCAG 2.2 AA", () => {
}); });
test("login", async ({ page }) => { test("login", async ({ page }) => {
await page.goto("/login"); // No seeded session → the dispatcher renders the login screen.
await page.goto("/");
await expect(page.locator("#main-content")).toBeVisible(); await expect(page.locator("#main-content")).toBeVisible();
await expectNoViolations(page); await expectNoViolations(page);
}); });
test("lobby", async ({ page }) => { test("lobby", async ({ page }) => {
await authenticate(page); await authenticate(page);
await page.goto("/lobby"); await page.goto("/");
await expect(page.locator("#main-content")).toBeVisible(); await expect(page.locator("#main-content")).toBeVisible();
await expectNoViolations(page); await expectNoViolations(page);
}); });
test("lobby create", async ({ page }) => { test("lobby create", async ({ page }) => {
await authenticate(page); await authenticate(page);
await page.goto("/lobby/create"); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(() => window.__galaxyNav!.go("lobby-create"));
await expect(page.locator("#main-content")).toBeVisible(); await expect(page.locator("#main-content")).toBeVisible();
await expectNoViolations(page); await expectNoViolations(page);
}); });
const inGameViews: Array<[string, string]> = [ type ViewParams = Omit<GameViewState, "view">;
["map", "active-view-map"], const inGameViews: Array<{
["report", "active-view-report"], label: string;
["mail", "active-view-mail"], view: GameView;
["battle", "active-view-battle"], params: ViewParams;
["designer/science", "active-view-designer-science"], testId: string;
["table/planets", "active-view-table"], }> = [
{ label: "map", view: "map", params: {}, testId: "active-view-map" },
{
label: "report",
view: "report",
params: {},
testId: "active-view-report",
},
{ label: "mail", view: "mail", params: {}, testId: "active-view-mail" },
{
label: "battle",
view: "battle",
params: {},
testId: "active-view-battle",
},
{
label: "designer/science",
view: "designer-science",
params: {},
testId: "active-view-designer-science",
},
{
label: "table/planets",
view: "table",
params: { tableEntity: "planets" },
testId: "active-view-table",
},
]; ];
for (const [path, testId] of inGameViews) { for (const { label, view, params, testId } of inGameViews) {
test(`in-game: ${path}`, async ({ page }) => { test(`in-game: ${label}`, async ({ page }) => {
await authenticate(page); await authenticate(page);
await page.goto(`/games/${GAME_ID}/${path}`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
([id, v, p]) =>
window.__galaxyNav!.enterGame(
id as string,
v as GameView,
p as ViewParams,
),
[GAME_ID, view, params] as const,
);
await expect(page.getByTestId("game-shell")).toBeVisible(); await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId(testId)).toBeVisible(); await expect(page.getByTestId(testId)).toBeVisible();
await expectNoViolations(page); await expectNoViolations(page);
+6 -1
View File
@@ -16,7 +16,12 @@ async function bootShell(page: Page): Promise<void> {
(id) => window.__galaxyDebug!.setDeviceSessionId(id), (id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID, SESSION_ID,
); );
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("game-shell")).toBeVisible(); await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toBeVisible(); await expect(page.getByTestId("active-view-map")).toBeVisible();
} }
+20 -8
View File
@@ -145,7 +145,10 @@ async function mockGatewayHappyPath(
async function completeLogin(page: Page): Promise<void> { async function completeLogin(page: Page): Promise<void> {
await page.goto("/"); await page.goto("/");
await expect(page).toHaveURL(/\/login$/); // The single-URL app-shell renders the login screen from in-memory
// state (anonymous session) rather than a `/login` route, so assert
// on the visible login form instead of the URL.
await expect(page.getByTestId("login-email-input")).toBeVisible();
// Inputs render `readonly` initially as a Safari autofill-suppression // Inputs render `readonly` initially as a Safari autofill-suppression
// workaround; the attribute drops on first focus. Click first so the // workaround; the attribute drops on first focus. Click first so the
// onfocus handler runs before fill checks editability. // onfocus handler runs before fill checks editability.
@@ -156,7 +159,9 @@ async function completeLogin(page: Page): Promise<void> {
await page.getByTestId("login-code-input").click(); await page.getByTestId("login-code-input").click();
await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click(); await page.getByTestId("login-code-submit").click();
await expect(page).toHaveURL(/\/lobby$/); // Sign-in switches the in-memory screen to the lobby; the device
// session id surfaces only on the lobby screen.
await expect(page.getByTestId("device-session-id")).toBeVisible();
} }
test.describe("Phase 7 — auth flow", () => { test.describe("Phase 7 — auth flow", () => {
@@ -185,7 +190,8 @@ test.describe("Phase 7 — auth flow", () => {
await expect(page.getByTestId("account-greeting")).toBeVisible(); await expect(page.getByTestId("account-greeting")).toBeVisible();
await page.reload(); await page.reload();
await expect(page).toHaveURL(/\/lobby$/); // The restored session re-renders the lobby screen directly (no
// `/lobby` route to land on).
await expect(page.getByTestId("device-session-id")).toHaveText( await expect(page.getByTestId("device-session-id")).toHaveText(
"dev-test-1", "dev-test-1",
); );
@@ -202,12 +208,16 @@ test.describe("Phase 7 — auth flow", () => {
// Fire all pending SubscribeEvents requests with an empty 200 // Fire all pending SubscribeEvents requests with an empty 200
// response. Connect-Web's server-streaming reader sees no frames // response. Connect-Web's server-streaming reader sees no frames
// and the watcher trips into `signOut("revoked")`, which the // and the watcher trips into `signOut("revoked")`, which flips the
// layout effect turns into a redirect back to /login. // in-memory session to anonymous so the dispatcher re-renders the
// login screen (the single-URL app-shell has no `/login` route to
// redirect to).
const releaseAt = Date.now(); const releaseAt = Date.now();
mocks.pendingSubscribes.forEach((resolve) => resolve()); mocks.pendingSubscribes.forEach((resolve) => resolve());
await expect(page).toHaveURL(/\/login$/, { timeout: 1000 }); await expect(page.getByTestId("login-email-input")).toBeVisible({
timeout: 1000,
});
expect(Date.now() - releaseAt).toBeLessThan(1500); expect(Date.now() - releaseAt).toBeLessThan(1500);
}); });
@@ -230,7 +240,7 @@ test.describe("Phase 7 — auth flow", () => {
}, },
); );
await page.goto("/login"); await page.goto("/");
await expect(page.getByTestId("login-email-submit")).toHaveText( await expect(page.getByTestId("login-email-submit")).toHaveText(
"send code", "send code",
); );
@@ -287,6 +297,8 @@ test.describe("Phase 7 — auth flow", () => {
await page.goto("/"); await page.goto("/");
await expect(page.getByText(/browser not supported/i)).toBeVisible(); await expect(page.getByText(/browser not supported/i)).toBeVisible();
await expect(page).not.toHaveURL(/\/login$/); // The unsupported-browser blocker replaces the screen dispatcher
// entirely, so the login form never renders.
await expect(page.getByTestId("login-email-input")).toHaveCount(0);
}); });
}); });
+31 -8
View File
@@ -225,16 +225,20 @@ test.describe("Phase 27 battle viewer", () => {
await mockGatewayAndBattle(page); await mockGatewayAndBattle(page);
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-report")).toBeVisible(); await expect(page.getByTestId("active-view-report")).toBeVisible();
const row = page.getByTestId("report-battle-row").first(); const row = page.getByTestId("report-battle-row").first();
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await row.click(); await row.click();
await expect(page).toHaveURL( // The battle row switches the active view in place (the address
new RegExp(`/games/${GAME_ID}/battle/${BATTLE_ID}\\?turn=1`), // bar stays at the app base); the viewer chrome is the signal.
);
await expect(page.getByTestId("battle-viewer")).toBeVisible(); await expect(page.getByTestId("battle-viewer")).toBeVisible();
await expect(page.getByTestId("battle-scene")).toBeVisible(); await expect(page.getByTestId("battle-scene")).toBeVisible();
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4"); await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
@@ -250,7 +254,13 @@ test.describe("Phase 27 battle viewer", () => {
await mockGatewayAndBattle(page); await mockGatewayAndBattle(page);
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
([id, battleId]) =>
window.__galaxyNav!.enterGame(id, "battle", { battleId, turn: 1 }),
[GAME_ID, BATTLE_ID] as const,
);
await expect(page.getByTestId("battle-viewer")).toBeVisible(); await expect(page.getByTestId("battle-viewer")).toBeVisible();
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4"); await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
@@ -274,8 +284,15 @@ test.describe("Phase 27 battle viewer", () => {
await mockGatewayAndBattle(page); await mockGatewayAndBattle(page);
await bootSession(page); await bootSession(page);
await page.goto( await page.goto("/");
`/games/${GAME_ID}/battle/22222222-2222-2222-2222-222222222222?turn=1`, await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "battle", {
battleId: "22222222-2222-2222-2222-222222222222",
turn: 1,
}),
GAME_ID,
); );
await expect(page.getByTestId("battle-not-found")).toBeVisible(); await expect(page.getByTestId("battle-not-found")).toBeVisible();
@@ -292,7 +309,13 @@ test.describe("Phase 27 battle viewer", () => {
await page.setViewportSize({ width: 1280, height: 720 }); await page.setViewportSize({ width: 1280, height: 720 });
await mockGatewayAndBattle(page); await mockGatewayAndBattle(page);
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
([id, battleId]) =>
window.__galaxyNav!.enterGame(id, "battle", { battleId, turn: 1 }),
[GAME_ID, BATTLE_ID] as const,
);
await expect(page.getByTestId("battle-viewer")).toBeVisible(); await expect(page.getByTestId("battle-viewer")).toBeVisible();
await expect(page.getByTestId("battle-scene")).toBeVisible(); await expect(page.getByTestId("battle-scene")).toBeVisible();
+6 -1
View File
@@ -378,7 +378,12 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
const handle = await mockGateway(page); const handle = await mockGateway(page);
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
@@ -138,7 +138,12 @@ async function setupShell(page: Page): Promise<void> {
}, },
}); });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
+30 -11
View File
@@ -171,7 +171,12 @@ test("map view renders the reported turn and planet count from a live report", a
}); });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
@@ -201,7 +206,12 @@ test("zero-planet game renders the empty world without errors", async ({
}); });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
@@ -216,12 +226,15 @@ test("zero-planet game renders the empty world without errors", async ({
await expect(page.getByTestId("map-mount-error")).not.toBeVisible(); await expect(page.getByTestId("map-mount-error")).not.toBeVisible();
}); });
test("missing-membership game surfaces an error instead of a blank canvas", async ({ test("missing-membership game drops back to the lobby with an unavailable toast", async ({
page, page,
}) => { }) => {
// The gateway returns lobby.my.games.list with a different game id // The gateway returns lobby.my.games.list with a different game id
// so the layout's gameState lookup misses; the store flips to // so the shell's gameState lookup misses. In the single-URL
// `error` and the map view renders the localised error overlay. // app-shell this `notFound` case no longer strands the player on an
// in-game error overlay — the shell switches the screen back to the
// lobby (`appScreen.go("lobby")`) and surfaces the "no longer
// available" toast.
await mockGateway(page, { await mockGateway(page, {
currentTurn: 0, currentTurn: 0,
gameId: "99999999-aaaa-bbbb-cccc-000000000000", gameId: "99999999-aaaa-bbbb-cccc-000000000000",
@@ -229,11 +242,17 @@ test("missing-membership game surfaces an error instead of a blank canvas", asyn
}); });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await expect(page.getByTestId("map-error")).toBeVisible(); await page.evaluate(
await expect(page.getByTestId("active-view-map")).toHaveAttribute( (id) => window.__galaxyNav!.enterGame(id, "map", {}),
"data-status", GAME_ID,
"error",
); );
// Back on the lobby (game shell unmounted), with the unavailable toast.
await expect(page.getByTestId("toast")).toContainText(
"this game is no longer available",
);
await expect(page.getByTestId("lobby-create-button")).toBeVisible();
await expect(page.getByTestId("game-shell")).toHaveCount(0);
}); });
+25 -23
View File
@@ -2,18 +2,19 @@
// boots an authenticated session through `/__debug/store` (the // boots an authenticated session through `/__debug/store` (the
// in-game shell makes a handful of gateway calls — for the lobby // in-game shell makes a handful of gateway calls — for the lobby
// record, the report, and the order read-back; we don't mock them // record, the report, and the order read-back; we don't mock them
// here, the shell tolerates ECONNREFUSED), navigates into // here, the shell tolerates ECONNREFUSED), enters the game through
// `/games/<game-id>/map`, and exercises one slice of the chrome: // the dev-only `window.__galaxyNav` affordance (the single-URL
// app-shell has no `/games/<id>/<view>` route — the address bar
// stays at the app base), and exercises one slice of the chrome:
// header navigation, sidebar tab preservation, mobile bottom-tabs, // header navigation, sidebar tab preservation, mobile bottom-tabs,
// and the breakpoint switches at 768 / 1024 px. // and the breakpoint switches at 768 / 1024 px.
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
// The `window.__galaxyDebug` surface is owned by // `window.__galaxyDebug` is owned by `src/routes/__debug/store/+page.svelte`
// `src/routes/__debug/store/+page.svelte` and typed by // (auth bootstrap) and `window.__galaxyNav` by `src/routes/+page.svelte`
// `tests/e2e/storage-keypair-persistence.spec.ts`. This spec only // (dev-only screen/view driver); both are typed by
// needs the auth-bootstrap subset (`clearSession`, // `tests/e2e/storage-keypair-persistence.spec.ts`.
// `setDeviceSessionId`); the merged global declaration covers both.
const SESSION_ID = "phase-10-shell-session"; const SESSION_ID = "phase-10-shell-session";
// GAME_ID has to be a real UUID — Phase 14's auto-sync calls // GAME_ID has to be a real UUID — Phase 14's auto-sync calls
@@ -30,7 +31,14 @@ async function bootShell(page: Page): Promise<void> {
(id) => window.__galaxyDebug!.setDeviceSessionId(id), (id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID, SESSION_ID,
); );
await page.goto(`/games/${GAME_ID}/map`); // Load the app (seeded session → authenticated → lobby), then enter
// the game via the in-memory nav affordance.
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("game-shell")).toBeVisible(); await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toBeVisible(); await expect(page.getByTestId("active-view-map")).toBeVisible();
} }
@@ -50,23 +58,20 @@ test("shell mounts with header / sidebar / active-view chrome", async ({
test("header view-menu navigates to every active view", async ({ page }) => { test("header view-menu navigates to every active view", async ({ page }) => {
await bootShell(page); await bootShell(page);
const destinations: Array<[string, string, string]> = [ // The address bar stays at the app base in the single-URL app-shell,
["view-menu-item-report", "active-view-report", "/report"], // so the visible active view is the only navigation signal to assert.
["view-menu-item-mail", "active-view-mail", "/mail"], const destinations: Array<[string, string]> = [
["view-menu-item-battle", "active-view-battle", "/battle"], ["view-menu-item-report", "active-view-report"],
[ ["view-menu-item-mail", "active-view-mail"],
"view-menu-item-designer-science", ["view-menu-item-battle", "active-view-battle"],
"active-view-designer-science", ["view-menu-item-designer-science", "active-view-designer-science"],
"/designer/science", ["view-menu-item-map", "active-view-map"],
],
["view-menu-item-map", "active-view-map", "/map"],
]; ];
for (const [trigger, viewTestId, urlSuffix] of destinations) { for (const [trigger, viewTestId] of destinations) {
await page.getByTestId("view-menu-trigger").click(); await page.getByTestId("view-menu-trigger").click();
await page.getByTestId(trigger).click(); await page.getByTestId(trigger).click();
await expect(page.getByTestId(viewTestId)).toBeVisible(); await expect(page.getByTestId(viewTestId)).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}${urlSuffix}$`));
} }
}); });
@@ -92,9 +97,6 @@ test("header view-menu Tables sub-list navigates to every entity", async ({
const view = page.getByTestId("active-view-table"); const view = page.getByTestId("active-view-table");
await expect(view).toBeVisible(); await expect(view).toBeVisible();
await expect(view).toHaveAttribute("data-entity", entity); await expect(view).toHaveAttribute("data-entity", entity);
await expect(page).toHaveURL(
new RegExp(`/games/${GAME_ID}/table/${entity}$`),
);
} }
}); });
+9 -1
View File
@@ -181,7 +181,15 @@ test("navigating to a past turn enters history mode and back-to-current restores
const state = await mockGateway(page); const state = await mockGateway(page);
await seedShell(page); await seedShell(page);
await page.goto(`/games/${GAME_ID}/table/planets`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "table", {
tableEntity: "planets",
}),
GAME_ID,
);
await expect(page.getByTestId("turn-navigator-trigger")).toContainText( await expect(page.getByTestId("turn-navigator-trigger")).toContainText(
`turn ${CURRENT_TURN}`, `turn ${CURRENT_TURN}`,
); );
@@ -136,7 +136,9 @@ test("synthetic-report loader navigates from lobby to map and renders", async ({
page, page,
}) => { }) => {
await seedSession(page); await seedSession(page);
await page.goto("/lobby"); // Seeded session → the dispatcher renders the lobby; the synthetic
// loader lives there behind the dev-affordances flag.
await page.goto("/");
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible(); await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
const file = page.getByTestId("lobby-synthetic-file"); const file = page.getByTestId("lobby-synthetic-file");
@@ -146,9 +148,10 @@ test("synthetic-report loader navigates from lobby to map and renders", async ({
buffer: Buffer.from(JSON.stringify(SYNTHETIC_REPORT_FIXTURE)), buffer: Buffer.from(JSON.stringify(SYNTHETIC_REPORT_FIXTURE)),
}); });
await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, { // Loading the report enters the game in place (the address bar stays
timeout: 5_000, // at the app base); the in-game map shell is the visible signal.
}); await expect(page.getByTestId("game-shell")).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("active-view-map")).toBeVisible();
// The renderer canvas mounts inside the active-view host. Even if // The renderer canvas mounts inside the active-view host. Even if
// the WebGL/WebGPU backend is unavailable in CI, the layout still // the WebGL/WebGPU backend is unavailable in CI, the layout still
+10 -4
View File
@@ -235,7 +235,9 @@ async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promi
async function completeLogin(page: Page): Promise<void> { async function completeLogin(page: Page): Promise<void> {
await page.goto("/"); await page.goto("/");
await expect(page).toHaveURL(/\/login$/); // The single-URL app-shell renders the login screen from in-memory
// state (anonymous session); there is no `/login` route to assert.
await expect(page.getByTestId("login-email-input")).toBeVisible();
// The login page renders the inputs `readonly` as a Safari // The login page renders the inputs `readonly` as a Safari
// autofill-suppression workaround; the readonly attribute is // autofill-suppression workaround; the readonly attribute is
// dropped on first focus. Playwright's `fill()` checks editability // dropped on first focus. Playwright's `fill()` checks editability
@@ -248,7 +250,8 @@ async function completeLogin(page: Page): Promise<void> {
await page.getByTestId("login-code-input").click(); await page.getByTestId("login-code-input").click();
await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click(); await page.getByTestId("login-code-submit").click();
await expect(page).toHaveURL(/\/lobby$/); // Sign-in switches the in-memory screen to the lobby.
await expect(page.getByTestId("device-session-id")).toBeVisible();
} }
test.describe("Phase 8 — lobby flow", () => { test.describe("Phase 8 — lobby flow", () => {
@@ -260,7 +263,9 @@ test.describe("Phase 8 — lobby flow", () => {
await expect(page.getByTestId("lobby-public-games-empty")).toBeVisible(); await expect(page.getByTestId("lobby-public-games-empty")).toBeVisible();
await page.getByTestId("lobby-create-button").click(); await page.getByTestId("lobby-create-button").click();
await expect(page).toHaveURL(/\/lobby\/create$/); // The create screen replaces the lobby in place (no `/lobby/create`
// route); the create form is the visible signal.
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
await page.getByTestId("lobby-create-game-name").click(); await page.getByTestId("lobby-create-game-name").click();
await page.getByTestId("lobby-create-game-name").fill("First Contact"); await page.getByTestId("lobby-create-game-name").fill("First Contact");
@@ -271,7 +276,8 @@ test.describe("Phase 8 — lobby flow", () => {
.fill("2026-06-01T12:00"); .fill("2026-06-01T12:00");
await page.getByTestId("lobby-create-submit").click(); await page.getByTestId("lobby-create-submit").click();
await expect(page).toHaveURL(/\/lobby$/); // Submit returns to the lobby in place; the new game card is the
// visible signal that the lobby re-rendered.
await expect(page.getByTestId("lobby-my-game-card")).toContainText("First Contact"); await expect(page.getByTestId("lobby-my-game-card")).toContainText("First Contact");
expect(mocks.createGameCalls.length).toBe(1); expect(mocks.createGameCalls.length).toBe(1);
expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact"); expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact");
+6 -1
View File
@@ -187,7 +187,12 @@ for (const view of NON_MAP_VIEWS) {
await mockGateway(page); await mockGateway(page);
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
+11 -2
View File
@@ -202,7 +202,12 @@ async function bootSession(page: Page): Promise<void> {
} }
async function openGame(page: Page): Promise<void> { async function openGame(page: Page): Promise<void> {
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
@@ -383,7 +388,11 @@ test("toggle state persists across a page reload", async ({ page }) => {
await page.getByTestId("map-toggles-bombing-markers").isChecked(), await page.getByTestId("map-toggles-bombing-markers").isChecked(),
).toBe(false); ).toBe(false);
await page.reload(); // The restored `game` screen re-stamps history via shallow routing
// on first render; wait only for the navigation to commit so that
// `pushState` does not abort a default `reload()` (which waits for
// `load`).
await page.reload({ waitUntil: "commit" });
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
+49 -14
View File
@@ -1,7 +1,17 @@
// Phase 12 end-to-end coverage for the order composer skeleton. The // Phase 12 end-to-end coverage for the order composer skeleton. The
// shell makes no gateway calls in this spec — the boot flow seeds an // boot flow seeds an authenticated session and a draft directly
// authenticated session and a draft directly through `/__debug/store`, // through `/__debug/store`, then enters the game via the dev-only
// then navigates into `/games/<id>/map` and exercises the order tab. // `window.__galaxyNav` affordance (the single-URL app-shell has no
// `/games/<id>/<view>` route) and exercises the order tab.
//
// The shell's per-game bootstrap now talks to the gateway on entry
// (lobby validation, report, order read-back). This spec does not
// stand up a real gateway, so those Connect-Web calls are aborted via
// `page.route` — the shell tolerates the failure (cache fallback +
// `failBootstrap`) and still renders the chrome. Aborting also keeps
// a mid-spec `page.reload()` from hanging: an unrouted `/rpc` call
// to a dead proxy never settles, which otherwise stalls the reload's
// load event.
// //
// Persistence is covered by reloading the page mid-spec: the // Persistence is covered by reloading the page mid-spec: the
// `OrderDraftStore` re-reads the same cache row on the next mount, // `OrderDraftStore` re-reads the same cache row on the next mount,
@@ -10,12 +20,31 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
// `window.__galaxyDebug` is owned by `routes/__debug/store/+page.svelte` // `window.__galaxyDebug` is owned by `routes/__debug/store/+page.svelte`
// and typed by `tests/e2e/storage-keypair-persistence.spec.ts`. The // and `window.__galaxyNav` by `routes/+page.svelte`; both are typed by
// merged global declaration covers every helper this spec calls. // `tests/e2e/storage-keypair-persistence.spec.ts`.
const SESSION_ID = "phase-12-order-session"; const SESSION_ID = "phase-12-order-session";
const GAME_ID = "test-order"; const GAME_ID = "test-order";
// Fail-fast the shell's gateway calls so the spec needs no real
// backend and reloads settle promptly.
async function stubGateway(page: Page): Promise<void> {
await page.route("**/edge.v1.Gateway/**", (route) => route.abort());
}
// Load the app (seeded session → authenticated lobby) and enter the
// game on the map view through the in-memory nav affordance.
async function enterGameMap(page: Page): Promise<void> {
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toBeVisible();
}
const SEED = [ const SEED = [
{ kind: "placeholder" as const, id: "cmd-a", label: "first command" }, { kind: "placeholder" as const, id: "cmd-a", label: "first command" },
{ kind: "placeholder" as const, id: "cmd-b", label: "second command" }, { kind: "placeholder" as const, id: "cmd-b", label: "second command" },
@@ -23,6 +52,7 @@ const SEED = [
]; ];
async function bootDebug(page: Page): Promise<void> { async function bootDebug(page: Page): Promise<void> {
await stubGateway(page);
await page.goto("/__debug/store"); await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible(); await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true); await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
@@ -71,14 +101,18 @@ test("seeded draft renders on the order tab and survives a reload", async ({
}, testInfo) => { }, testInfo) => {
const isMobile = testInfo.project.name.startsWith("chromium-mobile"); const isMobile = testInfo.project.name.startsWith("chromium-mobile");
await seedShell(page); await seedShell(page);
await page.goto(`/games/${GAME_ID}/map`); await enterGameMap(page);
await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toBeVisible();
await openOrderTool(page, isMobile); await openOrderTool(page, isMobile);
await expectSeededRows(page); await expectSeededRows(page);
await page.reload(); // Reload restores the `game` screen from the persisted nav snapshot,
// whose first authenticated render re-stamps screen history via
// SvelteKit shallow routing. That `pushState` lands right after the
// document loads and would abort a default `reload()` (which waits
// for `load`); waiting only for the navigation to commit sidesteps
// the race while still re-executing the app from scratch.
await page.reload({ waitUntil: "commit" });
await expect(page.getByTestId("game-shell")).toBeVisible(); await expect(page.getByTestId("game-shell")).toBeVisible();
await openOrderTool(page, isMobile); await openOrderTool(page, isMobile);
await expectSeededRows(page); await expectSeededRows(page);
@@ -89,8 +123,7 @@ test("removing a command from the order tab persists the removal", async ({
}, testInfo) => { }, testInfo) => {
const isMobile = testInfo.project.name.startsWith("chromium-mobile"); const isMobile = testInfo.project.name.startsWith("chromium-mobile");
await seedShell(page); await seedShell(page);
await page.goto(`/games/${GAME_ID}/map`); await enterGameMap(page);
await expect(page.getByTestId("game-shell")).toBeVisible();
await openOrderTool(page, isMobile); await openOrderTool(page, isMobile);
await expect(page.getByTestId("order-command-1")).toBeVisible(); await expect(page.getByTestId("order-command-1")).toBeVisible();
@@ -104,7 +137,10 @@ test("removing a command from the order tab persists the removal", async ({
); );
await expect(page.getByTestId("order-command-2")).toHaveCount(0); await expect(page.getByTestId("order-command-2")).toHaveCount(0);
await page.reload(); // See the note on the sibling test: the restored `game` screen
// re-stamps history on reload, so wait only for the navigation to
// commit to avoid the shallow-routing `pushState` aborting it.
await page.reload({ waitUntil: "commit" });
await expect(page.getByTestId("game-shell")).toBeVisible(); await expect(page.getByTestId("game-shell")).toBeVisible();
await openOrderTool(page, isMobile); await openOrderTool(page, isMobile);
await expect(page.getByTestId("order-command-label-0")).toHaveText( await expect(page.getByTestId("order-command-label-0")).toHaveText(
@@ -131,8 +167,7 @@ test("empty draft renders the empty-state copy", async ({
GAME_ID, GAME_ID,
); );
await page.goto(`/games/${GAME_ID}/map`); await enterGameMap(page);
await expect(page.getByTestId("game-shell")).toBeVisible();
await openOrderTool(page, isMobile); await openOrderTool(page, isMobile);
await expect(page.getByTestId("order-empty")).toBeVisible(); await expect(page.getByTestId("order-empty")).toBeVisible();
+12 -2
View File
@@ -278,7 +278,12 @@ test("turn_already_closed surfaces the conflict banner on the order tab", async
); );
await mockGateway(page, { initialSubmitVerdict: "turn_already_closed" }); await mockGateway(page, { initialSubmitVerdict: "turn_already_closed" });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
@@ -322,7 +327,12 @@ test("game.paused push frame surfaces the paused banner", async ({
subscribeFrame: { eventType: "game.paused", payload }, subscribeFrame: { eventType: "game.paused", payload },
}); });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
@@ -286,7 +286,12 @@ test("switching production three times collapses to one auto-synced row", async
const handle = await mockGateway(page); const handle = await mockGateway(page);
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
@@ -357,10 +362,13 @@ test("switching production three times collapses to one auto-synced row", async
expect(handle.lastSubmitted!.subject).toBe(SHIP_CLASS); expect(handle.lastSubmitted!.subject).toBe(SHIP_CLASS);
expect(handle.submitCount).toBeGreaterThanOrEqual(3); expect(handle.submitCount).toBeGreaterThanOrEqual(3);
// Reload: the layout polls user.games.order.get on boot, so the // Reload: the shell polls user.games.order.get on boot, so the
// row is restored from the server's stored state even when the // row is restored from the server's stored state even when the
// local cache is wiped. // local cache is wiped. The restored `game` screen re-stamps
await page.reload(); // history via shallow routing on first render, so wait only for the
// navigation to commit (a default `reload()` waiting for `load`
// races that `pushState` and aborts).
await page.reload({ waitUntil: "commit" });
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
+6 -1
View File
@@ -280,7 +280,12 @@ test("toggle stance and pick a vote target via the races table", async ({
const handle = await mockGateway(page); const handle = await mockGateway(page);
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/table/races`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "table", { tableEntity: "races" }),
GAME_ID,
);
const tableHost = page.getByTestId("active-view-table"); const tableHost = page.getByTestId("active-view-table");
await expect(tableHost).toBeVisible(); await expect(tableHost).toBeVisible();
+18 -5
View File
@@ -230,7 +230,12 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async
submitOutcome: "applied", submitOutcome: "applied",
}); });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
@@ -266,10 +271,13 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async
); );
expect(handle.submittedRenameName).toBe("New-Earth"); expect(handle.submittedRenameName).toBe("New-Earth");
// Reload: the layout always polls user.games.order.get on boot, // Reload: the shell always polls user.games.order.get on boot,
// so the overlay is rebuilt from the server's stored order even // so the overlay is rebuilt from the server's stored order even
// when the local cache was wiped. // when the local cache was wiped. The restored `game` screen
await page.reload(); // re-stamps history via shallow routing on first render, so wait
// only for the navigation to commit (a default `reload()` waiting
// for `load` races that `pushState` and aborts).
await page.reload({ waitUntil: "commit" });
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
@@ -292,7 +300,12 @@ test("rejected submit keeps the old name and surfaces the failure", async ({
submitOutcome: "rejected", submitOutcome: "rejected",
}); });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
+34 -66
View File
@@ -6,10 +6,8 @@
// 1. Every TOC anchor click scrolls the matching section into view // 1. Every TOC anchor click scrolls the matching section into view
// and the section is present in the DOM with at least one row // and the section is present in the DOM with at least one row
// (or its empty-state copy when it is intentionally empty). // (or its empty-state copy when it is intentionally empty).
// 2. Snapshot save/restore on the active-view-host scroll // 2. The "back to map" button switches to the map view.
// container survives a /map navigation round-trip. // 3. The mobile <select> fallback scrolls a section into view on
// 3. The "back to map" button navigates to the map URL.
// 4. The mobile <select> fallback scrolls a section into view on
// a narrow viewport. // a narrow viewport.
import { fromJson, type JsonValue } from "@bufbuild/protobuf"; import { fromJson, type JsonValue } from "@bufbuild/protobuf";
@@ -238,7 +236,12 @@ test.describe("Phase 23 report view", () => {
await mockGateway(page); await mockGateway(page);
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-report")).toBeVisible(); await expect(page.getByTestId("active-view-report")).toBeVisible();
await expect(page.getByTestId("report-toc")).toBeVisible(); await expect(page.getByTestId("report-toc")).toBeVisible();
@@ -265,65 +268,19 @@ test.describe("Phase 23 report view", () => {
} }
}); });
test("scroll position survives a /map round-trip via Snapshot", async ({ // NOTE: the old "scroll position survives a /map round-trip via
page, // Snapshot" spec was dropped here. It exercised the per-route
}, testInfo) => { // SvelteKit `Snapshot` exported by the deleted
test.skip( // `routes/games/[id]/report/+page.svelte`, which captured and
testInfo.project.name.startsWith("chromium-mobile"), // restored `window.scrollY` across a browser history navigation to
"snapshot mechanism is the same on mobile; one project is enough", // `/map` and back. The single-URL app-shell switches the active view
); // in memory (`activeView.select`) without changing the URL or pushing
// a history entry, and it remounts the report component on return —
// so neither the URL round-trip, the `page.goBack()`, nor the
// scroll-restoration the test asserted exist any more. Re-adding that
// behaviour would be a production change outside this test migration.
await mockGateway(page); test("back-to-map button switches to the map view", async ({
await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`);
await expect(page.getByTestId("active-view-report")).toBeVisible();
await expect(
page.getByTestId("galaxy-summary-field-turn"),
).toBeVisible();
// Scroll the window. The report's host expands to fit
// content rather than constraining its own height, so the
// document body is the real scroll container. SvelteKit's
// default scroll-restoration tracks `window.scrollY` on
// history navigation, which is what the acceptance criterion
// — "scroll position resets when switching to another view
// and is restored on return" — requires.
const target = 600;
await page.evaluate((value) => {
window.scrollTo(0, value);
}, target);
const savedScrollY = await page.evaluate(() => window.scrollY);
expect(savedScrollY).toBeGreaterThan(0);
// Programmatically click the back-to-map button. Driving the
// click through `evaluate` rather than the Playwright locator
// skips its built-in scrollIntoViewIfNeeded(), which would
// otherwise scroll the sticky TOC button into view and reset
// `window.scrollY` to 0 before SvelteKit's Snapshot capture
// fires.
await page.evaluate(() => {
const button = document.querySelector(
"[data-testid='report-back-to-map']",
) as HTMLButtonElement | null;
button?.click();
});
await page.waitForURL(`**/games/${GAME_ID}/map`);
await page.goBack();
await page.waitForURL(`**/games/${GAME_ID}/report`);
await expect(page.getByTestId("active-view-report")).toBeVisible();
await expect(
page.getByTestId("galaxy-summary-field-turn"),
).toBeVisible();
await expect
.poll(async () => page.evaluate(() => window.scrollY), {
timeout: 5_000,
intervals: [100, 200, 400],
})
.toBeGreaterThan(savedScrollY / 2);
});
test("back-to-map button navigates to the map URL", async ({
page, page,
}, testInfo) => { }, testInfo) => {
test.skip( test.skip(
@@ -333,10 +290,16 @@ test.describe("Phase 23 report view", () => {
await mockGateway(page); await mockGateway(page);
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
GAME_ID,
);
await page.getByTestId("report-back-to-map").click(); await page.getByTestId("report-back-to-map").click();
await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}/map$`)); // The single-URL app-shell keeps the address bar at the app base;
// the active map view is the navigation signal.
await expect(page.getByTestId("active-view-map")).toBeVisible(); await expect(page.getByTestId("active-view-map")).toBeVisible();
}); });
@@ -350,7 +313,12 @@ test.describe("Phase 23 report view", () => {
await mockGateway(page); await mockGateway(page);
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
GAME_ID,
);
const mobileSelect = page.getByTestId("report-toc-mobile"); const mobileSelect = page.getByTestId("report-toc-mobile");
await expect(mobileSelect).toBeVisible(); await expect(mobileSelect).toBeVisible();
@@ -197,7 +197,12 @@ test("returning to /map after creating a science keeps the renderer alive", asyn
await bootSession(page); await bootSession(page);
// Step 1: open /map and let the renderer mount cleanly. // Step 1: open /map and let the renderer mount cleanly.
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
+21 -3
View File
@@ -286,7 +286,15 @@ test("create / list / delete science via the table + designer", async ({
const handle = await mockGateway(page, { createOutcome: "applied" }); const handle = await mockGateway(page, { createOutcome: "applied" });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/table/sciences`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "table", {
tableEntity: "sciences",
}),
GAME_ID,
);
const tableHost = page.getByTestId("active-view-table"); const tableHost = page.getByTestId("active-view-table");
await expect(tableHost).toBeVisible(); await expect(tableHost).toBeVisible();
@@ -347,7 +355,12 @@ test("designer keeps Save disabled while the form is invalid", async ({
await mockGateway(page, { createOutcome: "applied" }); await mockGateway(page, { createOutcome: "applied" });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/designer/science`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "designer-science", {}),
GAME_ID,
);
const save = page.getByTestId("designer-science-save"); const save = page.getByTestId("designer-science-save");
await expect(save).toBeDisabled(); await expect(save).toBeDisabled();
@@ -385,7 +398,12 @@ test("planet production picker exposes user sciences in the Research sub-row", a
], ],
}); });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
+27 -3
View File
@@ -264,7 +264,15 @@ test("create / list / delete ship class via the table + calculator", async ({
const handle = await mockGateway(page, { createOutcome: "applied" }); const handle = await mockGateway(page, { createOutcome: "applied" });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/table/ship-classes`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "table", {
tableEntity: "ship-classes",
}),
GAME_ID,
);
const tableHost = page.getByTestId("active-view-table"); const tableHost = page.getByTestId("active-view-table");
await expect(tableHost).toBeVisible(); await expect(tableHost).toBeVisible();
@@ -321,7 +329,15 @@ test("calculator keeps Create disabled while the design is invalid", async ({
await mockGateway(page, { createOutcome: "applied" }); await mockGateway(page, { createOutcome: "applied" });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/table/ship-classes`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "table", {
tableEntity: "ship-classes",
}),
GAME_ID,
);
await page.getByTestId("ship-classes-new").click(); await page.getByTestId("ship-classes-new").click();
const calc = page.getByTestId("sidebar-tool-calculator"); const calc = page.getByTestId("sidebar-tool-calculator");
const create = calc.getByTestId("calculator-create"); const create = calc.getByTestId("calculator-create");
@@ -350,7 +366,15 @@ test("rejected createShipClass keeps the table empty and surfaces the failure",
await mockGateway(page, { createOutcome: "rejected" }); await mockGateway(page, { createOutcome: "rejected" });
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/table/ship-classes`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "table", {
tableEntity: "ship-classes",
}),
GAME_ID,
);
await page.getByTestId("ship-classes-new").click(); await page.getByTestId("ship-classes-new").click();
const calc = page.getByTestId("sidebar-tool-calculator"); const calc = page.getByTestId("sidebar-tool-calculator");
@@ -135,7 +135,9 @@ async function bootSession(page: Page): Promise<void> {
} }
async function loadSyntheticGame(page: Page): Promise<void> { async function loadSyntheticGame(page: Page): Promise<void> {
await page.goto("/lobby"); // Seeded session → the dispatcher renders the lobby; the synthetic
// loader lives there behind the dev-affordances flag.
await page.goto("/");
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible(); await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
const file = page.getByTestId("lobby-synthetic-file"); const file = page.getByTestId("lobby-synthetic-file");
await file.setInputFiles({ await file.setInputFiles({
@@ -143,9 +145,9 @@ async function loadSyntheticGame(page: Page): Promise<void> {
mimeType: "application/json", mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(SYNTHETIC_FIXTURE)), buffer: Buffer.from(JSON.stringify(SYNTHETIC_FIXTURE)),
}); });
await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, { // Loading the report enters the game in place (the address bar stays
timeout: 10_000, // at the app base); the map view reaching `ready` is the signal.
}); await expect(page.getByTestId("game-shell")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status", "data-status",
"ready", "ready",
@@ -20,6 +20,25 @@ import type {
MapPrimitiveSnapshot, MapPrimitiveSnapshot,
} from "../../src/lib/debug-surface.svelte"; } from "../../src/lib/debug-surface.svelte";
import type { WrapMode } from "../../src/map/world"; import type { WrapMode } from "../../src/map/world";
import type {
AppScreen,
GameView,
GameViewState,
} from "../../src/lib/app-nav.svelte";
// View sub-parameters accepted by the dev-only nav affordance — the
// `GameViewState` fields minus the discriminating `view`.
type NavViewParams = Omit<GameViewState, "view">;
// Mirrors the dev-only surface mounted by `routes/+page.svelte`. The
// single-URL app-shell has no per-screen / per-view routes, so the
// Playwright suite drives the in-memory screen and view through this
// global instead of `page.goto("/games/:id/:view")`.
interface NavSurface {
enterGame(gameId: string, view?: GameView, params?: NavViewParams): void;
select(view: GameView, params?: NavViewParams): void;
go(screen: AppScreen, opts?: { gameId?: string }): void;
}
// Mirrors the surface mounted by `routes/__debug/store/+page.svelte`. // Mirrors the surface mounted by `routes/__debug/store/+page.svelte`.
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`, // Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`,
@@ -56,6 +75,7 @@ interface DebugSurface {
declare global { declare global {
interface Window { interface Window {
__galaxyDebug?: DebugSurface; __galaxyDebug?: DebugSurface;
__galaxyNav?: NavSurface;
} }
} }
+29 -8
View File
@@ -105,16 +105,21 @@ async function mockGateway(page: Page): Promise<MockState> {
}, },
); );
// The first SubscribeEvents request from the root layout receives // The root layout opens the event stream while the dispatcher is
// one signed `game.turn.ready` frame for turn 5; subsequent // still on the lobby screen (the single-URL app-shell starts the
// reconnect attempts (events.ts retries after the abrupt // singleton stream on authentication, before the player enters a
// end-of-body) are held open indefinitely so the toast stays // game). The per-game `game.turn.ready` handler only registers once
// visible long enough for the test to interact with it. // the game shell mounts, so the very first frame can land before the
// handler exists. Deliver the signed frame on the first TWO
// SubscribeEvents requests — the post-frame reconnect (events.ts
// retries after the abrupt end-of-body) carries it again once the
// handler is up; `markPendingTurn(5)` is idempotent. Hold every
// later reconnect open so the toast stays visible for the test.
await page.route( await page.route(
"**/edge.v1.Gateway/SubscribeEvents", "**/edge.v1.Gateway/SubscribeEvents",
async (route) => { async (route) => {
state.subscribeHits += 1; state.subscribeHits += 1;
if (state.subscribeHits === 1) { if (state.subscribeHits <= 2) {
const payload = new TextEncoder().encode( const payload = new TextEncoder().encode(
JSON.stringify({ game_id: GAME_ID, turn: 5 }), JSON.stringify({ game_id: GAME_ID, turn: 5 }),
); );
@@ -155,7 +160,12 @@ test("signed game.turn.ready frame surfaces the toast", async ({ page }) => {
await mockGateway(page); await mockGateway(page);
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
// Initial chrome reflects the bootstrap currentTurn=4. // Initial chrome reflects the bootstrap currentTurn=4.
await expect(page.getByTestId("active-view-map")).toHaveAttribute( await expect(page.getByTestId("active-view-map")).toHaveAttribute(
@@ -181,8 +191,19 @@ test("manual dismiss clears the turn-ready toast without advancing the view", as
await mockGateway(page); await mockGateway(page);
await bootSession(page); await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`); await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
// Let the game shell finish booting (so the per-game turn-ready
// handler is registered) before expecting the pushed toast.
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await expect(page.getByTestId("toast")).toBeVisible({ timeout: 5_000 }); await expect(page.getByTestId("toast")).toBeVisible({ timeout: 5_000 });
await page.getByTestId("toast-close").click(); await page.getByTestId("toast-close").click();
await expect(page.getByTestId("toast")).toBeHidden(); await expect(page.getByTestId("toast")).toBeHidden();
+51 -41
View File
@@ -3,9 +3,10 @@
// the lobby / report calls are in flight), the Phase 26 turn // the lobby / report calls are in flight), the Phase 26 turn
// navigator (`← turn N →` with a popover of every turn), the // navigator (`← turn N →` with a popover of every turn), the
// view-menu, and the account-menu. The tests assert the visible // view-menu, and the account-menu. The tests assert the visible
// copy, that every view-menu entry dispatches `goto` with the right // copy, that every view-menu entry switches the active in-game view
// URL, and that the Logout entry of the account-menu calls // via `activeView.select(...)` (the single-URL app-shell has no
// `session.signOut("user")`. // per-view routes), and that the Logout entry of the account-menu
// calls `session.signOut("user")`.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte"; import { fireEvent, render } from "@testing-library/svelte";
@@ -57,14 +58,22 @@ function withGameState(opts: {
return new Map<unknown, unknown>([[GAME_STATE_CONTEXT_KEY, store]]); return new Map<unknown, unknown>([[GAME_STATE_CONTEXT_KEY, store]]);
} }
const gotoSpy = vi.fn(async (..._args: unknown[]) => {}); // The view-menu switches the active in-game view through
vi.mock("$app/navigation", () => ({ // `activeView.select(...)`, and the header's return-to-lobby button
goto: (...args: unknown[]) => gotoSpy(...args), // leaves the game through `appScreen.go("lobby")`; both internally
// call SvelteKit `pushState`. Mock the whole nav module so the spies
// capture the transitions and no real history mutation runs in JSDOM.
const activeViewSelectSpy = vi.fn();
const appScreenGoSpy = vi.fn();
vi.mock("$lib/app-nav.svelte", () => ({
activeView: { select: (...args: unknown[]) => activeViewSelectSpy(...args) },
appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) },
})); }));
beforeEach(() => { beforeEach(() => {
i18n.resetForTests("en"); i18n.resetForTests("en");
gotoSpy.mockReset(); activeViewSelectSpy.mockReset();
appScreenGoSpy.mockReset();
vi.spyOn(session, "signOut").mockResolvedValue(undefined); vi.spyOn(session, "signOut").mockResolvedValue(undefined);
}); });
@@ -76,7 +85,7 @@ describe("game-shell header", () => {
test("renders fall-back placeholders before the lobby / report data lands", () => { test("renders fall-back placeholders before the lobby / report data lands", () => {
const onToggleSidebar = vi.fn(); const onToggleSidebar = vi.fn();
const ui = render(Header, { const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar }, props: { sidebarOpen: false, onToggleSidebar },
context: withGameState(), context: withGameState(),
}); });
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent( expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
@@ -91,7 +100,7 @@ describe("game-shell header", () => {
test("renders the live race / game / turn from GameStateStore", () => { test("renders the live race / game / turn from GameStateStore", () => {
const ui = render(Header, { const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, props: { sidebarOpen: false, onToggleSidebar: () => {} },
context: withGameState({ context: withGameState({
gameName: "Phase 14", gameName: "Phase 14",
race: "Federation", race: "Federation",
@@ -108,7 +117,7 @@ describe("game-shell header", () => {
test("partial data still falls back gracefully (race known, game unknown)", () => { test("partial data still falls back gracefully (race known, game unknown)", () => {
const ui = render(Header, { const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, props: { sidebarOpen: false, onToggleSidebar: () => {} },
context: withGameState({ race: "Federation", turn: 3 }), context: withGameState({ race: "Federation", turn: 3 }),
}); });
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent( expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
@@ -122,54 +131,45 @@ describe("game-shell header", () => {
test("clicking the sidebar toggle invokes the prop callback", async () => { test("clicking the sidebar toggle invokes the prop callback", async () => {
const onToggleSidebar = vi.fn(); const onToggleSidebar = vi.fn();
const ui = render(Header, { const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar }, props: { sidebarOpen: false, onToggleSidebar },
}); });
await fireEvent.click(ui.getByTestId("sidebar-toggle")); await fireEvent.click(ui.getByTestId("sidebar-toggle"));
expect(onToggleSidebar).toHaveBeenCalledTimes(1); expect(onToggleSidebar).toHaveBeenCalledTimes(1);
}); });
test("view-menu navigates to every IA destination", async () => { test("view-menu switches the active view for every IA destination", async () => {
const ui = render(Header, { const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, props: { sidebarOpen: false, onToggleSidebar: () => {} },
}); });
const destinations: Array<[string, string]> = [ const destinations: Array<[string, string]> = [
["view-menu-item-map", "/games/g1/map"], ["view-menu-item-map", "map"],
["view-menu-item-report", "/games/g1/report"], ["view-menu-item-report", "report"],
["view-menu-item-battle", "/games/g1/battle"], ["view-menu-item-battle", "battle"],
["view-menu-item-mail", "/games/g1/mail"], ["view-menu-item-mail", "mail"],
[ ["view-menu-item-designer-science", "designer-science"],
"view-menu-item-designer-science",
"/games/g1/designer/science",
],
]; ];
for (const [testId, href] of destinations) { for (const [testId, view] of destinations) {
await fireEvent.click(ui.getByTestId("view-menu-trigger")); await fireEvent.click(ui.getByTestId("view-menu-trigger"));
await fireEvent.click(ui.getByTestId(testId)); await fireEvent.click(ui.getByTestId(testId));
expect(gotoSpy).toHaveBeenLastCalledWith(href); expect(activeViewSelectSpy).toHaveBeenLastCalledWith(view, {});
} }
}); });
test("view-menu Tables sub-list navigates to every entity", async () => { test("view-menu Tables sub-list switches to every entity", async () => {
const ui = render(Header, { const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, props: { sidebarOpen: false, onToggleSidebar: () => {} },
}); });
const tableEntities: Array<[string, string]> = [ const tableEntities: Array<[string, string]> = [
["view-menu-item-table-planets", "/games/g1/table/planets"], ["view-menu-item-table-planets", "planets"],
[ ["view-menu-item-table-ship-classes", "ship-classes"],
"view-menu-item-table-ship-classes", ["view-menu-item-table-ship-groups", "ship-groups"],
"/games/g1/table/ship-classes", ["view-menu-item-table-fleets", "fleets"],
], ["view-menu-item-table-sciences", "sciences"],
[ ["view-menu-item-table-races", "races"],
"view-menu-item-table-ship-groups",
"/games/g1/table/ship-groups",
],
["view-menu-item-table-fleets", "/games/g1/table/fleets"],
["view-menu-item-table-sciences", "/games/g1/table/sciences"],
["view-menu-item-table-races", "/games/g1/table/races"],
]; ];
for (const [testId, href] of tableEntities) { for (const [testId, entity] of tableEntities) {
await fireEvent.click(ui.getByTestId("view-menu-trigger")); await fireEvent.click(ui.getByTestId("view-menu-trigger"));
// Open the Tables sub-disclosure each iteration; the menu // Open the Tables sub-disclosure each iteration; the menu
// closes on every navigation. // closes on every navigation.
@@ -180,13 +180,23 @@ describe("game-shell header", () => {
await fireEvent.click(summary); await fireEvent.click(summary);
} }
await fireEvent.click(ui.getByTestId(testId)); await fireEvent.click(ui.getByTestId(testId));
expect(gotoSpy).toHaveBeenLastCalledWith(href); expect(activeViewSelectSpy).toHaveBeenLastCalledWith("table", {
tableEntity: entity,
});
} }
}); });
test("return-to-lobby button leaves the game for the lobby screen", async () => {
const ui = render(Header, {
props: { sidebarOpen: false, onToggleSidebar: () => {} },
});
await fireEvent.click(ui.getByTestId("return-to-lobby"));
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
});
test("account-menu Logout triggers session.signOut('user')", async () => { test("account-menu Logout triggers session.signOut('user')", async () => {
const ui = render(Header, { const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, props: { sidebarOpen: false, onToggleSidebar: () => {} },
}); });
await fireEvent.click(ui.getByTestId("account-menu-trigger")); await fireEvent.click(ui.getByTestId("account-menu-trigger"));
await fireEvent.click(ui.getByTestId("account-menu-logout")); await fireEvent.click(ui.getByTestId("account-menu-logout"));
@@ -195,7 +205,7 @@ describe("game-shell header", () => {
test("account-menu language picker switches the i18n locale", async () => { test("account-menu language picker switches the i18n locale", async () => {
const ui = render(Header, { const ui = render(Header, {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, props: { sidebarOpen: false, onToggleSidebar: () => {} },
}); });
await fireEvent.click(ui.getByTestId("account-menu-trigger")); await fireEvent.click(ui.getByTestId("account-menu-trigger"));
const select = ui.getByTestId("account-menu-language-select"); const select = ui.getByTestId("account-menu-language-select");
+9 -19
View File
@@ -1,8 +1,10 @@
// Component tests for the Phase 10 in-game shell sidebar. Validates // Component tests for the Phase 10 in-game shell sidebar. Validates
// the default selected tab, the Calculator / Inspector / Order // the default selected tab, the Calculator / Inspector / Order
// switching, the empty-state copy that matches the IA section, the // switching, the empty-state copy that matches the IA section, and
// `?sidebar=` URL seed convention used by the mobile bottom-tabs, // the Phase 13 selection-driven planet inspector content. The
// and the Phase 13 selection-driven planet inspector content. // single-URL app-shell dropped the old `?sidebar=` URL seed (the
// sidebar no longer reads `$app/state`); the shell drives the
// initial tab through the `activeTab` bindable instead.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte"; import { fireEvent, render } from "@testing-library/svelte";
@@ -34,15 +36,6 @@ import {
} from "../src/sync/order-draft.svelte"; } from "../src/sync/order-draft.svelte";
import type { GameReport, ReportPlanet } from "../src/api/game-state"; import type { GameReport, ReportPlanet } from "../src/api/game-state";
const pageMock = vi.hoisted(() => ({
url: new URL("http://localhost/games/g1/map"),
params: { id: "g1" } as Record<string, string>,
}));
vi.mock("$app/state", () => ({
page: pageMock,
}));
import Sidebar from "../src/lib/sidebar/sidebar.svelte"; import Sidebar from "../src/lib/sidebar/sidebar.svelte";
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet { function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
@@ -107,7 +100,6 @@ function withStores(report: GameReport | null): {
beforeEach(() => { beforeEach(() => {
i18n.resetForTests("en"); i18n.resetForTests("en");
pageMock.url = new URL("http://localhost/games/g1/map");
}); });
describe("game-shell sidebar", () => { describe("game-shell sidebar", () => {
@@ -148,10 +140,9 @@ describe("game-shell sidebar", () => {
); );
}); });
test("?sidebar=calc seeds the calculator tab on first mount", () => { test("the activeTab prop seeds the calculator tab on first mount", () => {
pageMock.url = new URL("http://localhost/games/g1/map?sidebar=calc");
const ui = render(Sidebar, { const ui = render(Sidebar, {
props: { open: false, onClose: () => {} }, props: { open: false, onClose: () => {}, activeTab: "calculator" },
}); });
expect(ui.getByTestId("sidebar-tool-calculator")).toBeInTheDocument(); expect(ui.getByTestId("sidebar-tool-calculator")).toBeInTheDocument();
expect(ui.getByTestId("sidebar")).toHaveAttribute( expect(ui.getByTestId("sidebar")).toHaveAttribute(
@@ -160,10 +151,9 @@ describe("game-shell sidebar", () => {
); );
}); });
test("?sidebar=order seeds the order tab on first mount", () => { test("the activeTab prop seeds the order tab on first mount", () => {
pageMock.url = new URL("http://localhost/games/g1/map?sidebar=order");
const ui = render(Sidebar, { const ui = render(Sidebar, {
props: { open: false, onClose: () => {} }, props: { open: false, onClose: () => {}, activeTab: "order" },
}); });
expect(ui.getByTestId("sidebar-tool-order")).toBeInTheDocument(); expect(ui.getByTestId("sidebar-tool-order")).toBeInTheDocument();
}); });
+21 -12
View File
@@ -1,7 +1,10 @@
// Component tests for the create-game form. The lobby API is mocked // Component tests for the create-game screen. The lobby API is mocked
// at module level; the GalaxyClient is replaced with a stub that does // at module level; the GalaxyClient is replaced with a stub that does
// nothing (the test only asserts the createGame wrapper is invoked // nothing (the test only asserts the createGame wrapper is invoked
// with the right shape). // with the right shape). The app-shell navigation store is mocked so
// cancel and post-submit both resolve to `appScreen.go("lobby")`
// without running real `pushState` in JSDOM — the single-URL shell has
// no `/lobby` route.
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte"; import { fireEvent, render, waitFor } from "@testing-library/svelte";
@@ -21,9 +24,13 @@ import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb";
import { IDBCache } from "../src/platform/store/idb-cache"; import { IDBCache } from "../src/platform/store/idb-cache";
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore"; import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
const gotoSpy = vi.fn<(url: string) => Promise<void>>(async () => {}); // The create screen returns to the lobby through `appScreen.go("lobby")`,
vi.mock("$app/navigation", () => ({ // which internally calls SvelteKit `pushState`. Mock the whole nav
goto: (url: string) => gotoSpy(url), // module so the spy captures the transition and no real history
// mutation runs in JSDOM.
const appScreenGoSpy = vi.fn();
vi.mock("$lib/app-nav.svelte", () => ({
appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) },
})); }));
const createGameSpy = vi.fn(); const createGameSpy = vi.fn();
@@ -82,7 +89,7 @@ beforeEach(async () => {
await session.signIn("device-1"); await session.signIn("device-1");
i18n.resetForTests("en"); i18n.resetForTests("en");
createGameSpy.mockReset(); createGameSpy.mockReset();
gotoSpy.mockReset(); appScreenGoSpy.mockReset();
}); });
afterEach(async () => { afterEach(async () => {
@@ -97,11 +104,13 @@ afterEach(async () => {
}); });
}); });
async function importCreatePage(): Promise<typeof import("../src/routes/lobby/create/+page.svelte")> { async function importCreatePage(): Promise<
return import("../src/routes/lobby/create/+page.svelte"); typeof import("../src/lib/screens/lobby-create-screen.svelte")
> {
return import("../src/lib/screens/lobby-create-screen.svelte");
} }
describe("lobby/create page", () => { describe("lobby/create screen", () => {
test("submitting a valid form invokes createGame with the entered values and navigates back", async () => { test("submitting a valid form invokes createGame with the entered values and navigates back", async () => {
createGameSpy.mockResolvedValue({ createGameSpy.mockResolvedValue({
gameId: "private-new", gameId: "private-new",
@@ -150,7 +159,7 @@ describe("lobby/create page", () => {
expect(input.startGapPlayers).toBe(2); expect(input.startGapPlayers).toBe(2);
expect(input.targetEngineVersion).toBe("v1"); expect(input.targetEngineVersion).toBe("v1");
expect(input.enrollmentEndsAt).toBeInstanceOf(Date); expect(input.enrollmentEndsAt).toBeInstanceOf(Date);
expect(gotoSpy).toHaveBeenCalledWith("/lobby"); expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
}); });
}); });
@@ -179,7 +188,7 @@ describe("lobby/create page", () => {
}); });
}); });
test("cancel button navigates back to /lobby without calling the API", async () => { test("cancel button navigates back to the lobby without calling the API", async () => {
const Page = (await importCreatePage()).default; const Page = (await importCreatePage()).default;
const ui = render(Page); const ui = render(Page);
@@ -189,7 +198,7 @@ describe("lobby/create page", () => {
await fireEvent.click(ui.getByTestId("lobby-create-cancel")); await fireEvent.click(ui.getByTestId("lobby-create-cancel"));
await waitFor(() => { await waitFor(() => {
expect(gotoSpy).toHaveBeenCalledWith("/lobby"); expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
expect(createGameSpy).not.toHaveBeenCalled(); expect(createGameSpy).not.toHaveBeenCalled();
}); });
}); });
+38 -7
View File
@@ -1,11 +1,14 @@
// Component tests for the Phase 8 lobby page. The lobby API and the // Component tests for the Phase 8 lobby screen. The lobby API and the
// gateway client are mocked at module level; the session singleton is // gateway client are mocked at module level; the session singleton is
// wired to a per-test `SessionStore`-backing IndexedDB so the page's // wired to a per-test `SessionStore`-backing IndexedDB so the page's
// boot path settles on `authenticated` and constructs a real // boot path settles on `authenticated` and constructs a real
// GalaxyClient (which is then never called because the lobby API // GalaxyClient (which is then never called because the lobby API
// wrappers are stubs). The tests assert the section rendering, the // wrappers are stubs). The tests assert the section rendering, the
// inline race-name form for public games, and the invitation Accept // inline race-name form for public games, and the invitation Accept
// flow. // flow. The app-shell navigation store is mocked so opening a game
// (`activeView.reset()` + `appScreen.go("game", …)`) or the create
// form (`appScreen.go("lobby-create")`) never runs real `pushState`
// in JSDOM; the single-URL shell has no `/lobby`/`/games` routes.
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte"; import { fireEvent, render, waitFor } from "@testing-library/svelte";
@@ -25,8 +28,19 @@ import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb";
import { IDBCache } from "../src/platform/store/idb-cache"; import { IDBCache } from "../src/platform/store/idb-cache";
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore"; import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
vi.mock("$app/navigation", () => ({ // The lobby screen navigates through the app-shell stores
goto: vi.fn(async () => {}), // (`appScreen.go`, `activeView.reset`/`select`), which internally call
// SvelteKit `pushState`. Mock the whole nav module so the spies
// capture the transitions and no real history mutation runs in JSDOM.
const appScreenGoSpy = vi.fn();
const activeViewResetSpy = vi.fn();
const activeViewSelectSpy = vi.fn();
vi.mock("$lib/app-nav.svelte", () => ({
appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) },
activeView: {
reset: (...args: unknown[]) => activeViewResetSpy(...args),
select: (...args: unknown[]) => activeViewSelectSpy(...args),
},
})); }));
const listMyGamesSpy = vi.fn(); const listMyGamesSpy = vi.fn();
@@ -105,6 +119,9 @@ beforeEach(async () => {
submitApplicationSpy.mockReset(); submitApplicationSpy.mockReset();
redeemInviteSpy.mockReset(); redeemInviteSpy.mockReset();
declineInviteSpy.mockReset(); declineInviteSpy.mockReset();
appScreenGoSpy.mockReset();
activeViewResetSpy.mockReset();
activeViewSelectSpy.mockReset();
}); });
afterEach(async () => { afterEach(async () => {
@@ -119,8 +136,10 @@ afterEach(async () => {
}); });
}); });
async function importLobbyPage(): Promise<typeof import("../src/routes/lobby/+page.svelte")> { async function importLobbyPage(): Promise<
return import("../src/routes/lobby/+page.svelte"); typeof import("../src/lib/screens/lobby-screen.svelte")
> {
return import("../src/lib/screens/lobby-screen.svelte");
} }
const baseDate = new Date("2026-05-07T10:00:00Z"); const baseDate = new Date("2026-05-07T10:00:00Z");
@@ -184,7 +203,7 @@ function makeApplication(id: string, status: string) {
}; };
} }
describe("lobby page", () => { describe("lobby screen", () => {
test("renders empty states for every section when API returns no items", async () => { test("renders empty states for every section when API returns no items", async () => {
listMyGamesSpy.mockResolvedValue([]); listMyGamesSpy.mockResolvedValue([]);
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 }); listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
@@ -375,6 +394,18 @@ describe("lobby page", () => {
expect(disabledByLabel["Closed Run"]).toBe(false); expect(disabledByLabel["Closed Run"]).toBe(false);
expect(disabledByLabel["Cancelled Run"]).toBe(true); expect(disabledByLabel["Cancelled Run"]).toBe(true);
expect(disabledByLabel["Draft Run"]).toBe(true); expect(disabledByLabel["Draft Run"]).toBe(true);
// Clicking a playable card resets the in-game view and enters the
// game screen with its id (the single-URL app-shell switches
// in-memory state instead of navigating to `/games/:id`).
const liveCard = cards.find(
(card) => card.querySelector("strong")?.textContent === "Live",
);
await fireEvent.click(liveCard!);
expect(activeViewResetSpy).toHaveBeenCalledTimes(1);
expect(appScreenGoSpy).toHaveBeenCalledWith("game", {
gameId: "g-running",
});
}); });
test("application status badges localise pending and approved states", async () => { test("application status badges localise pending and approved states", async () => {
+23 -11
View File
@@ -1,9 +1,11 @@
// Login page component tests. The `auth` API and the navigation // Login screen component tests. The `auth` API and the app-shell
// helper are mocked at module level; the session singleton is wired // navigation store are mocked at module level; the session singleton
// to a per-test `SessionStore`-backing IndexedDB so the keypair the // is wired to a per-test `SessionStore`-backing IndexedDB so the
// form passes to `confirmEmailCode` is a genuine 32-byte Ed25519 // keypair the form passes to `confirmEmailCode` is a genuine 32-byte
// public key without polluting the production `dbConnection()` // Ed25519 public key without polluting the production `dbConnection()`
// cache. // cache. The single-URL app-shell has no `/lobby` route: a successful
// sign-in advances the in-memory screen via `appScreen.go("lobby")`,
// so the test asserts against the mocked store instead of `goto`.
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte"; import { fireEvent, render, waitFor } from "@testing-library/svelte";
@@ -24,8 +26,13 @@ import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb";
import { IDBCache } from "../src/platform/store/idb-cache"; import { IDBCache } from "../src/platform/store/idb-cache";
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore"; import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
vi.mock("$app/navigation", () => ({ // The screen drives navigation through `appScreen.go(...)`, which
goto: vi.fn(async () => {}), // internally calls SvelteKit `pushState`. Mock the whole nav module
// so the spy captures the screen transition and no real history
// mutation runs in JSDOM.
const appScreenGoSpy = vi.fn();
vi.mock("$lib/app-nav.svelte", () => ({
appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) },
})); }));
const sendEmailCodeSpy = vi.fn(); const sendEmailCodeSpy = vi.fn();
@@ -58,11 +65,13 @@ beforeEach(async () => {
i18n.resetForTests("en"); i18n.resetForTests("en");
sendEmailCodeSpy.mockReset(); sendEmailCodeSpy.mockReset();
confirmEmailCodeSpy.mockReset(); confirmEmailCodeSpy.mockReset();
appScreenGoSpy.mockReset();
}); });
afterEach(async () => { afterEach(async () => {
sendEmailCodeSpy.mockReset(); sendEmailCodeSpy.mockReset();
confirmEmailCodeSpy.mockReset(); confirmEmailCodeSpy.mockReset();
appScreenGoSpy.mockReset();
session.resetForTests(); session.resetForTests();
i18n.resetForTests("en"); i18n.resetForTests("en");
db.close(); db.close();
@@ -74,11 +83,13 @@ afterEach(async () => {
}); });
}); });
async function importLoginPage(): Promise<typeof import("../src/routes/login/+page.svelte")> { async function importLoginPage(): Promise<
return import("../src/routes/login/+page.svelte"); typeof import("../src/lib/screens/login-screen.svelte")
> {
return import("../src/lib/screens/login-screen.svelte");
} }
describe("login page", () => { describe("login screen", () => {
test("submitting the email step calls sendEmailCode and advances to step=code", async () => { test("submitting the email step calls sendEmailCode and advances to step=code", async () => {
sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" }); sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" });
const Page = (await importLoginPage()).default; const Page = (await importLoginPage()).default;
@@ -145,6 +156,7 @@ describe("login page", () => {
expect(confirmEmailCodeSpy).toHaveBeenCalledTimes(1); expect(confirmEmailCodeSpy).toHaveBeenCalledTimes(1);
expect(session.deviceSessionId).toBe("dev-1"); expect(session.deviceSessionId).toBe("dev-1");
expect(session.status).toBe("authenticated"); expect(session.status).toBe("authenticated");
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
}); });
const args = confirmEmailCodeSpy.mock.calls[0]![1]!; const args = confirmEmailCodeSpy.mock.calls[0]![1]!;
expect(args.challengeId).toBe("ch-1"); expect(args.challengeId).toBe("ch-1");
+6 -3
View File
@@ -3,12 +3,15 @@
// registration, offline-from-cache load, and the version-keyed cache // registration, offline-from-cache load, and the version-keyed cache
// (a new deploy's `version` makes a new cache and `activate` drops the // (a new deploy's `version` makes a new cache and `activate` drops the
// old one — verified here as "exactly one galaxy cache, version-keyed"). // old one — verified here as "exactly one galaxy cache, version-keyed").
// The single-URL app-shell boots at the app base (`/`); with no seeded
// session the dispatcher renders the login screen, so the shell's
// `#main-content` region is the boot signal here.
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test.describe("PWA", () => { test.describe("PWA", () => {
test("links a web manifest with installable icons", async ({ page }) => { test("links a web manifest with installable icons", async ({ page }) => {
await page.goto("/login"); await page.goto("/");
const href = await page const href = await page
.locator('head link[rel="manifest"]') .locator('head link[rel="manifest"]')
.getAttribute("href"); .getAttribute("href");
@@ -33,7 +36,7 @@ test.describe("PWA", () => {
}); });
test("registers a service worker that controls the page", async ({ page }) => { test("registers a service worker that controls the page", async ({ page }) => {
await page.goto("/login"); await page.goto("/");
await page.waitForFunction( await page.waitForFunction(
() => navigator.serviceWorker.controller !== null, () => navigator.serviceWorker.controller !== null,
null, null,
@@ -50,7 +53,7 @@ test.describe("PWA", () => {
page, page,
context, context,
}) => { }) => {
await page.goto("/login"); await page.goto("/");
await page.waitForFunction( await page.waitForFunction(
() => navigator.serviceWorker.controller !== null, () => navigator.serviceWorker.controller !== null,
null, null,
+14 -12
View File
@@ -12,9 +12,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte"; import { i18n } from "../src/lib/i18n/index.svelte";
import type { TranslationKey } from "../src/lib/i18n/index.svelte"; import type { TranslationKey } from "../src/lib/i18n/index.svelte";
const gotoMock = vi.hoisted(() => vi.fn()); // The TOC's "back to map" button switches the active in-game view via
vi.mock("$app/navigation", () => ({ // `activeView.select("map")` (the single-URL app-shell has no
goto: gotoMock, // `/games/:id/map` route). Mock the nav store so the spy captures the
// view switch and no real `pushState` runs.
const activeViewSelectMock = vi.hoisted(() => vi.fn());
vi.mock("$lib/app-nav.svelte", () => ({
activeView: { select: activeViewSelectMock },
})); }));
import ReportToc, { import ReportToc, {
@@ -29,13 +33,13 @@ const ENTRIES: readonly TocEntry[] = [
beforeEach(() => { beforeEach(() => {
i18n.resetForTests("en"); i18n.resetForTests("en");
gotoMock.mockClear(); activeViewSelectMock.mockClear();
}); });
describe("report TOC", () => { describe("report TOC", () => {
test("renders one anchor per entry and one option in the mobile select", () => { test("renders one anchor per entry and one option in the mobile select", () => {
const ui = render(ReportToc, { const ui = render(ReportToc, {
props: { entries: ENTRIES, activeSlug: "galaxy-summary", gameId: "g-1" }, props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
}); });
for (const e of ENTRIES) { for (const e of ENTRIES) {
expect(ui.getByTestId(`report-toc-${e.slug}`)).toBeInTheDocument(); expect(ui.getByTestId(`report-toc-${e.slug}`)).toBeInTheDocument();
@@ -47,7 +51,7 @@ describe("report TOC", () => {
test("marks the active anchor with aria-current=location and a class", () => { test("marks the active anchor with aria-current=location and a class", () => {
const ui = render(ReportToc, { const ui = render(ReportToc, {
props: { entries: ENTRIES, activeSlug: "bombings", gameId: "g-1" }, props: { entries: ENTRIES, activeSlug: "bombings" },
}); });
const active = ui.getByTestId("report-toc-bombings"); const active = ui.getByTestId("report-toc-bombings");
expect(active).toHaveAttribute("aria-current", "location"); expect(active).toHaveAttribute("aria-current", "location");
@@ -58,17 +62,16 @@ describe("report TOC", () => {
expect(inactive).not.toHaveClass("active"); expect(inactive).not.toHaveClass("active");
}); });
test("back-to-map button calls goto with the active game's map URL", async () => { test("back-to-map button switches the active view to the map", async () => {
const ui = render(ReportToc, { const ui = render(ReportToc, {
props: { props: {
entries: ENTRIES, entries: ENTRIES,
activeSlug: "galaxy-summary", activeSlug: "galaxy-summary",
gameId: "abc",
}, },
}); });
const button = ui.getByTestId("report-back-to-map"); const button = ui.getByTestId("report-back-to-map");
await fireEvent.click(button); await fireEvent.click(button);
expect(gotoMock).toHaveBeenCalledWith("/games/abc/map"); expect(activeViewSelectMock).toHaveBeenCalledWith("map");
}); });
test("anchor click cancels the default jump and calls scrollIntoView on the target", async () => { test("anchor click cancels the default jump and calls scrollIntoView on the target", async () => {
@@ -97,7 +100,7 @@ describe("report TOC", () => {
}); });
const ui = render(ReportToc, { const ui = render(ReportToc, {
props: { entries: ENTRIES, activeSlug: "galaxy-summary", gameId: "g" }, props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
}); });
await fireEvent.click(ui.getByTestId("report-toc-bombings")); await fireEvent.click(ui.getByTestId("report-toc-bombings"));
expect(scrollSpy).toHaveBeenCalledWith({ expect(scrollSpy).toHaveBeenCalledWith({
@@ -132,13 +135,12 @@ describe("report TOC", () => {
props: { props: {
entries: ENTRIES, entries: ENTRIES,
activeSlug: "galaxy-summary", activeSlug: "galaxy-summary",
gameId: "g",
}, },
}); });
const select = ui.getByTestId("report-toc-mobile") as HTMLSelectElement; const select = ui.getByTestId("report-toc-mobile") as HTMLSelectElement;
await fireEvent.change(select, { target: { value: "votes" } }); await fireEvent.change(select, { target: { value: "votes" } });
expect(scrollSpy).toHaveBeenCalled(); expect(scrollSpy).toHaveBeenCalled();
expect(gotoMock).not.toHaveBeenCalled(); expect(activeViewSelectMock).not.toHaveBeenCalled();
target.remove(); target.remove();
}); });
+15 -20
View File
@@ -31,19 +31,15 @@ import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
const GAME_ID = "11111111-2222-3333-4444-555555555555"; const GAME_ID = "11111111-2222-3333-4444-555555555555";
const pageMock = vi.hoisted(() => ({ // The sciences table opens the science designer by switching the
url: new URL("http://localhost/games/g1/table/sciences"), // active in-game view via `activeView.select("designer-science", …)`
params: { id: "g1" } as Record<string, string>, // (the single-URL app-shell has no `/games/:id/designer/...` route).
})); // Mock the nav store so the spy captures the view switch and no real
// `pushState` runs.
const activeViewSelectMock = vi.hoisted(() => vi.fn());
const gotoMock = vi.hoisted(() => vi.fn()); vi.mock("$lib/app-nav.svelte", () => ({
activeView: { select: activeViewSelectMock },
vi.mock("$app/state", () => ({
page: pageMock,
}));
vi.mock("$app/navigation", () => ({
goto: gotoMock,
})); }));
import TableSciences from "../src/lib/active-view/table-sciences.svelte"; import TableSciences from "../src/lib/active-view/table-sciences.svelte";
@@ -60,8 +56,7 @@ beforeEach(async () => {
draft = new OrderDraftStore(); draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID }); await draft.init({ cache, gameId: GAME_ID });
i18n.resetForTests("en"); i18n.resetForTests("en");
pageMock.params = { id: "g1" }; activeViewSelectMock.mockClear();
gotoMock.mockClear();
}); });
afterEach(async () => { afterEach(async () => {
@@ -188,14 +183,14 @@ describe("sciences table", () => {
expect(names).toEqual(["Beta", "Gamma", "Alpha"]); expect(names).toEqual(["Beta", "Gamma", "Alpha"]);
}); });
test("dblclick on a row navigates to the designer for that science", async () => { test("dblclick on a row opens the designer for that science", async () => {
const ui = mountTable( const ui = mountTable(
makeReport([science({ name: "FirstStep", drive: 1 })]), makeReport([science({ name: "FirstStep", drive: 1 })]),
); );
await fireEvent.dblClick(ui.getByTestId("sciences-row")); await fireEvent.dblClick(ui.getByTestId("sciences-row"));
expect(gotoMock).toHaveBeenCalledWith( expect(activeViewSelectMock).toHaveBeenCalledWith("designer-science", {
"/games/g1/designer/science/FirstStep", scienceId: "FirstStep",
); });
}); });
test("delete button adds a removeScience to the draft", async () => { test("delete button adds a removeScience to the draft", async () => {
@@ -207,9 +202,9 @@ describe("sciences table", () => {
expect(cmd.name).toBe("FirstStep"); expect(cmd.name).toBe("FirstStep");
}); });
test("new button navigates to the empty designer", async () => { test("new button opens the empty designer", async () => {
const ui = mountTable(makeReport([])); const ui = mountTable(makeReport([]));
await fireEvent.click(ui.getByTestId("sciences-new")); await fireEvent.click(ui.getByTestId("sciences-new"));
expect(gotoMock).toHaveBeenCalledWith("/games/g1/designer/science"); expect(activeViewSelectMock).toHaveBeenCalledWith("designer-science");
}); });
}); });