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