From 6364bba6fd12d00e7114ab3b3b9733f927b30422 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 9 May 2026 08:29:03 +0200 Subject: [PATCH] =?UTF-8?q?ui/phase-13:=20planet=20inspector=20=E2=80=94?= =?UTF-8?q?=20read-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plumbs the map → inspector pathway: a click on a planet selects it through the new SelectionStore, the sidebar Inspector tab swaps its empty-state copy for a per-kind read-only field set, and a mobile-only bottom-sheet mirrors the same content over the map. Field projection in api/game-state.ts now surfaces every documented planet field. --- ui/PLAN.md | 80 +++++-- ui/docs/navigation.md | 81 ++++++- ui/frontend/src/api/game-state.ts | 48 +++- ui/frontend/src/lib/active-view/map.svelte | 48 +++- ui/frontend/src/lib/i18n/locales/en.ts | 20 ++ ui/frontend/src/lib/i18n/locales/ru.ts | 20 ++ .../src/lib/inspectors/planet-sheet.svelte | 82 +++++++ ui/frontend/src/lib/inspectors/planet.svelte | 197 ++++++++++++++++ ui/frontend/src/lib/selection.svelte.ts | 66 ++++++ .../src/lib/sidebar/inspector-tab.svelte | 55 ++++- ui/frontend/src/lib/sidebar/sidebar.svelte | 16 +- ui/frontend/src/map/render.ts | 21 ++ .../src/routes/games/[id]/+layout.svelte | 66 +++++- ui/frontend/tests/e2e/fixtures/report-fbs.ts | 60 ++++- .../tests/e2e/game-shell-inspector.spec.ts | 223 ++++++++++++++++++ ui/frontend/tests/game-shell-sidebar.test.ts | 120 +++++++++- ui/frontend/tests/inspector-planet.test.ts | 218 +++++++++++++++++ ui/frontend/tests/selection-store.test.ts | 47 ++++ ui/frontend/tests/state-binding.test.ts | 47 +++- 19 files changed, 1440 insertions(+), 75 deletions(-) create mode 100644 ui/frontend/src/lib/inspectors/planet-sheet.svelte create mode 100644 ui/frontend/src/lib/inspectors/planet.svelte create mode 100644 ui/frontend/src/lib/selection.svelte.ts create mode 100644 ui/frontend/tests/e2e/game-shell-inspector.spec.ts create mode 100644 ui/frontend/tests/inspector-planet.test.ts create mode 100644 ui/frontend/tests/selection-store.test.ts diff --git a/ui/PLAN.md b/ui/PLAN.md index 21de290..ff57628 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -1453,36 +1453,74 @@ on the map; read-only, no actions yet. Artifacts: - `ui/frontend/src/lib/sidebar/inspector-tab.svelte` empty state - (`select an object on the map`) and routing per selected-object kind + (`select an object on the map`) and routing per selected-object + kind. The tab reads the selection and game-state stores from + context and hands a resolved `ReportPlanet` to the planet inspector + component. - `ui/frontend/src/lib/inspectors/planet.svelte` read-only display of - every planet field documented in `docs/FUNCTIONAL.md` §6 and the - [`rules.txt`](../game/rules.txt) planet section: name, coordinates, size, population, - industry, materials stockpile, industry stockpile, colonists, - natural resources, current production type, free production - potential -- map click handler that selects the planet and switches sidebar to - Inspector (or raises bottom-sheet on mobile) -- selection store `ui/frontend/src/lib/selection.ts` holding the - currently selected map object id + every planet field carried by the FBS report and documented in + the `rules.txt` planet section: name, coordinates, size, population, + colonists, industry, industry stockpile (`capital`, `$`), materials + stockpile (`material`, `M`), natural resources, current production + type, free production potential. Per-kind nullable fields collapse + silently — uninhabited and unidentified planets render the smaller + field set the engine carries for them. +- `ui/frontend/src/lib/inspectors/planet-sheet.svelte` mobile-only + bottom-sheet that wraps the same planet component for the < 768 px + breakpoint. Visibility is gated on `effectiveTool === "map"` so the + sheet does not stack with the calc / order overlays. +- `ui/frontend/src/lib/active-view/map.svelte` registers a click + handler against the new `RendererHandle.onClick` (built on + `pixi-viewport`'s `clicked` event), translates the hit into a + planet, and calls `SelectionStore.selectPlanet(number)`. +- `ui/frontend/src/lib/selection.svelte.ts` runes store with the + selected-object union (`{ kind: "planet"; id: number } | null`), + exposed via `setContext` from the in-game shell layout. Lifetime + matches the layout instance — selection survives every active-view + switch but does not persist across reloads. +- `ui/frontend/src/api/game-state.ts` projection extended to surface + every planet field needed by the inspector (`industryStockpile`, + `materialsStockpile`, `industry`, `population`, `colonists`, + `production`, `freeIndustry`, plus the existing `owner`). +- `ui/frontend/src/routes/games/[id]/+layout.svelte` lifts + `activeTab` into a layout-level rune bound into the sidebar, owns + the `SelectionStore`, mounts the bottom-sheet, and runs the + reveal `$effect` that flips the sidebar to the inspector tab and + opens the tablet drawer when a new selection lands. Dependencies: Phase 11. Acceptance criteria: - clicking any visible planet on the map shows its details in the - inspector tab on desktop and bottom-sheet on mobile; -- selection state persists across view switches (per global state- - preservation rule); + inspector tab on desktop and tablet (drawer auto-opens), and in a + bottom-sheet on mobile; +- selection state persists across view switches inside `/games/:id/*` + (per global state-preservation rule); reload starts fresh; +- a click on empty map area is a no-op — selection is cleared only + by the explicit close button (`✕`) on the mobile sheet; - empty inspector renders the empty-state message when no planet is - selected. + selected; +- mobile dismissal is the close button only; swipe-to-dismiss and + tap-outside-to-dismiss are deferred to Phase 35; +- a selection that no longer matches a visible planet (visibility + lost between turns) collapses to the empty state instead of + showing stale rows; +- selected-planet visual feedback on the map (ring / halo) is + intentionally out of scope and rolls into Phase 35. Targeted tests: -- Vitest component tests for the planet inspector with fixture data; -- Playwright e2e: click a seeded planet, assert all expected fields are - rendered; -- mobile-viewport Playwright run validating the bottom-sheet - presentation. +- Vitest unit (`tests/selection-store.test.ts`) for the runes store; +- Vitest component (`tests/inspector-planet.test.ts`) for per-kind + field rendering against synthetic `ReportPlanet` fixtures; +- Vitest component (`tests/game-shell-sidebar.test.ts`) extended for + the selection-driven inspector content and the missing-planet + fallback; +- Playwright e2e (`tests/e2e/game-shell-inspector.spec.ts`) clicks a + seeded planet on `chromium-desktop` and asserts the sidebar + inspector content, and on `chromium-mobile-iphone-13` asserts the + bottom-sheet appears and the close button clears it. ## Phase 14. First End-to-End Command — Rename Planet @@ -2342,6 +2380,10 @@ Artifacts: - accessibility audit results recorded under `ui/docs/a11y.md` - keyboard-only navigation paths for lobby, game view, and login - focus rings, ARIA labels, screen-reader-only text where needed +- mobile bottom-sheet swipe-down dismissal and tap-outside dismissal, + on top of the close button shipped in Phase 13 +- selected-planet visual on the map (ring or halo), wired off the + Phase 13 `SelectionStore` Dependencies: Phase 33. diff --git a/ui/docs/navigation.md b/ui/docs/navigation.md index 492e067..95dd93b 100644 --- a/ui/docs/navigation.md +++ b/ui/docs/navigation.md @@ -48,13 +48,18 @@ The desktop sidebar hosts three tools: | Inspector | `lib/sidebar/inspector-tab.svelte` | Phase 13 / 19 | | Order | `lib/sidebar/order-tab.svelte` | Phase 12 / 14 | -The sidebar's selected-tab state is a `$state` rune inside -`lib/sidebar/sidebar.svelte`. The component is mounted by the layout -at `routes/games/[id]/+layout.svelte`, and SvelteKit keeps that -layout instance alive while the user navigates between child routes -(`/games/:id/map` → `/games/:id/report` → …). The rune therefore -survives every active-view switch automatically, with no URL coupling -needed. +The selected-tab state is a `$state` rune in +`routes/games/[id]/+layout.svelte`, bound into +`lib/sidebar/sidebar.svelte` via `$bindable()`. The layout owns the +rune so external events — Phase 13's planet click, future similar +flows — can drive the active tab from outside the sidebar without +plumbing callbacks. The component is mounted by the layout, and +SvelteKit keeps that layout instance alive while the user navigates +between child routes (`/games/:id/map` → `/games/:id/report` → …), +so the rune survives every active-view switch automatically with no +URL coupling needed. The URL seed and the history-mode reset +described below still live inside the sidebar — they mutate the +bindable in place; the layout sees the change through the binding. A `?sidebar=calc|calculator|inspector|order` URL param is read once on mount and seeds the initial tab. Later phases that want to land @@ -95,9 +100,9 @@ Three discrete CSS modes matched to the IA section diagrams: view-menu trigger swaps to a hamburger icon (☰) that opens the drop-down as a full-width drawer below the header. -Inspector is intentionally unreachable on mobile in Phase 10. Per the -IA section the mobile inspector is a bottom-sheet raised by tapping a -map object, and that mechanism waits for Phase 13. +On mobile the bottom tab row does not include `Inspector`. The +inspector content is reached by tapping a map object instead, which +raises a bottom-sheet — see [Planet selection](#planet-selection-phase-13). ## Mobile bottom-tabs and tool overlay @@ -132,6 +137,62 @@ back-stack mechanism. Phase 34 lands the back-stack alongside its first user (multi-turn projection, range circles in the ship-class designer). +## Planet selection (Phase 13) + +The map view turns into the entry point for the inspector by +translating a renderer click into a planet selection. The flow: + +1. The renderer (`src/map/render.ts`) exposes `onClick(cb)` next to + the existing `hitAt(cursor)`. It is built on `pixi-viewport`'s + `clicked` event, which already differentiates a click from a + pan-drag, so a click handler will not race the pan plugin. +2. `lib/active-view/map.svelte` wires that callback after a successful + `mountRenderer`. On a click it asks the renderer for the hit + primitive, looks the planet up by `number` in the live + `GameStateStore.report`, and calls `SelectionStore.selectPlanet(number)`. +3. `SelectionStore` (`lib/selection.svelte.ts`) is a runes store + instantiated by the layout and exposed via Svelte context under + `SELECTION_CONTEXT_KEY`. It carries a discriminated union — Phase + 13 only models `{ kind: "planet"; id: number }`; Phase 19 widens + it for ship groups. Selection is in-memory only: it survives the + layout's lifetime (active-view switches inside `/games/:id/*`) + but does not persist across reloads — that contrast with the + order draft is intentional. +4. The layout watches the selection rune and, on the null → planet + transition, flips its bound `activeTab` to `inspector` and + `sidebarOpen` to `true`. Desktop already has the sidebar pinned; + tablet needs the drawer to surface; mobile is unaffected by the + tab rune because the sidebar is CSS-hidden there. +5. `lib/sidebar/inspector-tab.svelte` and + `lib/inspectors/planet-sheet.svelte` both read the selection + store, resolve it against the live report, and either render + `lib/inspectors/planet.svelte` or fall back to the empty state. + A selection that points at a planet missing from the current + report (visibility lost between turns) collapses to the empty + state instead of holding stale rows. + +The mobile bottom-sheet is mounted alongside `` in the +layout. Its visibility is conditional on `effectiveTool === "map"` so +it does not stack on top of the calc / order overlays. Phase 13 ships +the minimal dismissal surface: a close button (`✕`) that calls +`SelectionStore.clear()`. Tap-outside and swipe-down dismissal from +the IA section are deferred to Phase 35 polish. A click that lands on +empty space is a no-op — selection is mutated only by an explicit +planet click or by the close button. + +The planet inspector itself is a presentational component: it takes +a `ReportPlanet` snapshot as a prop and renders the documented field +set per planet kind. The wrapper in `api/game-state.ts` exposes every +field the FBS schema carries (`industryStockpile` for `capital`, +`materialsStockpile` for `material`, `industry`, `population`, +`colonists`, `production`, `freeIndustry`, plus `owner` for `other`). +Fields the FBS table does not project for a given kind read as `null` +and the inspector simply omits the row. + +The selected-planet visual on the map (a ring or halo) is **not** +shipped in Phase 13. It rolls into Phase 35 polish together with the +sheet's swipe-to-dismiss gesture. + ## Auth gate The root `+layout.svelte` redirects `anonymous → /login` for any diff --git a/ui/frontend/src/api/game-state.ts b/ui/frontend/src/api/game-state.ts index ccb3f22..6c3dcd2 100644 --- a/ui/frontend/src/api/game-state.ts +++ b/ui/frontend/src/api/game-state.ts @@ -1,9 +1,15 @@ // Typed wrapper around `GalaxyClient.executeCommand("user.games.report", // ...)`. The signed-gRPC wire shape is the FlatBuffers // `report.GameReportRequest` for the request and `report.Report` for -// the response (see `pkg/schema/fbs/report.fbs`). Phase 11 only -// surfaces the planet subset of the response — full ship / fleet / +// the response (see `pkg/schema/fbs/report.fbs`). Full ship / fleet / // science decoding lands in Phases 17-22. +// +// Phase 13 expanded the per-planet projection so the inspector can +// render every documented field without a second round-trip. Each +// planet field is optional: the FBS schema carries different field +// sets for `LocalPlanet`, `OtherPlanet`, `UninhabitedPlanet`, and +// `UnidentifiedPlanet`, and the wrapper preserves that nullability +// instead of inventing zero values. import { Builder, ByteBuffer } from "flatbuffers"; @@ -37,6 +43,16 @@ export interface ReportPlanet { owner: string | null; size: number | null; resources: number | null; + // Engine field naming carries history: `capital` ($) is the + // industry stockpile, `material` (M) is the materials stockpile. + // `pkg/model/report/planet.go` is the source of truth for these. + industryStockpile: number | null; + materialsStockpile: number | null; + industry: number | null; + population: number | null; + colonists: number | null; + production: string | null; + freeIndustry: number | null; } export interface GameReport { @@ -85,6 +101,13 @@ function decodeReport(report: Report): GameReport { owner: null, size: p.size(), resources: p.resources(), + industryStockpile: p.capital(), + materialsStockpile: p.material(), + industry: p.industry(), + population: p.population(), + colonists: p.colonists(), + production: p.production() ?? null, + freeIndustry: p.freeIndustry(), }); } @@ -100,6 +123,13 @@ function decodeReport(report: Report): GameReport { owner: p.owner() ?? null, size: p.size(), resources: p.resources(), + industryStockpile: p.capital(), + materialsStockpile: p.material(), + industry: p.industry(), + population: p.population(), + colonists: p.colonists(), + production: p.production() ?? null, + freeIndustry: p.freeIndustry(), }); } @@ -115,6 +145,13 @@ function decodeReport(report: Report): GameReport { owner: null, size: p.size(), resources: p.resources(), + industryStockpile: p.capital(), + materialsStockpile: p.material(), + industry: null, + population: null, + colonists: null, + production: null, + freeIndustry: null, }); } @@ -130,6 +167,13 @@ function decodeReport(report: Report): GameReport { owner: null, size: null, resources: null, + industryStockpile: null, + materialsStockpile: null, + industry: null, + population: null, + colonists: null, + production: null, + freeIndustry: null, }); } diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index f44dfbf..0a39cc0 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -8,10 +8,16 @@ the existing renderer instance alive). Empty-planet reports render the empty world without errors — the regression test in `tests/e2e/game-shell-map.spec.ts` covers this. -Phase 9 owns the renderer's hit-test and pan/zoom semantics; Phase 13 -will plug map clicks into the inspector. Phase 29 wires the wrap-mode -toggle on top of the per-game `wrapMode` preference the store -already manages. +Phase 9 owns the renderer's hit-test and pan/zoom semantics. Phase 13 +plugs map clicks into the inspector by translating the renderer's +`clicked` event into a hit-test, looking the planet up by id in the +report, and calling `SelectionStore.selectPlanet`. The selection +store, set in the layout, drives both the desktop sidebar inspector +tab and the mobile bottom-sheet — the map view itself does not need +to know which surface is showing the result. + +Phase 29 wires the wrap-mode toggle on top of the per-game `wrapMode` +preference the store already manages. --> + +{#if planet !== null && onMap} +
+ + +
+{/if} + + diff --git a/ui/frontend/src/lib/inspectors/planet.svelte b/ui/frontend/src/lib/inspectors/planet.svelte new file mode 100644 index 0000000..2680207 --- /dev/null +++ b/ui/frontend/src/lib/inspectors/planet.svelte @@ -0,0 +1,197 @@ + + + +
+
+

{kindLabel}

+ {#if planet.kind !== "unidentified"} +

{planet.name}

+ {/if} +
+ +
+ {#if planet.kind === "other" && planet.owner !== null} +
+
{i18n.t("game.inspector.planet.field.owner")}
+
{planet.owner}
+
+ {/if} + +
+
{i18n.t("game.inspector.planet.field.coordinates")}
+
{coordinates}
+
+ + {#if planet.size !== null} +
+
{i18n.t("game.inspector.planet.field.size")}
+
{formatNumber(planet.size)}
+
+ {/if} + + {#if planet.resources !== null} +
+
{i18n.t("game.inspector.planet.field.natural_resources")}
+
{formatNumber(planet.resources)}
+
+ {/if} + + {#if planet.population !== null} +
+
{i18n.t("game.inspector.planet.field.population")}
+
{formatNumber(planet.population)}
+
+ {/if} + + {#if planet.colonists !== null} +
+
{i18n.t("game.inspector.planet.field.colonists")}
+
{formatNumber(planet.colonists)}
+
+ {/if} + + {#if planet.industry !== null} +
+
{i18n.t("game.inspector.planet.field.industry")}
+
{formatNumber(planet.industry)}
+
+ {/if} + + {#if planet.industryStockpile !== null} +
+
{i18n.t("game.inspector.planet.field.industry_stockpile")}
+
{formatNumber(planet.industryStockpile)}
+
+ {/if} + + {#if planet.materialsStockpile !== null} +
+
{i18n.t("game.inspector.planet.field.materials_stockpile")}
+
{formatNumber(planet.materialsStockpile)}
+
+ {/if} + + {#if planet.production !== null} +
+
{i18n.t("game.inspector.planet.field.production")}
+
{productionLabel}
+
+ {/if} + + {#if planet.freeIndustry !== null} +
+
{i18n.t("game.inspector.planet.field.free_industry")}
+
{formatNumber(planet.freeIndustry)}
+
+ {/if} +
+ + {#if planet.kind === "unidentified"} +

+ {i18n.t("game.inspector.planet.unidentified_no_data")} +

+ {/if} +
+ + diff --git a/ui/frontend/src/lib/selection.svelte.ts b/ui/frontend/src/lib/selection.svelte.ts new file mode 100644 index 0000000..f0a651e --- /dev/null +++ b/ui/frontend/src/lib/selection.svelte.ts @@ -0,0 +1,66 @@ +// Per-game selection state: which on-map object the user is +// currently inspecting. Phase 13 only models planet selection, so +// the union has a single variant; later phases (Phase 19 ship-group +// inspector) will widen it. +// +// The store is in-memory only: lifetime matches the in-game shell +// layout instance, which itself is preserved across active-view +// switches inside `/games/:id/*`. Persisting selection across +// reloads is intentionally out of scope — the Phase 13 acceptance +// criterion calls out "across view switches", and survival across a +// reload would be a surprising contrast with the empty-state copy +// users see on first load. +// +// Like `GameStateStore` and `OrderDraftStore`, the store is +// instantiated by the layout and shared with descendants through +// Svelte context. The map view pushes selection events into it; the +// inspector tab and the mobile bottom-sheet read from it. +// +// The store deliberately carries no Svelte component imports so it +// can be tested directly without rendering any UI. + +/** + * Selected describes the currently selected map object. Phase 13 + * ships only the planet variant; later inspector phases extend the + * discriminated union (`ship-group`, etc.) without changing the + * store's contract. + */ +export type Selected = { kind: "planet"; id: number }; + +/** + * SELECTION_CONTEXT_KEY is the Svelte context key the in-game shell + * layout uses to expose its `SelectionStore` instance to descendants. + * Map view, inspector tab, and the mobile bottom-sheet resolve the + * store via `getContext(SELECTION_CONTEXT_KEY)`. + */ +export const SELECTION_CONTEXT_KEY = Symbol("selection"); + +export class SelectionStore { + selected: Selected | null = $state(null); + + private destroyed = false; + + /** + * selectPlanet sets the active selection to the planet identified + * by its engine `number`. A no-op once the store has been disposed. + */ + selectPlanet(id: number): void { + if (this.destroyed) return; + this.selected = { kind: "planet", id }; + } + + /** + * clear drops the current selection. The mobile sheet's close + * button calls this; otherwise selection persists across active- + * view switches. + */ + clear(): void { + if (this.destroyed) return; + this.selected = null; + } + + dispose(): void { + this.destroyed = true; + this.selected = null; + } +} diff --git a/ui/frontend/src/lib/sidebar/inspector-tab.svelte b/ui/frontend/src/lib/sidebar/inspector-tab.svelte index f505278..2c00825 100644 --- a/ui/frontend/src/lib/sidebar/inspector-tab.svelte +++ b/ui/frontend/src/lib/sidebar/inspector-tab.svelte @@ -1,29 +1,66 @@
-

{i18n.t("game.sidebar.tab.inspector")}

-

{i18n.t("game.sidebar.empty.inspector")}

+ {#if selectedPlanet !== null} + + {:else} +

{i18n.t("game.sidebar.tab.inspector")}

+

{i18n.t("game.sidebar.empty.inspector")}

+ {/if}
diff --git a/ui/frontend/src/lib/sidebar/sidebar.svelte b/ui/frontend/src/lib/sidebar/sidebar.svelte index 344ad32..1bb11b2 100644 --- a/ui/frontend/src/lib/sidebar/sidebar.svelte +++ b/ui/frontend/src/lib/sidebar/sidebar.svelte @@ -14,6 +14,12 @@ The `historyMode` prop hides the Order tab when true: the tab-bar filters it out and any URL seed targeting `order` falls back to `inspector`. Phase 12 wires the prop through the layout as a constant `false`; Phase 26 flips it on for past-turn snapshots. + +`activeTab` is a `$bindable` prop so the layout can drive it from +external events (Phase 13 reveals the inspector tab when a planet +is clicked on the map). The URL seed and the history-mode reset +both mutate the bindable in place; the layout sees the change +through the binding without extra plumbing. -->