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
|
> realistic multi-turn projection, and the cross-platform acceptance
|
||||||
> pass in [ROADMAP.md](ROADMAP.md). This file is retained as the staged
|
> pass in [ROADMAP.md](ROADMAP.md). This file is retained as the staged
|
||||||
> record of how the MVP was built.
|
> record of how the MVP was built.
|
||||||
|
>
|
||||||
|
> **Routing — superseded by the app-shell.** After the MVP, the
|
||||||
|
> URL-based routing the per-phase artifacts below describe was refactored
|
||||||
|
> into a single-URL **app-shell**: the game UI is one SvelteKit route at
|
||||||
|
> `/game/`, the screen and the in-game view are in-memory rune state
|
||||||
|
> (`lib/app-nav.svelte.ts`), the `routes/games/[id]/` subtree and the
|
||||||
|
> per-view `+page.svelte` wrappers were removed, the in-game layout
|
||||||
|
> became `lib/game/game-shell.svelte`, and the login / lobby /
|
||||||
|
> lobby-create screens moved under `lib/screens/`. Browser Back/Forward
|
||||||
|
> move between screens via shallow routing without changing the URL — a
|
||||||
|
> model that also suits the bundled standalone targets (Wails /
|
||||||
|
> Capacitor / gomobile) that have no URLs. The current navigation model
|
||||||
|
> is described in [docs/navigation.md](docs/navigation.md) and in the
|
||||||
|
> reframed `Information Architecture and Navigation` section and Phase 10
|
||||||
|
> decisions below; the per-phase `routes/games/[id]/…` artifact paths are
|
||||||
|
> left as the historical record of what each phase delivered at the time.
|
||||||
|
|
||||||
The existing Fyne client in `client/` is deprecated and is not modified
|
The existing Fyne client in `client/` is deprecated and is not modified
|
||||||
or imported by the new code. The architectural overview is mirrored into
|
or imported by the new code. The architectural overview is mirrored into
|
||||||
@@ -130,38 +146,55 @@ The intended v1 architecture is:
|
|||||||
|
|
||||||
## Information Architecture and Navigation
|
## Information Architecture and Navigation
|
||||||
|
|
||||||
The client is a single-page application with **one active view at a
|
The client is a single-page **app-shell** with **one active view at a
|
||||||
time**. Navigation is mobile-first: floating panels never overlap the
|
time**. It is served at a single URL (`/game/`) that never changes:
|
||||||
map, the main area never splits into multiple visible panels on small
|
the visible screen and view are in-memory state, not routes. Navigation
|
||||||
screens. Desktop and mobile share the same model; on desktop, the
|
is mobile-first: floating panels never overlap the map, the main area
|
||||||
sidebar sits beside the active view, on mobile it lives behind a
|
never splits into multiple visible panels on small screens. Desktop
|
||||||
bottom-tab bar.
|
and mobile share the same model; on desktop, the sidebar sits beside
|
||||||
|
the active view, on mobile it lives behind a bottom-tab bar.
|
||||||
|
|
||||||
### View model
|
### Screen and view model
|
||||||
|
|
||||||
|
Two pieces of in-memory state (rune singletons in
|
||||||
|
`lib/app-nav.svelte.ts`) replace what URLs used to encode — `appScreen`
|
||||||
|
(the top-level screen plus the active game id) and `activeView` (the
|
||||||
|
in-game view plus its sub-parameters):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ActiveView ∈ {
|
appScreen.screen ∈ {
|
||||||
/login, (anonymous only)
|
login, (anonymous only)
|
||||||
/lobby, (auth required)
|
lobby, (auth required)
|
||||||
/games/:id/map, (default in-game view)
|
lobby-create, (auth required)
|
||||||
/games/:id/table/:entity, (entity ∈
|
game, (auth required; carries appScreen.gameId)
|
||||||
planets | ship-classes |
|
}
|
||||||
ship-groups | fleets |
|
|
||||||
sciences | races)
|
activeView.view ∈ { (meaningful only while screen === game)
|
||||||
/games/:id/report,
|
map, (default in-game view)
|
||||||
/games/:id/battle/:battleId,
|
table, (+ tableEntity ∈ planets | ship-classes |
|
||||||
/games/:id/mail,
|
ship-groups | fleets | sciences | races)
|
||||||
/games/:id/designer/ship-class/:id?,
|
report,
|
||||||
/games/:id/designer/science/:id?,
|
battle, (+ battleId, turn)
|
||||||
|
mail,
|
||||||
|
designer-science, (+ scienceId; absent = new-science form)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The top-level screen is chosen by the single-route dispatcher
|
||||||
|
(`routes/+page.svelte`) from `session.status` + `appScreen.screen`;
|
||||||
|
the in-game shell (`lib/game/game-shell.svelte`) renders the active
|
||||||
|
view from `activeView`. Browser Back/Forward move between screens
|
||||||
|
(Back from a game → lobby) via SvelteKit shallow routing, without
|
||||||
|
changing the URL; in-game view switches do not create history entries.
|
||||||
|
|
||||||
Switching between views happens through the header dropdown (desktop)
|
Switching between views happens through the header dropdown (desktop)
|
||||||
or hamburger menu (mobile). Double-tapping a row in a `table:` view
|
or hamburger menu (mobile), driven by `activeView.select(...)`.
|
||||||
returns to `/map` with `focus=<objectId>`. Some views can push a
|
Double-tapping a row in a table view returns to the map focused on the
|
||||||
transient map overlay with a back affordance (for example, ship-class
|
object. Some views can push a transient map overlay with a back
|
||||||
designer pushes a range-preview overlay onto the map). The transient
|
affordance (for example, ship-class designer pushes a range-preview
|
||||||
overlay clears when the user navigates to any other view.
|
overlay onto the map). The transient overlay clears when the user
|
||||||
|
selects any other view. The implementation is documented in
|
||||||
|
[docs/navigation.md](docs/navigation.md).
|
||||||
|
|
||||||
### Layout per breakpoint
|
### Layout per breakpoint
|
||||||
|
|
||||||
@@ -257,12 +290,20 @@ turn current` action.
|
|||||||
- The account menu (top-right on desktop, last hamburger entry on
|
- The account menu (top-right on desktop, last hamburger entry on
|
||||||
mobile) holds Settings, Sessions, Theme, Language, Logout.
|
mobile) holds Settings, Sessions, Theme, Language, Logout.
|
||||||
|
|
||||||
### Authenticated route transitions
|
### Authenticated screen transitions
|
||||||
|
|
||||||
- `/login` → `/lobby` after successful confirm-email-code.
|
All transitions are in-memory screen/view changes; the URL stays
|
||||||
- `/lobby` → `/games/:id/map` when a game card is selected.
|
`/game/` throughout.
|
||||||
- Any view → `/login` immediately on session revocation push event.
|
|
||||||
- Designer views can push a transient overlay onto `/map`; the back
|
- login → lobby after successful confirm-email-code (`session.status`
|
||||||
|
settles to `authenticated`).
|
||||||
|
- lobby → game (view `map`) when a game card is selected
|
||||||
|
(`appScreen.go("game", { gameId })`).
|
||||||
|
- any screen → login immediately on session revocation push event
|
||||||
|
(`session.status` settles back to `anonymous`).
|
||||||
|
- the in-game header carries a "return to lobby" control
|
||||||
|
(`appScreen.go("lobby")`); browser Back from a game does the same.
|
||||||
|
- Designer views can push a transient overlay onto the map; the back
|
||||||
affordance returns to the originating designer.
|
affordance returns to the originating designer.
|
||||||
|
|
||||||
Per-screen behaviour (validations, exact field names, error mappings)
|
Per-screen behaviour (validations, exact field names, error mappings)
|
||||||
@@ -1062,37 +1103,58 @@ end-to-end before any data is wired.
|
|||||||
|
|
||||||
Decisions taken with the project owner during implementation:
|
Decisions taken with the project owner during implementation:
|
||||||
|
|
||||||
1. **Routing — file-system based, no extra dispatcher.** The
|
1. **Routing — single-URL app-shell, in-memory dispatch.** The game
|
||||||
"view router" called out in the original artifact list is
|
UI is one SvelteKit route served at `/game/`; the address bar never
|
||||||
implemented as SvelteKit's file-system routes plus thin
|
changes. The "view router" called out in the original artifact list
|
||||||
`+page.svelte` wrappers that mount the matching
|
is the in-memory dispatch in `lib/game/game-shell.svelte` — an
|
||||||
`lib/active-view/<name>.svelte` stub. No separate dispatch
|
`{#if}` ladder over `activeView.view` that mounts the matching
|
||||||
component lives in the codebase; each route file is a two-line
|
`lib/active-view/<name>.svelte` stub. The top-level screen
|
||||||
wrapper.
|
(login / lobby / lobby-create / game) is chosen by the single-route
|
||||||
2. **Optional designer ID segments.** Both designer URLs ship as
|
dispatcher `routes/+page.svelte` from `session.status` +
|
||||||
`[[id]]` optional segments
|
`appScreen.screen`. Both `appScreen` and `activeView` are rune
|
||||||
(`designer/ship-class/[[classId]]/`,
|
singletons in `lib/app-nav.svelte.ts`; there are no per-screen or
|
||||||
`designer/science/[[scienceId]]/`) so Phase 18 / 21 can read
|
per-view file routes (only the dev/test `/__debug/*` ones remain).
|
||||||
the param without a routing migration. Phase 10 stubs ignore
|
Screen-level browser history (Back → lobby) is layered on top via
|
||||||
the param.
|
SvelteKit shallow routing (`pushState`/`replaceState` + `page.state`)
|
||||||
3. **Battle URL — optional id.** `battle/[[battleId]]/` accepts
|
so the URL stays `/game/`. This single-URL model is also the natural
|
||||||
both the list URL (`/battle`) and a specific battle URL
|
fit for the deferred standalone wrappers (Wails desktop, Capacitor /
|
||||||
(`/battle/<id>`). Phase 27 keeps the optional segment and
|
gomobile mobile in [ROADMAP.md](ROADMAP.md)), which load a single
|
||||||
switches behaviour based on presence.
|
bundled `index.html` with no URLs or history. See
|
||||||
|
[docs/navigation.md](docs/navigation.md).
|
||||||
|
|
||||||
|
> This decision supersedes the original "file-system routes plus
|
||||||
|
> thin `+page.svelte` wrappers" plan. The app-shell transition was
|
||||||
|
> implemented after the MVP phases: the `routes/games/[id]/`
|
||||||
|
> subtree and the per-view route wrappers were removed, the layout
|
||||||
|
> became `lib/game/game-shell.svelte`, and the login / lobby /
|
||||||
|
> lobby-create screens moved under `lib/screens/`. The
|
||||||
|
> `lib/active-view/*` components are unchanged — only how they are
|
||||||
|
> mounted changed.
|
||||||
|
2. **In-game view sub-parameters — `activeView` state, not URL
|
||||||
|
segments.** What were optional URL segments are now optional fields
|
||||||
|
on `activeView` state: the science designer reads `scienceId`
|
||||||
|
(absent = new-science form), the battle view reads `battleId`
|
||||||
|
(empty = list) and `turn`, and the table view reads `tableEntity`.
|
||||||
|
Later phases set these through `activeView.select(view, params)`
|
||||||
|
instead of navigating a URL.
|
||||||
|
3. **Battle view — optional id.** The battle view accepts both the
|
||||||
|
list state (no `battleId`) and a specific battle (`battleId` set).
|
||||||
|
Phase 27 keeps the optional sub-param and switches behaviour based
|
||||||
|
on presence.
|
||||||
4. **Tablet sidebar — click toggle, not swipe.** The 768–1024 px
|
4. **Tablet sidebar — click toggle, not swipe.** The 768–1024 px
|
||||||
tablet sidebar slides in from a header-button click rather
|
tablet sidebar slides in from a header-button click rather
|
||||||
than the IA section's swipe-from-right gesture. The structural
|
than the IA section's swipe-from-right gesture. The structural
|
||||||
breakpoint switch satisfies Phase 10's acceptance criterion;
|
breakpoint switch satisfies Phase 10's acceptance criterion;
|
||||||
Phase 35 polish lands the swipe gesture.
|
Phase 35 polish lands the swipe gesture.
|
||||||
5. **Mobile tool overlay — `mobileTool` state, gated by URL.**
|
5. **Mobile tool overlay — `mobileTool` state, gated by active view.**
|
||||||
The mobile bottom-tabs Calc / Order navigate to `/map` and
|
The mobile bottom-tabs Calc / Order select the map view and
|
||||||
set a layout-owned `mobileTool` rune. The layout's derived
|
set a shell-owned `mobileTool` rune. The shell's derived
|
||||||
`effectiveTool` only honours the rune when the URL is `/map`,
|
`effectiveTool` only honours the rune while `activeView.view ===
|
||||||
so navigating to any other view via the More drawer or the
|
"map"`, so selecting any other view via the More drawer or the
|
||||||
header view-menu naturally drops the overlay. The desktop
|
header view-menu naturally drops the overlay. The sidebar tool
|
||||||
sidebar separately accepts a `?sidebar=calc|inspector|order`
|
state is pure in-memory rune state — there is no `?sidebar=` URL
|
||||||
URL param that seeds the initial tab on first mount, used by
|
param (the app-shell carries no per-screen URL); the sidebar opens
|
||||||
later phases that want to land directly on a particular tool.
|
on its `inspector` default and external events flip the tab.
|
||||||
6. **Sidebar tool filenames — `*-tab.svelte`.** Phase 12 / 13 / 30
|
6. **Sidebar tool filenames — `*-tab.svelte`.** Phase 12 / 13 / 30
|
||||||
each name their final implementation
|
each name their final implementation
|
||||||
(`order-tab.svelte`, `inspector-tab.svelte`,
|
(`order-tab.svelte`, `inspector-tab.svelte`,
|
||||||
@@ -1103,11 +1165,16 @@ Decisions taken with the project owner during implementation:
|
|||||||
name is the static `race ?` string from i18n, mirroring the
|
name is the static `race ?` string from i18n, mirroring the
|
||||||
spec's static `turn ?` placeholder. Phase 11 wires both from
|
spec's static `turn ?` placeholder. Phase 11 wires both from
|
||||||
`user.games.report` data through `lib/header/turn-counter.svelte`.
|
`user.games.report` data through `lib/header/turn-counter.svelte`.
|
||||||
8. **Auth gate inherited.** The root `+layout.svelte` already
|
8. **Auth gate — state-based in the dispatcher.** The single-route
|
||||||
redirects `anonymous → /login`; the in-game shell needs no
|
dispatcher (`routes/+page.svelte`) renders the login screen for an
|
||||||
extra guard. Phase 10 verified this by booting the e2e shell
|
`anonymous` session and the authenticated screens for an
|
||||||
spec via `__galaxyDebug.setDeviceSessionId` and observing the
|
`authenticated` one; there is no `goto` redirect (the app-shell
|
||||||
post-`session.init` `authenticated` status.
|
stays at `/game/`). The in-game shell needs no extra guard. Phase 10
|
||||||
|
verified the gate by booting the e2e shell spec via
|
||||||
|
`__galaxyDebug.setDeviceSessionId` and observing the
|
||||||
|
post-`session.init` `authenticated` status. (Originally the gate was
|
||||||
|
a `goto("/login")` redirect in the root layout; the app-shell
|
||||||
|
transition replaced it with state-based rendering.)
|
||||||
9. **More drawer mirrors the view-menu.** The mobile bottom-tabs
|
9. **More drawer mirrors the view-menu.** The mobile bottom-tabs
|
||||||
"More" drawer renders the same seven destinations as the
|
"More" drawer renders the same seven destinations as the
|
||||||
header view-menu. The IA section's narrower More list (Mail,
|
header view-menu. The IA section's narrower More list (Mail,
|
||||||
@@ -1136,9 +1203,11 @@ Artifacts (delivered):
|
|||||||
`i18n.setLocale`; Logout calls `session.signOut("user")`)
|
`i18n.setLocale`; Logout calls `session.signOut("user")`)
|
||||||
- `ui/frontend/src/lib/sidebar/{sidebar, tab-bar, calculator-tab,
|
- `ui/frontend/src/lib/sidebar/{sidebar, tab-bar, calculator-tab,
|
||||||
inspector-tab, order-tab, bottom-tabs}.svelte` — three-tab
|
inspector-tab, order-tab, bottom-tabs}.svelte` — three-tab
|
||||||
sidebar with `inspector` default and `?sidebar=` URL seed;
|
sidebar with `inspector` default (the app-shell transition later
|
||||||
mobile-only bottom-tabs with `[Map, Calc, Order, More]` plus a
|
dropped the original `?sidebar=` URL seed — there is no per-screen
|
||||||
More drawer duplicating the view-menu destinations
|
URL to carry it); mobile-only bottom-tabs with
|
||||||
|
`[Map, Calc, Order, More]` plus a More drawer duplicating the
|
||||||
|
view-menu destinations
|
||||||
- `ui/frontend/src/lib/sidebar/types.ts` — shared `SidebarTab`
|
- `ui/frontend/src/lib/sidebar/types.ts` — shared `SidebarTab`
|
||||||
and `MobileTool` types
|
and `MobileTool` types
|
||||||
- `ui/frontend/src/lib/active-view/{map, table, report, battle,
|
- `ui/frontend/src/lib/active-view/{map, table, report, battle,
|
||||||
@@ -1173,7 +1242,7 @@ Targeted tests (delivered):
|
|||||||
view-menu navigation to every IA destination, account-menu
|
view-menu navigation to every IA destination, account-menu
|
||||||
Logout / Language wiring);
|
Logout / Language wiring);
|
||||||
- Vitest component tests for the sidebar (default tab, switching,
|
- Vitest component tests for the sidebar (default tab, switching,
|
||||||
empty-state copy, `?sidebar=` URL seed, close button);
|
empty-state copy, close button);
|
||||||
- Vitest component tests for every active-view stub (title,
|
- Vitest component tests for every active-view stub (title,
|
||||||
`coming soon` copy, table-entity prop, battle-id prop);
|
`coming soon` copy, table-entity prop, battle-id prop);
|
||||||
- Playwright e2e: visit every view stub via header dropdown and
|
- Playwright e2e: visit every view stub via header dropdown and
|
||||||
@@ -1430,8 +1499,7 @@ Artifacts (delivered):
|
|||||||
`tab-bar.svelte`, `bottom-tabs.svelte` — `historyMode` prop on
|
`tab-bar.svelte`, `bottom-tabs.svelte` — `historyMode` prop on
|
||||||
the sidebar forwards to `hideOrder` on tab-bar / bottom-tabs;
|
the sidebar forwards to `hideOrder` on tab-bar / bottom-tabs;
|
||||||
active-tab `order` is reset to `inspector` if the flag flips
|
active-tab `order` is reset to `inspector` if the flag flips
|
||||||
on, and the `?sidebar=order` URL seed falls back to
|
on while it is selected.
|
||||||
`inspector` while the flag is true.
|
|
||||||
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
|
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
|
||||||
instantiates `OrderDraftStore`, sets context, runs
|
instantiates `OrderDraftStore`, sets context, runs
|
||||||
`init({ cache, gameId })` next to `gameState.init` through
|
`init({ cache, gameId })` next to `gameState.init` through
|
||||||
|
|||||||
+13
-5
@@ -53,9 +53,15 @@ quick orientation; deeper design notes live under `ui/docs/`.
|
|||||||
+ SQLite on desktop, iOS Keychain / Android Keystore + SQLite on
|
+ SQLite on desktop, iOS Keychain / Android Keystore + SQLite on
|
||||||
mobile, all behind a single `KeyStore` and `Cache` TypeScript
|
mobile, all behind a single `KeyStore` and `Cache` TypeScript
|
||||||
interface.
|
interface.
|
||||||
- **Mobile-first navigation:** one active view occupies the main area
|
- **Single-URL app-shell navigation:** the game UI is one route served
|
||||||
at a time; the sidebar holds a single tool (calculator, inspector,
|
at `/game/`; the screen (login / lobby / game) and the in-game view
|
||||||
or order) with persistent state on switch.
|
are in-memory state (`lib/app-nav.svelte.ts`), not URLs, so the
|
||||||
|
address bar never changes. Browser Back/Forward move between screens
|
||||||
|
via shallow routing without touching the URL — a model that also
|
||||||
|
suits the bundled standalone targets (Wails / Capacitor) that have no
|
||||||
|
URLs. One active view occupies the main area at a time; the sidebar
|
||||||
|
holds a single tool (calculator, inspector, or order) with persistent
|
||||||
|
state on switch. See [`docs/navigation.md`](docs/navigation.md).
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
@@ -81,16 +87,18 @@ ui/
|
|||||||
├── mobile/ Capacitor project (planned — see ROADMAP.md)
|
├── mobile/ Capacitor project (planned — see ROADMAP.md)
|
||||||
└── frontend/ SvelteKit / Vite source
|
└── frontend/ SvelteKit / Vite source
|
||||||
├── src/api/ GalaxyClient + typed Connect client + auth + session
|
├── src/api/ GalaxyClient + typed Connect client + auth + session
|
||||||
├── src/lib/ env config, session store, revocation watcher
|
├── src/lib/ app-shell nav + screens + game shell, env config, session store, stores
|
||||||
├── src/platform/core/ Core interface + WasmCore adapter
|
├── src/platform/core/ Core interface + WasmCore adapter
|
||||||
├── src/platform/store/ KeyStore/Cache interfaces + web adapter
|
├── src/platform/store/ KeyStore/Cache interfaces + web adapter
|
||||||
├── src/proto/ generated Protobuf-ES + Connect descriptors + FlatBuffers TS bindings
|
├── src/proto/ generated Protobuf-ES + Connect descriptors + FlatBuffers TS bindings
|
||||||
├── src/routes/ SvelteKit routes (/, /login, /lobby, /lobby/create)
|
├── src/routes/ single-URL app-shell: `/game/` dispatcher (+page.svelte) + `/__debug/*`
|
||||||
└── static/ core.wasm + wasm_exec.js (built by `make wasm` / CI; gitignored)
|
└── static/ core.wasm + wasm_exec.js (built by `make wasm` / CI; gitignored)
|
||||||
```
|
```
|
||||||
|
|
||||||
Linked topic docs:
|
Linked topic docs:
|
||||||
|
|
||||||
|
- [`docs/navigation.md`](docs/navigation.md) — single-URL app-shell,
|
||||||
|
screens and views as in-memory state, screen history, sidebar tools.
|
||||||
- [`docs/auth-flow.md`](docs/auth-flow.md) — email-code login,
|
- [`docs/auth-flow.md`](docs/auth-flow.md) — email-code login,
|
||||||
session store state machine, revocation watcher.
|
session store state machine, revocation watcher.
|
||||||
- [`docs/lobby.md`](docs/lobby.md) — lobby UI sections, application
|
- [`docs/lobby.md`](docs/lobby.md) — lobby UI sections, application
|
||||||
|
|||||||
+29
-20
@@ -18,10 +18,15 @@ authoritative in [`docs/FUNCTIONAL.md` §1](../../docs/FUNCTIONAL.md).
|
|||||||
- `ui/frontend/src/lib/revocation-watcher.ts` — minimal
|
- `ui/frontend/src/lib/revocation-watcher.ts` — minimal
|
||||||
`SubscribeEvents` watcher that triggers `signOut("revoked")` on
|
`SubscribeEvents` watcher that triggers `signOut("revoked")` on
|
||||||
any non-aborted stream termination.
|
any non-aborted stream termination.
|
||||||
- `ui/frontend/src/routes/login/+page.svelte` — two-step form.
|
- `ui/frontend/src/lib/screens/login-screen.svelte` — two-step form.
|
||||||
- `ui/frontend/src/routes/lobby/+page.svelte` — placeholder lobby
|
- `ui/frontend/src/lib/screens/lobby-screen.svelte` — lobby that
|
||||||
that issues the first authenticated `user.account.get`.
|
issues the first authenticated `user.account.get`.
|
||||||
- `ui/frontend/src/routes/+layout.svelte` — route guard plus the
|
- `ui/frontend/src/routes/+page.svelte` — the state-based auth gate /
|
||||||
|
screen dispatcher (anonymous → login, authenticated → the
|
||||||
|
`appScreen` screen). The single-URL app-shell has no per-screen
|
||||||
|
routes; see [`navigation.md`](navigation.md).
|
||||||
|
- `ui/frontend/src/routes/+layout.svelte` — boot-time session init,
|
||||||
|
the `loading` / `unsupported` interception, and the
|
||||||
browser-not-supported blocker.
|
browser-not-supported blocker.
|
||||||
|
|
||||||
## State machine (`SessionStatus`)
|
## State machine (`SessionStatus`)
|
||||||
@@ -50,8 +55,9 @@ authoritative in [`docs/FUNCTIONAL.md` §1](../../docs/FUNCTIONAL.md).
|
|||||||
```
|
```
|
||||||
|
|
||||||
`signOut("revoked")` shares the same observable end state as
|
`signOut("revoked")` shares the same observable end state as
|
||||||
`signOut("user")`; the reason exists only for telemetry. Both
|
`signOut("user")`; the reason exists only for telemetry. Both settle
|
||||||
trigger the layout effect's `anonymous → /login` redirect.
|
`status` to `anonymous`, which the dispatcher renders as the login
|
||||||
|
screen — there is no URL redirect (the app-shell stays at `/game/`).
|
||||||
|
|
||||||
## UX states and error mapping
|
## UX states and error mapping
|
||||||
|
|
||||||
@@ -67,7 +73,7 @@ those branches.
|
|||||||
| 200 from `send-email-code` | advance to step `code`, focus the code input |
|
| 200 from `send-email-code` | advance to step `code`, focus the code input |
|
||||||
| `invalid_request` from `send` | stay on step `email`, surface the gateway message |
|
| `invalid_request` from `send` | stay on step `email`, surface the gateway message |
|
||||||
| `service_unavailable` from `send` | stay on step `email`, surface "service is temporarily unavailable" |
|
| `service_unavailable` from `send` | stay on step `email`, surface "service is temporarily unavailable" |
|
||||||
| 200 from `confirm-email-code` | persist `device_session_id`, redirect to `/lobby` |
|
| 200 from `confirm-email-code` | persist `device_session_id`, settle `status` to `authenticated` (dispatcher shows the lobby) |
|
||||||
| `invalid_request` from `confirm` | bounce to step `email`, message: "code expired or already used" |
|
| `invalid_request` from `confirm` | bounce to step `email`, message: "code expired or already used" |
|
||||||
| any other error from `confirm` | stay on step `code`, surface the gateway message |
|
| any other error from `confirm` | stay on step `code`, surface the gateway message |
|
||||||
|
|
||||||
@@ -89,8 +95,10 @@ After `confirm-email-code` succeeds, `session.signIn` writes the
|
|||||||
`device_session_id` into the IDB cache (`namespace=session`,
|
`device_session_id` into the IDB cache (`namespace=session`,
|
||||||
`key=device-session-id`). On the next page load,
|
`key=device-session-id`). On the next page load,
|
||||||
`SessionStore.init` reads it back and settles `status` to
|
`SessionStore.init` reads it back and settles `status` to
|
||||||
`authenticated`, so the layout effect routes the user straight to
|
`authenticated`, so the dispatcher renders the authenticated screen
|
||||||
`/lobby`.
|
straight away. Which authenticated screen it is comes from the
|
||||||
|
restored `appScreen` snapshot (lobby by default; see
|
||||||
|
[`navigation.md`](navigation.md)), not from the URL.
|
||||||
|
|
||||||
The keypair lives next to the id in the same database (object
|
The keypair lives next to the id in the same database (object
|
||||||
store `keypair`, key `device`). Clearing site data wipes both;
|
store `keypair`, key `device`). Clearing site data wipes both;
|
||||||
@@ -102,21 +110,22 @@ again. This is the documented re-login path — there is no paired
|
|||||||
|
|
||||||
The keystore relies on WebCrypto Ed25519, which currently lands in
|
The keystore relies on WebCrypto Ed25519, which currently lands in
|
||||||
Chrome ≥ 137, Firefox ≥ 130, Safari ≥ 17.4 (see
|
Chrome ≥ 137, Firefox ≥ 130, Safari ≥ 17.4 (see
|
||||||
[`storage.md`](storage.md) for the rationale). On boot the layout
|
[`storage.md`](storage.md) for the rationale). On boot the root
|
||||||
runs a sanity probe (`crypto.subtle.generateKey` for `Ed25519`); if
|
layout runs a sanity probe (`crypto.subtle.generateKey` for
|
||||||
it rejects, the layout switches to a `browser not supported` page
|
`Ed25519`); if it rejects, `status` settles to `unsupported` and the
|
||||||
instead of rendering `/login`. The client deliberately does not ship a
|
layout renders a `browser not supported` page instead of the login
|
||||||
JavaScript Ed25519 fallback — the design decision is modern-browser
|
screen. The client deliberately does not ship a JavaScript Ed25519
|
||||||
baseline only.
|
fallback — the design decision is modern-browser baseline only.
|
||||||
|
|
||||||
## Revocation
|
## Revocation
|
||||||
|
|
||||||
The lobby layout opens a long-running `SubscribeEvents` stream as
|
The root layout opens a long-running `SubscribeEvents` stream as
|
||||||
soon as `status` becomes `authenticated`. Its only contract is
|
soon as `status` becomes `authenticated`. Its only contract is
|
||||||
liveness: any non-aborted termination of the stream is treated as
|
liveness: any non-aborted termination of the stream is treated as
|
||||||
a server-side session revocation, the watcher calls
|
a server-side session revocation, the watcher calls
|
||||||
`session.signOut("revoked")`, and the layout effect redirects to
|
`session.signOut("revoked")`, `status` settles to `anonymous`, and
|
||||||
`/login`.
|
the dispatcher swaps to the login screen on the next render — the
|
||||||
|
URL stays `/game/`.
|
||||||
|
|
||||||
Session revocation closes the active client within one second: the
|
Session revocation closes the active client within one second: the
|
||||||
gateway closes the stream the moment it observes a
|
gateway closes the stream the moment it observes a
|
||||||
@@ -126,8 +135,8 @@ reacts on the next event-loop tick.
|
|||||||
## Localisation
|
## Localisation
|
||||||
|
|
||||||
The login form, the root layout's blocker page, and the lobby
|
The login form, the root layout's blocker page, and the lobby
|
||||||
placeholder go through the i18n primitive in `src/lib/i18n/`. The
|
screen go through the i18n primitive in `src/lib/i18n/`. The
|
||||||
language picker on `/login` lists every entry in
|
language picker on the login screen lists every entry in
|
||||||
`SUPPORTED_LOCALES` by its native name and is initialised from
|
`SUPPORTED_LOCALES` by its native name and is initialised from
|
||||||
`navigator.languages` (web) with `en` as the fallback. Picking a
|
`navigator.languages` (web) with `en` as the fallback. Picking a
|
||||||
different language re-renders the form in place and is forwarded
|
different language re-renders the form in place and is forwarded
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# Battle Viewer UX
|
# Battle Viewer UX
|
||||||
|
|
||||||
The battle viewer is a dedicated view for battles
|
The battle viewer is a dedicated active view for battles
|
||||||
(`/games/<id>/battle/<battleId>`). Bombings are a separate static
|
(`activeView.view === "battle"`, with `battleId` and `turn`
|
||||||
|
sub-parameters; the app-shell has no per-view URL — see
|
||||||
|
[`navigation.md`](navigation.md)). Bombings are a separate static
|
||||||
table in the Reports view (`section-bombings.svelte`). The two
|
table in the Reports view (`section-bombings.svelte`). The two
|
||||||
domains are deliberately not mixed in any visual surface or click
|
domains are deliberately not mixed in any visual surface or click
|
||||||
target.
|
target.
|
||||||
@@ -212,7 +214,8 @@ result is an X-shaped cross overlaid on the planet glyph.
|
|||||||
The stroke width is computed by `battleMarkerStrokeWidth(shots)`:
|
The stroke width is computed by `battleMarkerStrokeWidth(shots)`:
|
||||||
1 shot → 1 px, 100 shots → 5 px, linearly interpolated in between
|
1 shot → 1 px, 100 shots → 5 px, linearly interpolated in between
|
||||||
(`width = 1 + (shots − 1) × 4 / 99`, clamped). A click on either
|
(`width = 1 + (shots − 1) × 4 / 99`, clamped). A click on either
|
||||||
line navigates to `/games/<id>/battle/<battleId>?turn=<turn>`.
|
line opens the battle viewer in memory via
|
||||||
|
`activeView.select("battle", { battleId, turn })`.
|
||||||
|
|
||||||
### Bombing marker — colored ring
|
### Bombing marker — colored ring
|
||||||
|
|
||||||
@@ -223,10 +226,10 @@ Colour:
|
|||||||
- yellow (`#FFD400`) when `wiped: false`,
|
- yellow (`#FFD400`) when `wiped: false`,
|
||||||
- red (`#FF3030`) when `wiped: true`.
|
- red (`#FF3030`) when `wiped: true`.
|
||||||
|
|
||||||
A click on the ring navigates to `/games/<id>/report#report-bombings`
|
A click on the ring switches to the report view
|
||||||
and scrolls the matching `report-bombing-row` (by `data-planet`)
|
(`activeView.select("report")`) and scrolls the matching
|
||||||
into view. Bombing markers never open the Battle Viewer — the two
|
`report-bombing-row` (by `data-planet`) into view. Bombing markers
|
||||||
domains stay separate.
|
never open the Battle Viewer — the two domains stay separate.
|
||||||
|
|
||||||
## Data source
|
## Data source
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# In-game diplomatic mail UI
|
# In-game diplomatic mail UI
|
||||||
|
|
||||||
The in-game mail view consumes the `diplomail` subsystem in the
|
The in-game mail view consumes the `diplomail` subsystem in the
|
||||||
backend. The route lives at `/games/:id/mail` and replaces the
|
backend. It is the `mail` active view (`activeView.view === "mail"`)
|
||||||
active view when the user opens the "diplomatic mail" entry in the
|
and replaces the active view when the user opens the "diplomatic mail"
|
||||||
header menu.
|
entry in the header menu (`activeView.select("mail")`). The app-shell
|
||||||
|
has no per-view URL — see [`navigation.md`](navigation.md).
|
||||||
|
|
||||||
## Wire surface
|
## Wire surface
|
||||||
|
|
||||||
@@ -70,11 +71,12 @@ render the original directly with no toggle.
|
|||||||
|
|
||||||
`diplomail.message.received` push frames are dispatched from
|
`diplomail.message.received` push frames are dispatched from
|
||||||
`api/events.svelte.ts` via the singleton SubscribeEvents stream. The
|
`api/events.svelte.ts` via the singleton SubscribeEvents stream. The
|
||||||
in-game layout (`routes/games/[id]/+layout.svelte`) parses the
|
in-game shell (`lib/game/game-shell.svelte`) parses the
|
||||||
verified payload, calls `mailStore.applyPushEvent(gameId)` (which
|
verified payload, calls `mailStore.applyPushEvent(gameId)` (which
|
||||||
re-fetches the inbox — the payload only carries a preview), and
|
re-fetches the inbox — the payload only carries a preview), and
|
||||||
raises a toast through `lib/toast.svelte.ts` with a "view"
|
raises a toast through `lib/toast.svelte.ts` whose "view" action
|
||||||
deep-link to `/games/:id/mail`.
|
switches to the mail view in memory (`activeView.select("mail")`) —
|
||||||
|
no URL navigation.
|
||||||
|
|
||||||
The header view-menu's mail entry shows `mailStore.unreadCount` as
|
The header view-menu's mail entry shows `mailStore.unreadCount` as
|
||||||
an inline pill — the only chrome the badge needs.
|
an inline pill — the only chrome the badge needs.
|
||||||
|
|||||||
+5
-5
@@ -93,11 +93,11 @@ reconnect.
|
|||||||
});
|
});
|
||||||
onDestroy(off);
|
onDestroy(off);
|
||||||
```
|
```
|
||||||
2. If the handler reads scoped data (per-game, per-route), register
|
2. If the handler reads scoped data (per-game), register from a
|
||||||
from a layout that owns that scope and pass the gameId via a
|
component that owns that scope and pass the gameId via a closure.
|
||||||
closure. The handler should filter events whose payload does not
|
The handler should filter events whose payload does not match its
|
||||||
match its scope (see `routes/games/[id]/+layout.svelte` for the
|
scope (see `lib/game/game-shell.svelte` for the `game.turn.ready`
|
||||||
`game.turn.ready` filter).
|
filter).
|
||||||
3. The payload encoding is owned by the producer side: the
|
3. The payload encoding is owned by the producer side: the
|
||||||
`game.turn.ready` event uses JSON `{game_id, turn}`. Document
|
`game.turn.ready` event uses JSON `{game_id, turn}`. Document
|
||||||
the schema next to the producer (e.g. `backend/README.md` §10).
|
the schema next to the producer (e.g. `backend/README.md` §10).
|
||||||
|
|||||||
+32
-12
@@ -6,13 +6,15 @@ inspector tabs, the order composer, and the calculator.
|
|||||||
|
|
||||||
## Lifecycle
|
## Lifecycle
|
||||||
|
|
||||||
`routes/games/[id]/+layout.svelte` instantiates one `GameStateStore`
|
The in-game shell (`lib/game/game-shell.svelte`) instantiates one
|
||||||
per game (the layout remounts when the user navigates to a different
|
`GameStateStore` per game. The shell is mounted by the single-route
|
||||||
game id, so each game gets a fresh store). The layout exposes the
|
dispatcher only while `appScreen.screen === "game"`, and remounts when
|
||||||
instance through Svelte context under `GAME_STATE_CONTEXT_KEY`;
|
`appScreen.gameId` changes, so each game gets a fresh store. The shell
|
||||||
descendants read it via `getContext(GAME_STATE_CONTEXT_KEY)`.
|
exposes the instance through Svelte context under
|
||||||
|
`GAME_STATE_CONTEXT_KEY`; descendants read it via
|
||||||
|
`getContext(GAME_STATE_CONTEXT_KEY)`.
|
||||||
|
|
||||||
The layout's `onMount` builds the `GalaxyClient`, loads `Cache`
|
The shell's boot effect builds the `GalaxyClient`, loads `Cache`
|
||||||
through `loadStore()`, then calls `gameState.init({ client, cache,
|
through `loadStore()`, then calls `gameState.init({ client, cache,
|
||||||
gameId })`. `init`:
|
gameId })`. `init`:
|
||||||
|
|
||||||
@@ -21,9 +23,10 @@ gameId })`. `init`:
|
|||||||
2. calls `setGame(gameId)`, which:
|
2. calls `setGame(gameId)`, which:
|
||||||
- reads the per-game wrap-mode preference from `Cache`
|
- reads the per-game wrap-mode preference from `Cache`
|
||||||
(`game-prefs / <gameId>/wrap-mode`, default `torus`);
|
(`game-prefs / <gameId>/wrap-mode`, default `torus`);
|
||||||
- calls `lobby.my.games.list` and finds the game record
|
- calls `lobby.my.games.list` (`findGame`) and finds the game
|
||||||
(`GameSummary` carries `current_turn`); if the user is not a
|
record (`GameSummary` carries `current_turn`); if the game is not
|
||||||
member, the store flips to `error`;
|
in the player's list, the store sets the `notFound` flag (see
|
||||||
|
below);
|
||||||
- calls `user.games.report` for the discovered turn and decodes
|
- calls `user.games.report` for the discovered turn and decodes
|
||||||
the FlatBuffers response into a TS-friendly `GameReport` shape.
|
the FlatBuffers response into a TS-friendly `GameReport` shape.
|
||||||
|
|
||||||
@@ -40,6 +43,23 @@ The store exposes:
|
|||||||
| `pendingTurn` | `number \| null` | latest server turn the user has not yet opened |
|
| `pendingTurn` | `number \| null` | latest server turn the user has not yet opened |
|
||||||
| `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` |
|
| `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` |
|
||||||
| `error` | `string \| null` | localised error message when `status === "error"` |
|
| `error` | `string \| null` | localised error message when `status === "error"` |
|
||||||
|
| `notFound` | `boolean` | true when the game is not in the player's list (cancelled / removed / access revoked); the shell drops to the lobby |
|
||||||
|
|
||||||
|
## Missing or inaccessible game
|
||||||
|
|
||||||
|
A restored or stale game id (a `sessionStorage` snapshot pointing at a
|
||||||
|
game that was cancelled, removed, or whose access was revoked) is a
|
||||||
|
distinct case from a transient failure. When `findGame` returns no
|
||||||
|
matching record, `setGame` sets the boolean `notFound` flag rather
|
||||||
|
than synthesising an error message. After `init` resolves, the in-game
|
||||||
|
shell reads `gameState.notFound` and, when true, calls
|
||||||
|
`appScreen.go("lobby")` and shows a `game.events.unavailable` toast —
|
||||||
|
the player lands back in the lobby instead of on an in-game error
|
||||||
|
screen. A transient network failure takes the catch path instead,
|
||||||
|
leaving `notFound` false and flipping `status` to `error` so the
|
||||||
|
in-game error state offers a retry. `notFound` resets to false at the
|
||||||
|
start of every `setGame` / `advanceToPending`. See
|
||||||
|
[`navigation.md`](navigation.md) for the restore-and-validate flow.
|
||||||
|
|
||||||
## Store extensions
|
## Store extensions
|
||||||
|
|
||||||
@@ -48,7 +68,7 @@ wire lands (ships, fleets, sciences, routes, battles, mail).
|
|||||||
`currentTurn` is split from `viewedTurn`, and `viewTurn(turn)` /
|
`currentTurn` is split from `viewedTurn`, and `viewTurn(turn)` /
|
||||||
`returnToCurrent()` handle history navigation. The derived
|
`returnToCurrent()` handle history navigation. The derived
|
||||||
`historyMode` rune flips automatically when `viewedTurn <
|
`historyMode` rune flips automatically when `viewedTurn <
|
||||||
currentTurn`; the layout passes it to the sidebar / bottom-tabs
|
currentTurn`; the shell passes it to the sidebar / bottom-tabs
|
||||||
wiring (which hides the order tab) and to
|
wiring (which hides the order tab) and to
|
||||||
`OrderDraftStore.bindClient` (which gates `add` / `remove` / `move`).
|
`OrderDraftStore.bindClient` (which gates `add` / `remove` / `move`).
|
||||||
See "History mode" below for the cache and refresh rules.
|
See "History mode" below for the cache and refresh rules.
|
||||||
@@ -161,9 +181,9 @@ without losing the live snapshot. The store keeps two turn runes:
|
|||||||
The derived `historyMode` rune (`status === "ready" && viewedTurn
|
The derived `historyMode` rune (`status === "ready" && viewedTurn
|
||||||
< currentTurn`) drives every history-aware consumer:
|
< currentTurn`) drives every history-aware consumer:
|
||||||
|
|
||||||
- the layout passes it to `Sidebar` / `BottomTabs` so the order
|
- the shell passes it to `Sidebar` / `BottomTabs` so the order
|
||||||
tab vanishes;
|
tab vanishes;
|
||||||
- the layout passes a `getHistoryMode` getter to
|
- the shell passes a `getHistoryMode` getter to
|
||||||
`OrderDraftStore.bindClient` so `add` / `remove` / `move` are
|
`OrderDraftStore.bindClient` so `add` / `remove` / `move` are
|
||||||
no-ops while the user is looking at a past turn;
|
no-ops while the user is looking at a past turn;
|
||||||
- `RenderedReportSource` returns the raw report (no order overlay)
|
- `RenderedReportSource` returns the raw report (no order overlay)
|
||||||
|
|||||||
+1
-1
@@ -85,7 +85,7 @@ helper is platform-agnostic by design.
|
|||||||
The boot locale resolves once at module load (no async init):
|
The boot locale resolves once at module load (no async init):
|
||||||
an explicit stored choice wins, otherwise browser/system detection,
|
an explicit stored choice wins, otherwise browser/system detection,
|
||||||
otherwise `DEFAULT_LOCALE`. Callers that mutate the locale (the language
|
otherwise `DEFAULT_LOCALE`. Callers that mutate the locale (the language
|
||||||
pickers on `/login` and in the account menu) call `i18n.setLocale(next)`,
|
pickers on the login screen and in the account menu) call `i18n.setLocale(next)`,
|
||||||
which **persists** the choice to `localStorage` (key `galaxy-locale`) so
|
which **persists** the choice to `localStorage` (key `galaxy-locale`) so
|
||||||
it survives reloads. An unrecognised stored value is ignored and falls
|
it survives reloads. An unrecognised stored value is ignored and falls
|
||||||
back to detection.
|
back to detection.
|
||||||
|
|||||||
+6
-5
@@ -15,8 +15,8 @@ width.
|
|||||||
|
|
||||||
| Section | Empty state | Source | Action |
|
| Section | Empty state | Source | Action |
|
||||||
| -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- |
|
| -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- |
|
||||||
| `create new game` | (always visible) | — | Navigates to `/lobby/create` |
|
| `create new game` | (always visible) | — | Opens the create screen (`appScreen.go("lobby-create")`) |
|
||||||
| `my games` | `no games yet` | `lobby.my.games.list` | Click → `/games/:id/map` |
|
| `my games` | `no games yet` | `lobby.my.games.list` | Click → enters the game on the map view (`activeView.reset()` + `appScreen.go("game", { gameId })`) |
|
||||||
| `pending invitations`| `no invitations` | `lobby.my.invites.list` | Accept (`lobby.invite.redeem`) / Decline (`lobby.invite.decline`) |
|
| `pending invitations`| `no invitations` | `lobby.my.invites.list` | Accept (`lobby.invite.redeem`) / Decline (`lobby.invite.decline`) |
|
||||||
| `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) |
|
| `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) |
|
||||||
| `public games` | `no public games` | `lobby.public.games.list` | Submit application via inline race-name form (`lobby.application.submit`) |
|
| `public games` | `no public games` | `lobby.public.games.list` | Submit application via inline race-name form (`lobby.application.submit`) |
|
||||||
@@ -85,9 +85,10 @@ public game (FUNCTIONAL.md §3.3). Fields:
|
|||||||
| `start_gap_players` | Advanced toggle | `2` | |
|
| `start_gap_players` | Advanced toggle | `2` | |
|
||||||
| `target_engine_version` | Advanced toggle | `v1` | Falls back to `v1` if blank |
|
| `target_engine_version` | Advanced toggle | `v1` | Falls back to `v1` if blank |
|
||||||
|
|
||||||
On success the page navigates back to `/lobby` and the new game shows
|
On success the create screen returns to the lobby
|
||||||
up in `my games` once the lobby's onMount has had a chance to refresh
|
(`appScreen.go("lobby")`) and the new game shows up in `my games`
|
||||||
the list.
|
once the lobby's onMount has had a chance to refresh the list (the
|
||||||
|
lobby screen remounts on return, so its onMount re-fires).
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
|
|||||||
+162
-76
@@ -1,46 +1,124 @@
|
|||||||
# In-game shell — navigation model
|
# In-game shell — navigation model
|
||||||
|
|
||||||
This doc covers the chrome that wraps every in-game view: the
|
This doc covers the chrome that wraps every in-game view: the
|
||||||
responsive layout shell, the active-view router built on SvelteKit's
|
single-URL app-shell that selects screens and views from in-memory
|
||||||
file-system routes, the sidebar with three tools and its
|
state, the responsive layout shell, the sidebar with three tools and
|
||||||
state-preservation rule, and the mobile bottom-tabs. The user-facing
|
its state-preservation rule, and the mobile bottom-tabs. The
|
||||||
spec — view list, breakpoint diagrams, history-mode plans — lives in
|
user-facing spec — view list, breakpoint diagrams, history-mode plans
|
||||||
[`../PLAN.md`](../PLAN.md), section
|
— lives in [`../PLAN.md`](../PLAN.md), section
|
||||||
`Information Architecture and Navigation`. This doc is the source of
|
`Information Architecture and Navigation`. This doc is the source of
|
||||||
truth for how those rules are implemented.
|
truth for how those rules are implemented.
|
||||||
|
|
||||||
## Active-view model
|
## App-shell: one URL, screens and views as state
|
||||||
|
|
||||||
The client renders **one active view at a time**. Every active view is
|
The game UI is a **single SvelteKit route served at `/game/`**. There
|
||||||
a SvelteKit route under `routes/games/[id]/`; the route file is a
|
are no per-screen or per-view routes — the address bar stays `/game/`
|
||||||
two-line wrapper that mounts the matching content component from
|
for the whole session. The only other routes are the dev/test-only
|
||||||
`src/lib/active-view/<name>.svelte`. The "view router" mentioned in
|
`/__debug/*` surfaces. What the URL used to encode now lives in two
|
||||||
the plan is the file system plus those wrappers — there is no
|
rune singletons in `src/lib/app-nav.svelte.ts`:
|
||||||
separate dispatch component.
|
|
||||||
|
|
||||||
| URL | Active view component |
|
- **`appScreen`** — the top-level screen
|
||||||
| ------------------------------------------ | ---------------------------------------------------------------------- |
|
(`login` / `lobby` / `lobby-create` / `game`) plus the active
|
||||||
| `/games/:id/map` | `lib/active-view/map.svelte` |
|
`gameId`. It replaces the old `goto`-based redirects and the `[id]`
|
||||||
| `/games/:id/table/:entity` | `lib/active-view/table.svelte` |
|
route param.
|
||||||
| `/games/:id/report` | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) |
|
- **`activeView`** — the in-game view (`map` / `table` / `report` /
|
||||||
| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` |
|
`battle` / `mail` / `designer-science`) plus the sub-parameters the
|
||||||
| `/games/:id/mail` | `lib/active-view/mail.svelte` |
|
old route segments carried (`tableEntity`, `battleId`, `turn`,
|
||||||
| `/games/:id/designer/science/:scienceId?` | `lib/active-view/designer-science.svelte` |
|
`scienceId`). It replaces the URL params the route wrappers read.
|
||||||
|
|
||||||
`/games/:id` (no trailing view) redirects to `/games/:id/map`. The
|
A single-route dispatcher (`src/routes/+page.svelte`) chooses what to
|
||||||
optional `:scienceId?` segment on the science designer route matches
|
render: it gates on `session.status` (anonymous → login, authenticated
|
||||||
SvelteKit's `[[scienceId]]` syntax — `/designer/science` opens the
|
→ the `appScreen.screen`), and for the authenticated tree mounts the
|
||||||
empty new-science form, `/designer/science/{name}` opens the named
|
matching screen component from `src/lib/screens/`
|
||||||
science. Ship-class design is folded into the sidebar ship-class
|
(`login-screen.svelte`, `lobby-screen.svelte`,
|
||||||
|
`lobby-create-screen.svelte`) or, for `screen === "game"`, the in-game
|
||||||
|
shell `src/lib/game/game-shell.svelte`. The game shell in turn renders
|
||||||
|
the active view from `activeView` (see below). Navigation is
|
||||||
|
`appScreen.go(screen, { gameId })` and `activeView.select(view,
|
||||||
|
params)` — never `goto`.
|
||||||
|
|
||||||
|
### Active-view dispatch
|
||||||
|
|
||||||
|
The client renders **one active view at a time**. The game shell
|
||||||
|
(`game-shell.svelte`) holds an `{#if}` ladder keyed on
|
||||||
|
`activeView.view` that mounts the matching content component from
|
||||||
|
`src/lib/active-view/<name>.svelte`:
|
||||||
|
|
||||||
|
| `activeView.view` | sub-params | Active view component |
|
||||||
|
| ------------------- | ----------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| `map` | — | `lib/active-view/map.svelte` |
|
||||||
|
| `table` | `tableEntity` | `lib/active-view/table.svelte` |
|
||||||
|
| `report` | — | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) |
|
||||||
|
| `battle` | `battleId`, `turn` | `lib/active-view/battle.svelte` |
|
||||||
|
| `mail` | — | `lib/active-view/mail.svelte` |
|
||||||
|
| `designer-science` | `scienceId` | `lib/active-view/designer-science.svelte` |
|
||||||
|
|
||||||
|
Entering a game defaults the view to `map` (`activeView.reset()`).
|
||||||
|
The optional `scienceId` sub-param on the science designer is absent
|
||||||
|
for the empty new-science form and set to the science name for an
|
||||||
|
existing one. Ship-class design is folded into the sidebar ship-class
|
||||||
calculator (`lib/sidebar/calculator-tab.svelte`, see
|
calculator (`lib/sidebar/calculator-tab.svelte`, see
|
||||||
[calculator-ux.md](calculator-ux.md)), reached from the ship-classes
|
[calculator-ux.md](calculator-ux.md)), reached from the ship-classes
|
||||||
table and the view/bottom menus.
|
table and the view/bottom menus.
|
||||||
|
|
||||||
The `entity` slug on the table route is kebab-case (`planets`,
|
The `tableEntity` slug is kebab-case (`planets`, `ship-classes`,
|
||||||
`ship-classes`, `ship-groups`, `fleets`, `sciences`, `races`).
|
`ship-groups`, `fleets`, `sciences`, `races`). `table.svelte` is the
|
||||||
`table.svelte` is the active-view router: it dispatches by slug to
|
table dispatcher: it switches by slug to the per-entity component
|
||||||
the per-entity component (`ship-classes` → `table-ship-classes.svelte`;
|
(`ship-classes` → `table-ship-classes.svelte`; other entities dispatch
|
||||||
other entities dispatch to their respective components).
|
to their respective components).
|
||||||
|
|
||||||
|
## Screen history: Back/Forward without a URL
|
||||||
|
|
||||||
|
Browser **Back/Forward move between screens**, not views, and they do
|
||||||
|
so without ever changing the URL. The shell layers screen history on
|
||||||
|
top of `appScreen` via SvelteKit shallow routing: `appScreen.go(...)`
|
||||||
|
calls `pushState("", { screen, gameId })` for the overlay screens
|
||||||
|
(`game`, `lobby-create`) and `replaceState(...)` for `lobby` / `login`,
|
||||||
|
so browser **Back from a game returns to the lobby** beneath it. On
|
||||||
|
the first authenticated render the dispatcher stamps the restored
|
||||||
|
overlay on top of the load entry, then mirrors `page.state` back into
|
||||||
|
the store on every popstate through `appScreen.syncFromHistory(...)`.
|
||||||
|
The store is the source of truth; history only mirrors it.
|
||||||
|
|
||||||
|
In-game **view switches do not create history entries** —
|
||||||
|
`activeView.select(...)` only mutates state and persists the snapshot.
|
||||||
|
Back from any in-game view therefore leaves the game entirely rather
|
||||||
|
than stepping through the views the player visited.
|
||||||
|
|
||||||
|
A **"return to lobby" control** lives in the in-game header
|
||||||
|
(`lib/header/header.svelte`, `data-testid="return-to-lobby"`,
|
||||||
|
label `game.shell.menu.return_to_lobby`); it calls
|
||||||
|
`appScreen.go("lobby")`.
|
||||||
|
|
||||||
|
## Refresh and restore
|
||||||
|
|
||||||
|
`appScreen` / `activeView` persist a snapshot (screen, game id, view +
|
||||||
|
sub-params) to `sessionStorage` (`galaxy-app-nav`) on every mutation
|
||||||
|
and read it back once at construction, so a refresh restores the last
|
||||||
|
screen and view. Re-entering a game from the lobby is not a restore: the
|
||||||
|
lobby resets `activeView` to the map before `appScreen.go("game")`, so
|
||||||
|
only an in-place refresh replays the saved view — browser Back and the
|
||||||
|
in-game return-to-lobby control both exit to the lobby. On a full load
|
||||||
|
the dispatcher records the restored
|
||||||
|
game id (`appScreen.restoredGameId`); the game shell's boot path then
|
||||||
|
validates it against the player's game list. `GameStateStore.init`
|
||||||
|
looks the game up through `listMyGames` / `findGame`, and if the game
|
||||||
|
is gone (cancelled, removed, or access revoked) it sets the distinct
|
||||||
|
`gameState.notFound` flag. The shell reacts by dropping to the lobby
|
||||||
|
(`appScreen.go("lobby")`) with an `game.events.unavailable` toast
|
||||||
|
rather than stranding the user on an in-game error. A transient
|
||||||
|
network error keeps `notFound` false and surfaces the in-game error
|
||||||
|
state instead. See [`game-state.md`](game-state.md) for the
|
||||||
|
`notFound` semantics.
|
||||||
|
|
||||||
|
## Standalone-target compatibility
|
||||||
|
|
||||||
|
The single-URL app-shell is the natural fit for the planned
|
||||||
|
standalone wrappers (Wails desktop, Capacitor / gomobile mobile —
|
||||||
|
see [`../ROADMAP.md`](../ROADMAP.md)). Those targets load a single
|
||||||
|
bundled `index.html` with no server, no per-route URLs, and no browser
|
||||||
|
history to rely on; an in-memory screen/view model and shallow-routing
|
||||||
|
history that never touches the address bar work there unchanged.
|
||||||
|
|
||||||
## Sidebar tools and state preservation
|
## Sidebar tools and state preservation
|
||||||
|
|
||||||
@@ -53,36 +131,38 @@ The desktop sidebar hosts three tools:
|
|||||||
| Order | `lib/sidebar/order-tab.svelte` |
|
| Order | `lib/sidebar/order-tab.svelte` |
|
||||||
|
|
||||||
The selected-tab state is a `$state` rune in
|
The selected-tab state is a `$state` rune in
|
||||||
`routes/games/[id]/+layout.svelte`, bound into
|
`lib/game/game-shell.svelte`, bound into
|
||||||
`lib/sidebar/sidebar.svelte` via `$bindable()`. The layout owns the
|
`lib/sidebar/sidebar.svelte` via `$bindable()`. The shell owns the
|
||||||
rune so external events — such as a planet click — can drive the
|
rune so external events — such as a planet click — can drive the
|
||||||
active tab from outside the sidebar without plumbing callbacks. The component is mounted by the layout, and
|
active tab from outside the sidebar without plumbing callbacks. The
|
||||||
SvelteKit keeps that layout instance alive while the user navigates
|
shell instance lives for the lifetime of the `game` screen, and an
|
||||||
between child routes (`/games/:id/map` → `/games/:id/report` → …),
|
in-game view switch is a pure `activeView` state change that never
|
||||||
so the rune survives every active-view switch automatically with no
|
remounts the shell, so the rune survives every active-view switch
|
||||||
URL coupling needed. The URL seed and the history-mode reset
|
automatically — it is in-memory state, with no URL coupling. The
|
||||||
described below still live inside the sidebar — they mutate the
|
history-mode reset described below lives inside the sidebar — it
|
||||||
bindable in place; the layout sees the change through the binding.
|
mutates the bindable in place; the shell sees the change through the
|
||||||
|
binding.
|
||||||
|
|
||||||
A `?sidebar=calc|calculator|inspector|order` URL param is read once
|
The tool state is pure in-memory rune state. There is no `?sidebar=`
|
||||||
on mount and seeds the initial tab. Navigation flows that want to
|
URL param (the app-shell has no per-screen URL to carry one) and no
|
||||||
land the user on a particular tool can set this param on navigation.
|
default-tab URL seed; the shell opens on its `inspector` default and
|
||||||
|
external events flip the tab.
|
||||||
|
|
||||||
The Order entry is hidden when the layout's `historyMode` flag is
|
The Order entry is hidden when the shell's `historyMode` flag is
|
||||||
true. `+layout.svelte` forwards a derived value to `Sidebar`, which
|
true. `game-shell.svelte` forwards a derived value to `Sidebar`, which
|
||||||
forwards `hideOrder` to its `TabBar`; the same flag goes to
|
forwards `hideOrder` to its `TabBar`; the same flag goes to
|
||||||
`BottomTabs` so the mobile `Order` button is also suppressed. A
|
`BottomTabs` so the mobile `Order` button is also suppressed. An
|
||||||
`?sidebar=order` URL seed that arrives while the flag is true falls
|
`$effect` on the sidebar resets `activeTab` away from `order` if the
|
||||||
back to `inspector`, and an `$effect` on the sidebar resets
|
flag flips on mid-session.
|
||||||
`activeTab` away from `order` if the flag flips on mid-session.
|
|
||||||
|
|
||||||
The `historyMode` flag is derived from the live history signal owned
|
The `historyMode` flag is derived from the live history signal owned
|
||||||
by `GameStateStore`. The derivation lives directly in `+layout.svelte`
|
by `GameStateStore`. The derivation lives directly in
|
||||||
|
`game-shell.svelte`
|
||||||
(`const historyMode = $derived(gameState.historyMode)`) — no
|
(`const historyMode = $derived(gameState.historyMode)`) — no
|
||||||
separate `lib/history-mode.ts` module exists, because the layout is
|
separate `lib/history-mode.ts` module exists, because the shell is
|
||||||
the single consumer and the project's compactness rule rejects a
|
the single consumer and the project's compactness rule rejects a
|
||||||
one-line indirection. The order draft survives the toggle because
|
one-line indirection. The order draft survives the toggle because
|
||||||
`OrderDraftStore` lives one level above the sidebar in the layout
|
`OrderDraftStore` lives one level above the sidebar in the shell
|
||||||
hierarchy; the same `historyMode` derivation is also fed into
|
hierarchy; the same `historyMode` derivation is also fed into
|
||||||
`OrderDraftStore.bindClient` so inspector-driven mutations
|
`OrderDraftStore.bindClient` so inspector-driven mutations
|
||||||
(`add` / `remove` / `move`) become no-ops while the user is
|
(`add` / `remove` / `move`) become no-ops while the user is
|
||||||
@@ -107,7 +187,7 @@ header whenever `gameState.historyMode === true`. It shows
|
|||||||
"Viewing turn {N} · read-only" with a "Return to current turn"
|
"Viewing turn {N} · read-only" with a "Return to current turn"
|
||||||
button that delegates back to `gameState.returnToCurrent()`. Both
|
button that delegates back to `gameState.returnToCurrent()`. Both
|
||||||
the navigator and the banner read `gameState` through context, so
|
the navigator and the banner read `gameState` through context, so
|
||||||
the layout is the only place where the wiring lives.
|
the game shell is the only place where the wiring lives.
|
||||||
|
|
||||||
## Layout breakpoints
|
## Layout breakpoints
|
||||||
|
|
||||||
@@ -134,18 +214,20 @@ raises a bottom-sheet — see [Planet selection](#planet-selection).
|
|||||||
|
|
||||||
## Mobile bottom-tabs and tool overlay
|
## Mobile bottom-tabs and tool overlay
|
||||||
|
|
||||||
The bottom-tabs row is `[Map, Calc, Order, More]`. Map navigates to
|
The bottom-tabs row is `[Map, Calc, Order, More]`. Map selects the
|
||||||
`/games/:id/map` and clears any tool overlay. Calc and Order navigate
|
map view (`activeView.select("map")`) and clears any tool overlay.
|
||||||
to `/games/:id/map` too — but they also flip the layout's
|
Calc and Order select the map view too — but they also flip the
|
||||||
`mobileTool` state to `calc` / `order`, which the layout uses to
|
shell's `mobileTool` state to `calc` / `order`, which the shell uses
|
||||||
swap the active-view slot for the Calculator / Order tool component.
|
to swap the active-view slot for the Calculator / Order tool
|
||||||
|
component.
|
||||||
|
|
||||||
The tool overlay only applies when the URL is `/map`. Navigating to
|
The tool overlay only applies while the active view is the map.
|
||||||
any other view through the More drawer or the header view-menu makes
|
The shell's derived `effectiveTool` is gated by
|
||||||
the layout's derived `effectiveTool` collapse back to `map`, so the
|
`activeView.view === "map"`: selecting any other view through the More
|
||||||
user always sees the URL's active view rather than a stale overlay.
|
drawer or the header view-menu collapses `effectiveTool` back to
|
||||||
The next time the user taps a Calc or Order bottom-tab, the
|
`map`, so the user always sees the active view rather than a stale
|
||||||
navigation re-routes them to `/map` and re-applies the overlay.
|
overlay. The next time the user taps a Calc or Order bottom-tab, the
|
||||||
|
selection switches back to the map view and re-applies the overlay.
|
||||||
|
|
||||||
The `More` button opens a drawer that mirrors the header view-menu
|
The `More` button opens a drawer that mirrors the header view-menu
|
||||||
content. A narrower "More" list (Mail, Battle log, Tables, History,
|
content. A narrower "More" list (Mail, Battle log, Tables, History,
|
||||||
@@ -155,12 +237,12 @@ a single source of truth for destinations.
|
|||||||
|
|
||||||
## Transient map overlays
|
## Transient map overlays
|
||||||
|
|
||||||
Some views can push a transient overlay onto `/map` with a back
|
Some views can push a transient overlay onto the map view with a back
|
||||||
affordance. (The calculator reach circles are a simpler, always-on
|
affordance. (The calculator reach circles are a simpler, always-on
|
||||||
map extra rather than a back-stacked overlay; the transient
|
map extra rather than a back-stacked overlay; the transient
|
||||||
back-stack mechanism is planned — see
|
back-stack mechanism is planned — see
|
||||||
[../ROADMAP.md](../ROADMAP.md).) A transient overlay clears when the
|
[../ROADMAP.md](../ROADMAP.md).) A transient overlay clears when the
|
||||||
user navigates to any other view via the header or the bottom-tabs.
|
user selects any other view via the header or the bottom-tabs.
|
||||||
|
|
||||||
The back-stack mechanism is not yet implemented; it is planned
|
The back-stack mechanism is not yet implemented; it is planned
|
||||||
alongside its first user (multi-turn projection, range circles in the
|
alongside its first user (multi-turn projection, range circles in the
|
||||||
@@ -180,14 +262,14 @@ translating a renderer click into a planet selection. The flow:
|
|||||||
primitive, looks the planet up by `number` in the live
|
primitive, looks the planet up by `number` in the live
|
||||||
`GameStateStore.report`, and calls `SelectionStore.selectPlanet(number)`.
|
`GameStateStore.report`, and calls `SelectionStore.selectPlanet(number)`.
|
||||||
3. `SelectionStore` (`lib/selection.svelte.ts`) is a runes store
|
3. `SelectionStore` (`lib/selection.svelte.ts`) is a runes store
|
||||||
instantiated by the layout and exposed via Svelte context under
|
instantiated by the game shell and exposed via Svelte context under
|
||||||
`SELECTION_CONTEXT_KEY`. It carries a discriminated union —
|
`SELECTION_CONTEXT_KEY`. It carries a discriminated union —
|
||||||
`{ kind: "planet"; id: number }` for planets and widened for
|
`{ kind: "planet"; id: number }` for planets and widened for
|
||||||
ship groups. Selection is in-memory only: it survives the
|
ship groups. Selection is in-memory only: it survives the
|
||||||
layout's lifetime (active-view switches inside `/games/:id/*`)
|
shell's lifetime (in-memory `activeView` switches inside the game
|
||||||
but does not persist across reloads — that contrast with the
|
screen) but does not persist across reloads — that contrast with
|
||||||
order draft is intentional.
|
the order draft is intentional.
|
||||||
4. The layout watches the selection rune and, on the null → planet
|
4. The shell watches the selection rune and, on the null → planet
|
||||||
transition, flips its bound `activeTab` to `inspector` and
|
transition, flips its bound `activeTab` to `inspector` and
|
||||||
`sidebarOpen` to `true`. Desktop already has the sidebar pinned;
|
`sidebarOpen` to `true`. Desktop already has the sidebar pinned;
|
||||||
tablet needs the drawer to surface; mobile is unaffected by the
|
tablet needs the drawer to surface; mobile is unaffected by the
|
||||||
@@ -201,7 +283,7 @@ translating a renderer click into a planet selection. The flow:
|
|||||||
state instead of holding stale rows.
|
state instead of holding stale rows.
|
||||||
|
|
||||||
The mobile bottom-sheet is mounted alongside `<BottomTabs />` in the
|
The mobile bottom-sheet is mounted alongside `<BottomTabs />` in the
|
||||||
layout. Its visibility is conditional on `effectiveTool === "map"` so
|
game shell. Its visibility is conditional on `effectiveTool === "map"` so
|
||||||
it does not stack on top of the calc / order overlays. The dismissal
|
it does not stack on top of the calc / order overlays. The dismissal
|
||||||
surface is a close button (`✕`) that calls `SelectionStore.clear()`.
|
surface is a close button (`✕`) that calls `SelectionStore.clear()`.
|
||||||
Tap-outside and swipe-down dismissal are deferred to the finalization
|
Tap-outside and swipe-down dismissal are deferred to the finalization
|
||||||
@@ -224,8 +306,12 @@ together with the sheet's swipe-to-dismiss gesture.
|
|||||||
|
|
||||||
## Auth gate
|
## Auth gate
|
||||||
|
|
||||||
The root `+layout.svelte` redirects `anonymous → /login` for any
|
The auth gate is state-based, applied by the dispatcher
|
||||||
non-`/__debug/` path; the in-game shell inherits that gate without
|
(`src/routes/+page.svelte`): an `anonymous` session renders the login
|
||||||
any extra check. When a session is revoked while the user is in the
|
screen, an `authenticated` one renders the `appScreen.screen` (lobby /
|
||||||
shell, the same redirect fires through the existing
|
game / …). There is no `goto("/login")` redirect. When a session is
|
||||||
revocation watcher.
|
revoked while the user is in the game shell, the revocation watcher
|
||||||
|
flips `session.status` back to `anonymous`, and the dispatcher swaps
|
||||||
|
the whole tree to the login screen on the next render — the URL stays
|
||||||
|
`/game/` throughout. See [`auth-flow.md`](auth-flow.md) for the
|
||||||
|
session state machine.
|
||||||
|
|||||||
@@ -195,15 +195,15 @@ Lifecycle:
|
|||||||
| `dispose()` | Marks the store destroyed; subsequent `persist()` calls are no-ops so a fast game-switch does not write stale state into the next id. |
|
| `dispose()` | Marks the store destroyed; subsequent `persist()` calls are no-ops so a fast game-switch does not write stale state into the next id. |
|
||||||
|
|
||||||
Mutations made before `init` resolves are silently ignored — the
|
Mutations made before `init` resolves are silently ignored — the
|
||||||
layout always awaits `init` through `Promise.all([...])` next to
|
shell always awaits `init` through `Promise.all([...])` next to
|
||||||
`gameState.init` before exposing the store.
|
`gameState.init` before exposing the store.
|
||||||
|
|
||||||
Layout integration mirrors `GameStateStore`:
|
Shell integration mirrors `GameStateStore`:
|
||||||
|
|
||||||
- One instance per game, created in
|
- One instance per game, created in the in-game shell
|
||||||
[`../frontend/src/routes/games/[id]/+layout.svelte`](../frontend/src/routes/games/[id]/+layout.svelte).
|
[`../frontend/src/lib/game/game-shell.svelte`](../frontend/src/lib/game/game-shell.svelte).
|
||||||
- Exposed through the `ORDER_DRAFT_CONTEXT_KEY` Svelte context.
|
- Exposed through the `ORDER_DRAFT_CONTEXT_KEY` Svelte context.
|
||||||
- Disposed in the layout's `onDestroy`.
|
- Disposed in the shell's `onDestroy`.
|
||||||
|
|
||||||
The order tab and the planet inspector both consume the store via
|
The order tab and the planet inspector both consume the store via
|
||||||
`getContext(ORDER_DRAFT_CONTEXT_KEY)` to push new commands.
|
`getContext(ORDER_DRAFT_CONTEXT_KEY)` to push new commands.
|
||||||
@@ -257,7 +257,7 @@ snapshot (history mode is the planned reader).
|
|||||||
|
|
||||||
`OrderDraftStore` records `needsServerHydration = true` when no
|
`OrderDraftStore` records `needsServerHydration = true` when no
|
||||||
cache row exists for the active game (fresh install, cleared
|
cache row exists for the active game (fresh install, cleared
|
||||||
storage, switching device). After the layout boot resolves both
|
storage, switching device). After the shell boot resolves both
|
||||||
`gameState.init` and `orderDraft.init`, it calls
|
`gameState.init` and `orderDraft.init`, it calls
|
||||||
`orderDraft.hydrateFromServer({ client, turn })` which issues
|
`orderDraft.hydrateFromServer({ client, turn })` which issues
|
||||||
`user.games.order.get` against the gateway. A `found = false`
|
`user.games.order.get` against the gateway. A `found = false`
|
||||||
@@ -294,14 +294,12 @@ report as it was. The Order tab is hidden when history mode is active
|
|||||||
— the player is browsing an immutable snapshot, and composing commands
|
— the player is browsing an immutable snapshot, and composing commands
|
||||||
against it would be confusing.
|
against it would be confusing.
|
||||||
|
|
||||||
The layout owns the `historyMode` flag and passes it to:
|
The in-game shell owns the `historyMode` flag and passes it to:
|
||||||
|
|
||||||
- `Sidebar` as `historyMode`. The sidebar forwards it to its
|
- `Sidebar` as `historyMode`. The sidebar forwards it to its
|
||||||
`TabBar` as `hideOrder`. The Order entry is filtered out of the
|
`TabBar` as `hideOrder`. The Order entry is filtered out of the
|
||||||
tab list when true. If a `?sidebar=order` URL seed lands while
|
tab list when true. If the active tab is `order` when the flag
|
||||||
the flag is true, the sidebar falls back to `inspector`. If the
|
flips on, an effect resets it to `inspector`.
|
||||||
active tab is `order` when the flag flips on, an effect resets
|
|
||||||
it to `inspector`.
|
|
||||||
- `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order`
|
- `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order`
|
||||||
button is suppressed when true.
|
button is suppressed when true.
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -4,12 +4,21 @@ The web client is an installable, offline-tolerant PWA. It uses
|
|||||||
SvelteKit's native service worker (no Workbox) so there is no extra
|
SvelteKit's native service worker (no Workbox) so there is no extra
|
||||||
build dependency and the cache logic stays explicit.
|
build dependency and the cache logic stays explicit.
|
||||||
|
|
||||||
|
The single-URL app-shell (see [`navigation.md`](navigation.md)) makes
|
||||||
|
the offline story simpler: the whole game UI lives at one route
|
||||||
|
(`${base}/`, i.e. `/game/` under the single-origin deployment), so
|
||||||
|
there is exactly one navigation target to precache and fall back to —
|
||||||
|
no per-screen routes to enumerate. The service-worker scope and the
|
||||||
|
manifest are unchanged by that refactor; both were already base-aware
|
||||||
|
(the SW keys everything off `$service-worker`'s `base`, and the
|
||||||
|
manifest uses relative `./` `start_url` / `scope`).
|
||||||
|
|
||||||
## Pieces
|
## Pieces
|
||||||
|
|
||||||
- [`src/service-worker.ts`](../frontend/src/service-worker.ts) — the
|
- [`src/service-worker.ts`](../frontend/src/service-worker.ts) — the
|
||||||
worker. SvelteKit registers it automatically in the production build.
|
worker. SvelteKit registers it automatically in the production build.
|
||||||
It precaches the app shell (`/`), the build artefacts (JS/CSS +
|
It precaches the app shell (`${base}/`), the build artefacts (JS/CSS
|
||||||
`core.wasm`), and the static files under a **version-keyed** cache
|
+ `core.wasm`), and the static files under a **version-keyed** cache
|
||||||
(`galaxy-cache-<version>`, `version` from `$service-worker`). On
|
(`galaxy-cache-<version>`, `version` from `$service-worker`). On
|
||||||
`activate` it deletes every other cache, so a new deploy never serves
|
`activate` it deletes every other cache, so a new deploy never serves
|
||||||
stale code. Strategy: cache-first for the version-keyed build/files;
|
stale code. Strategy: cache-first for the version-keyed build/files;
|
||||||
@@ -17,7 +26,8 @@ build dependency and the cache logic stays explicit.
|
|||||||
shell answers navigations when fully offline. The gateway (cross-
|
shell answers navigations when fully offline. The gateway (cross-
|
||||||
origin) is never intercepted — it is always live network.
|
origin) is never intercepted — it is always live network.
|
||||||
- [`static/manifest.webmanifest`](../frontend/static/manifest.webmanifest)
|
- [`static/manifest.webmanifest`](../frontend/static/manifest.webmanifest)
|
||||||
— name, `standalone` display, `start_url`/`scope` `/`, dark
|
— name, `standalone` display, relative `./` `start_url`/`scope` (so
|
||||||
|
it resolves under whatever `base` the build is deployed at), dark
|
||||||
`theme_color`/`background_color`, and the icon set.
|
`theme_color`/`background_color`, and the icon set.
|
||||||
- [`static/icons/`](../frontend/static/icons/) — `192`/`512` (`any`),
|
- [`static/icons/`](../frontend/static/icons/) — `192`/`512` (`any`),
|
||||||
a `512` `maskable`, and a `180` apple-touch icon. They are placeholder
|
a `512` `maskable`, and a `180` apple-touch icon. They are placeholder
|
||||||
|
|||||||
+18
-28
@@ -107,36 +107,23 @@ visible area so that scrolling down advances the highlight
|
|||||||
naturally. The observer is created on mount and torn down on
|
naturally. The observer is created on mount and torn down on
|
||||||
unmount.
|
unmount.
|
||||||
|
|
||||||
The in-game shell layout (`routes/games/[id]/+layout.svelte`)
|
The in-game shell (`lib/game/game-shell.svelte`)
|
||||||
expands `<main class="active-view-host">` to fit content rather
|
expands `<main class="active-view-host">` to fit content rather
|
||||||
than constraining it, so the document body is the actual scroll
|
than constraining it, so the document body is the actual scroll
|
||||||
container — not the host. The IntersectionObserver root is `null`
|
container — not the host. The IntersectionObserver root is `null`
|
||||||
to match.
|
to match.
|
||||||
|
|
||||||
## Scroll save / restore
|
## Scroll position
|
||||||
|
|
||||||
`routes/games/[id]/report/+page.svelte` exports a SvelteKit
|
The report is the `report` active view; switching to another view is
|
||||||
`Snapshot<{ scrollY: number }>`:
|
an in-memory `activeView` state change, not a navigation, and the
|
||||||
|
report component is remounted when the user returns to it. The
|
||||||
- `capture()` reads `window.scrollY` when SvelteKit's
|
single-URL app-shell therefore does not carry SvelteKit's route-keyed
|
||||||
`beforeNavigate` cycle runs.
|
`Snapshot` scroll save/restore — that mechanism was tied to the old
|
||||||
- `restore(value)` schedules a short
|
`/games/:id/report` route and was removed with it. A re-entered report
|
||||||
`requestAnimationFrame` poll that waits for
|
opens at the top; the IntersectionObserver re-derives the active TOC
|
||||||
`document.documentElement.scrollHeight` to grow tall enough to
|
slug from the scroll position on the next animation frame, so the
|
||||||
honour the saved offset, then calls `window.scrollTo(0, value)`.
|
highlight stays consistent without a separate source of truth.
|
||||||
The poll caps at ~60 frames (≈ 1 second) so a never-tall-enough
|
|
||||||
body never pins a frame loop.
|
|
||||||
|
|
||||||
The capture / restore pair is keyed by route, so:
|
|
||||||
- Forward navigation from `/report` to `/map` lands `/map` at
|
|
||||||
scrollY 0 (no snapshot for `/map` to restore from).
|
|
||||||
- History-back from `/map` to `/report` restores the previously
|
|
||||||
captured scrollY — the user returns to the same section.
|
|
||||||
|
|
||||||
The Snapshot API does not capture the active sidebar slug; the
|
|
||||||
IntersectionObserver re-derives it from the restored scroll
|
|
||||||
position on the next animation frame, which keeps the TOC
|
|
||||||
highlight consistent without a second source of truth.
|
|
||||||
|
|
||||||
## i18n namespace
|
## i18n namespace
|
||||||
|
|
||||||
@@ -169,10 +156,13 @@ couple them silently.
|
|||||||
/ IntersectionObserver are out of scope.
|
/ IntersectionObserver are out of scope.
|
||||||
- **Playwright** — `tests/e2e/report-sections.spec.ts` exercises
|
- **Playwright** — `tests/e2e/report-sections.spec.ts` exercises
|
||||||
the full integration: every TOC anchor lands its section in
|
the full integration: every TOC anchor lands its section in
|
||||||
view, the snapshot mechanism preserves `window.scrollY` on
|
view, the back-to-map button switches to the map view
|
||||||
history navigation, the back-to-map button reaches `/map`, the
|
(`activeView.select("map")`), and the mobile `<select>` scrolls
|
||||||
mobile `<select>` scrolls to the chosen section on a narrow
|
to the chosen section on a narrow viewport. The spec drives the
|
||||||
viewport.
|
app-shell through `window.__galaxyNav` (the dev-only nav surface)
|
||||||
|
instead of `page.goto` per-view URLs. The old "scroll position
|
||||||
|
survives a `/map` round-trip via SvelteKit `Snapshot`" case was
|
||||||
|
dropped — see the [scroll position](#scroll-position) note.
|
||||||
|
|
||||||
Test IDs follow the pattern `report-section-<slug>` for section
|
Test IDs follow the pattern `report-section-<slug>` for section
|
||||||
roots, `report-toc-<slug>` for TOC anchors, and per-section row
|
roots, `report-toc-<slug>` for TOC anchors, and per-section row
|
||||||
|
|||||||
+2
-1
@@ -186,7 +186,8 @@ Thin orchestration layer over `KeyStore` + `Cache`:
|
|||||||
push-event-driven revocation path.
|
push-event-driven revocation path.
|
||||||
|
|
||||||
A `null` `deviceSessionId` is the signal that the session is
|
A `null` `deviceSessionId` is the signal that the session is
|
||||||
unauthenticated — the root layout routes such users to `/login`.
|
unauthenticated — `session.status` settles to `anonymous` and the
|
||||||
|
dispatcher renders the login screen (the app-shell stays at `/game/`).
|
||||||
|
|
||||||
## Test layout
|
## Test layout
|
||||||
|
|
||||||
|
|||||||
+9
-8
@@ -128,9 +128,10 @@ report" affordance (`import.meta.env.DEV`). The flow is:
|
|||||||
|
|
||||||
2. Run the UI dev server (`pnpm -C ui/frontend dev`), open the lobby,
|
2. Run the UI dev server (`pnpm -C ui/frontend dev`), open the lobby,
|
||||||
and use the "Load JSON…" file picker in the **Synthetic test
|
and use the "Load JSON…" file picker in the **Synthetic test
|
||||||
reports (DEV)** section. The page navigates to
|
reports (DEV)** section. The lobby enters a `synthetic-<uuid>` game
|
||||||
`/games/synthetic-<uuid>/map` with the report wired into the
|
on the map view (`activeView.reset()` + `appScreen.go("game", {
|
||||||
in-game shell.
|
gameId })`) with the report wired into the in-game shell. The
|
||||||
|
app-shell URL stays `/game/` — see [`navigation.md`](navigation.md).
|
||||||
|
|
||||||
In synthetic mode:
|
In synthetic mode:
|
||||||
|
|
||||||
@@ -139,16 +140,16 @@ In synthetic mode:
|
|||||||
- Composing orders works locally (overlay applies through
|
- Composing orders works locally (overlay applies through
|
||||||
`applyOrderOverlay` as usual), but **nothing is sent to the
|
`applyOrderOverlay` as usual), but **nothing is sent to the
|
||||||
gateway** — `OrderDraftStore.scheduleSync` short-circuits because
|
gateway** — `OrderDraftStore.scheduleSync` short-circuits because
|
||||||
the synthetic id is not a UUID and the layout deliberately does
|
the synthetic id is not a UUID and the in-game shell deliberately
|
||||||
not bind a `GalaxyClient` for this game.
|
does not bind a `GalaxyClient` for this game.
|
||||||
- The order draft is persisted into the platform `Cache` under the
|
- The order draft is persisted into the platform `Cache` under the
|
||||||
same `order-drafts` namespace as real games, keyed by the
|
same `order-drafts` namespace as real games, keyed by the
|
||||||
synthetic id, so navigating back into the same synthetic session
|
synthetic id, so navigating back into the same synthetic session
|
||||||
restores the draft. The cache is cleared with
|
restores the draft. The cache is cleared with
|
||||||
`__galaxyDebug.clearOrderDraft(gameId)` (DEV debug surface).
|
`__galaxyDebug.clearOrderDraft(gameId)` (DEV debug surface).
|
||||||
- A page reload loses the in-memory report registry; opening the
|
- A page reload loses the in-memory report registry; a restored
|
||||||
same synthetic id afterwards redirects to /lobby. Re-load the JSON
|
synthetic game whose report is gone falls back to the lobby
|
||||||
to reseed.
|
(`appScreen.go("lobby")`). Re-load the JSON to reseed.
|
||||||
|
|
||||||
The synthetic-report parity rule requires every change that extends
|
The synthetic-report parity rule requires every change that extends
|
||||||
`decodeReport` to also extend the legacy parser in lockstep, or to
|
`decodeReport` to also extend the legacy parser in lockstep, or to
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ declare global {
|
|||||||
const __APP_VERSION__: string;
|
const __APP_VERSION__: string;
|
||||||
|
|
||||||
namespace App {
|
namespace App {
|
||||||
// future-phase types added later
|
// Shallow-routing state for the single-URL app-shell: the screen
|
||||||
|
// (and active game) live in `page.state` so browser Back/Forward
|
||||||
|
// move between screens while the address bar stays at /game/.
|
||||||
|
interface PageState {
|
||||||
|
screen?: "login" | "lobby" | "lobby-create" | "game";
|
||||||
|
gameId?: string | null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ header now — we just hand the routes down as callbacks so the
|
|||||||
viewer keeps its prop-driven contract.
|
viewer keeps its prop-driven contract.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BattleFetchError,
|
BattleFetchError,
|
||||||
@@ -127,10 +126,10 @@ viewer keeps its prop-driven contract.
|
|||||||
});
|
});
|
||||||
|
|
||||||
function backToReport() {
|
function backToReport() {
|
||||||
goto(withBase(`/games/${gameId}/report`));
|
activeView.select("report");
|
||||||
}
|
}
|
||||||
function backToMap() {
|
function backToMap() {
|
||||||
goto(withBase(`/games/${gameId}/map`));
|
activeView.select("map");
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,8 @@ fractions is a Phase 21 decision documented in
|
|||||||
`ui/docs/science-designer-ux.md`.
|
`ui/docs/science-designer-ux.md`.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { getContext, tick } from "svelte";
|
import { getContext, tick } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
import { page } from "$app/state";
|
|
||||||
|
|
||||||
import type { ScienceSummary } from "../../api/game-state";
|
import type { ScienceSummary } from "../../api/game-state";
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
@@ -53,8 +51,11 @@ fractions is a Phase 21 decision documented in
|
|||||||
ORDER_DRAFT_CONTEXT_KEY,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
);
|
);
|
||||||
|
|
||||||
const gameId = $derived(page.params.id ?? "");
|
// `scienceId` is the only sub-parameter the science designer needs;
|
||||||
const scienceId = $derived(page.params.scienceId ?? "");
|
// the active game id is implicit (the shell only mounts this view
|
||||||
|
// for the active game) and is read from `appScreen` where required.
|
||||||
|
let { scienceId = "" }: { scienceId?: string } = $props();
|
||||||
|
|
||||||
const isViewMode = $derived(scienceId !== "");
|
const isViewMode = $derived(scienceId !== "");
|
||||||
|
|
||||||
const localScience = $derived<ScienceSummary[]>(
|
const localScience = $derived<ScienceSummary[]>(
|
||||||
@@ -126,7 +127,7 @@ fractions is a Phase 21 decision documented in
|
|||||||
}
|
}
|
||||||
|
|
||||||
function backToTable(): void {
|
function backToTable(): void {
|
||||||
void goto(withBase(`/games/${gameId}/table/sciences`));
|
activeView.select("table", { tableEntity: "sciences" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(): Promise<void> {
|
async function save(): Promise<void> {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ pane, system-item pane, compose form) live under
|
|||||||
`./mail/*.svelte`.
|
`./mail/*.svelte`.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/state";
|
import { appScreen } from "$lib/app-nav.svelte";
|
||||||
|
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import { mailStore, type MailListEntry } from "$lib/mail-store.svelte";
|
import { mailStore, type MailListEntry } from "$lib/mail-store.svelte";
|
||||||
@@ -19,7 +19,7 @@ pane, system-item pane, compose form) live under
|
|||||||
let selectedKey = $state<string | null>(null);
|
let selectedKey = $state<string | null>(null);
|
||||||
let composeOpen = $state(false);
|
let composeOpen = $state(false);
|
||||||
|
|
||||||
const gameId = $derived(page.params.id ?? "");
|
const gameId = $derived(appScreen.gameId ?? "");
|
||||||
|
|
||||||
const entries = $derived(mailStore.entries);
|
const entries = $derived(mailStore.entries);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!--
|
<!--
|
||||||
Phase 11 map active view: integrates the Phase 9 renderer with the
|
Phase 11 map active view: integrates the Phase 9 renderer with the
|
||||||
per-game `GameStateStore` provided through context by
|
per-game `GameStateStore` provided through context by
|
||||||
`routes/games/[id]/+layout.svelte`. The view mounts the renderer
|
`lib/game/game-shell.svelte`. The view mounts the renderer
|
||||||
once the store has produced a report and re-mounts when the
|
once the store has produced a report and re-mounts when the
|
||||||
report's turn changes (a refresh that returns the same turn keeps
|
report's turn changes (a refresh that returns the same turn keeps
|
||||||
the existing renderer instance alive). Empty-planet reports render
|
the existing renderer instance alive). Empty-planet reports render
|
||||||
@@ -20,10 +20,8 @@ Phase 29 wires the wrap-mode toggle on top of the per-game `wrapMode`
|
|||||||
preference the store already manages.
|
preference the store already manages.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { getContext, onDestroy, onMount, untrack } from "svelte";
|
import { getContext, onDestroy, onMount, untrack } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
import { page } from "$app/state";
|
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import {
|
import {
|
||||||
createRenderer,
|
createRenderer,
|
||||||
@@ -615,6 +613,29 @@ preference the store already manages.
|
|||||||
// through the same `hit-test` plumbing — the hitLookup map keyed
|
// through the same `hit-test` plumbing — the hitLookup map keyed
|
||||||
// by primitive id resolves a hit back to either a planet or a
|
// by primitive id resolves a hit back to either a planet or a
|
||||||
// ship-group selection variant.
|
// ship-group selection variant.
|
||||||
|
// scrollToBombingRow waits for the report's bombing row for the
|
||||||
|
// given planet to mount, then scrolls it into view. The map context
|
||||||
|
// menu switches to the report view through a store mutation, so the
|
||||||
|
// section renders on a later frame; a short bounded poll bridges
|
||||||
|
// that gap without coupling the map to the report's render timing.
|
||||||
|
function scrollToBombingRow(planet: number): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
let attempts = 60;
|
||||||
|
const tick = (): void => {
|
||||||
|
const row = document.querySelector(
|
||||||
|
`[data-testid="report-bombing-row"][data-planet="${planet}"]`,
|
||||||
|
);
|
||||||
|
if (row instanceof HTMLElement) {
|
||||||
|
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempts -= 1;
|
||||||
|
if (attempts <= 0) return;
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
function handleMapClick(cursorPx: { x: number; y: number }): void {
|
function handleMapClick(cursorPx: { x: number; y: number }): void {
|
||||||
if (handle === null || store?.report === undefined || store.report === null) {
|
if (handle === null || store?.report === undefined || store.report === null) {
|
||||||
return;
|
return;
|
||||||
@@ -634,26 +655,20 @@ preference the store already manages.
|
|||||||
selection.selectShipGroup(target.ref);
|
selection.selectShipGroup(target.ref);
|
||||||
break;
|
break;
|
||||||
case "battle": {
|
case "battle": {
|
||||||
const gameId = page.params.id ?? "";
|
|
||||||
const turn = store?.report?.turn ?? 0;
|
const turn = store?.report?.turn ?? 0;
|
||||||
void goto(
|
activeView.select("battle", {
|
||||||
withBase(`/games/${gameId}/battle/${target.battleId}?turn=${turn}`),
|
battleId: target.battleId,
|
||||||
);
|
turn,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "bombing": {
|
case "bombing": {
|
||||||
const gameId = page.params.id ?? "";
|
activeView.select("report");
|
||||||
void goto(
|
// The report sections render reactively after the view
|
||||||
withBase(`/games/${gameId}/report#report-bombings`),
|
// switches above, so there is no navigation promise to
|
||||||
).then(() => {
|
// await; poll a bounded number of animation frames for
|
||||||
if (typeof document === "undefined") return;
|
// the bombing row, then scroll it into view.
|
||||||
const row = document.querySelector(
|
scrollToBombingRow(target.planet);
|
||||||
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
|
|
||||||
);
|
|
||||||
if (row && row.scrollIntoView) {
|
|
||||||
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,22 +7,15 @@ section is its own component under `lib/active-view/report/` — the
|
|||||||
data shapes are too varied for one generic table, and the
|
data shapes are too varied for one generic table, and the
|
||||||
component-per-section seam matches Phase 23's targeted-test contract.
|
component-per-section seam matches Phase 23's targeted-test contract.
|
||||||
|
|
||||||
Active-section highlighting and scroll save/restore land here:
|
Active-section highlighting lands here: an `IntersectionObserver`
|
||||||
- `IntersectionObserver` rooted on the active-view-host element
|
rooted on the viewport watches every `<section id="report-<slug>">`
|
||||||
(`bind:this` in `+layout.svelte`, plumbed through
|
and updates a local `activeSlug` rune that drives the TOC highlight.
|
||||||
`ACTIVE_VIEW_HOST_CONTEXT_KEY`) watches every `<section
|
|
||||||
id="report-<slug>">` and updates a local `activeSlug` rune.
|
|
||||||
- The matching `+page.svelte` exports a SvelteKit `Snapshot` that
|
|
||||||
captures and restores `host.element.scrollTop`, so navigating to
|
|
||||||
/map and back lands on the same scroll position. The save lives in
|
|
||||||
`+page.svelte` because SvelteKit binds snapshots per route.
|
|
||||||
|
|
||||||
The 20-section list lives here as a single source of truth so the
|
The 20-section list lives here as a single source of truth so the
|
||||||
TOC and the body iterate the same data.
|
TOC and the body iterate the same data.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { page } from "$app/state";
|
|
||||||
|
|
||||||
import ReportToc, {
|
import ReportToc, {
|
||||||
type TocEntry,
|
type TocEntry,
|
||||||
@@ -71,8 +64,6 @@ TOC and the body iterate the same data.
|
|||||||
{ slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" },
|
{ slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const gameId = $derived(page.params.id ?? "");
|
|
||||||
|
|
||||||
let activeSlug = $state<string>(ENTRIES[0]?.slug ?? "");
|
let activeSlug = $state<string>(ENTRIES[0]?.slug ?? "");
|
||||||
let bodyEl: HTMLDivElement | null = $state(null);
|
let bodyEl: HTMLDivElement | null = $state(null);
|
||||||
|
|
||||||
@@ -116,7 +107,7 @@ TOC and the body iterate the same data.
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="report-view" data-testid="active-view-report">
|
<div class="report-view" data-testid="active-view-report">
|
||||||
<ReportToc entries={ENTRIES} {activeSlug} {gameId} />
|
<ReportToc entries={ENTRIES} {activeSlug} />
|
||||||
|
|
||||||
<div class="report-body" bind:this={bodyEl}>
|
<div class="report-body" bind:this={bodyEl}>
|
||||||
<SectionGalaxySummary />
|
<SectionGalaxySummary />
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ Phase 23 Report View table of contents.
|
|||||||
|
|
||||||
Responsibilities:
|
Responsibilities:
|
||||||
- "Back to map" button at the top — visible on both desktop sidebar
|
- "Back to map" button at the top — visible on both desktop sidebar
|
||||||
and mobile sticky toolbar. Navigates via `$app/navigation.goto` so
|
and mobile sticky toolbar. Switches the active view to the map
|
||||||
active-view-host scroll restoration plays through SvelteKit's
|
through `activeView.select("map")`; the shell's tool gate resets
|
||||||
history machinery and the layout's `mobileTool` resets naturally.
|
the `mobileTool` overlay naturally once the map is no longer the
|
||||||
|
active view.
|
||||||
- Desktop / tablet sidebar: a vertical list of anchor links, one per
|
- Desktop / tablet sidebar: a vertical list of anchor links, one per
|
||||||
section. The active link gets `aria-current="location"` and a
|
section. The active link gets `aria-current="location"` and a
|
||||||
`.active` style. Click scrolls the active-view-host (not the
|
`.active` style. Click scrolls the active-view-host (not the
|
||||||
@@ -20,8 +21,7 @@ The active section is computed by the orchestrator
|
|||||||
`activeSlug` prop. The TOC itself owns no observers.
|
`activeSlug` prop. The TOC itself owns no observers.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
|
|
||||||
@@ -33,10 +33,9 @@ The active section is computed by the orchestrator
|
|||||||
type Props = {
|
type Props = {
|
||||||
entries: readonly TocEntry[];
|
entries: readonly TocEntry[];
|
||||||
activeSlug: string;
|
activeSlug: string;
|
||||||
gameId: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let { entries, activeSlug, gameId }: Props = $props();
|
let { entries, activeSlug }: Props = $props();
|
||||||
|
|
||||||
function scrollToSlug(slug: string): void {
|
function scrollToSlug(slug: string): void {
|
||||||
const target = document.getElementById(`report-${slug}`);
|
const target = document.getElementById(`report-${slug}`);
|
||||||
@@ -62,8 +61,8 @@ The active section is computed by the orchestrator
|
|||||||
scrollToSlug(slug);
|
scrollToSlug(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function backToMap(): Promise<void> {
|
function backToMap(): void {
|
||||||
await goto(withBase(`/games/${gameId}/map`));
|
activeView.select("map");
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<!--
|
<!--
|
||||||
Phase 27 Report View — battles section. Each row is a link into the
|
Phase 27 Report View — battles section. Each row opens the Battle
|
||||||
Battle Viewer at `/games/<id>/battle/<uuid>?turn=<turn>` where
|
Viewer through `activeView.select("battle", { battleId, turn })`
|
||||||
`turn` follows the current report's turn so history-mode views land
|
where `turn` follows the current report's turn so history-mode views
|
||||||
on the right battle. Phase 23 rendered the same rows as inactive
|
land on the right battle. Phase 23 rendered the same rows as inactive
|
||||||
monospace `<span>`; the rewire here is the one-liner the Phase 23
|
monospace `<span>`; the rewire here is the one-liner the Phase 23
|
||||||
decision log called out.
|
decision log called out.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { page } from "$app/state";
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
|
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import {
|
import {
|
||||||
@@ -22,8 +21,11 @@ decision log called out.
|
|||||||
);
|
);
|
||||||
const report = $derived(rendered?.report ?? null);
|
const report = $derived(rendered?.report ?? null);
|
||||||
const battles = $derived(report?.battles ?? []);
|
const battles = $derived(report?.battles ?? []);
|
||||||
const gameId = $derived(page.params.id ?? "");
|
|
||||||
const turn = $derived(report?.turn ?? 0);
|
const turn = $derived(report?.turn ?? 0);
|
||||||
|
|
||||||
|
function openBattle(battleId: string): void {
|
||||||
|
activeView.select("battle", { battleId, turn });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -46,12 +48,13 @@ decision log called out.
|
|||||||
<span class="label">
|
<span class="label">
|
||||||
{i18n.t("game.report.section.battles.id_label")}
|
{i18n.t("game.report.section.battles.id_label")}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<button
|
||||||
|
type="button"
|
||||||
class="uuid"
|
class="uuid"
|
||||||
href={withBase(`/games/${gameId}/battle/${b.id}?turn=${turn}`)}
|
onclick={() => openBattle(b.id)}
|
||||||
data-testid="report-battle-row"
|
data-testid="report-battle-row"
|
||||||
data-id={b.id}
|
data-id={b.id}
|
||||||
>{b.id}</a>
|
>{b.id}</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -90,10 +93,15 @@ decision log called out.
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
.uuid {
|
.uuid {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.uuid:hover {
|
.uuid:hover {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|||||||
@@ -11,16 +11,14 @@ The four tech proportions are stored on the wire as fractions in
|
|||||||
`[0, 1]` and surfaced here as percentages with one decimal so the
|
`[0, 1]` and surfaced here as percentages with one decimal so the
|
||||||
table matches the designer's input units.
|
table matches the designer's input units.
|
||||||
|
|
||||||
The component sits inside the active-view slot owned by
|
The component sits inside the active-view area owned by
|
||||||
`routes/games/[id]/+layout.svelte`, so it inherits the per-game
|
`lib/game/game-shell.svelte`, so it inherits the per-game
|
||||||
`OrderDraftStore` and `RenderedReportSource` through context. No
|
`OrderDraftStore` and `RenderedReportSource` through context. No
|
||||||
data fetching is performed here — the layout is responsible.
|
data fetching is performed here — the shell is responsible.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
import { page } from "$app/state";
|
|
||||||
|
|
||||||
import type { ScienceSummary } from "../../api/game-state";
|
import type { ScienceSummary } from "../../api/game-state";
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
@@ -60,8 +58,6 @@ data fetching is performed here — the layout is responsible.
|
|||||||
ORDER_DRAFT_CONTEXT_KEY,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
);
|
);
|
||||||
|
|
||||||
const gameId = $derived(page.params.id ?? "");
|
|
||||||
|
|
||||||
let sortColumn: SortColumn = $state("name");
|
let sortColumn: SortColumn = $state("name");
|
||||||
let sortDirection: SortDirection = $state("asc");
|
let sortDirection: SortDirection = $state("asc");
|
||||||
let filter: string = $state("");
|
let filter: string = $state("");
|
||||||
@@ -118,11 +114,11 @@ data fetching is performed here — the layout is responsible.
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openDesigner(name: string): void {
|
function openDesigner(name: string): void {
|
||||||
void goto(withBase(`/games/${gameId}/designer/science/${encodeURIComponent(name)}`));
|
activeView.select("designer-science", { scienceId: name });
|
||||||
}
|
}
|
||||||
|
|
||||||
function newScience(): void {
|
function newScience(): void {
|
||||||
void goto(withBase(`/games/${gameId}/designer/science`));
|
activeView.select("designer-science");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteScience(name: string): Promise<void> {
|
async function deleteScience(name: string): Promise<void> {
|
||||||
|
|||||||
@@ -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 });
|
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
|
||||||
error: string | null = $state(null);
|
error: string | null = $state(null);
|
||||||
|
/**
|
||||||
|
* notFound is the distinct "this game is not in the player's list"
|
||||||
|
* signal, set when `findGame` returns null (cancelled, removed, or
|
||||||
|
* access revoked). It is a clean flag the app-shell reads after
|
||||||
|
* `init` to drop a restored/stale game back to the lobby with a
|
||||||
|
* toast, rather than string-matching the `error` message. Transient
|
||||||
|
* network failures keep `notFound` false (they take the catch path)
|
||||||
|
* so they still surface the in-game error state for a retry. Reset
|
||||||
|
* to false at the start of every `setGame` / `advanceToPending`.
|
||||||
|
*/
|
||||||
|
notFound = $state(false);
|
||||||
/**
|
/**
|
||||||
* currentTurn mirrors the engine's turn number for the running
|
* currentTurn mirrors the engine's turn number for the running
|
||||||
* game (lifted from the lobby record on `setGame`). Phase 14
|
* game (lifted from the lobby record on `setGame`). Phase 14
|
||||||
@@ -218,6 +229,7 @@ export class GameStateStore {
|
|||||||
this.gameId = gameId;
|
this.gameId = gameId;
|
||||||
this.status = "loading";
|
this.status = "loading";
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
this.notFound = false;
|
||||||
this.report = null;
|
this.report = null;
|
||||||
|
|
||||||
this.wrapMode = await readWrapMode(this.cache, gameId);
|
this.wrapMode = await readWrapMode(this.cache, gameId);
|
||||||
@@ -229,6 +241,7 @@ export class GameStateStore {
|
|||||||
if (summary === null) {
|
if (summary === null) {
|
||||||
this.status = "error";
|
this.status = "error";
|
||||||
this.error = `game ${gameId} is not in your list`;
|
this.error = `game ${gameId} is not in your list`;
|
||||||
|
this.notFound = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.gameName = summary.gameName;
|
this.gameName = summary.gameName;
|
||||||
@@ -306,11 +319,13 @@ export class GameStateStore {
|
|||||||
}
|
}
|
||||||
this.status = "loading";
|
this.status = "loading";
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
this.notFound = false;
|
||||||
try {
|
try {
|
||||||
const summary = await this.findGame(this.gameId);
|
const summary = await this.findGame(this.gameId);
|
||||||
if (summary === null) {
|
if (summary === null) {
|
||||||
this.status = "error";
|
this.status = "error";
|
||||||
this.error = `game ${this.gameId} is not in your list`;
|
this.error = `game ${this.gameId} is not in your list`;
|
||||||
|
this.notFound = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.gameName = summary.gameName;
|
this.gameName = summary.gameName;
|
||||||
@@ -558,6 +573,18 @@ export class GameStateStore {
|
|||||||
|
|
||||||
private installVisibilityListener(): void {
|
private installVisibilityListener(): void {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
|
// Idempotent on re-init. In the single-URL app-shell a direct
|
||||||
|
// game → game switch (a push deep-link or a refresh-restore)
|
||||||
|
// re-runs `init` without unmounting the shell, so a naive
|
||||||
|
// `addEventListener` here would stack a second listener on every
|
||||||
|
// switch. Drop any previously-registered one first so exactly one
|
||||||
|
// stays live.
|
||||||
|
if (this.visibilityListener !== null) {
|
||||||
|
document.removeEventListener(
|
||||||
|
"visibilitychange",
|
||||||
|
this.visibilityListener,
|
||||||
|
);
|
||||||
|
}
|
||||||
const listener = (): void => {
|
const listener = (): void => {
|
||||||
if (document.visibilityState === "visible" && this.status === "ready") {
|
if (document.visibilityState === "visible" && this.status === "ready") {
|
||||||
void this.refresh();
|
void this.refresh();
|
||||||
|
|||||||
+145
-76
@@ -1,59 +1,63 @@
|
|||||||
<!--
|
<!--
|
||||||
Phase 10 in-game shell. Composes the header, a conditionally-visible
|
In-game shell. Composes the header, a conditionally-visible sidebar
|
||||||
sidebar (Calculator / Inspector / Order tabs), the active-view slot
|
(Calculator / Inspector / Order tabs), the active-view area selected
|
||||||
filled by the child route, and a mobile-only bottom-tab bar. The
|
by `activeView`, and a mobile-only bottom-tab bar. In the single-URL
|
||||||
layout owns:
|
app-shell there are no per-view routes: the active game id comes from
|
||||||
|
`appScreen.gameId` and the visible view from `activeView`, both held
|
||||||
|
in `$lib/app-nav.svelte`. The shell owns:
|
||||||
|
|
||||||
- `sidebarOpen` — tablet-only drawer toggle. Desktop keeps the
|
- `sidebarOpen` — tablet-only drawer toggle. Desktop keeps the
|
||||||
sidebar pinned via CSS; mobile hides it entirely.
|
sidebar pinned via CSS; mobile hides it entirely.
|
||||||
- `mobileTool` — mobile-only tool overlay state. The tool only
|
- `mobileTool` — mobile-only tool overlay state. The tool only
|
||||||
visually overrides the active-view slot when the URL is `/map`,
|
visually overrides the active-view area when the active view is the
|
||||||
so navigating to any other view through the More drawer or the
|
map, so switching to any other view through the More drawer or the
|
||||||
header view-menu naturally drops the overlay even if `mobileTool`
|
header view-menu naturally drops the overlay even if `mobileTool`
|
||||||
was set on a previous tap.
|
was set on a previous tap.
|
||||||
- `activeTab` — current sidebar tool (`calculator` / `inspector` /
|
- `activeTab` — current sidebar tool (`calculator` / `inspector` /
|
||||||
`order`). Held here, bound into the sidebar so a planet click on
|
`order`). Held here, bound into the sidebar so a planet click on
|
||||||
the map can flip it to `inspector` from the outside (Phase 13).
|
the map can flip it to `inspector` from the outside.
|
||||||
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
|
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
|
||||||
Phase 13 `SelectionStore`. All three are exposed to descendants
|
`SelectionStore`. All three are exposed to descendants via Svelte
|
||||||
via Svelte context; their lifetimes match the layout instance,
|
context; their lifetimes match the shell instance.
|
||||||
which itself stays mounted across active-view switches inside
|
|
||||||
`/games/:id/*`.
|
|
||||||
|
|
||||||
Phase 11 added the per-game `GameStateStore` instance owned by this
|
The per-game `GameStateStore` constructs the `GalaxyClient`, fetches
|
||||||
layout: it constructs the `GalaxyClient`, fetches the matching lobby
|
the matching lobby record to discover `current_turn`, then loads the
|
||||||
record to discover `current_turn`, then loads the report. The store
|
report. The store is shared with descendants via
|
||||||
is shared with descendants via `setContext("gameState", ...)` so the
|
`setContext(GAME_STATE_CONTEXT_KEY, ...)` so the header turn counter,
|
||||||
header turn counter, the map view, and the inspector tab all read
|
the map view, and the inspector tab all read from the same snapshot.
|
||||||
from the same snapshot.
|
|
||||||
|
|
||||||
Phase 13 adds the planet inspector. The layout watches the selection
|
The planet inspector: the shell watches the selection store and, on
|
||||||
store and, on the null → planet transition, flips `activeTab` to
|
the null → planet transition, flips `activeTab` to `inspector` and
|
||||||
`inspector` and `sidebarOpen` to `true` so the inspector becomes
|
`sidebarOpen` to `true` so the inspector becomes visible regardless
|
||||||
visible regardless of breakpoint (desktop already has the sidebar
|
of breakpoint (desktop already has the sidebar pinned; tablet needs
|
||||||
pinned; tablet needs the drawer to surface). On mobile the
|
the drawer to surface). On mobile the `<PlanetSheet />` overlay reads
|
||||||
`<PlanetSheet />` overlay reads the same selection and displays a
|
the same selection and displays a read-only sheet over the map;
|
||||||
read-only sheet over the map; closing the sheet clears the
|
closing the sheet clears the selection.
|
||||||
selection.
|
|
||||||
|
|
||||||
State preservation across active-view switches works for free
|
The per-game bootstrap (client construction, store init, push-event
|
||||||
because SvelteKit keeps this layout instance mounted while children
|
subscriptions) runs from an `$effect` keyed on `appScreen.gameId`:
|
||||||
swap; navigating between games unmounts and remounts the layout, so
|
the cleanup tears the previous game's subscriptions down and the body
|
||||||
the next game's snapshot — and the next game's selection — start
|
re-initialises the shared stores for the new id, so a direct
|
||||||
fresh.
|
game → game switch (without leaving the shell) rebinds cleanly. The
|
||||||
|
shell unmounts when the dispatcher leaves the `game` screen, so a
|
||||||
|
return to the lobby still disposes the stores via `onDestroy`.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
import { onDestroy, setContext, untrack } from "svelte";
|
||||||
import { onDestroy, onMount, setContext, untrack } from "svelte";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import { appScreen, activeView } from "$lib/app-nav.svelte";
|
||||||
import Header from "$lib/header/header.svelte";
|
import Header from "$lib/header/header.svelte";
|
||||||
import HistoryBanner from "$lib/header/history-banner.svelte";
|
import HistoryBanner from "$lib/header/history-banner.svelte";
|
||||||
import Sidebar from "$lib/sidebar/sidebar.svelte";
|
import Sidebar from "$lib/sidebar/sidebar.svelte";
|
||||||
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
|
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
|
||||||
import Calculator from "$lib/sidebar/calculator-tab.svelte";
|
import Calculator from "$lib/sidebar/calculator-tab.svelte";
|
||||||
import Order from "$lib/sidebar/order-tab.svelte";
|
import Order from "$lib/sidebar/order-tab.svelte";
|
||||||
|
import MapView from "$lib/active-view/map.svelte";
|
||||||
|
import TableView from "$lib/active-view/table.svelte";
|
||||||
|
import ReportView from "$lib/active-view/report.svelte";
|
||||||
|
import BattleView from "$lib/active-view/battle.svelte";
|
||||||
|
import MailView from "$lib/active-view/mail.svelte";
|
||||||
|
import DesignerScience from "$lib/active-view/designer-science.svelte";
|
||||||
import PlanetSheet from "$lib/inspectors/planet-sheet.svelte";
|
import PlanetSheet from "$lib/inspectors/planet-sheet.svelte";
|
||||||
import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte";
|
import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte";
|
||||||
import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte";
|
import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte";
|
||||||
@@ -71,7 +75,7 @@ fresh.
|
|||||||
import {
|
import {
|
||||||
ORDER_DRAFT_CONTEXT_KEY,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
OrderDraftStore,
|
OrderDraftStore,
|
||||||
} from "../../../sync/order-draft.svelte";
|
} from "../../sync/order-draft.svelte";
|
||||||
import {
|
import {
|
||||||
MAP_PICK_CONTEXT_KEY,
|
MAP_PICK_CONTEXT_KEY,
|
||||||
MapPickService,
|
MapPickService,
|
||||||
@@ -85,30 +89,30 @@ fresh.
|
|||||||
CoreHolder,
|
CoreHolder,
|
||||||
} from "$lib/core-context.svelte";
|
} from "$lib/core-context.svelte";
|
||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
import { loadStore } from "../../../platform/store/index";
|
import { loadStore } from "../../platform/store/index";
|
||||||
import { loadCore } from "../../../platform/core/index";
|
import { loadCore } from "../../platform/core/index";
|
||||||
import { createGatewayClient } from "../../../api/connect";
|
import { createGatewayClient } from "../../api/connect";
|
||||||
import { GalaxyClient } from "../../../api/galaxy-client";
|
import { GalaxyClient } from "../../api/galaxy-client";
|
||||||
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||||
import {
|
import {
|
||||||
getSyntheticReport,
|
getSyntheticReport,
|
||||||
isSyntheticGameId,
|
isSyntheticGameId,
|
||||||
} from "../../../api/synthetic-report";
|
} from "../../api/synthetic-report";
|
||||||
import {
|
import {
|
||||||
eventStream,
|
eventStream,
|
||||||
type VerifiedEvent,
|
type VerifiedEvent,
|
||||||
} from "../../../api/events.svelte";
|
} from "../../api/events.svelte";
|
||||||
import { toast } from "$lib/toast.svelte";
|
import { toast } from "$lib/toast.svelte";
|
||||||
import { mailStore } from "$lib/mail-store.svelte";
|
import { mailStore } from "$lib/mail-store.svelte";
|
||||||
|
|
||||||
let { children } = $props();
|
|
||||||
|
|
||||||
let sidebarOpen = $state(false);
|
let sidebarOpen = $state(false);
|
||||||
let mobileTool: MobileTool = $state("map");
|
let mobileTool: MobileTool = $state("map");
|
||||||
let activeTab: SidebarTab = $state("inspector");
|
let activeTab: SidebarTab = $state("inspector");
|
||||||
|
|
||||||
const gameId = $derived(page.params.id ?? "");
|
// The tool overlay (Calculator / Order) only replaces the active
|
||||||
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname));
|
// view while the map is showing; switching to any other view drops
|
||||||
|
// it, matching the previous URL-driven behaviour.
|
||||||
|
const isOnMap = $derived(activeView.view === "map");
|
||||||
const effectiveTool: MobileTool = $derived.by(() =>
|
const effectiveTool: MobileTool = $derived.by(() =>
|
||||||
isOnMap ? mobileTool : "map",
|
isOnMap ? mobileTool : "map",
|
||||||
);
|
);
|
||||||
@@ -363,18 +367,45 @@ fresh.
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
function teardownSubscriptions(): void {
|
||||||
|
if (unsubTurnReady !== null) {
|
||||||
|
unsubTurnReady();
|
||||||
|
unsubTurnReady = null;
|
||||||
|
}
|
||||||
|
if (unsubGamePaused !== null) {
|
||||||
|
unsubGamePaused();
|
||||||
|
unsubGamePaused = null;
|
||||||
|
}
|
||||||
|
if (unsubMailReceived !== null) {
|
||||||
|
unsubMailReceived();
|
||||||
|
unsubMailReceived = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-game bootstrap. The effect re-runs whenever `appScreen.gameId`
|
||||||
|
// changes: its cleanup tears the previous game's push-event
|
||||||
|
// subscriptions down, then the body rebinds the shared stores to the
|
||||||
|
// new id. The shared store instances persist across the switch
|
||||||
|
// (descendants captured them through context at construction), so a
|
||||||
|
// game → game switch re-initialises them in place rather than
|
||||||
|
// recreating them; `onDestroy` performs the terminal `dispose()`
|
||||||
|
// when the dispatcher leaves the `game` screen and unmounts the
|
||||||
|
// shell. A null id (no active game) is a no-op.
|
||||||
|
$effect(() => {
|
||||||
|
const activeGameId = appScreen.gameId;
|
||||||
|
if (activeGameId === null || activeGameId === "") return;
|
||||||
|
|
||||||
(async (): Promise<void> => {
|
(async (): Promise<void> => {
|
||||||
// DEV-only synthetic-report path. The lobby's "Load
|
// DEV-only synthetic-report path. The lobby's "Load
|
||||||
// synthetic report" affordance navigates here with a
|
// synthetic report" affordance enters the game with a
|
||||||
// `synthetic-<uuid>` id and the matching report
|
// `synthetic-<uuid>` id and the matching report
|
||||||
// pre-registered in an in-memory map. A page reload
|
// pre-registered in an in-memory map. A page reload
|
||||||
// loses the map entry; that case redirects to /lobby
|
// loses the map entry; that case returns to the lobby
|
||||||
// so the user reloads the JSON.
|
// so the user reloads the JSON.
|
||||||
if (isSyntheticGameId(gameId)) {
|
if (isSyntheticGameId(activeGameId)) {
|
||||||
const report = getSyntheticReport(gameId);
|
const report = getSyntheticReport(activeGameId);
|
||||||
if (report === undefined) {
|
if (report === undefined) {
|
||||||
await goto(withBase("/lobby"));
|
appScreen.go("lobby");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -392,8 +423,8 @@ fresh.
|
|||||||
]);
|
]);
|
||||||
coreHolder.set(core);
|
coreHolder.set(core);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
gameState.initSynthetic({ cache, gameId, report }),
|
gameState.initSynthetic({ cache, gameId: activeGameId, report }),
|
||||||
orderDraft.init({ cache, gameId }),
|
orderDraft.init({ cache, gameId: activeGameId }),
|
||||||
]);
|
]);
|
||||||
// Deliberately no `galaxyClient.set` and no
|
// Deliberately no `galaxyClient.set` and no
|
||||||
// `orderDraft.bindClient`: synthetic mode never
|
// `orderDraft.bindClient`: synthetic mode never
|
||||||
@@ -439,7 +470,7 @@ fresh.
|
|||||||
// became history at the cutoff.
|
// became history at the cutoff.
|
||||||
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
|
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
|
||||||
const parsed = parseTurnReadyPayload(event);
|
const parsed = parseTurnReadyPayload(event);
|
||||||
if (parsed === null || parsed.gameId !== gameId) {
|
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
gameState.markPendingTurn(parsed.turn);
|
gameState.markPendingTurn(parsed.turn);
|
||||||
@@ -455,7 +486,7 @@ fresh.
|
|||||||
});
|
});
|
||||||
unsubGamePaused = eventStream.on("game.paused", (event) => {
|
unsubGamePaused = eventStream.on("game.paused", (event) => {
|
||||||
const parsed = parseGamePausedPayload(event);
|
const parsed = parseGamePausedPayload(event);
|
||||||
if (parsed === null || parsed.gameId !== gameId) {
|
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
orderDraft.markPaused({ reason: parsed.reason });
|
orderDraft.markPaused({ reason: parsed.reason });
|
||||||
@@ -464,7 +495,7 @@ fresh.
|
|||||||
"diplomail.message.received",
|
"diplomail.message.received",
|
||||||
(event) => {
|
(event) => {
|
||||||
const parsed = parseMailReceivedPayload(event);
|
const parsed = parseMailReceivedPayload(event);
|
||||||
if (parsed === null || parsed.gameId !== gameId) {
|
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void mailStore.applyPushEvent(parsed.gameId);
|
void mailStore.applyPushEvent(parsed.gameId);
|
||||||
@@ -473,17 +504,36 @@ fresh.
|
|||||||
messageParams: { from: parsed.from },
|
messageParams: { from: parsed.from },
|
||||||
actionLabelKey: "game.events.mail_new.action",
|
actionLabelKey: "game.events.mail_new.action",
|
||||||
onAction: () => {
|
onAction: () => {
|
||||||
void goto(withBase(`/games/${gameId}/mail`));
|
activeView.select("mail");
|
||||||
},
|
},
|
||||||
durationMs: 8000,
|
durationMs: 8000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
gameState.init({ client, cache, gameId }),
|
gameState.init({ client, cache, gameId: activeGameId }),
|
||||||
orderDraft.init({ cache, gameId }),
|
orderDraft.init({ cache, gameId: activeGameId }),
|
||||||
mailStore.init({ client, cache, gameId }),
|
mailStore.init({ client, cache, gameId: activeGameId }),
|
||||||
]);
|
]);
|
||||||
|
// A restored or stale game id may point at a game that is
|
||||||
|
// no longer in the player's list (cancelled, removed, or
|
||||||
|
// access revoked). `init` flags that distinct case via
|
||||||
|
// `gameState.notFound` (a transient network error keeps it
|
||||||
|
// false and surfaces the in-game error state instead). Drop
|
||||||
|
// to the lobby with a toast rather than stranding the user
|
||||||
|
// on the in-game "not in your list" error. Leaving the
|
||||||
|
// `game` screen unmounts the shell, so the stores are
|
||||||
|
// disposed via `onDestroy`; the rest of the bootstrap
|
||||||
|
// (client bind, server hydration) is skipped for the dead
|
||||||
|
// game.
|
||||||
|
if (gameState.notFound) {
|
||||||
|
appScreen.go("lobby");
|
||||||
|
toast.show({
|
||||||
|
messageKey: "game.events.unavailable.message",
|
||||||
|
durationMs: 8000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
galaxyClient.set(client);
|
galaxyClient.set(client);
|
||||||
orderDraft.bindClient(client, {
|
orderDraft.bindClient(client, {
|
||||||
getCurrentTurn: () => gameState.currentTurn,
|
getCurrentTurn: () => gameState.currentTurn,
|
||||||
@@ -503,21 +553,12 @@ fresh.
|
|||||||
gameState.failBootstrap(describeBootstrapError(err));
|
gameState.failBootstrap(describeBootstrapError(err));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
return teardownSubscriptions;
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (unsubTurnReady !== null) {
|
teardownSubscriptions();
|
||||||
unsubTurnReady();
|
|
||||||
unsubTurnReady = null;
|
|
||||||
}
|
|
||||||
if (unsubGamePaused !== null) {
|
|
||||||
unsubGamePaused();
|
|
||||||
unsubGamePaused = null;
|
|
||||||
}
|
|
||||||
if (unsubMailReceived !== null) {
|
|
||||||
unsubMailReceived();
|
|
||||||
unsubMailReceived = null;
|
|
||||||
}
|
|
||||||
gameState.dispose();
|
gameState.dispose();
|
||||||
orderDraft.dispose();
|
orderDraft.dispose();
|
||||||
selection.dispose();
|
selection.dispose();
|
||||||
@@ -534,7 +575,6 @@ fresh.
|
|||||||
{i18n.t("common.skip_to_content")}
|
{i18n.t("common.skip_to_content")}
|
||||||
</a>
|
</a>
|
||||||
<Header
|
<Header
|
||||||
{gameId}
|
|
||||||
{sidebarOpen}
|
{sidebarOpen}
|
||||||
onToggleSidebar={toggleSidebar}
|
onToggleSidebar={toggleSidebar}
|
||||||
/>
|
/>
|
||||||
@@ -550,8 +590,22 @@ fresh.
|
|||||||
<Calculator />
|
<Calculator />
|
||||||
{:else if effectiveTool === "order"}
|
{:else if effectiveTool === "order"}
|
||||||
<Order />
|
<Order />
|
||||||
{:else}
|
{:else if activeView.view === "map"}
|
||||||
{@render children()}
|
<MapView />
|
||||||
|
{:else if activeView.view === "table"}
|
||||||
|
<TableView entity={activeView.state.tableEntity ?? ""} />
|
||||||
|
{:else if activeView.view === "report"}
|
||||||
|
<ReportView />
|
||||||
|
{:else if activeView.view === "battle"}
|
||||||
|
<BattleView
|
||||||
|
gameId={appScreen.gameId ?? ""}
|
||||||
|
turn={activeView.state.turn ?? 0}
|
||||||
|
battleId={activeView.state.battleId ?? ""}
|
||||||
|
/>
|
||||||
|
{:else if activeView.view === "mail"}
|
||||||
|
<MailView />
|
||||||
|
{:else if activeView.view === "designer-science"}
|
||||||
|
<DesignerScience scienceId={activeView.state.scienceId} />
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -562,7 +616,6 @@ fresh.
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<BottomTabs
|
<BottomTabs
|
||||||
{gameId}
|
|
||||||
activeTool={effectiveTool}
|
activeTool={effectiveTool}
|
||||||
onSelectTool={(tool) => (mobileTool = tool)}
|
onSelectTool={(tool) => (mobileTool = tool)}
|
||||||
hideOrder={historyMode}
|
hideOrder={historyMode}
|
||||||
@@ -618,6 +671,22 @@ fresh.
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
|
/*
|
||||||
|
Pin the shell to the viewport on mobile and take it out of
|
||||||
|
document flow. With `min-height: 100vh` the shell can overflow
|
||||||
|
the viewport by a few sub-pixels, which makes the document
|
||||||
|
scrollable; scrolling then toggles the mobile browser's dynamic
|
||||||
|
toolbar, resizing the viewport and the `position: fixed` overlays
|
||||||
|
(map-toggles menu, bottom-tab drawer, planet sheet) mid-gesture.
|
||||||
|
`position: fixed; inset: 0` keeps the viewport — and those
|
||||||
|
overlays — stable, leaving the active-view area as the single
|
||||||
|
internal scroll region.
|
||||||
|
*/
|
||||||
|
.game-shell {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
.body {
|
.body {
|
||||||
padding-bottom: 3.25rem;
|
padding-bottom: 3.25rem;
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@ absent until Phase 24 wires push-event state.
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import { appScreen } from "$lib/app-nav.svelte";
|
||||||
import {
|
import {
|
||||||
GAME_STATE_CONTEXT_KEY,
|
GAME_STATE_CONTEXT_KEY,
|
||||||
type GameStateStore,
|
type GameStateStore,
|
||||||
@@ -27,11 +28,10 @@ absent until Phase 24 wires push-event state.
|
|||||||
import TurnNavigator from "./turn-navigator.svelte";
|
import TurnNavigator from "./turn-navigator.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
gameId: string;
|
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
};
|
};
|
||||||
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props();
|
let { sidebarOpen, onToggleSidebar }: Props = $props();
|
||||||
|
|
||||||
const gameState = getContext<GameStateStore | undefined>(
|
const gameState = getContext<GameStateStore | undefined>(
|
||||||
GAME_STATE_CONTEXT_KEY,
|
GAME_STATE_CONTEXT_KEY,
|
||||||
@@ -57,6 +57,14 @@ absent until Phase 24 wires push-event state.
|
|||||||
<TurnNavigator />
|
<TurnNavigator />
|
||||||
</div>
|
</div>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="return-to-lobby"
|
||||||
|
data-testid="return-to-lobby"
|
||||||
|
onclick={() => appScreen.go("lobby")}
|
||||||
|
>
|
||||||
|
{i18n.t("game.shell.menu.return_to_lobby")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="sidebar-toggle"
|
class="sidebar-toggle"
|
||||||
@@ -69,7 +77,7 @@ absent until Phase 24 wires push-event state.
|
|||||||
>
|
>
|
||||||
⤧
|
⤧
|
||||||
</button>
|
</button>
|
||||||
<ViewMenu {gameId} />
|
<ViewMenu />
|
||||||
<AccountMenu />
|
<AccountMenu />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -102,6 +110,20 @@ absent until Phase 24 wires push-event state.
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.return-to-lobby {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.return-to-lobby:hover {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
.sidebar-toggle {
|
.sidebar-toggle {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
|||||||
@@ -7,21 +7,18 @@ itself is identical. The same component is reused for the mobile
|
|||||||
|
|
||||||
Lists the seven IA destinations: map, tables (sub-list of six
|
Lists the seven IA destinations: map, tables (sub-list of six
|
||||||
entities), report, battle, mail, ship-class designer, science
|
entities), report, battle, mail, ship-class designer, science
|
||||||
designer. Closes on Escape, on outside click, and after a
|
designer. Each entry mutates `activeView` (the single-URL app-shell
|
||||||
navigation. Phase 26 introduces the history-mode entry; Phase 35
|
has no per-view routes) and closes the menu. Closes on Escape, on
|
||||||
polishes microcopy.
|
outside click, and after a selection. Phase 26 introduces the
|
||||||
|
history-mode entry; Phase 35 polishes microcopy.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { activeView, type GameView } from "$lib/app-nav.svelte";
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
import { mailStore } from "$lib/mail-store.svelte";
|
import { mailStore } from "$lib/mail-store.svelte";
|
||||||
import { restoreFocus } from "$lib/a11y/restore-focus";
|
import { restoreFocus } from "$lib/a11y/restore-focus";
|
||||||
|
|
||||||
type Props = { gameId: string };
|
|
||||||
let { gameId }: Props = $props();
|
|
||||||
|
|
||||||
const mailUnread = $derived(mailStore.unreadCount);
|
const mailUnread = $derived(mailStore.unreadCount);
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -40,9 +37,12 @@ polishes microcopy.
|
|||||||
open = !open;
|
open = !open;
|
||||||
}
|
}
|
||||||
|
|
||||||
function go(path: string): void {
|
function select(
|
||||||
|
view: GameView,
|
||||||
|
params: { tableEntity?: string } = {},
|
||||||
|
): void {
|
||||||
open = false;
|
open = false;
|
||||||
void goto(withBase(path));
|
activeView.select(view, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(event: KeyboardEvent): void {
|
function onKeyDown(event: KeyboardEvent): void {
|
||||||
@@ -93,7 +93,7 @@ polishes microcopy.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="view-menu-item-map"
|
data-testid="view-menu-item-map"
|
||||||
onclick={() => go(`/games/${gameId}/map`)}
|
onclick={() => select("map")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.map")}
|
{i18n.t("game.view.map")}
|
||||||
</button>
|
</button>
|
||||||
@@ -105,7 +105,7 @@ polishes microcopy.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="view-menu-item-table-{entry.slug}"
|
data-testid="view-menu-item-table-{entry.slug}"
|
||||||
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)}
|
onclick={() => select("table", { tableEntity: entry.slug })}
|
||||||
>
|
>
|
||||||
{i18n.t(entry.key)}
|
{i18n.t(entry.key)}
|
||||||
</button>
|
</button>
|
||||||
@@ -116,7 +116,7 @@ polishes microcopy.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="view-menu-item-report"
|
data-testid="view-menu-item-report"
|
||||||
onclick={() => go(`/games/${gameId}/report`)}
|
onclick={() => select("report")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.report")}
|
{i18n.t("game.view.report")}
|
||||||
</button>
|
</button>
|
||||||
@@ -124,7 +124,7 @@ polishes microcopy.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="view-menu-item-battle"
|
data-testid="view-menu-item-battle"
|
||||||
onclick={() => go(`/games/${gameId}/battle`)}
|
onclick={() => select("battle")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.battle")}
|
{i18n.t("game.view.battle")}
|
||||||
</button>
|
</button>
|
||||||
@@ -133,7 +133,7 @@ polishes microcopy.
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="view-menu-item-mail"
|
data-testid="view-menu-item-mail"
|
||||||
class="with-badge"
|
class="with-badge"
|
||||||
onclick={() => go(`/games/${gameId}/mail`)}
|
onclick={() => select("mail")}
|
||||||
>
|
>
|
||||||
<span>{i18n.t("game.view.mail")}</span>
|
<span>{i18n.t("game.view.mail")}</span>
|
||||||
{#if mailUnread > 0}
|
{#if mailUnread > 0}
|
||||||
@@ -146,7 +146,7 @@ polishes microcopy.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="view-menu-item-designer-science"
|
data-testid="view-menu-item-designer-science"
|
||||||
onclick={() => go(`/games/${gameId}/designer/science`)}
|
onclick={() => select("designer-science")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.designer.science")}
|
{i18n.t("game.view.designer.science")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const en = {
|
|||||||
"game.events.turn_ready.message": "turn {turn} is ready",
|
"game.events.turn_ready.message": "turn {turn} is ready",
|
||||||
"game.events.turn_ready.action": "view now",
|
"game.events.turn_ready.action": "view now",
|
||||||
"game.events.signature_failed": "verification failed, reconnecting…",
|
"game.events.signature_failed": "verification failed, reconnecting…",
|
||||||
|
"game.events.unavailable.message": "this game is no longer available",
|
||||||
|
|
||||||
"login.title": "sign in to Galaxy",
|
"login.title": "sign in to Galaxy",
|
||||||
"login.email_label": "email",
|
"login.email_label": "email",
|
||||||
@@ -118,6 +119,7 @@ const en = {
|
|||||||
"game.shell.menu.theme_light": "light",
|
"game.shell.menu.theme_light": "light",
|
||||||
"game.shell.menu.theme_dark": "dark",
|
"game.shell.menu.theme_dark": "dark",
|
||||||
"game.shell.menu.language": "language",
|
"game.shell.menu.language": "language",
|
||||||
|
"game.shell.menu.return_to_lobby": "return to lobby",
|
||||||
"game.shell.menu.logout": "logout",
|
"game.shell.menu.logout": "logout",
|
||||||
"game.shell.coming_soon": "coming soon",
|
"game.shell.coming_soon": "coming soon",
|
||||||
"game.shell.turn.label": "turn {turn}",
|
"game.shell.turn.label": "turn {turn}",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.events.turn_ready.message": "ход {turn} готов",
|
"game.events.turn_ready.message": "ход {turn} готов",
|
||||||
"game.events.turn_ready.action": "открыть",
|
"game.events.turn_ready.action": "открыть",
|
||||||
"game.events.signature_failed": "подпись повреждена, переподключение…",
|
"game.events.signature_failed": "подпись повреждена, переподключение…",
|
||||||
|
"game.events.unavailable.message": "эта игра больше недоступна",
|
||||||
|
|
||||||
"login.title": "вход в Galaxy",
|
"login.title": "вход в Galaxy",
|
||||||
"login.email_label": "электронная почта",
|
"login.email_label": "электронная почта",
|
||||||
@@ -119,6 +120,7 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.shell.menu.theme_light": "светлая",
|
"game.shell.menu.theme_light": "светлая",
|
||||||
"game.shell.menu.theme_dark": "тёмная",
|
"game.shell.menu.theme_dark": "тёмная",
|
||||||
"game.shell.menu.language": "язык",
|
"game.shell.menu.language": "язык",
|
||||||
|
"game.shell.menu.return_to_lobby": "вернуться в лобби",
|
||||||
"game.shell.menu.logout": "выйти",
|
"game.shell.menu.logout": "выйти",
|
||||||
"game.shell.coming_soon": "скоро будет",
|
"game.shell.coming_soon": "скоро будет",
|
||||||
"game.shell.turn.label": "ход {turn}",
|
"game.shell.turn.label": "ход {turn}",
|
||||||
|
|||||||
+7
-8
@@ -1,14 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { appScreen } from "$lib/app-nav.svelte";
|
||||||
|
|
||||||
import { createGatewayClient } from "../../../api/connect";
|
import { createGatewayClient } from "../../api/connect";
|
||||||
import { GalaxyClient } from "../../../api/galaxy-client";
|
import { GalaxyClient } from "../../api/galaxy-client";
|
||||||
import { LobbyError, createGame } from "../../../api/lobby";
|
import { LobbyError, createGame } from "../../api/lobby";
|
||||||
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
import { loadCore } from "../../../platform/core/index";
|
import { loadCore } from "../../platform/core/index";
|
||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
|
|
||||||
const DEFAULT_MIN_PLAYERS = 2;
|
const DEFAULT_MIN_PLAYERS = 2;
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancel(): void {
|
function cancel(): void {
|
||||||
goto(withBase("/lobby"));
|
appScreen.go("lobby");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(): Promise<void> {
|
async function submit(): Promise<void> {
|
||||||
@@ -94,7 +93,7 @@
|
|||||||
turnSchedule: trimmedSchedule,
|
turnSchedule: trimmedSchedule,
|
||||||
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
|
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
|
||||||
});
|
});
|
||||||
goto(withBase("/lobby"));
|
appScreen.go("lobby");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
formError = describeLobbyError(err);
|
formError = describeLobbyError(err);
|
||||||
} finally {
|
} finally {
|
||||||
+11
-8
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { appScreen, activeView } from "$lib/app-nav.svelte";
|
||||||
|
|
||||||
import { createGatewayClient } from "../../api/connect";
|
import { createGatewayClient } from "../../api/connect";
|
||||||
import { GalaxyClient } from "../../api/galaxy-client";
|
import { GalaxyClient } from "../../api/galaxy-client";
|
||||||
@@ -185,11 +184,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function gotoCreate(): void {
|
function gotoCreate(): void {
|
||||||
goto(withBase("/lobby/create"));
|
appScreen.go("lobby-create");
|
||||||
}
|
}
|
||||||
|
|
||||||
function gotoGame(gameId: string): void {
|
function gotoGame(gameId: string): void {
|
||||||
goto(withBase(`/games/${gameId}/map`));
|
// Enter a fresh game on the map view: reset the in-game view
|
||||||
|
// state first so a stale snapshot from a previous game does not
|
||||||
|
// leak into the new one, then switch the top-level screen.
|
||||||
|
activeView.reset();
|
||||||
|
appScreen.go("game", { gameId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSyntheticFileChange(
|
async function onSyntheticFileChange(
|
||||||
@@ -208,7 +211,8 @@
|
|||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const json: unknown = JSON.parse(text);
|
const json: unknown = JSON.parse(text);
|
||||||
const { gameId } = loadSyntheticReportFromJSON(json);
|
const { gameId } = loadSyntheticReportFromJSON(json);
|
||||||
await goto(withBase(`/games/${gameId}/map`));
|
activeView.reset();
|
||||||
|
appScreen.go("game", { gameId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof SyntheticReportError) {
|
if (err instanceof SyntheticReportError) {
|
||||||
syntheticError = err.message;
|
syntheticError = err.message;
|
||||||
@@ -227,9 +231,8 @@
|
|||||||
// Statuses for which the game has a navigable in-game view.
|
// Statuses for which the game has a navigable in-game view.
|
||||||
// Lobby-internal statuses (draft, enrollment_open, ready_to_start,
|
// Lobby-internal statuses (draft, enrollment_open, ready_to_start,
|
||||||
// starting, start_failed) and terminal ones (cancelled) stay
|
// starting, start_failed) and terminal ones (cancelled) stay
|
||||||
// non-clickable; clicking them otherwise lands on a 404 because
|
// non-clickable; entering them otherwise opens the game shell on a
|
||||||
// /games/:id/map only meaningfully exists once the runtime has
|
// game whose runtime state does not exist yet.
|
||||||
// produced game state.
|
|
||||||
function isPlayableStatus(status: string): boolean {
|
function isPlayableStatus(status: string): boolean {
|
||||||
return status === "running" || status === "paused" || status === "finished";
|
return status === "running" || status === "paused" || status === "finished";
|
||||||
}
|
}
|
||||||
+2
-3
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
import { appScreen } from "$lib/app-nav.svelte";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import {
|
import {
|
||||||
AuthError,
|
AuthError,
|
||||||
confirmEmailCode,
|
confirmEmailCode,
|
||||||
@@ -89,7 +88,7 @@
|
|||||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
});
|
});
|
||||||
await session.signIn(result.deviceSessionId);
|
await session.signIn(result.deviceSessionId);
|
||||||
void goto(withBase("/lobby"), { replaceState: true });
|
appScreen.go("lobby");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AuthError && err.code === "invalid_request") {
|
if (err instanceof AuthError && err.code === "invalid_request") {
|
||||||
challengeId = null;
|
challengeId = null;
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
// asynchronously; the watcher in `lib/revocation-watcher.ts` calls
|
// asynchronously; the watcher in `lib/revocation-watcher.ts` calls
|
||||||
// it without user interaction. The post-condition is the same as
|
// it without user interaction. The post-condition is the same as
|
||||||
// `signOut("user")` — keypair regenerated, session id wiped,
|
// `signOut("user")` — keypair regenerated, session id wiped,
|
||||||
// status returned to `anonymous` — so the layout's existing
|
// status returned to `anonymous` — so the dispatcher's state-based
|
||||||
// `anonymous → /login` redirect handles both reasons uniformly.
|
// auth gate renders the login screen for both reasons uniformly.
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Cache,
|
Cache,
|
||||||
@@ -83,7 +83,7 @@ export class SessionStore {
|
|||||||
* revoked public key, and returns the status to `anonymous`. The
|
* revoked public key, and returns the status to `anonymous`. The
|
||||||
* `reason` is recorded in console output for telemetry but does
|
* `reason` is recorded in console output for telemetry but does
|
||||||
* not change the post-state — both user-driven logout and
|
* not change the post-state — both user-driven logout and
|
||||||
* gateway-driven revocation land the user back on `/login`.
|
* gateway-driven revocation return the user to the login screen.
|
||||||
*/
|
*/
|
||||||
async signOut(reason: "user" | "revoked"): Promise<void> {
|
async signOut(reason: "user" | "revoked"): Promise<void> {
|
||||||
if (this.keyStore === null || this.cache === null) {
|
if (this.keyStore === null || this.cache === null) {
|
||||||
|
|||||||
@@ -1,33 +1,31 @@
|
|||||||
<!--
|
<!--
|
||||||
Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map navigates
|
Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map switches
|
||||||
to `/games/:id/map` and resets the tool overlay. Calc and Order also
|
the active view to the map and resets the tool overlay. Calc and
|
||||||
navigate to `/games/:id/map` — the layout's tool gate replaces the
|
Order also switch to the map view — the shell's tool gate replaces
|
||||||
active view with the matching sidebar tool only when the URL is
|
the active view with the matching sidebar tool only while the map is
|
||||||
`/map`, so navigating to any other view via the More drawer or the
|
the active view, so navigating to any other view via the More drawer
|
||||||
header view-menu naturally drops the overlay.
|
or the header view-menu naturally drops the overlay.
|
||||||
|
|
||||||
More opens a drawer with the same destination list as the header
|
More opens a drawer with the same destination list as the header
|
||||||
view-menu. Phase 35 polish narrows it to the IA-spec subset
|
view-menu, each entry mutating `activeView` directly (the single-URL
|
||||||
(Mail, Battle log, Tables, History, Settings, Logout) once History
|
app-shell has no per-view routes). Phase 35 polish narrows it to the
|
||||||
exists; until then the convenience of one source of truth for
|
IA-spec subset (Mail, Battle log, Tables, History, Settings, Logout)
|
||||||
destinations beats the duplication.
|
once History exists; until then the convenience of one source of
|
||||||
|
truth for destinations beats the duplication.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { withBase } from "$lib/paths";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { activeView, type GameView } from "$lib/app-nav.svelte";
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
import { restoreFocus } from "$lib/a11y/restore-focus";
|
import { restoreFocus } from "$lib/a11y/restore-focus";
|
||||||
import type { MobileTool } from "./types";
|
import type { MobileTool } from "./types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
gameId: string;
|
|
||||||
activeTool: MobileTool;
|
activeTool: MobileTool;
|
||||||
onSelectTool: (tool: MobileTool) => void;
|
onSelectTool: (tool: MobileTool) => void;
|
||||||
hideOrder?: boolean;
|
hideOrder?: boolean;
|
||||||
};
|
};
|
||||||
let {
|
let {
|
||||||
gameId,
|
|
||||||
activeTool,
|
activeTool,
|
||||||
onSelectTool,
|
onSelectTool,
|
||||||
hideOrder = false,
|
hideOrder = false,
|
||||||
@@ -45,16 +43,18 @@ destinations beats the duplication.
|
|||||||
{ slug: "races", key: "game.view.table.races" },
|
{ slug: "races", key: "game.view.table.races" },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function selectTool(tool: MobileTool): Promise<void> {
|
function selectTool(tool: MobileTool): void {
|
||||||
moreOpen = false;
|
moreOpen = false;
|
||||||
onSelectTool(tool);
|
onSelectTool(tool);
|
||||||
await goto(withBase(`/games/${gameId}/map`));
|
// Calc / Order surface only over the map; selecting Map simply
|
||||||
|
// drops the overlay. Either way the map must be the active view.
|
||||||
|
activeView.select("map");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function go(path: string): Promise<void> {
|
function go(view: GameView, params: { tableEntity?: string } = {}): void {
|
||||||
moreOpen = false;
|
moreOpen = false;
|
||||||
onSelectTool("map");
|
onSelectTool("map");
|
||||||
await goto(withBase(path));
|
activeView.select(view, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMore(): void {
|
function toggleMore(): void {
|
||||||
@@ -143,7 +143,7 @@ destinations beats the duplication.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="bottom-tabs-more-map"
|
data-testid="bottom-tabs-more-map"
|
||||||
onclick={() => go(`/games/${gameId}/map`)}
|
onclick={() => go("map")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.map")}
|
{i18n.t("game.view.map")}
|
||||||
</button>
|
</button>
|
||||||
@@ -155,7 +155,7 @@ destinations beats the duplication.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="bottom-tabs-more-table-{entry.slug}"
|
data-testid="bottom-tabs-more-table-{entry.slug}"
|
||||||
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)}
|
onclick={() => go("table", { tableEntity: entry.slug })}
|
||||||
>
|
>
|
||||||
{i18n.t(entry.key)}
|
{i18n.t(entry.key)}
|
||||||
</button>
|
</button>
|
||||||
@@ -166,7 +166,7 @@ destinations beats the duplication.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="bottom-tabs-more-report"
|
data-testid="bottom-tabs-more-report"
|
||||||
onclick={() => go(`/games/${gameId}/report`)}
|
onclick={() => go("report")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.report")}
|
{i18n.t("game.view.report")}
|
||||||
</button>
|
</button>
|
||||||
@@ -174,7 +174,7 @@ destinations beats the duplication.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="bottom-tabs-more-battle"
|
data-testid="bottom-tabs-more-battle"
|
||||||
onclick={() => go(`/games/${gameId}/battle`)}
|
onclick={() => go("battle")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.battle")}
|
{i18n.t("game.view.battle")}
|
||||||
</button>
|
</button>
|
||||||
@@ -182,7 +182,7 @@ destinations beats the duplication.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="bottom-tabs-more-mail"
|
data-testid="bottom-tabs-more-mail"
|
||||||
onclick={() => go(`/games/${gameId}/mail`)}
|
onclick={() => go("mail")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.mail")}
|
{i18n.t("game.view.mail")}
|
||||||
</button>
|
</button>
|
||||||
@@ -190,7 +190,7 @@ destinations beats the duplication.
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="bottom-tabs-more-designer-science"
|
data-testid="bottom-tabs-more-designer-science"
|
||||||
onclick={() => go(`/games/${gameId}/designer/science`)}
|
onclick={() => go("designer-science")}
|
||||||
>
|
>
|
||||||
{i18n.t("game.view.designer.science")}
|
{i18n.t("game.view.designer.science")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { page } from "$app/state";
|
import { appScreen } from "$lib/app-nav.svelte";
|
||||||
|
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
import {
|
import {
|
||||||
@@ -67,7 +67,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
// Reset the design when the active game changes; a no-op otherwise, so
|
// Reset the design when the active game changes; a no-op otherwise, so
|
||||||
// the design persists across tab switches within a game.
|
// the design persists across tab switches within a game.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
cs.ensureGame(page.params.id ?? "");
|
cs.ensureGame(appScreen.gameId ?? "");
|
||||||
});
|
});
|
||||||
|
|
||||||
const core = $derived(coreHandle?.core ?? null);
|
const core = $derived(coreHandle?.core ?? null);
|
||||||
|
|||||||
@@ -1,29 +1,23 @@
|
|||||||
<!--
|
<!--
|
||||||
Sidebar with three tabs (Calculator, Inspector, Order). The parent
|
Sidebar with three tabs (Calculator, Inspector, Order). The parent
|
||||||
layout decides whether the sidebar is rendered at all (mobile hides
|
shell decides whether the sidebar is rendered at all (mobile hides
|
||||||
it, tablet collapses it behind the header toggle, desktop keeps it
|
it, tablet collapses it behind the header toggle, desktop keeps it
|
||||||
always visible). State preservation across active-view switches
|
always visible). State preservation across active-view switches
|
||||||
works for free because the layout never remounts when the user
|
works for free because the shell never remounts when the user
|
||||||
navigates within `/games/:id/*`.
|
switches the active view within a game.
|
||||||
|
|
||||||
The optional `?sidebar=calc|calculator|inspector|order` URL param
|
|
||||||
seeds the initial tab on first mount — used by the lobby card path
|
|
||||||
when later phases want to land directly on a particular tool.
|
|
||||||
|
|
||||||
The `historyMode` prop hides the Order tab when true: the tab-bar
|
The `historyMode` prop hides the Order tab when true: the tab-bar
|
||||||
filters it out and any URL seed targeting `order` falls back to
|
filters it out and the history-mode reset falls back to `inspector`.
|
||||||
`inspector`. Phase 12 wires the prop through the layout as a
|
Phase 12 wires the prop through the shell as a constant `false`;
|
||||||
constant `false`; Phase 26 flips it on for past-turn snapshots.
|
Phase 26 flips it on for past-turn snapshots.
|
||||||
|
|
||||||
`activeTab` is a `$bindable` prop so the layout can drive it from
|
`activeTab` is a `$bindable` prop so the shell can drive it from
|
||||||
external events (Phase 13 reveals the inspector tab when a planet
|
external events (Phase 13 reveals the inspector tab when a planet
|
||||||
is clicked on the map). The URL seed and the history-mode reset
|
is clicked on the map). The history-mode reset mutates the bindable
|
||||||
both mutate the bindable in place; the layout sees the change
|
in place; the shell sees the change through the binding without
|
||||||
through the binding without extra plumbing.
|
extra plumbing.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import TabBar from "./tab-bar.svelte";
|
import TabBar from "./tab-bar.svelte";
|
||||||
import Calculator from "./calculator-tab.svelte";
|
import Calculator from "./calculator-tab.svelte";
|
||||||
import Inspector from "./inspector-tab.svelte";
|
import Inspector from "./inspector-tab.svelte";
|
||||||
@@ -44,29 +38,11 @@ through the binding without extra plumbing.
|
|||||||
activeTab = $bindable<SidebarTab>("inspector"),
|
activeTab = $bindable<SidebarTab>("inspector"),
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function readUrlSeed(): SidebarTab | null {
|
|
||||||
const v = page.url.searchParams.get("sidebar");
|
|
||||||
if (v === "calc" || v === "calculator") return "calculator";
|
|
||||||
if (v === "inspector") return "inspector";
|
|
||||||
if (v === "order") return "order";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (historyMode && activeTab === "order") {
|
if (historyMode && activeTab === "order") {
|
||||||
activeTab = "inspector";
|
activeTab = "inspector";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const seed = readUrlSeed();
|
|
||||||
if (seed === null) return;
|
|
||||||
if (seed === "order" && historyMode) {
|
|
||||||
activeTab = "inspector";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeTab = seed;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
import "$lib/theme/tokens.css";
|
import "$lib/theme/tokens.css";
|
||||||
import "$lib/theme/base.css";
|
import "$lib/theme/base.css";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import { dev } from "$app/environment";
|
import { dev } from "$app/environment";
|
||||||
import { appBase, withBase } from "$lib/paths";
|
import { withBase } from "$lib/paths";
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
import { eventStream } from "../api/events.svelte";
|
import { eventStream } from "../api/events.svelte";
|
||||||
@@ -77,25 +75,6 @@
|
|||||||
eventStream.stop();
|
eventStream.stop();
|
||||||
streamSessionId = null;
|
streamSessionId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// page.url.pathname includes the configured base path; strip it so
|
|
||||||
// the route comparisons below stay base-agnostic.
|
|
||||||
const pathname = page.url.pathname.slice(appBase.length);
|
|
||||||
// Debug-only routes under /__debug/* run their own bootstrap
|
|
||||||
// path against the storage primitives and must bypass the
|
|
||||||
// auth guard so Phase 6's Playwright spec can drive the
|
|
||||||
// keystore directly.
|
|
||||||
if (pathname.startsWith("/__debug/")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (session.status === "anonymous" && pathname !== "/login") {
|
|
||||||
void goto(withBase("/login"), { replaceState: true });
|
|
||||||
} else if (
|
|
||||||
session.status === "authenticated" &&
|
|
||||||
(pathname === "/login" || pathname === "/")
|
|
||||||
) {
|
|
||||||
void goto(withBase("/lobby"), { replaceState: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,98 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The app root renders no content of its own. The root layout's auth
|
// Single-route screen dispatcher for the app-shell. There are no
|
||||||
// guard redirects "/" to /lobby (authenticated) or /login
|
// per-screen routes: the visible screen is selected from in-memory
|
||||||
// (anonymous); this placeholder only shows for the brief moment
|
// state (`session.status` for the auth gate, `appScreen.screen` for
|
||||||
// before that client-side redirect resolves.
|
// the authenticated screen) rather than from the URL. The root
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
// layout intercepts the `loading` and `unsupported` session states
|
||||||
|
// before this component renders, so here `session.status` is either
|
||||||
|
// `anonymous` (login) or `authenticated` (lobby / create / game).
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { dev } from "$app/environment";
|
||||||
|
import { session } from "$lib/session-store.svelte";
|
||||||
|
import {
|
||||||
|
appScreen,
|
||||||
|
activeView,
|
||||||
|
type AppScreen,
|
||||||
|
type GameView,
|
||||||
|
type GameViewState,
|
||||||
|
} from "$lib/app-nav.svelte";
|
||||||
|
import LoginScreen from "$lib/screens/login-screen.svelte";
|
||||||
|
import LobbyScreen from "$lib/screens/lobby-screen.svelte";
|
||||||
|
import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte";
|
||||||
|
import GameShell from "$lib/game/game-shell.svelte";
|
||||||
|
import { pushState } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
// Dev-only navigation affordance for the Playwright e2e suite. The
|
||||||
|
// single-URL app-shell has no per-screen / per-view routes, so a
|
||||||
|
// spec can no longer drive the UI by `page.goto("/games/:id/:view")`.
|
||||||
|
// Instead the suite seeds the session, loads `/` (which lands on the
|
||||||
|
// authenticated lobby), then calls `window.__galaxyNav.enterGame(...)`
|
||||||
|
// to switch the in-memory screen and view. Guarded by `dev` so it is
|
||||||
|
// stripped from the production bundle — `import.meta.env.DEV` (and the
|
||||||
|
// SvelteKit `dev` re-export) is statically `false` there, so the
|
||||||
|
// whole `onMount` body tree-shakes away.
|
||||||
|
type ViewParams = Omit<GameViewState, "view">;
|
||||||
|
interface NavSurface {
|
||||||
|
enterGame(gameId: string, view?: GameView, params?: ViewParams): void;
|
||||||
|
select(view: GameView, params?: ViewParams): void;
|
||||||
|
go(screen: AppScreen, opts?: { gameId?: string }): void;
|
||||||
|
}
|
||||||
|
type NavWindow = typeof globalThis & { __galaxyNav?: NavSurface };
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!dev) return;
|
||||||
|
(window as NavWindow).__galaxyNav = {
|
||||||
|
enterGame(gameId, view = "map", params = {}): void {
|
||||||
|
activeView.select(view, params);
|
||||||
|
appScreen.go("game", { gameId });
|
||||||
|
},
|
||||||
|
select(view, params = {}): void {
|
||||||
|
activeView.select(view, params);
|
||||||
|
},
|
||||||
|
go(screen, opts = {}): void {
|
||||||
|
appScreen.go(screen, opts);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Screen-level browser history (Back → lobby) without changing the URL.
|
||||||
|
// On the first authenticated render, stamp a restored overlay (game /
|
||||||
|
// lobby-create) on top of the load entry so Back falls through to lobby.
|
||||||
|
let historyStamped = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
if (session.status === "authenticated" && !historyStamped) {
|
||||||
|
historyStamped = true;
|
||||||
|
if (appScreen.screen === "game" && appScreen.gameId !== null) {
|
||||||
|
pushState("", { screen: "game", gameId: appScreen.gameId });
|
||||||
|
} else if (appScreen.screen === "lobby-create") {
|
||||||
|
pushState("", { screen: "lobby-create" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync the store from history on Back/Forward (popstate updates
|
||||||
|
// `page.state`). Skipped until the baseline is stamped so it never
|
||||||
|
// clobbers the restored screen on first render.
|
||||||
|
$effect(() => {
|
||||||
|
if (!historyStamped) return;
|
||||||
|
appScreen.syncFromHistory(page.state.screen, page.state.gameId ?? null);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="status">
|
{#if session.status === "authenticated"}
|
||||||
<p>{i18n.t("common.loading")}</p>
|
{#if appScreen.screen === "lobby-create"}
|
||||||
</main>
|
<LobbyCreateScreen />
|
||||||
|
{:else if appScreen.screen === "game" && appScreen.gameId !== null}
|
||||||
<style>
|
<GameShell />
|
||||||
.status {
|
{:else}
|
||||||
padding: var(--space-6);
|
<!--
|
||||||
font-family: var(--font-sans);
|
Default authenticated screen. Covers `lobby`, a stale `login`
|
||||||
}
|
screen restored from a previous anonymous session, and a `game`
|
||||||
</style>
|
screen with no active game id (a snapshot that lost its id).
|
||||||
|
-->
|
||||||
|
<LobbyScreen />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<LoginScreen />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
// SPA mode for the in-game shell, mirroring the root layout. The
|
|
||||||
// session bootstrap and the auth gate already live in the root
|
|
||||||
// `+layout.svelte`; this layout just inherits the SPA flags so the
|
|
||||||
// static adapter does not try to prerender a per-game shell at build
|
|
||||||
// time.
|
|
||||||
|
|
||||||
export const ssr = false;
|
|
||||||
export const prerender = false;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// A bare `/games/:id` URL is not in the IA section — every in-game
|
|
||||||
// view sits under one of the typed sub-routes (`map`, `table/...`,
|
|
||||||
// etc.). Default the user to the map view so the URL is always
|
|
||||||
// pointing at a real active view; SvelteKit's `redirect` runs in the
|
|
||||||
// browser because the layout disables SSR.
|
|
||||||
|
|
||||||
import { redirect } from "@sveltejs/kit";
|
|
||||||
import type { PageLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load: PageLoad = ({ params }) => {
|
|
||||||
throw redirect(307, `/games/${params.id}/map`);
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import BattleView from "$lib/active-view/battle.svelte";
|
|
||||||
|
|
||||||
const turn = $derived.by(() => {
|
|
||||||
const raw = page.url.searchParams.get("turn");
|
|
||||||
const n = raw === null ? NaN : Number(raw);
|
|
||||||
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : 0;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<BattleView
|
|
||||||
gameId={page.params.id ?? ""}
|
|
||||||
{turn}
|
|
||||||
battleId={page.params.battleId ?? ""}
|
|
||||||
/>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import DesignerScience from "$lib/active-view/designer-science.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<DesignerScience />
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import MailView from "$lib/active-view/mail.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<MailView />
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import MapView from "$lib/active-view/map.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<MapView />
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<!--
|
|
||||||
Phase 23 turn-report route. The orchestrator renders the table of
|
|
||||||
contents and the twenty sections; scroll save/restore is wired
|
|
||||||
through SvelteKit's `Snapshot` API on this route file.
|
|
||||||
`window.scrollY` is captured before navigating away and restored
|
|
||||||
after `afterNavigate` re-mounts the route. The in-game shell
|
|
||||||
layout expands the active-view-host to fit content rather than
|
|
||||||
constraining its own height, so the document body is what scrolls
|
|
||||||
— hence `window.scroll` rather than a host-element scrollTop.
|
|
||||||
|
|
||||||
A short `requestAnimationFrame` poll waits for the body to grow
|
|
||||||
tall enough to honour the saved offset, because the captured
|
|
||||||
position usually exceeds the viewport height before the sections
|
|
||||||
mount on return navigation.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { Snapshot } from "@sveltejs/kit";
|
|
||||||
|
|
||||||
import ReportView from "$lib/active-view/report.svelte";
|
|
||||||
|
|
||||||
function restoreScroll(target: number): void {
|
|
||||||
if (target <= 0) return;
|
|
||||||
let attempts = 60;
|
|
||||||
const tick = (): void => {
|
|
||||||
const need = target + window.innerHeight;
|
|
||||||
const have = document.documentElement.scrollHeight;
|
|
||||||
if (have >= need || attempts === 0) {
|
|
||||||
window.scrollTo(0, target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
attempts -= 1;
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const snapshot: Snapshot<{ scrollY: number }> = {
|
|
||||||
capture() {
|
|
||||||
return { scrollY: window.scrollY };
|
|
||||||
},
|
|
||||||
restore(value) {
|
|
||||||
restoreScroll(value.scrollY);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ReportView />
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import TableView from "$lib/active-view/table.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TableView entity={page.params.entity ?? ""} />
|
|
||||||
@@ -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 GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||||
|
|
||||||
const pageMock = vi.hoisted(() => ({
|
// The science designer reads its target science from the `scienceId`
|
||||||
url: new URL("http://localhost/games/g1/designer/science"),
|
// prop (the single-URL app-shell passes view sub-parameters as props,
|
||||||
params: { id: "g1" } as Record<string, string>,
|
// not URL segments) and returns to the sciences table by switching the
|
||||||
}));
|
// active in-game view via `activeView.select("table", …)`. Mock the
|
||||||
|
// nav store so the spy captures the view switch and no real `pushState`
|
||||||
|
// runs.
|
||||||
|
const activeViewSelectMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
const gotoMock = vi.hoisted(() => vi.fn());
|
vi.mock("$lib/app-nav.svelte", () => ({
|
||||||
|
activeView: { select: activeViewSelectMock },
|
||||||
vi.mock("$app/state", () => ({
|
|
||||||
page: pageMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$app/navigation", () => ({
|
|
||||||
goto: gotoMock,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import DesignerScience from "../src/lib/active-view/designer-science.svelte";
|
import DesignerScience from "../src/lib/active-view/designer-science.svelte";
|
||||||
@@ -62,8 +59,7 @@ beforeEach(async () => {
|
|||||||
draft = new OrderDraftStore();
|
draft = new OrderDraftStore();
|
||||||
await draft.init({ cache, gameId: GAME_ID });
|
await draft.init({ cache, gameId: GAME_ID });
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
pageMock.params = { id: "g1" };
|
activeViewSelectMock.mockClear();
|
||||||
gotoMock.mockClear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -113,9 +109,6 @@ function mountDesigner(opts: {
|
|||||||
report?: GameReport | null;
|
report?: GameReport | null;
|
||||||
}) {
|
}) {
|
||||||
const report = opts.report ?? makeReport();
|
const report = opts.report ?? makeReport();
|
||||||
pageMock.params = opts.scienceId
|
|
||||||
? { id: "g1", scienceId: opts.scienceId }
|
|
||||||
: { id: "g1" };
|
|
||||||
const renderedReport = {
|
const renderedReport = {
|
||||||
get report() {
|
get report() {
|
||||||
return report;
|
return report;
|
||||||
@@ -125,7 +118,10 @@ function mountDesigner(opts: {
|
|||||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||||
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
|
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
|
||||||
]);
|
]);
|
||||||
return render(DesignerScience, { context });
|
return render(DesignerScience, {
|
||||||
|
props: opts.scienceId ? { scienceId: opts.scienceId } : {},
|
||||||
|
context,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("science designer (new mode)", () => {
|
describe("science designer (new mode)", () => {
|
||||||
@@ -172,7 +168,9 @@ describe("science designer (new mode)", () => {
|
|||||||
expect(cmd.shields).toBeCloseTo(0.25, 12);
|
expect(cmd.shields).toBeCloseTo(0.25, 12);
|
||||||
expect(cmd.cargo).toBeCloseTo(0.25, 12);
|
expect(cmd.cargo).toBeCloseTo(0.25, 12);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences"),
|
expect(activeViewSelectMock).toHaveBeenCalledWith("table", {
|
||||||
|
tableEntity: "sciences",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -238,7 +236,9 @@ describe("science designer (new mode)", () => {
|
|||||||
const ui = mountDesigner({});
|
const ui = mountDesigner({});
|
||||||
await fireEvent.click(ui.getByTestId("designer-science-cancel"));
|
await fireEvent.click(ui.getByTestId("designer-science-cancel"));
|
||||||
expect(draft.commands).toHaveLength(0);
|
expect(draft.commands).toHaveLength(0);
|
||||||
expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences");
|
expect(activeViewSelectMock).toHaveBeenCalledWith("table", {
|
||||||
|
tableEntity: "sciences",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -286,7 +286,9 @@ describe("science designer (view mode)", () => {
|
|||||||
if (cmd.kind !== "removeScience") throw new Error("wrong kind");
|
if (cmd.kind !== "removeScience") throw new Error("wrong kind");
|
||||||
expect(cmd.name).toBe("FirstStep");
|
expect(cmd.name).toBe("FirstStep");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences"),
|
expect(activeViewSelectMock).toHaveBeenCalledWith("table", {
|
||||||
|
tableEntity: "sciences",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
// webkit/mobile projects adds cost without new signal).
|
// webkit/mobile projects adds cost without new signal).
|
||||||
//
|
//
|
||||||
// Auth is bootstrapped through `/__debug/store` exactly as the
|
// Auth is bootstrapped through `/__debug/store` exactly as the
|
||||||
// game-shell specs do; the in-game layout tolerates a missing gateway
|
// game-shell specs do; the in-game shell tolerates a missing gateway
|
||||||
// (ECONNREFUSED) and still renders the chrome + view shells, which is
|
// (ECONNREFUSED) and still renders the chrome + view shells, which is
|
||||||
// what the structural a11y scan needs.
|
// what the structural a11y scan needs. Screens and in-game views are
|
||||||
|
// reached through the dev-only `window.__galaxyNav` affordance — the
|
||||||
|
// single-URL app-shell has no per-screen / per-view routes.
|
||||||
|
|
||||||
import AxeBuilder from "@axe-core/playwright";
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import type { GameView, GameViewState } from "../../src/lib/app-nav.svelte";
|
||||||
|
|
||||||
const SESSION_ID = "f2-a11y-axe-session";
|
const SESSION_ID = "f2-a11y-axe-session";
|
||||||
// A real UUID — the layout's auto-sync calls `uuidToHiLo` on it.
|
// A real UUID — the layout's auto-sync calls `uuidToHiLo` on it.
|
||||||
@@ -46,38 +49,77 @@ test.describe("axe WCAG 2.2 AA", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("login", async ({ page }) => {
|
test("login", async ({ page }) => {
|
||||||
await page.goto("/login");
|
// No seeded session → the dispatcher renders the login screen.
|
||||||
|
await page.goto("/");
|
||||||
await expect(page.locator("#main-content")).toBeVisible();
|
await expect(page.locator("#main-content")).toBeVisible();
|
||||||
await expectNoViolations(page);
|
await expectNoViolations(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lobby", async ({ page }) => {
|
test("lobby", async ({ page }) => {
|
||||||
await authenticate(page);
|
await authenticate(page);
|
||||||
await page.goto("/lobby");
|
await page.goto("/");
|
||||||
await expect(page.locator("#main-content")).toBeVisible();
|
await expect(page.locator("#main-content")).toBeVisible();
|
||||||
await expectNoViolations(page);
|
await expectNoViolations(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lobby create", async ({ page }) => {
|
test("lobby create", async ({ page }) => {
|
||||||
await authenticate(page);
|
await authenticate(page);
|
||||||
await page.goto("/lobby/create");
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(() => window.__galaxyNav!.go("lobby-create"));
|
||||||
await expect(page.locator("#main-content")).toBeVisible();
|
await expect(page.locator("#main-content")).toBeVisible();
|
||||||
await expectNoViolations(page);
|
await expectNoViolations(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
const inGameViews: Array<[string, string]> = [
|
type ViewParams = Omit<GameViewState, "view">;
|
||||||
["map", "active-view-map"],
|
const inGameViews: Array<{
|
||||||
["report", "active-view-report"],
|
label: string;
|
||||||
["mail", "active-view-mail"],
|
view: GameView;
|
||||||
["battle", "active-view-battle"],
|
params: ViewParams;
|
||||||
["designer/science", "active-view-designer-science"],
|
testId: string;
|
||||||
["table/planets", "active-view-table"],
|
}> = [
|
||||||
|
{ label: "map", view: "map", params: {}, testId: "active-view-map" },
|
||||||
|
{
|
||||||
|
label: "report",
|
||||||
|
view: "report",
|
||||||
|
params: {},
|
||||||
|
testId: "active-view-report",
|
||||||
|
},
|
||||||
|
{ label: "mail", view: "mail", params: {}, testId: "active-view-mail" },
|
||||||
|
{
|
||||||
|
label: "battle",
|
||||||
|
view: "battle",
|
||||||
|
params: {},
|
||||||
|
testId: "active-view-battle",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "designer/science",
|
||||||
|
view: "designer-science",
|
||||||
|
params: {},
|
||||||
|
testId: "active-view-designer-science",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "table/planets",
|
||||||
|
view: "table",
|
||||||
|
params: { tableEntity: "planets" },
|
||||||
|
testId: "active-view-table",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [path, testId] of inGameViews) {
|
for (const { label, view, params, testId } of inGameViews) {
|
||||||
test(`in-game: ${path}`, async ({ page }) => {
|
test(`in-game: ${label}`, async ({ page }) => {
|
||||||
await authenticate(page);
|
await authenticate(page);
|
||||||
await page.goto(`/games/${GAME_ID}/${path}`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
([id, v, p]) =>
|
||||||
|
window.__galaxyNav!.enterGame(
|
||||||
|
id as string,
|
||||||
|
v as GameView,
|
||||||
|
p as ViewParams,
|
||||||
|
),
|
||||||
|
[GAME_ID, view, params] as const,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("game-shell")).toBeVisible();
|
await expect(page.getByTestId("game-shell")).toBeVisible();
|
||||||
await expect(page.getByTestId(testId)).toBeVisible();
|
await expect(page.getByTestId(testId)).toBeVisible();
|
||||||
await expectNoViolations(page);
|
await expectNoViolations(page);
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ async function bootShell(page: Page): Promise<void> {
|
|||||||
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
|
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
|
||||||
SESSION_ID,
|
SESSION_ID,
|
||||||
);
|
);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("game-shell")).toBeVisible();
|
await expect(page.getByTestId("game-shell")).toBeVisible();
|
||||||
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,10 @@ async function mockGatewayHappyPath(
|
|||||||
|
|
||||||
async function completeLogin(page: Page): Promise<void> {
|
async function completeLogin(page: Page): Promise<void> {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page).toHaveURL(/\/login$/);
|
// The single-URL app-shell renders the login screen from in-memory
|
||||||
|
// state (anonymous session) rather than a `/login` route, so assert
|
||||||
|
// on the visible login form instead of the URL.
|
||||||
|
await expect(page.getByTestId("login-email-input")).toBeVisible();
|
||||||
// Inputs render `readonly` initially as a Safari autofill-suppression
|
// Inputs render `readonly` initially as a Safari autofill-suppression
|
||||||
// workaround; the attribute drops on first focus. Click first so the
|
// workaround; the attribute drops on first focus. Click first so the
|
||||||
// onfocus handler runs before fill checks editability.
|
// onfocus handler runs before fill checks editability.
|
||||||
@@ -156,7 +159,9 @@ async function completeLogin(page: Page): Promise<void> {
|
|||||||
await page.getByTestId("login-code-input").click();
|
await page.getByTestId("login-code-input").click();
|
||||||
await page.getByTestId("login-code-input").fill("123456");
|
await page.getByTestId("login-code-input").fill("123456");
|
||||||
await page.getByTestId("login-code-submit").click();
|
await page.getByTestId("login-code-submit").click();
|
||||||
await expect(page).toHaveURL(/\/lobby$/);
|
// Sign-in switches the in-memory screen to the lobby; the device
|
||||||
|
// session id surfaces only on the lobby screen.
|
||||||
|
await expect(page.getByTestId("device-session-id")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Phase 7 — auth flow", () => {
|
test.describe("Phase 7 — auth flow", () => {
|
||||||
@@ -185,7 +190,8 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
await expect(page.getByTestId("account-greeting")).toBeVisible();
|
await expect(page.getByTestId("account-greeting")).toBeVisible();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page).toHaveURL(/\/lobby$/);
|
// The restored session re-renders the lobby screen directly (no
|
||||||
|
// `/lobby` route to land on).
|
||||||
await expect(page.getByTestId("device-session-id")).toHaveText(
|
await expect(page.getByTestId("device-session-id")).toHaveText(
|
||||||
"dev-test-1",
|
"dev-test-1",
|
||||||
);
|
);
|
||||||
@@ -202,12 +208,16 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
|
|
||||||
// Fire all pending SubscribeEvents requests with an empty 200
|
// Fire all pending SubscribeEvents requests with an empty 200
|
||||||
// response. Connect-Web's server-streaming reader sees no frames
|
// response. Connect-Web's server-streaming reader sees no frames
|
||||||
// and the watcher trips into `signOut("revoked")`, which the
|
// and the watcher trips into `signOut("revoked")`, which flips the
|
||||||
// layout effect turns into a redirect back to /login.
|
// in-memory session to anonymous so the dispatcher re-renders the
|
||||||
|
// login screen (the single-URL app-shell has no `/login` route to
|
||||||
|
// redirect to).
|
||||||
const releaseAt = Date.now();
|
const releaseAt = Date.now();
|
||||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/login$/, { timeout: 1000 });
|
await expect(page.getByTestId("login-email-input")).toBeVisible({
|
||||||
|
timeout: 1000,
|
||||||
|
});
|
||||||
expect(Date.now() - releaseAt).toBeLessThan(1500);
|
expect(Date.now() - releaseAt).toBeLessThan(1500);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -230,7 +240,7 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.goto("/login");
|
await page.goto("/");
|
||||||
await expect(page.getByTestId("login-email-submit")).toHaveText(
|
await expect(page.getByTestId("login-email-submit")).toHaveText(
|
||||||
"send code",
|
"send code",
|
||||||
);
|
);
|
||||||
@@ -287,6 +297,8 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByText(/browser not supported/i)).toBeVisible();
|
await expect(page.getByText(/browser not supported/i)).toBeVisible();
|
||||||
await expect(page).not.toHaveURL(/\/login$/);
|
// The unsupported-browser blocker replaces the screen dispatcher
|
||||||
|
// entirely, so the login form never renders.
|
||||||
|
await expect(page.getByTestId("login-email-input")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -225,16 +225,20 @@ test.describe("Phase 27 battle viewer", () => {
|
|||||||
|
|
||||||
await mockGatewayAndBattle(page);
|
await mockGatewayAndBattle(page);
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/report`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
|
||||||
await expect(page.getByTestId("active-view-report")).toBeVisible();
|
await expect(page.getByTestId("active-view-report")).toBeVisible();
|
||||||
const row = page.getByTestId("report-battle-row").first();
|
const row = page.getByTestId("report-battle-row").first();
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
await row.click();
|
await row.click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(
|
// The battle row switches the active view in place (the address
|
||||||
new RegExp(`/games/${GAME_ID}/battle/${BATTLE_ID}\\?turn=1`),
|
// bar stays at the app base); the viewer chrome is the signal.
|
||||||
);
|
|
||||||
await expect(page.getByTestId("battle-viewer")).toBeVisible();
|
await expect(page.getByTestId("battle-viewer")).toBeVisible();
|
||||||
await expect(page.getByTestId("battle-scene")).toBeVisible();
|
await expect(page.getByTestId("battle-scene")).toBeVisible();
|
||||||
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
|
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
|
||||||
@@ -250,7 +254,13 @@ test.describe("Phase 27 battle viewer", () => {
|
|||||||
|
|
||||||
await mockGatewayAndBattle(page);
|
await mockGatewayAndBattle(page);
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
([id, battleId]) =>
|
||||||
|
window.__galaxyNav!.enterGame(id, "battle", { battleId, turn: 1 }),
|
||||||
|
[GAME_ID, BATTLE_ID] as const,
|
||||||
|
);
|
||||||
|
|
||||||
await expect(page.getByTestId("battle-viewer")).toBeVisible();
|
await expect(page.getByTestId("battle-viewer")).toBeVisible();
|
||||||
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
|
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
|
||||||
@@ -274,8 +284,15 @@ test.describe("Phase 27 battle viewer", () => {
|
|||||||
|
|
||||||
await mockGatewayAndBattle(page);
|
await mockGatewayAndBattle(page);
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(
|
await page.goto("/");
|
||||||
`/games/${GAME_ID}/battle/22222222-2222-2222-2222-222222222222?turn=1`,
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) =>
|
||||||
|
window.__galaxyNav!.enterGame(id, "battle", {
|
||||||
|
battleId: "22222222-2222-2222-2222-222222222222",
|
||||||
|
turn: 1,
|
||||||
|
}),
|
||||||
|
GAME_ID,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(page.getByTestId("battle-not-found")).toBeVisible();
|
await expect(page.getByTestId("battle-not-found")).toBeVisible();
|
||||||
@@ -292,7 +309,13 @@ test.describe("Phase 27 battle viewer", () => {
|
|||||||
await page.setViewportSize({ width: 1280, height: 720 });
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
await mockGatewayAndBattle(page);
|
await mockGatewayAndBattle(page);
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
([id, battleId]) =>
|
||||||
|
window.__galaxyNav!.enterGame(id, "battle", { battleId, turn: 1 }),
|
||||||
|
[GAME_ID, BATTLE_ID] as const,
|
||||||
|
);
|
||||||
|
|
||||||
await expect(page.getByTestId("battle-viewer")).toBeVisible();
|
await expect(page.getByTestId("battle-viewer")).toBeVisible();
|
||||||
await expect(page.getByTestId("battle-scene")).toBeVisible();
|
await expect(page.getByTestId("battle-scene")).toBeVisible();
|
||||||
|
|||||||
@@ -378,7 +378,12 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
|
|||||||
|
|
||||||
const handle = await mockGateway(page);
|
const handle = await mockGateway(page);
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -138,7 +138,12 @@ async function setupShell(page: Page): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -171,7 +171,12 @@ test("map view renders the reported turn and planet count from a live report", a
|
|||||||
});
|
});
|
||||||
|
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
@@ -201,7 +206,12 @@ test("zero-planet game renders the empty world without errors", async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
@@ -216,12 +226,15 @@ test("zero-planet game renders the empty world without errors", async ({
|
|||||||
await expect(page.getByTestId("map-mount-error")).not.toBeVisible();
|
await expect(page.getByTestId("map-mount-error")).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("missing-membership game surfaces an error instead of a blank canvas", async ({
|
test("missing-membership game drops back to the lobby with an unavailable toast", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// The gateway returns lobby.my.games.list with a different game id
|
// The gateway returns lobby.my.games.list with a different game id
|
||||||
// so the layout's gameState lookup misses; the store flips to
|
// so the shell's gameState lookup misses. In the single-URL
|
||||||
// `error` and the map view renders the localised error overlay.
|
// app-shell this `notFound` case no longer strands the player on an
|
||||||
|
// in-game error overlay — the shell switches the screen back to the
|
||||||
|
// lobby (`appScreen.go("lobby")`) and surfaces the "no longer
|
||||||
|
// available" toast.
|
||||||
await mockGateway(page, {
|
await mockGateway(page, {
|
||||||
currentTurn: 0,
|
currentTurn: 0,
|
||||||
gameId: "99999999-aaaa-bbbb-cccc-000000000000",
|
gameId: "99999999-aaaa-bbbb-cccc-000000000000",
|
||||||
@@ -229,11 +242,17 @@ test("missing-membership game surfaces an error instead of a blank canvas", asyn
|
|||||||
});
|
});
|
||||||
|
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
await expect(page.getByTestId("map-error")).toBeVisible();
|
await page.evaluate(
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
"data-status",
|
GAME_ID,
|
||||||
"error",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Back on the lobby (game shell unmounted), with the unavailable toast.
|
||||||
|
await expect(page.getByTestId("toast")).toContainText(
|
||||||
|
"this game is no longer available",
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("lobby-create-button")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("game-shell")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,18 +2,19 @@
|
|||||||
// boots an authenticated session through `/__debug/store` (the
|
// boots an authenticated session through `/__debug/store` (the
|
||||||
// in-game shell makes a handful of gateway calls — for the lobby
|
// in-game shell makes a handful of gateway calls — for the lobby
|
||||||
// record, the report, and the order read-back; we don't mock them
|
// record, the report, and the order read-back; we don't mock them
|
||||||
// here, the shell tolerates ECONNREFUSED), navigates into
|
// here, the shell tolerates ECONNREFUSED), enters the game through
|
||||||
// `/games/<game-id>/map`, and exercises one slice of the chrome:
|
// the dev-only `window.__galaxyNav` affordance (the single-URL
|
||||||
|
// app-shell has no `/games/<id>/<view>` route — the address bar
|
||||||
|
// stays at the app base), and exercises one slice of the chrome:
|
||||||
// header navigation, sidebar tab preservation, mobile bottom-tabs,
|
// header navigation, sidebar tab preservation, mobile bottom-tabs,
|
||||||
// and the breakpoint switches at 768 / 1024 px.
|
// and the breakpoint switches at 768 / 1024 px.
|
||||||
|
|
||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
// The `window.__galaxyDebug` surface is owned by
|
// `window.__galaxyDebug` is owned by `src/routes/__debug/store/+page.svelte`
|
||||||
// `src/routes/__debug/store/+page.svelte` and typed by
|
// (auth bootstrap) and `window.__galaxyNav` by `src/routes/+page.svelte`
|
||||||
// `tests/e2e/storage-keypair-persistence.spec.ts`. This spec only
|
// (dev-only screen/view driver); both are typed by
|
||||||
// needs the auth-bootstrap subset (`clearSession`,
|
// `tests/e2e/storage-keypair-persistence.spec.ts`.
|
||||||
// `setDeviceSessionId`); the merged global declaration covers both.
|
|
||||||
|
|
||||||
const SESSION_ID = "phase-10-shell-session";
|
const SESSION_ID = "phase-10-shell-session";
|
||||||
// GAME_ID has to be a real UUID — Phase 14's auto-sync calls
|
// GAME_ID has to be a real UUID — Phase 14's auto-sync calls
|
||||||
@@ -30,7 +31,14 @@ async function bootShell(page: Page): Promise<void> {
|
|||||||
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
|
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
|
||||||
SESSION_ID,
|
SESSION_ID,
|
||||||
);
|
);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
// Load the app (seeded session → authenticated → lobby), then enter
|
||||||
|
// the game via the in-memory nav affordance.
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("game-shell")).toBeVisible();
|
await expect(page.getByTestId("game-shell")).toBeVisible();
|
||||||
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -50,23 +58,20 @@ test("shell mounts with header / sidebar / active-view chrome", async ({
|
|||||||
test("header view-menu navigates to every active view", async ({ page }) => {
|
test("header view-menu navigates to every active view", async ({ page }) => {
|
||||||
await bootShell(page);
|
await bootShell(page);
|
||||||
|
|
||||||
const destinations: Array<[string, string, string]> = [
|
// The address bar stays at the app base in the single-URL app-shell,
|
||||||
["view-menu-item-report", "active-view-report", "/report"],
|
// so the visible active view is the only navigation signal to assert.
|
||||||
["view-menu-item-mail", "active-view-mail", "/mail"],
|
const destinations: Array<[string, string]> = [
|
||||||
["view-menu-item-battle", "active-view-battle", "/battle"],
|
["view-menu-item-report", "active-view-report"],
|
||||||
[
|
["view-menu-item-mail", "active-view-mail"],
|
||||||
"view-menu-item-designer-science",
|
["view-menu-item-battle", "active-view-battle"],
|
||||||
"active-view-designer-science",
|
["view-menu-item-designer-science", "active-view-designer-science"],
|
||||||
"/designer/science",
|
["view-menu-item-map", "active-view-map"],
|
||||||
],
|
|
||||||
["view-menu-item-map", "active-view-map", "/map"],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [trigger, viewTestId, urlSuffix] of destinations) {
|
for (const [trigger, viewTestId] of destinations) {
|
||||||
await page.getByTestId("view-menu-trigger").click();
|
await page.getByTestId("view-menu-trigger").click();
|
||||||
await page.getByTestId(trigger).click();
|
await page.getByTestId(trigger).click();
|
||||||
await expect(page.getByTestId(viewTestId)).toBeVisible();
|
await expect(page.getByTestId(viewTestId)).toBeVisible();
|
||||||
await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}${urlSuffix}$`));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,9 +97,6 @@ test("header view-menu Tables sub-list navigates to every entity", async ({
|
|||||||
const view = page.getByTestId("active-view-table");
|
const view = page.getByTestId("active-view-table");
|
||||||
await expect(view).toBeVisible();
|
await expect(view).toBeVisible();
|
||||||
await expect(view).toHaveAttribute("data-entity", entity);
|
await expect(view).toHaveAttribute("data-entity", entity);
|
||||||
await expect(page).toHaveURL(
|
|
||||||
new RegExp(`/games/${GAME_ID}/table/${entity}$`),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,15 @@ test("navigating to a past turn enters history mode and back-to-current restores
|
|||||||
const state = await mockGateway(page);
|
const state = await mockGateway(page);
|
||||||
await seedShell(page);
|
await seedShell(page);
|
||||||
|
|
||||||
await page.goto(`/games/${GAME_ID}/table/planets`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) =>
|
||||||
|
window.__galaxyNav!.enterGame(id, "table", {
|
||||||
|
tableEntity: "planets",
|
||||||
|
}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("turn-navigator-trigger")).toContainText(
|
await expect(page.getByTestId("turn-navigator-trigger")).toContainText(
|
||||||
`turn ${CURRENT_TURN}`,
|
`turn ${CURRENT_TURN}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -136,7 +136,9 @@ test("synthetic-report loader navigates from lobby to map and renders", async ({
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await seedSession(page);
|
await seedSession(page);
|
||||||
await page.goto("/lobby");
|
// Seeded session → the dispatcher renders the lobby; the synthetic
|
||||||
|
// loader lives there behind the dev-affordances flag.
|
||||||
|
await page.goto("/");
|
||||||
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
|
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
|
||||||
|
|
||||||
const file = page.getByTestId("lobby-synthetic-file");
|
const file = page.getByTestId("lobby-synthetic-file");
|
||||||
@@ -146,9 +148,10 @@ test("synthetic-report loader navigates from lobby to map and renders", async ({
|
|||||||
buffer: Buffer.from(JSON.stringify(SYNTHETIC_REPORT_FIXTURE)),
|
buffer: Buffer.from(JSON.stringify(SYNTHETIC_REPORT_FIXTURE)),
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, {
|
// Loading the report enters the game in place (the address bar stays
|
||||||
timeout: 5_000,
|
// at the app base); the in-game map shell is the visible signal.
|
||||||
});
|
await expect(page.getByTestId("game-shell")).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
||||||
|
|
||||||
// The renderer canvas mounts inside the active-view host. Even if
|
// The renderer canvas mounts inside the active-view host. Even if
|
||||||
// the WebGL/WebGPU backend is unavailable in CI, the layout still
|
// the WebGL/WebGPU backend is unavailable in CI, the layout still
|
||||||
|
|||||||
@@ -235,7 +235,9 @@ async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promi
|
|||||||
|
|
||||||
async function completeLogin(page: Page): Promise<void> {
|
async function completeLogin(page: Page): Promise<void> {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page).toHaveURL(/\/login$/);
|
// The single-URL app-shell renders the login screen from in-memory
|
||||||
|
// state (anonymous session); there is no `/login` route to assert.
|
||||||
|
await expect(page.getByTestId("login-email-input")).toBeVisible();
|
||||||
// The login page renders the inputs `readonly` as a Safari
|
// The login page renders the inputs `readonly` as a Safari
|
||||||
// autofill-suppression workaround; the readonly attribute is
|
// autofill-suppression workaround; the readonly attribute is
|
||||||
// dropped on first focus. Playwright's `fill()` checks editability
|
// dropped on first focus. Playwright's `fill()` checks editability
|
||||||
@@ -248,7 +250,8 @@ async function completeLogin(page: Page): Promise<void> {
|
|||||||
await page.getByTestId("login-code-input").click();
|
await page.getByTestId("login-code-input").click();
|
||||||
await page.getByTestId("login-code-input").fill("123456");
|
await page.getByTestId("login-code-input").fill("123456");
|
||||||
await page.getByTestId("login-code-submit").click();
|
await page.getByTestId("login-code-submit").click();
|
||||||
await expect(page).toHaveURL(/\/lobby$/);
|
// Sign-in switches the in-memory screen to the lobby.
|
||||||
|
await expect(page.getByTestId("device-session-id")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Phase 8 — lobby flow", () => {
|
test.describe("Phase 8 — lobby flow", () => {
|
||||||
@@ -260,7 +263,9 @@ test.describe("Phase 8 — lobby flow", () => {
|
|||||||
await expect(page.getByTestId("lobby-public-games-empty")).toBeVisible();
|
await expect(page.getByTestId("lobby-public-games-empty")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("lobby-create-button").click();
|
await page.getByTestId("lobby-create-button").click();
|
||||||
await expect(page).toHaveURL(/\/lobby\/create$/);
|
// The create screen replaces the lobby in place (no `/lobby/create`
|
||||||
|
// route); the create form is the visible signal.
|
||||||
|
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("lobby-create-game-name").click();
|
await page.getByTestId("lobby-create-game-name").click();
|
||||||
await page.getByTestId("lobby-create-game-name").fill("First Contact");
|
await page.getByTestId("lobby-create-game-name").fill("First Contact");
|
||||||
@@ -271,7 +276,8 @@ test.describe("Phase 8 — lobby flow", () => {
|
|||||||
.fill("2026-06-01T12:00");
|
.fill("2026-06-01T12:00");
|
||||||
await page.getByTestId("lobby-create-submit").click();
|
await page.getByTestId("lobby-create-submit").click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/lobby$/);
|
// Submit returns to the lobby in place; the new game card is the
|
||||||
|
// visible signal that the lobby re-rendered.
|
||||||
await expect(page.getByTestId("lobby-my-game-card")).toContainText("First Contact");
|
await expect(page.getByTestId("lobby-my-game-card")).toContainText("First Contact");
|
||||||
expect(mocks.createGameCalls.length).toBe(1);
|
expect(mocks.createGameCalls.length).toBe(1);
|
||||||
expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact");
|
expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact");
|
||||||
|
|||||||
@@ -187,7 +187,12 @@ for (const view of NON_MAP_VIEWS) {
|
|||||||
await mockGateway(page);
|
await mockGateway(page);
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
|
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -202,7 +202,12 @@ async function bootSession(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openGame(page: Page): Promise<void> {
|
async function openGame(page: Page): Promise<void> {
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
@@ -383,7 +388,11 @@ test("toggle state persists across a page reload", async ({ page }) => {
|
|||||||
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
|
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|
||||||
await page.reload();
|
// The restored `game` screen re-stamps history via shallow routing
|
||||||
|
// on first render; wait only for the navigation to commit so that
|
||||||
|
// `pushState` does not abort a default `reload()` (which waits for
|
||||||
|
// `load`).
|
||||||
|
await page.reload({ waitUntil: "commit" });
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
// Phase 12 end-to-end coverage for the order composer skeleton. The
|
// Phase 12 end-to-end coverage for the order composer skeleton. The
|
||||||
// shell makes no gateway calls in this spec — the boot flow seeds an
|
// boot flow seeds an authenticated session and a draft directly
|
||||||
// authenticated session and a draft directly through `/__debug/store`,
|
// through `/__debug/store`, then enters the game via the dev-only
|
||||||
// then navigates into `/games/<id>/map` and exercises the order tab.
|
// `window.__galaxyNav` affordance (the single-URL app-shell has no
|
||||||
|
// `/games/<id>/<view>` route) and exercises the order tab.
|
||||||
|
//
|
||||||
|
// The shell's per-game bootstrap now talks to the gateway on entry
|
||||||
|
// (lobby validation, report, order read-back). This spec does not
|
||||||
|
// stand up a real gateway, so those Connect-Web calls are aborted via
|
||||||
|
// `page.route` — the shell tolerates the failure (cache fallback +
|
||||||
|
// `failBootstrap`) and still renders the chrome. Aborting also keeps
|
||||||
|
// a mid-spec `page.reload()` from hanging: an unrouted `/rpc` call
|
||||||
|
// to a dead proxy never settles, which otherwise stalls the reload's
|
||||||
|
// load event.
|
||||||
//
|
//
|
||||||
// Persistence is covered by reloading the page mid-spec: the
|
// Persistence is covered by reloading the page mid-spec: the
|
||||||
// `OrderDraftStore` re-reads the same cache row on the next mount,
|
// `OrderDraftStore` re-reads the same cache row on the next mount,
|
||||||
@@ -10,12 +20,31 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
// `window.__galaxyDebug` is owned by `routes/__debug/store/+page.svelte`
|
// `window.__galaxyDebug` is owned by `routes/__debug/store/+page.svelte`
|
||||||
// and typed by `tests/e2e/storage-keypair-persistence.spec.ts`. The
|
// and `window.__galaxyNav` by `routes/+page.svelte`; both are typed by
|
||||||
// merged global declaration covers every helper this spec calls.
|
// `tests/e2e/storage-keypair-persistence.spec.ts`.
|
||||||
|
|
||||||
const SESSION_ID = "phase-12-order-session";
|
const SESSION_ID = "phase-12-order-session";
|
||||||
const GAME_ID = "test-order";
|
const GAME_ID = "test-order";
|
||||||
|
|
||||||
|
// Fail-fast the shell's gateway calls so the spec needs no real
|
||||||
|
// backend and reloads settle promptly.
|
||||||
|
async function stubGateway(page: Page): Promise<void> {
|
||||||
|
await page.route("**/edge.v1.Gateway/**", (route) => route.abort());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the app (seeded session → authenticated lobby) and enter the
|
||||||
|
// game on the map view through the in-memory nav affordance.
|
||||||
|
async function enterGameMap(page: Page): Promise<void> {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("game-shell")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
const SEED = [
|
const SEED = [
|
||||||
{ kind: "placeholder" as const, id: "cmd-a", label: "first command" },
|
{ kind: "placeholder" as const, id: "cmd-a", label: "first command" },
|
||||||
{ kind: "placeholder" as const, id: "cmd-b", label: "second command" },
|
{ kind: "placeholder" as const, id: "cmd-b", label: "second command" },
|
||||||
@@ -23,6 +52,7 @@ const SEED = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
async function bootDebug(page: Page): Promise<void> {
|
async function bootDebug(page: Page): Promise<void> {
|
||||||
|
await stubGateway(page);
|
||||||
await page.goto("/__debug/store");
|
await page.goto("/__debug/store");
|
||||||
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
|
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
|
||||||
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
|
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
|
||||||
@@ -71,14 +101,18 @@ test("seeded draft renders on the order tab and survives a reload", async ({
|
|||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const isMobile = testInfo.project.name.startsWith("chromium-mobile");
|
const isMobile = testInfo.project.name.startsWith("chromium-mobile");
|
||||||
await seedShell(page);
|
await seedShell(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await enterGameMap(page);
|
||||||
await expect(page.getByTestId("game-shell")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
|
||||||
|
|
||||||
await openOrderTool(page, isMobile);
|
await openOrderTool(page, isMobile);
|
||||||
await expectSeededRows(page);
|
await expectSeededRows(page);
|
||||||
|
|
||||||
await page.reload();
|
// Reload restores the `game` screen from the persisted nav snapshot,
|
||||||
|
// whose first authenticated render re-stamps screen history via
|
||||||
|
// SvelteKit shallow routing. That `pushState` lands right after the
|
||||||
|
// document loads and would abort a default `reload()` (which waits
|
||||||
|
// for `load`); waiting only for the navigation to commit sidesteps
|
||||||
|
// the race while still re-executing the app from scratch.
|
||||||
|
await page.reload({ waitUntil: "commit" });
|
||||||
await expect(page.getByTestId("game-shell")).toBeVisible();
|
await expect(page.getByTestId("game-shell")).toBeVisible();
|
||||||
await openOrderTool(page, isMobile);
|
await openOrderTool(page, isMobile);
|
||||||
await expectSeededRows(page);
|
await expectSeededRows(page);
|
||||||
@@ -89,8 +123,7 @@ test("removing a command from the order tab persists the removal", async ({
|
|||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const isMobile = testInfo.project.name.startsWith("chromium-mobile");
|
const isMobile = testInfo.project.name.startsWith("chromium-mobile");
|
||||||
await seedShell(page);
|
await seedShell(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await enterGameMap(page);
|
||||||
await expect(page.getByTestId("game-shell")).toBeVisible();
|
|
||||||
await openOrderTool(page, isMobile);
|
await openOrderTool(page, isMobile);
|
||||||
|
|
||||||
await expect(page.getByTestId("order-command-1")).toBeVisible();
|
await expect(page.getByTestId("order-command-1")).toBeVisible();
|
||||||
@@ -104,7 +137,10 @@ test("removing a command from the order tab persists the removal", async ({
|
|||||||
);
|
);
|
||||||
await expect(page.getByTestId("order-command-2")).toHaveCount(0);
|
await expect(page.getByTestId("order-command-2")).toHaveCount(0);
|
||||||
|
|
||||||
await page.reload();
|
// See the note on the sibling test: the restored `game` screen
|
||||||
|
// re-stamps history on reload, so wait only for the navigation to
|
||||||
|
// commit to avoid the shallow-routing `pushState` aborting it.
|
||||||
|
await page.reload({ waitUntil: "commit" });
|
||||||
await expect(page.getByTestId("game-shell")).toBeVisible();
|
await expect(page.getByTestId("game-shell")).toBeVisible();
|
||||||
await openOrderTool(page, isMobile);
|
await openOrderTool(page, isMobile);
|
||||||
await expect(page.getByTestId("order-command-label-0")).toHaveText(
|
await expect(page.getByTestId("order-command-label-0")).toHaveText(
|
||||||
@@ -131,8 +167,7 @@ test("empty draft renders the empty-state copy", async ({
|
|||||||
GAME_ID,
|
GAME_ID,
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await enterGameMap(page);
|
||||||
await expect(page.getByTestId("game-shell")).toBeVisible();
|
|
||||||
await openOrderTool(page, isMobile);
|
await openOrderTool(page, isMobile);
|
||||||
|
|
||||||
await expect(page.getByTestId("order-empty")).toBeVisible();
|
await expect(page.getByTestId("order-empty")).toBeVisible();
|
||||||
|
|||||||
@@ -278,7 +278,12 @@ test("turn_already_closed surfaces the conflict banner on the order tab", async
|
|||||||
);
|
);
|
||||||
await mockGateway(page, { initialSubmitVerdict: "turn_already_closed" });
|
await mockGateway(page, { initialSubmitVerdict: "turn_already_closed" });
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
@@ -322,7 +327,12 @@ test("game.paused push frame surfaces the paused banner", async ({
|
|||||||
subscribeFrame: { eventType: "game.paused", payload },
|
subscribeFrame: { eventType: "game.paused", payload },
|
||||||
});
|
});
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -286,7 +286,12 @@ test("switching production three times collapses to one auto-synced row", async
|
|||||||
|
|
||||||
const handle = await mockGateway(page);
|
const handle = await mockGateway(page);
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
@@ -357,10 +362,13 @@ test("switching production three times collapses to one auto-synced row", async
|
|||||||
expect(handle.lastSubmitted!.subject).toBe(SHIP_CLASS);
|
expect(handle.lastSubmitted!.subject).toBe(SHIP_CLASS);
|
||||||
expect(handle.submitCount).toBeGreaterThanOrEqual(3);
|
expect(handle.submitCount).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
// Reload: the layout polls user.games.order.get on boot, so the
|
// Reload: the shell polls user.games.order.get on boot, so the
|
||||||
// row is restored from the server's stored state even when the
|
// row is restored from the server's stored state even when the
|
||||||
// local cache is wiped.
|
// local cache is wiped. The restored `game` screen re-stamps
|
||||||
await page.reload();
|
// history via shallow routing on first render, so wait only for the
|
||||||
|
// navigation to commit (a default `reload()` waiting for `load`
|
||||||
|
// races that `pushState` and aborts).
|
||||||
|
await page.reload({ waitUntil: "commit" });
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -280,7 +280,12 @@ test("toggle stance and pick a vote target via the races table", async ({
|
|||||||
|
|
||||||
const handle = await mockGateway(page);
|
const handle = await mockGateway(page);
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/table/races`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "table", { tableEntity: "races" }),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
|
||||||
const tableHost = page.getByTestId("active-view-table");
|
const tableHost = page.getByTestId("active-view-table");
|
||||||
await expect(tableHost).toBeVisible();
|
await expect(tableHost).toBeVisible();
|
||||||
|
|||||||
@@ -230,7 +230,12 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async
|
|||||||
submitOutcome: "applied",
|
submitOutcome: "applied",
|
||||||
});
|
});
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
@@ -266,10 +271,13 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async
|
|||||||
);
|
);
|
||||||
expect(handle.submittedRenameName).toBe("New-Earth");
|
expect(handle.submittedRenameName).toBe("New-Earth");
|
||||||
|
|
||||||
// Reload: the layout always polls user.games.order.get on boot,
|
// Reload: the shell always polls user.games.order.get on boot,
|
||||||
// so the overlay is rebuilt from the server's stored order even
|
// so the overlay is rebuilt from the server's stored order even
|
||||||
// when the local cache was wiped.
|
// when the local cache was wiped. The restored `game` screen
|
||||||
await page.reload();
|
// re-stamps history via shallow routing on first render, so wait
|
||||||
|
// only for the navigation to commit (a default `reload()` waiting
|
||||||
|
// for `load` races that `pushState` and aborts).
|
||||||
|
await page.reload({ waitUntil: "commit" });
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
@@ -292,7 +300,12 @@ test("rejected submit keeps the old name and surfaces the failure", async ({
|
|||||||
submitOutcome: "rejected",
|
submitOutcome: "rejected",
|
||||||
});
|
});
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -6,10 +6,8 @@
|
|||||||
// 1. Every TOC anchor click scrolls the matching section into view
|
// 1. Every TOC anchor click scrolls the matching section into view
|
||||||
// and the section is present in the DOM with at least one row
|
// and the section is present in the DOM with at least one row
|
||||||
// (or its empty-state copy when it is intentionally empty).
|
// (or its empty-state copy when it is intentionally empty).
|
||||||
// 2. Snapshot save/restore on the active-view-host scroll
|
// 2. The "back to map" button switches to the map view.
|
||||||
// container survives a /map navigation round-trip.
|
// 3. The mobile <select> fallback scrolls a section into view on
|
||||||
// 3. The "back to map" button navigates to the map URL.
|
|
||||||
// 4. The mobile <select> fallback scrolls a section into view on
|
|
||||||
// a narrow viewport.
|
// a narrow viewport.
|
||||||
|
|
||||||
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
||||||
@@ -238,7 +236,12 @@ test.describe("Phase 23 report view", () => {
|
|||||||
|
|
||||||
await mockGateway(page);
|
await mockGateway(page);
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/report`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
|
||||||
await expect(page.getByTestId("active-view-report")).toBeVisible();
|
await expect(page.getByTestId("active-view-report")).toBeVisible();
|
||||||
await expect(page.getByTestId("report-toc")).toBeVisible();
|
await expect(page.getByTestId("report-toc")).toBeVisible();
|
||||||
@@ -265,65 +268,19 @@ test.describe("Phase 23 report view", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("scroll position survives a /map round-trip via Snapshot", async ({
|
// NOTE: the old "scroll position survives a /map round-trip via
|
||||||
page,
|
// Snapshot" spec was dropped here. It exercised the per-route
|
||||||
}, testInfo) => {
|
// SvelteKit `Snapshot` exported by the deleted
|
||||||
test.skip(
|
// `routes/games/[id]/report/+page.svelte`, which captured and
|
||||||
testInfo.project.name.startsWith("chromium-mobile"),
|
// restored `window.scrollY` across a browser history navigation to
|
||||||
"snapshot mechanism is the same on mobile; one project is enough",
|
// `/map` and back. The single-URL app-shell switches the active view
|
||||||
);
|
// in memory (`activeView.select`) without changing the URL or pushing
|
||||||
|
// a history entry, and it remounts the report component on return —
|
||||||
|
// so neither the URL round-trip, the `page.goBack()`, nor the
|
||||||
|
// scroll-restoration the test asserted exist any more. Re-adding that
|
||||||
|
// behaviour would be a production change outside this test migration.
|
||||||
|
|
||||||
await mockGateway(page);
|
test("back-to-map button switches to the map view", async ({
|
||||||
await bootSession(page);
|
|
||||||
await page.goto(`/games/${GAME_ID}/report`);
|
|
||||||
|
|
||||||
await expect(page.getByTestId("active-view-report")).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByTestId("galaxy-summary-field-turn"),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Scroll the window. The report's host expands to fit
|
|
||||||
// content rather than constraining its own height, so the
|
|
||||||
// document body is the real scroll container. SvelteKit's
|
|
||||||
// default scroll-restoration tracks `window.scrollY` on
|
|
||||||
// history navigation, which is what the acceptance criterion
|
|
||||||
// — "scroll position resets when switching to another view
|
|
||||||
// and is restored on return" — requires.
|
|
||||||
const target = 600;
|
|
||||||
await page.evaluate((value) => {
|
|
||||||
window.scrollTo(0, value);
|
|
||||||
}, target);
|
|
||||||
const savedScrollY = await page.evaluate(() => window.scrollY);
|
|
||||||
expect(savedScrollY).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Programmatically click the back-to-map button. Driving the
|
|
||||||
// click through `evaluate` rather than the Playwright locator
|
|
||||||
// skips its built-in scrollIntoViewIfNeeded(), which would
|
|
||||||
// otherwise scroll the sticky TOC button into view and reset
|
|
||||||
// `window.scrollY` to 0 before SvelteKit's Snapshot capture
|
|
||||||
// fires.
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const button = document.querySelector(
|
|
||||||
"[data-testid='report-back-to-map']",
|
|
||||||
) as HTMLButtonElement | null;
|
|
||||||
button?.click();
|
|
||||||
});
|
|
||||||
await page.waitForURL(`**/games/${GAME_ID}/map`);
|
|
||||||
await page.goBack();
|
|
||||||
await page.waitForURL(`**/games/${GAME_ID}/report`);
|
|
||||||
await expect(page.getByTestId("active-view-report")).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByTestId("galaxy-summary-field-turn"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect
|
|
||||||
.poll(async () => page.evaluate(() => window.scrollY), {
|
|
||||||
timeout: 5_000,
|
|
||||||
intervals: [100, 200, 400],
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(savedScrollY / 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("back-to-map button navigates to the map URL", async ({
|
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
test.skip(
|
test.skip(
|
||||||
@@ -333,10 +290,16 @@ test.describe("Phase 23 report view", () => {
|
|||||||
|
|
||||||
await mockGateway(page);
|
await mockGateway(page);
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/report`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
|
||||||
await page.getByTestId("report-back-to-map").click();
|
await page.getByTestId("report-back-to-map").click();
|
||||||
await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}/map$`));
|
// The single-URL app-shell keeps the address bar at the app base;
|
||||||
|
// the active map view is the navigation signal.
|
||||||
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,7 +313,12 @@ test.describe("Phase 23 report view", () => {
|
|||||||
|
|
||||||
await mockGateway(page);
|
await mockGateway(page);
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/report`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
|
||||||
const mobileSelect = page.getByTestId("report-toc-mobile");
|
const mobileSelect = page.getByTestId("report-toc-mobile");
|
||||||
await expect(mobileSelect).toBeVisible();
|
await expect(mobileSelect).toBeVisible();
|
||||||
|
|||||||
@@ -197,7 +197,12 @@ test("returning to /map after creating a science keeps the renderer alive", asyn
|
|||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
|
|
||||||
// Step 1: open /map and let the renderer mount cleanly.
|
// Step 1: open /map and let the renderer mount cleanly.
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -286,7 +286,15 @@ test("create / list / delete science via the table + designer", async ({
|
|||||||
|
|
||||||
const handle = await mockGateway(page, { createOutcome: "applied" });
|
const handle = await mockGateway(page, { createOutcome: "applied" });
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/table/sciences`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) =>
|
||||||
|
window.__galaxyNav!.enterGame(id, "table", {
|
||||||
|
tableEntity: "sciences",
|
||||||
|
}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
|
||||||
const tableHost = page.getByTestId("active-view-table");
|
const tableHost = page.getByTestId("active-view-table");
|
||||||
await expect(tableHost).toBeVisible();
|
await expect(tableHost).toBeVisible();
|
||||||
@@ -347,7 +355,12 @@ test("designer keeps Save disabled while the form is invalid", async ({
|
|||||||
|
|
||||||
await mockGateway(page, { createOutcome: "applied" });
|
await mockGateway(page, { createOutcome: "applied" });
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/designer/science`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "designer-science", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
|
||||||
const save = page.getByTestId("designer-science-save");
|
const save = page.getByTestId("designer-science-save");
|
||||||
await expect(save).toBeDisabled();
|
await expect(save).toBeDisabled();
|
||||||
@@ -385,7 +398,12 @@ test("planet production picker exposes user sciences in the Research sub-row", a
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -264,7 +264,15 @@ test("create / list / delete ship class via the table + calculator", async ({
|
|||||||
|
|
||||||
const handle = await mockGateway(page, { createOutcome: "applied" });
|
const handle = await mockGateway(page, { createOutcome: "applied" });
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/table/ship-classes`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) =>
|
||||||
|
window.__galaxyNav!.enterGame(id, "table", {
|
||||||
|
tableEntity: "ship-classes",
|
||||||
|
}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
|
||||||
const tableHost = page.getByTestId("active-view-table");
|
const tableHost = page.getByTestId("active-view-table");
|
||||||
await expect(tableHost).toBeVisible();
|
await expect(tableHost).toBeVisible();
|
||||||
@@ -321,7 +329,15 @@ test("calculator keeps Create disabled while the design is invalid", async ({
|
|||||||
|
|
||||||
await mockGateway(page, { createOutcome: "applied" });
|
await mockGateway(page, { createOutcome: "applied" });
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/table/ship-classes`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) =>
|
||||||
|
window.__galaxyNav!.enterGame(id, "table", {
|
||||||
|
tableEntity: "ship-classes",
|
||||||
|
}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await page.getByTestId("ship-classes-new").click();
|
await page.getByTestId("ship-classes-new").click();
|
||||||
const calc = page.getByTestId("sidebar-tool-calculator");
|
const calc = page.getByTestId("sidebar-tool-calculator");
|
||||||
const create = calc.getByTestId("calculator-create");
|
const create = calc.getByTestId("calculator-create");
|
||||||
@@ -350,7 +366,15 @@ test("rejected createShipClass keeps the table empty and surfaces the failure",
|
|||||||
|
|
||||||
await mockGateway(page, { createOutcome: "rejected" });
|
await mockGateway(page, { createOutcome: "rejected" });
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/table/ship-classes`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) =>
|
||||||
|
window.__galaxyNav!.enterGame(id, "table", {
|
||||||
|
tableEntity: "ship-classes",
|
||||||
|
}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
await page.getByTestId("ship-classes-new").click();
|
await page.getByTestId("ship-classes-new").click();
|
||||||
const calc = page.getByTestId("sidebar-tool-calculator");
|
const calc = page.getByTestId("sidebar-tool-calculator");
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ async function bootSession(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSyntheticGame(page: Page): Promise<void> {
|
async function loadSyntheticGame(page: Page): Promise<void> {
|
||||||
await page.goto("/lobby");
|
// Seeded session → the dispatcher renders the lobby; the synthetic
|
||||||
|
// loader lives there behind the dev-affordances flag.
|
||||||
|
await page.goto("/");
|
||||||
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
|
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
|
||||||
const file = page.getByTestId("lobby-synthetic-file");
|
const file = page.getByTestId("lobby-synthetic-file");
|
||||||
await file.setInputFiles({
|
await file.setInputFiles({
|
||||||
@@ -143,9 +145,9 @@ async function loadSyntheticGame(page: Page): Promise<void> {
|
|||||||
mimeType: "application/json",
|
mimeType: "application/json",
|
||||||
buffer: Buffer.from(JSON.stringify(SYNTHETIC_FIXTURE)),
|
buffer: Buffer.from(JSON.stringify(SYNTHETIC_FIXTURE)),
|
||||||
});
|
});
|
||||||
await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, {
|
// Loading the report enters the game in place (the address bar stays
|
||||||
timeout: 10_000,
|
// at the app base); the map view reaching `ready` is the signal.
|
||||||
});
|
await expect(page.getByTestId("game-shell")).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -20,6 +20,25 @@ import type {
|
|||||||
MapPrimitiveSnapshot,
|
MapPrimitiveSnapshot,
|
||||||
} from "../../src/lib/debug-surface.svelte";
|
} from "../../src/lib/debug-surface.svelte";
|
||||||
import type { WrapMode } from "../../src/map/world";
|
import type { WrapMode } from "../../src/map/world";
|
||||||
|
import type {
|
||||||
|
AppScreen,
|
||||||
|
GameView,
|
||||||
|
GameViewState,
|
||||||
|
} from "../../src/lib/app-nav.svelte";
|
||||||
|
|
||||||
|
// View sub-parameters accepted by the dev-only nav affordance — the
|
||||||
|
// `GameViewState` fields minus the discriminating `view`.
|
||||||
|
type NavViewParams = Omit<GameViewState, "view">;
|
||||||
|
|
||||||
|
// Mirrors the dev-only surface mounted by `routes/+page.svelte`. The
|
||||||
|
// single-URL app-shell has no per-screen / per-view routes, so the
|
||||||
|
// Playwright suite drives the in-memory screen and view through this
|
||||||
|
// global instead of `page.goto("/games/:id/:view")`.
|
||||||
|
interface NavSurface {
|
||||||
|
enterGame(gameId: string, view?: GameView, params?: NavViewParams): void;
|
||||||
|
select(view: GameView, params?: NavViewParams): void;
|
||||||
|
go(screen: AppScreen, opts?: { gameId?: string }): void;
|
||||||
|
}
|
||||||
|
|
||||||
// Mirrors the surface mounted by `routes/__debug/store/+page.svelte`.
|
// Mirrors the surface mounted by `routes/__debug/store/+page.svelte`.
|
||||||
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`,
|
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`,
|
||||||
@@ -56,6 +75,7 @@ interface DebugSurface {
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__galaxyDebug?: DebugSurface;
|
__galaxyDebug?: DebugSurface;
|
||||||
|
__galaxyNav?: NavSurface;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,16 +105,21 @@ async function mockGateway(page: Page): Promise<MockState> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// The first SubscribeEvents request from the root layout receives
|
// The root layout opens the event stream while the dispatcher is
|
||||||
// one signed `game.turn.ready` frame for turn 5; subsequent
|
// still on the lobby screen (the single-URL app-shell starts the
|
||||||
// reconnect attempts (events.ts retries after the abrupt
|
// singleton stream on authentication, before the player enters a
|
||||||
// end-of-body) are held open indefinitely so the toast stays
|
// game). The per-game `game.turn.ready` handler only registers once
|
||||||
// visible long enough for the test to interact with it.
|
// the game shell mounts, so the very first frame can land before the
|
||||||
|
// handler exists. Deliver the signed frame on the first TWO
|
||||||
|
// SubscribeEvents requests — the post-frame reconnect (events.ts
|
||||||
|
// retries after the abrupt end-of-body) carries it again once the
|
||||||
|
// handler is up; `markPendingTurn(5)` is idempotent. Hold every
|
||||||
|
// later reconnect open so the toast stays visible for the test.
|
||||||
await page.route(
|
await page.route(
|
||||||
"**/edge.v1.Gateway/SubscribeEvents",
|
"**/edge.v1.Gateway/SubscribeEvents",
|
||||||
async (route) => {
|
async (route) => {
|
||||||
state.subscribeHits += 1;
|
state.subscribeHits += 1;
|
||||||
if (state.subscribeHits === 1) {
|
if (state.subscribeHits <= 2) {
|
||||||
const payload = new TextEncoder().encode(
|
const payload = new TextEncoder().encode(
|
||||||
JSON.stringify({ game_id: GAME_ID, turn: 5 }),
|
JSON.stringify({ game_id: GAME_ID, turn: 5 }),
|
||||||
);
|
);
|
||||||
@@ -155,7 +160,12 @@ test("signed game.turn.ready frame surfaces the toast", async ({ page }) => {
|
|||||||
await mockGateway(page);
|
await mockGateway(page);
|
||||||
|
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
|
||||||
// Initial chrome reflects the bootstrap currentTurn=4.
|
// Initial chrome reflects the bootstrap currentTurn=4.
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
@@ -181,8 +191,19 @@ test("manual dismiss clears the turn-ready toast without advancing the view", as
|
|||||||
await mockGateway(page);
|
await mockGateway(page);
|
||||||
|
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Let the game shell finish booting (so the per-game turn-ready
|
||||||
|
// handler is registered) before expecting the pushed toast.
|
||||||
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
|
"data-status",
|
||||||
|
"ready",
|
||||||
|
);
|
||||||
await expect(page.getByTestId("toast")).toBeVisible({ timeout: 5_000 });
|
await expect(page.getByTestId("toast")).toBeVisible({ timeout: 5_000 });
|
||||||
await page.getByTestId("toast-close").click();
|
await page.getByTestId("toast-close").click();
|
||||||
await expect(page.getByTestId("toast")).toBeHidden();
|
await expect(page.getByTestId("toast")).toBeHidden();
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
// the lobby / report calls are in flight), the Phase 26 turn
|
// the lobby / report calls are in flight), the Phase 26 turn
|
||||||
// navigator (`← turn N →` with a popover of every turn), the
|
// navigator (`← turn N →` with a popover of every turn), the
|
||||||
// view-menu, and the account-menu. The tests assert the visible
|
// view-menu, and the account-menu. The tests assert the visible
|
||||||
// copy, that every view-menu entry dispatches `goto` with the right
|
// copy, that every view-menu entry switches the active in-game view
|
||||||
// URL, and that the Logout entry of the account-menu calls
|
// via `activeView.select(...)` (the single-URL app-shell has no
|
||||||
// `session.signOut("user")`.
|
// per-view routes), and that the Logout entry of the account-menu
|
||||||
|
// calls `session.signOut("user")`.
|
||||||
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { fireEvent, render } from "@testing-library/svelte";
|
import { fireEvent, render } from "@testing-library/svelte";
|
||||||
@@ -57,14 +58,22 @@ function withGameState(opts: {
|
|||||||
return new Map<unknown, unknown>([[GAME_STATE_CONTEXT_KEY, store]]);
|
return new Map<unknown, unknown>([[GAME_STATE_CONTEXT_KEY, store]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const gotoSpy = vi.fn(async (..._args: unknown[]) => {});
|
// The view-menu switches the active in-game view through
|
||||||
vi.mock("$app/navigation", () => ({
|
// `activeView.select(...)`, and the header's return-to-lobby button
|
||||||
goto: (...args: unknown[]) => gotoSpy(...args),
|
// leaves the game through `appScreen.go("lobby")`; both internally
|
||||||
|
// call SvelteKit `pushState`. Mock the whole nav module so the spies
|
||||||
|
// capture the transitions and no real history mutation runs in JSDOM.
|
||||||
|
const activeViewSelectSpy = vi.fn();
|
||||||
|
const appScreenGoSpy = vi.fn();
|
||||||
|
vi.mock("$lib/app-nav.svelte", () => ({
|
||||||
|
activeView: { select: (...args: unknown[]) => activeViewSelectSpy(...args) },
|
||||||
|
appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
gotoSpy.mockReset();
|
activeViewSelectSpy.mockReset();
|
||||||
|
appScreenGoSpy.mockReset();
|
||||||
vi.spyOn(session, "signOut").mockResolvedValue(undefined);
|
vi.spyOn(session, "signOut").mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +85,7 @@ describe("game-shell header", () => {
|
|||||||
test("renders fall-back placeholders before the lobby / report data lands", () => {
|
test("renders fall-back placeholders before the lobby / report data lands", () => {
|
||||||
const onToggleSidebar = vi.fn();
|
const onToggleSidebar = vi.fn();
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
|
props: { sidebarOpen: false, onToggleSidebar },
|
||||||
context: withGameState(),
|
context: withGameState(),
|
||||||
});
|
});
|
||||||
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
|
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
|
||||||
@@ -91,7 +100,7 @@ describe("game-shell header", () => {
|
|||||||
|
|
||||||
test("renders the live race / game / turn from GameStateStore", () => {
|
test("renders the live race / game / turn from GameStateStore", () => {
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
props: { sidebarOpen: false, onToggleSidebar: () => {} },
|
||||||
context: withGameState({
|
context: withGameState({
|
||||||
gameName: "Phase 14",
|
gameName: "Phase 14",
|
||||||
race: "Federation",
|
race: "Federation",
|
||||||
@@ -108,7 +117,7 @@ describe("game-shell header", () => {
|
|||||||
|
|
||||||
test("partial data still falls back gracefully (race known, game unknown)", () => {
|
test("partial data still falls back gracefully (race known, game unknown)", () => {
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
props: { sidebarOpen: false, onToggleSidebar: () => {} },
|
||||||
context: withGameState({ race: "Federation", turn: 3 }),
|
context: withGameState({ race: "Federation", turn: 3 }),
|
||||||
});
|
});
|
||||||
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
|
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
|
||||||
@@ -122,54 +131,45 @@ describe("game-shell header", () => {
|
|||||||
test("clicking the sidebar toggle invokes the prop callback", async () => {
|
test("clicking the sidebar toggle invokes the prop callback", async () => {
|
||||||
const onToggleSidebar = vi.fn();
|
const onToggleSidebar = vi.fn();
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
|
props: { sidebarOpen: false, onToggleSidebar },
|
||||||
});
|
});
|
||||||
await fireEvent.click(ui.getByTestId("sidebar-toggle"));
|
await fireEvent.click(ui.getByTestId("sidebar-toggle"));
|
||||||
expect(onToggleSidebar).toHaveBeenCalledTimes(1);
|
expect(onToggleSidebar).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("view-menu navigates to every IA destination", async () => {
|
test("view-menu switches the active view for every IA destination", async () => {
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
props: { sidebarOpen: false, onToggleSidebar: () => {} },
|
||||||
});
|
});
|
||||||
|
|
||||||
const destinations: Array<[string, string]> = [
|
const destinations: Array<[string, string]> = [
|
||||||
["view-menu-item-map", "/games/g1/map"],
|
["view-menu-item-map", "map"],
|
||||||
["view-menu-item-report", "/games/g1/report"],
|
["view-menu-item-report", "report"],
|
||||||
["view-menu-item-battle", "/games/g1/battle"],
|
["view-menu-item-battle", "battle"],
|
||||||
["view-menu-item-mail", "/games/g1/mail"],
|
["view-menu-item-mail", "mail"],
|
||||||
[
|
["view-menu-item-designer-science", "designer-science"],
|
||||||
"view-menu-item-designer-science",
|
|
||||||
"/games/g1/designer/science",
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [testId, href] of destinations) {
|
for (const [testId, view] of destinations) {
|
||||||
await fireEvent.click(ui.getByTestId("view-menu-trigger"));
|
await fireEvent.click(ui.getByTestId("view-menu-trigger"));
|
||||||
await fireEvent.click(ui.getByTestId(testId));
|
await fireEvent.click(ui.getByTestId(testId));
|
||||||
expect(gotoSpy).toHaveBeenLastCalledWith(href);
|
expect(activeViewSelectSpy).toHaveBeenLastCalledWith(view, {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("view-menu Tables sub-list navigates to every entity", async () => {
|
test("view-menu Tables sub-list switches to every entity", async () => {
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
props: { sidebarOpen: false, onToggleSidebar: () => {} },
|
||||||
});
|
});
|
||||||
const tableEntities: Array<[string, string]> = [
|
const tableEntities: Array<[string, string]> = [
|
||||||
["view-menu-item-table-planets", "/games/g1/table/planets"],
|
["view-menu-item-table-planets", "planets"],
|
||||||
[
|
["view-menu-item-table-ship-classes", "ship-classes"],
|
||||||
"view-menu-item-table-ship-classes",
|
["view-menu-item-table-ship-groups", "ship-groups"],
|
||||||
"/games/g1/table/ship-classes",
|
["view-menu-item-table-fleets", "fleets"],
|
||||||
],
|
["view-menu-item-table-sciences", "sciences"],
|
||||||
[
|
["view-menu-item-table-races", "races"],
|
||||||
"view-menu-item-table-ship-groups",
|
|
||||||
"/games/g1/table/ship-groups",
|
|
||||||
],
|
|
||||||
["view-menu-item-table-fleets", "/games/g1/table/fleets"],
|
|
||||||
["view-menu-item-table-sciences", "/games/g1/table/sciences"],
|
|
||||||
["view-menu-item-table-races", "/games/g1/table/races"],
|
|
||||||
];
|
];
|
||||||
for (const [testId, href] of tableEntities) {
|
for (const [testId, entity] of tableEntities) {
|
||||||
await fireEvent.click(ui.getByTestId("view-menu-trigger"));
|
await fireEvent.click(ui.getByTestId("view-menu-trigger"));
|
||||||
// Open the Tables sub-disclosure each iteration; the menu
|
// Open the Tables sub-disclosure each iteration; the menu
|
||||||
// closes on every navigation.
|
// closes on every navigation.
|
||||||
@@ -180,13 +180,23 @@ describe("game-shell header", () => {
|
|||||||
await fireEvent.click(summary);
|
await fireEvent.click(summary);
|
||||||
}
|
}
|
||||||
await fireEvent.click(ui.getByTestId(testId));
|
await fireEvent.click(ui.getByTestId(testId));
|
||||||
expect(gotoSpy).toHaveBeenLastCalledWith(href);
|
expect(activeViewSelectSpy).toHaveBeenLastCalledWith("table", {
|
||||||
|
tableEntity: entity,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("return-to-lobby button leaves the game for the lobby screen", async () => {
|
||||||
|
const ui = render(Header, {
|
||||||
|
props: { sidebarOpen: false, onToggleSidebar: () => {} },
|
||||||
|
});
|
||||||
|
await fireEvent.click(ui.getByTestId("return-to-lobby"));
|
||||||
|
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
|
||||||
|
});
|
||||||
|
|
||||||
test("account-menu Logout triggers session.signOut('user')", async () => {
|
test("account-menu Logout triggers session.signOut('user')", async () => {
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
props: { sidebarOpen: false, onToggleSidebar: () => {} },
|
||||||
});
|
});
|
||||||
await fireEvent.click(ui.getByTestId("account-menu-trigger"));
|
await fireEvent.click(ui.getByTestId("account-menu-trigger"));
|
||||||
await fireEvent.click(ui.getByTestId("account-menu-logout"));
|
await fireEvent.click(ui.getByTestId("account-menu-logout"));
|
||||||
@@ -195,7 +205,7 @@ describe("game-shell header", () => {
|
|||||||
|
|
||||||
test("account-menu language picker switches the i18n locale", async () => {
|
test("account-menu language picker switches the i18n locale", async () => {
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
props: { sidebarOpen: false, onToggleSidebar: () => {} },
|
||||||
});
|
});
|
||||||
await fireEvent.click(ui.getByTestId("account-menu-trigger"));
|
await fireEvent.click(ui.getByTestId("account-menu-trigger"));
|
||||||
const select = ui.getByTestId("account-menu-language-select");
|
const select = ui.getByTestId("account-menu-language-select");
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// Component tests for the Phase 10 in-game shell sidebar. Validates
|
// Component tests for the Phase 10 in-game shell sidebar. Validates
|
||||||
// the default selected tab, the Calculator / Inspector / Order
|
// the default selected tab, the Calculator / Inspector / Order
|
||||||
// switching, the empty-state copy that matches the IA section, the
|
// switching, the empty-state copy that matches the IA section, and
|
||||||
// `?sidebar=` URL seed convention used by the mobile bottom-tabs,
|
// the Phase 13 selection-driven planet inspector content. The
|
||||||
// and the Phase 13 selection-driven planet inspector content.
|
// single-URL app-shell dropped the old `?sidebar=` URL seed (the
|
||||||
|
// sidebar no longer reads `$app/state`); the shell drives the
|
||||||
|
// initial tab through the `activeTab` bindable instead.
|
||||||
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { fireEvent, render } from "@testing-library/svelte";
|
import { fireEvent, render } from "@testing-library/svelte";
|
||||||
@@ -34,15 +36,6 @@ import {
|
|||||||
} from "../src/sync/order-draft.svelte";
|
} from "../src/sync/order-draft.svelte";
|
||||||
import type { GameReport, ReportPlanet } from "../src/api/game-state";
|
import type { GameReport, ReportPlanet } from "../src/api/game-state";
|
||||||
|
|
||||||
const pageMock = vi.hoisted(() => ({
|
|
||||||
url: new URL("http://localhost/games/g1/map"),
|
|
||||||
params: { id: "g1" } as Record<string, string>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$app/state", () => ({
|
|
||||||
page: pageMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import Sidebar from "../src/lib/sidebar/sidebar.svelte";
|
import Sidebar from "../src/lib/sidebar/sidebar.svelte";
|
||||||
|
|
||||||
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
|
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
|
||||||
@@ -107,7 +100,6 @@ function withStores(report: GameReport | null): {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
pageMock.url = new URL("http://localhost/games/g1/map");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("game-shell sidebar", () => {
|
describe("game-shell sidebar", () => {
|
||||||
@@ -148,10 +140,9 @@ describe("game-shell sidebar", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("?sidebar=calc seeds the calculator tab on first mount", () => {
|
test("the activeTab prop seeds the calculator tab on first mount", () => {
|
||||||
pageMock.url = new URL("http://localhost/games/g1/map?sidebar=calc");
|
|
||||||
const ui = render(Sidebar, {
|
const ui = render(Sidebar, {
|
||||||
props: { open: false, onClose: () => {} },
|
props: { open: false, onClose: () => {}, activeTab: "calculator" },
|
||||||
});
|
});
|
||||||
expect(ui.getByTestId("sidebar-tool-calculator")).toBeInTheDocument();
|
expect(ui.getByTestId("sidebar-tool-calculator")).toBeInTheDocument();
|
||||||
expect(ui.getByTestId("sidebar")).toHaveAttribute(
|
expect(ui.getByTestId("sidebar")).toHaveAttribute(
|
||||||
@@ -160,10 +151,9 @@ describe("game-shell sidebar", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("?sidebar=order seeds the order tab on first mount", () => {
|
test("the activeTab prop seeds the order tab on first mount", () => {
|
||||||
pageMock.url = new URL("http://localhost/games/g1/map?sidebar=order");
|
|
||||||
const ui = render(Sidebar, {
|
const ui = render(Sidebar, {
|
||||||
props: { open: false, onClose: () => {} },
|
props: { open: false, onClose: () => {}, activeTab: "order" },
|
||||||
});
|
});
|
||||||
expect(ui.getByTestId("sidebar-tool-order")).toBeInTheDocument();
|
expect(ui.getByTestId("sidebar-tool-order")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// Component tests for the create-game form. The lobby API is mocked
|
// Component tests for the create-game screen. The lobby API is mocked
|
||||||
// at module level; the GalaxyClient is replaced with a stub that does
|
// at module level; the GalaxyClient is replaced with a stub that does
|
||||||
// nothing (the test only asserts the createGame wrapper is invoked
|
// nothing (the test only asserts the createGame wrapper is invoked
|
||||||
// with the right shape).
|
// with the right shape). The app-shell navigation store is mocked so
|
||||||
|
// cancel and post-submit both resolve to `appScreen.go("lobby")`
|
||||||
|
// without running real `pushState` in JSDOM — the single-URL shell has
|
||||||
|
// no `/lobby` route.
|
||||||
|
|
||||||
import "fake-indexeddb/auto";
|
import "fake-indexeddb/auto";
|
||||||
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||||
@@ -21,9 +24,13 @@ import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb";
|
|||||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
||||||
|
|
||||||
const gotoSpy = vi.fn<(url: string) => Promise<void>>(async () => {});
|
// The create screen returns to the lobby through `appScreen.go("lobby")`,
|
||||||
vi.mock("$app/navigation", () => ({
|
// which internally calls SvelteKit `pushState`. Mock the whole nav
|
||||||
goto: (url: string) => gotoSpy(url),
|
// module so the spy captures the transition and no real history
|
||||||
|
// mutation runs in JSDOM.
|
||||||
|
const appScreenGoSpy = vi.fn();
|
||||||
|
vi.mock("$lib/app-nav.svelte", () => ({
|
||||||
|
appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createGameSpy = vi.fn();
|
const createGameSpy = vi.fn();
|
||||||
@@ -82,7 +89,7 @@ beforeEach(async () => {
|
|||||||
await session.signIn("device-1");
|
await session.signIn("device-1");
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
createGameSpy.mockReset();
|
createGameSpy.mockReset();
|
||||||
gotoSpy.mockReset();
|
appScreenGoSpy.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -97,11 +104,13 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function importCreatePage(): Promise<typeof import("../src/routes/lobby/create/+page.svelte")> {
|
async function importCreatePage(): Promise<
|
||||||
return import("../src/routes/lobby/create/+page.svelte");
|
typeof import("../src/lib/screens/lobby-create-screen.svelte")
|
||||||
|
> {
|
||||||
|
return import("../src/lib/screens/lobby-create-screen.svelte");
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("lobby/create page", () => {
|
describe("lobby/create screen", () => {
|
||||||
test("submitting a valid form invokes createGame with the entered values and navigates back", async () => {
|
test("submitting a valid form invokes createGame with the entered values and navigates back", async () => {
|
||||||
createGameSpy.mockResolvedValue({
|
createGameSpy.mockResolvedValue({
|
||||||
gameId: "private-new",
|
gameId: "private-new",
|
||||||
@@ -150,7 +159,7 @@ describe("lobby/create page", () => {
|
|||||||
expect(input.startGapPlayers).toBe(2);
|
expect(input.startGapPlayers).toBe(2);
|
||||||
expect(input.targetEngineVersion).toBe("v1");
|
expect(input.targetEngineVersion).toBe("v1");
|
||||||
expect(input.enrollmentEndsAt).toBeInstanceOf(Date);
|
expect(input.enrollmentEndsAt).toBeInstanceOf(Date);
|
||||||
expect(gotoSpy).toHaveBeenCalledWith("/lobby");
|
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,7 +188,7 @@ describe("lobby/create page", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cancel button navigates back to /lobby without calling the API", async () => {
|
test("cancel button navigates back to the lobby without calling the API", async () => {
|
||||||
const Page = (await importCreatePage()).default;
|
const Page = (await importCreatePage()).default;
|
||||||
const ui = render(Page);
|
const ui = render(Page);
|
||||||
|
|
||||||
@@ -189,7 +198,7 @@ describe("lobby/create page", () => {
|
|||||||
await fireEvent.click(ui.getByTestId("lobby-create-cancel"));
|
await fireEvent.click(ui.getByTestId("lobby-create-cancel"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(gotoSpy).toHaveBeenCalledWith("/lobby");
|
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
|
||||||
expect(createGameSpy).not.toHaveBeenCalled();
|
expect(createGameSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
// Component tests for the Phase 8 lobby page. The lobby API and the
|
// Component tests for the Phase 8 lobby screen. The lobby API and the
|
||||||
// gateway client are mocked at module level; the session singleton is
|
// gateway client are mocked at module level; the session singleton is
|
||||||
// wired to a per-test `SessionStore`-backing IndexedDB so the page's
|
// wired to a per-test `SessionStore`-backing IndexedDB so the page's
|
||||||
// boot path settles on `authenticated` and constructs a real
|
// boot path settles on `authenticated` and constructs a real
|
||||||
// GalaxyClient (which is then never called because the lobby API
|
// GalaxyClient (which is then never called because the lobby API
|
||||||
// wrappers are stubs). The tests assert the section rendering, the
|
// wrappers are stubs). The tests assert the section rendering, the
|
||||||
// inline race-name form for public games, and the invitation Accept
|
// inline race-name form for public games, and the invitation Accept
|
||||||
// flow.
|
// flow. The app-shell navigation store is mocked so opening a game
|
||||||
|
// (`activeView.reset()` + `appScreen.go("game", …)`) or the create
|
||||||
|
// form (`appScreen.go("lobby-create")`) never runs real `pushState`
|
||||||
|
// in JSDOM; the single-URL shell has no `/lobby`/`/games` routes.
|
||||||
|
|
||||||
import "fake-indexeddb/auto";
|
import "fake-indexeddb/auto";
|
||||||
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||||
@@ -25,8 +28,19 @@ import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb";
|
|||||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
||||||
|
|
||||||
vi.mock("$app/navigation", () => ({
|
// The lobby screen navigates through the app-shell stores
|
||||||
goto: vi.fn(async () => {}),
|
// (`appScreen.go`, `activeView.reset`/`select`), which internally call
|
||||||
|
// SvelteKit `pushState`. Mock the whole nav module so the spies
|
||||||
|
// capture the transitions and no real history mutation runs in JSDOM.
|
||||||
|
const appScreenGoSpy = vi.fn();
|
||||||
|
const activeViewResetSpy = vi.fn();
|
||||||
|
const activeViewSelectSpy = vi.fn();
|
||||||
|
vi.mock("$lib/app-nav.svelte", () => ({
|
||||||
|
appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) },
|
||||||
|
activeView: {
|
||||||
|
reset: (...args: unknown[]) => activeViewResetSpy(...args),
|
||||||
|
select: (...args: unknown[]) => activeViewSelectSpy(...args),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const listMyGamesSpy = vi.fn();
|
const listMyGamesSpy = vi.fn();
|
||||||
@@ -105,6 +119,9 @@ beforeEach(async () => {
|
|||||||
submitApplicationSpy.mockReset();
|
submitApplicationSpy.mockReset();
|
||||||
redeemInviteSpy.mockReset();
|
redeemInviteSpy.mockReset();
|
||||||
declineInviteSpy.mockReset();
|
declineInviteSpy.mockReset();
|
||||||
|
appScreenGoSpy.mockReset();
|
||||||
|
activeViewResetSpy.mockReset();
|
||||||
|
activeViewSelectSpy.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -119,8 +136,10 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function importLobbyPage(): Promise<typeof import("../src/routes/lobby/+page.svelte")> {
|
async function importLobbyPage(): Promise<
|
||||||
return import("../src/routes/lobby/+page.svelte");
|
typeof import("../src/lib/screens/lobby-screen.svelte")
|
||||||
|
> {
|
||||||
|
return import("../src/lib/screens/lobby-screen.svelte");
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseDate = new Date("2026-05-07T10:00:00Z");
|
const baseDate = new Date("2026-05-07T10:00:00Z");
|
||||||
@@ -184,7 +203,7 @@ function makeApplication(id: string, status: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("lobby page", () => {
|
describe("lobby screen", () => {
|
||||||
test("renders empty states for every section when API returns no items", async () => {
|
test("renders empty states for every section when API returns no items", async () => {
|
||||||
listMyGamesSpy.mockResolvedValue([]);
|
listMyGamesSpy.mockResolvedValue([]);
|
||||||
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||||
@@ -375,6 +394,18 @@ describe("lobby page", () => {
|
|||||||
expect(disabledByLabel["Closed Run"]).toBe(false);
|
expect(disabledByLabel["Closed Run"]).toBe(false);
|
||||||
expect(disabledByLabel["Cancelled Run"]).toBe(true);
|
expect(disabledByLabel["Cancelled Run"]).toBe(true);
|
||||||
expect(disabledByLabel["Draft Run"]).toBe(true);
|
expect(disabledByLabel["Draft Run"]).toBe(true);
|
||||||
|
|
||||||
|
// Clicking a playable card resets the in-game view and enters the
|
||||||
|
// game screen with its id (the single-URL app-shell switches
|
||||||
|
// in-memory state instead of navigating to `/games/:id`).
|
||||||
|
const liveCard = cards.find(
|
||||||
|
(card) => card.querySelector("strong")?.textContent === "Live",
|
||||||
|
);
|
||||||
|
await fireEvent.click(liveCard!);
|
||||||
|
expect(activeViewResetSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(appScreenGoSpy).toHaveBeenCalledWith("game", {
|
||||||
|
gameId: "g-running",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("application status badges localise pending and approved states", async () => {
|
test("application status badges localise pending and approved states", async () => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// Login page component tests. The `auth` API and the navigation
|
// Login screen component tests. The `auth` API and the app-shell
|
||||||
// helper are mocked at module level; the session singleton is wired
|
// navigation store are mocked at module level; the session singleton
|
||||||
// to a per-test `SessionStore`-backing IndexedDB so the keypair the
|
// is wired to a per-test `SessionStore`-backing IndexedDB so the
|
||||||
// form passes to `confirmEmailCode` is a genuine 32-byte Ed25519
|
// keypair the form passes to `confirmEmailCode` is a genuine 32-byte
|
||||||
// public key without polluting the production `dbConnection()`
|
// Ed25519 public key without polluting the production `dbConnection()`
|
||||||
// cache.
|
// cache. The single-URL app-shell has no `/lobby` route: a successful
|
||||||
|
// sign-in advances the in-memory screen via `appScreen.go("lobby")`,
|
||||||
|
// so the test asserts against the mocked store instead of `goto`.
|
||||||
|
|
||||||
import "fake-indexeddb/auto";
|
import "fake-indexeddb/auto";
|
||||||
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||||
@@ -24,8 +26,13 @@ import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb";
|
|||||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
||||||
|
|
||||||
vi.mock("$app/navigation", () => ({
|
// The screen drives navigation through `appScreen.go(...)`, which
|
||||||
goto: vi.fn(async () => {}),
|
// internally calls SvelteKit `pushState`. Mock the whole nav module
|
||||||
|
// so the spy captures the screen transition and no real history
|
||||||
|
// mutation runs in JSDOM.
|
||||||
|
const appScreenGoSpy = vi.fn();
|
||||||
|
vi.mock("$lib/app-nav.svelte", () => ({
|
||||||
|
appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const sendEmailCodeSpy = vi.fn();
|
const sendEmailCodeSpy = vi.fn();
|
||||||
@@ -58,11 +65,13 @@ beforeEach(async () => {
|
|||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
sendEmailCodeSpy.mockReset();
|
sendEmailCodeSpy.mockReset();
|
||||||
confirmEmailCodeSpy.mockReset();
|
confirmEmailCodeSpy.mockReset();
|
||||||
|
appScreenGoSpy.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
sendEmailCodeSpy.mockReset();
|
sendEmailCodeSpy.mockReset();
|
||||||
confirmEmailCodeSpy.mockReset();
|
confirmEmailCodeSpy.mockReset();
|
||||||
|
appScreenGoSpy.mockReset();
|
||||||
session.resetForTests();
|
session.resetForTests();
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
db.close();
|
db.close();
|
||||||
@@ -74,11 +83,13 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function importLoginPage(): Promise<typeof import("../src/routes/login/+page.svelte")> {
|
async function importLoginPage(): Promise<
|
||||||
return import("../src/routes/login/+page.svelte");
|
typeof import("../src/lib/screens/login-screen.svelte")
|
||||||
|
> {
|
||||||
|
return import("../src/lib/screens/login-screen.svelte");
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("login page", () => {
|
describe("login screen", () => {
|
||||||
test("submitting the email step calls sendEmailCode and advances to step=code", async () => {
|
test("submitting the email step calls sendEmailCode and advances to step=code", async () => {
|
||||||
sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" });
|
sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" });
|
||||||
const Page = (await importLoginPage()).default;
|
const Page = (await importLoginPage()).default;
|
||||||
@@ -145,6 +156,7 @@ describe("login page", () => {
|
|||||||
expect(confirmEmailCodeSpy).toHaveBeenCalledTimes(1);
|
expect(confirmEmailCodeSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(session.deviceSessionId).toBe("dev-1");
|
expect(session.deviceSessionId).toBe("dev-1");
|
||||||
expect(session.status).toBe("authenticated");
|
expect(session.status).toBe("authenticated");
|
||||||
|
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
|
||||||
});
|
});
|
||||||
const args = confirmEmailCodeSpy.mock.calls[0]![1]!;
|
const args = confirmEmailCodeSpy.mock.calls[0]![1]!;
|
||||||
expect(args.challengeId).toBe("ch-1");
|
expect(args.challengeId).toBe("ch-1");
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
// registration, offline-from-cache load, and the version-keyed cache
|
// registration, offline-from-cache load, and the version-keyed cache
|
||||||
// (a new deploy's `version` makes a new cache and `activate` drops the
|
// (a new deploy's `version` makes a new cache and `activate` drops the
|
||||||
// old one — verified here as "exactly one galaxy cache, version-keyed").
|
// old one — verified here as "exactly one galaxy cache, version-keyed").
|
||||||
|
// The single-URL app-shell boots at the app base (`/`); with no seeded
|
||||||
|
// session the dispatcher renders the login screen, so the shell's
|
||||||
|
// `#main-content` region is the boot signal here.
|
||||||
|
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test.describe("PWA", () => {
|
test.describe("PWA", () => {
|
||||||
test("links a web manifest with installable icons", async ({ page }) => {
|
test("links a web manifest with installable icons", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/");
|
||||||
const href = await page
|
const href = await page
|
||||||
.locator('head link[rel="manifest"]')
|
.locator('head link[rel="manifest"]')
|
||||||
.getAttribute("href");
|
.getAttribute("href");
|
||||||
@@ -33,7 +36,7 @@ test.describe("PWA", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("registers a service worker that controls the page", async ({ page }) => {
|
test("registers a service worker that controls the page", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/");
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => navigator.serviceWorker.controller !== null,
|
() => navigator.serviceWorker.controller !== null,
|
||||||
null,
|
null,
|
||||||
@@ -50,7 +53,7 @@ test.describe("PWA", () => {
|
|||||||
page,
|
page,
|
||||||
context,
|
context,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto("/login");
|
await page.goto("/");
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => navigator.serviceWorker.controller !== null,
|
() => navigator.serviceWorker.controller !== null,
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
|||||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||||
import type { TranslationKey } from "../src/lib/i18n/index.svelte";
|
import type { TranslationKey } from "../src/lib/i18n/index.svelte";
|
||||||
|
|
||||||
const gotoMock = vi.hoisted(() => vi.fn());
|
// The TOC's "back to map" button switches the active in-game view via
|
||||||
vi.mock("$app/navigation", () => ({
|
// `activeView.select("map")` (the single-URL app-shell has no
|
||||||
goto: gotoMock,
|
// `/games/:id/map` route). Mock the nav store so the spy captures the
|
||||||
|
// view switch and no real `pushState` runs.
|
||||||
|
const activeViewSelectMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("$lib/app-nav.svelte", () => ({
|
||||||
|
activeView: { select: activeViewSelectMock },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import ReportToc, {
|
import ReportToc, {
|
||||||
@@ -29,13 +33,13 @@ const ENTRIES: readonly TocEntry[] = [
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
gotoMock.mockClear();
|
activeViewSelectMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("report TOC", () => {
|
describe("report TOC", () => {
|
||||||
test("renders one anchor per entry and one option in the mobile select", () => {
|
test("renders one anchor per entry and one option in the mobile select", () => {
|
||||||
const ui = render(ReportToc, {
|
const ui = render(ReportToc, {
|
||||||
props: { entries: ENTRIES, activeSlug: "galaxy-summary", gameId: "g-1" },
|
props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
|
||||||
});
|
});
|
||||||
for (const e of ENTRIES) {
|
for (const e of ENTRIES) {
|
||||||
expect(ui.getByTestId(`report-toc-${e.slug}`)).toBeInTheDocument();
|
expect(ui.getByTestId(`report-toc-${e.slug}`)).toBeInTheDocument();
|
||||||
@@ -47,7 +51,7 @@ describe("report TOC", () => {
|
|||||||
|
|
||||||
test("marks the active anchor with aria-current=location and a class", () => {
|
test("marks the active anchor with aria-current=location and a class", () => {
|
||||||
const ui = render(ReportToc, {
|
const ui = render(ReportToc, {
|
||||||
props: { entries: ENTRIES, activeSlug: "bombings", gameId: "g-1" },
|
props: { entries: ENTRIES, activeSlug: "bombings" },
|
||||||
});
|
});
|
||||||
const active = ui.getByTestId("report-toc-bombings");
|
const active = ui.getByTestId("report-toc-bombings");
|
||||||
expect(active).toHaveAttribute("aria-current", "location");
|
expect(active).toHaveAttribute("aria-current", "location");
|
||||||
@@ -58,17 +62,16 @@ describe("report TOC", () => {
|
|||||||
expect(inactive).not.toHaveClass("active");
|
expect(inactive).not.toHaveClass("active");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("back-to-map button calls goto with the active game's map URL", async () => {
|
test("back-to-map button switches the active view to the map", async () => {
|
||||||
const ui = render(ReportToc, {
|
const ui = render(ReportToc, {
|
||||||
props: {
|
props: {
|
||||||
entries: ENTRIES,
|
entries: ENTRIES,
|
||||||
activeSlug: "galaxy-summary",
|
activeSlug: "galaxy-summary",
|
||||||
gameId: "abc",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const button = ui.getByTestId("report-back-to-map");
|
const button = ui.getByTestId("report-back-to-map");
|
||||||
await fireEvent.click(button);
|
await fireEvent.click(button);
|
||||||
expect(gotoMock).toHaveBeenCalledWith("/games/abc/map");
|
expect(activeViewSelectMock).toHaveBeenCalledWith("map");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("anchor click cancels the default jump and calls scrollIntoView on the target", async () => {
|
test("anchor click cancels the default jump and calls scrollIntoView on the target", async () => {
|
||||||
@@ -97,7 +100,7 @@ describe("report TOC", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ui = render(ReportToc, {
|
const ui = render(ReportToc, {
|
||||||
props: { entries: ENTRIES, activeSlug: "galaxy-summary", gameId: "g" },
|
props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
|
||||||
});
|
});
|
||||||
await fireEvent.click(ui.getByTestId("report-toc-bombings"));
|
await fireEvent.click(ui.getByTestId("report-toc-bombings"));
|
||||||
expect(scrollSpy).toHaveBeenCalledWith({
|
expect(scrollSpy).toHaveBeenCalledWith({
|
||||||
@@ -132,13 +135,12 @@ describe("report TOC", () => {
|
|||||||
props: {
|
props: {
|
||||||
entries: ENTRIES,
|
entries: ENTRIES,
|
||||||
activeSlug: "galaxy-summary",
|
activeSlug: "galaxy-summary",
|
||||||
gameId: "g",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const select = ui.getByTestId("report-toc-mobile") as HTMLSelectElement;
|
const select = ui.getByTestId("report-toc-mobile") as HTMLSelectElement;
|
||||||
await fireEvent.change(select, { target: { value: "votes" } });
|
await fireEvent.change(select, { target: { value: "votes" } });
|
||||||
expect(scrollSpy).toHaveBeenCalled();
|
expect(scrollSpy).toHaveBeenCalled();
|
||||||
expect(gotoMock).not.toHaveBeenCalled();
|
expect(activeViewSelectMock).not.toHaveBeenCalled();
|
||||||
target.remove();
|
target.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,19 +31,15 @@ import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
|||||||
|
|
||||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||||
|
|
||||||
const pageMock = vi.hoisted(() => ({
|
// The sciences table opens the science designer by switching the
|
||||||
url: new URL("http://localhost/games/g1/table/sciences"),
|
// active in-game view via `activeView.select("designer-science", …)`
|
||||||
params: { id: "g1" } as Record<string, string>,
|
// (the single-URL app-shell has no `/games/:id/designer/...` route).
|
||||||
}));
|
// Mock the nav store so the spy captures the view switch and no real
|
||||||
|
// `pushState` runs.
|
||||||
|
const activeViewSelectMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
const gotoMock = vi.hoisted(() => vi.fn());
|
vi.mock("$lib/app-nav.svelte", () => ({
|
||||||
|
activeView: { select: activeViewSelectMock },
|
||||||
vi.mock("$app/state", () => ({
|
|
||||||
page: pageMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$app/navigation", () => ({
|
|
||||||
goto: gotoMock,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import TableSciences from "../src/lib/active-view/table-sciences.svelte";
|
import TableSciences from "../src/lib/active-view/table-sciences.svelte";
|
||||||
@@ -60,8 +56,7 @@ beforeEach(async () => {
|
|||||||
draft = new OrderDraftStore();
|
draft = new OrderDraftStore();
|
||||||
await draft.init({ cache, gameId: GAME_ID });
|
await draft.init({ cache, gameId: GAME_ID });
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
pageMock.params = { id: "g1" };
|
activeViewSelectMock.mockClear();
|
||||||
gotoMock.mockClear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -188,14 +183,14 @@ describe("sciences table", () => {
|
|||||||
expect(names).toEqual(["Beta", "Gamma", "Alpha"]);
|
expect(names).toEqual(["Beta", "Gamma", "Alpha"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("dblclick on a row navigates to the designer for that science", async () => {
|
test("dblclick on a row opens the designer for that science", async () => {
|
||||||
const ui = mountTable(
|
const ui = mountTable(
|
||||||
makeReport([science({ name: "FirstStep", drive: 1 })]),
|
makeReport([science({ name: "FirstStep", drive: 1 })]),
|
||||||
);
|
);
|
||||||
await fireEvent.dblClick(ui.getByTestId("sciences-row"));
|
await fireEvent.dblClick(ui.getByTestId("sciences-row"));
|
||||||
expect(gotoMock).toHaveBeenCalledWith(
|
expect(activeViewSelectMock).toHaveBeenCalledWith("designer-science", {
|
||||||
"/games/g1/designer/science/FirstStep",
|
scienceId: "FirstStep",
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("delete button adds a removeScience to the draft", async () => {
|
test("delete button adds a removeScience to the draft", async () => {
|
||||||
@@ -207,9 +202,9 @@ describe("sciences table", () => {
|
|||||||
expect(cmd.name).toBe("FirstStep");
|
expect(cmd.name).toBe("FirstStep");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("new button navigates to the empty designer", async () => {
|
test("new button opens the empty designer", async () => {
|
||||||
const ui = mountTable(makeReport([]));
|
const ui = mountTable(makeReport([]));
|
||||||
await fireEvent.click(ui.getByTestId("sciences-new"));
|
await fireEvent.click(ui.getByTestId("sciences-new"));
|
||||||
expect(gotoMock).toHaveBeenCalledWith("/games/g1/designer/science");
|
expect(activeViewSelectMock).toHaveBeenCalledWith("designer-science");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user