ui/phase-13: planet inspector — read-only

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.
This commit is contained in:
Ilia Denisov
2026-05-09 08:29:03 +02:00
parent a3fdcfe9c5
commit 6364bba6fd
19 changed files with 1440 additions and 75 deletions
+46 -2
View File
@@ -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,
});
}
+44 -4
View File
@@ -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.
-->
<script lang="ts">
import { getContext, onDestroy, onMount, untrack } from "svelte";
@@ -26,8 +32,13 @@ already manages.
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
let canvasEl: HTMLCanvasElement | null = $state(null);
let containerEl: HTMLDivElement | null = $state(null);
@@ -37,6 +48,7 @@ already manages.
let mountedTurn: number | null = null;
let mountedGameId: string | null = null;
let onResize: (() => void) | null = null;
let detachClick: (() => void) | null = null;
let mounted = false;
$effect(() => {
@@ -70,6 +82,10 @@ already manages.
mode: "torus" | "no-wrap",
): Promise<void> {
if (canvasEl === null || containerEl === null) return;
if (detachClick !== null) {
detachClick();
detachClick = null;
}
if (handle !== null) {
handle.dispose();
handle = null;
@@ -92,6 +108,7 @@ already manages.
);
handle.viewport.setZoom(minScale * 1.05, true);
if (mode === "no-wrap") handle.setMode("no-wrap");
detachClick = handle.onClick(handleMapClick);
mountedTurn = report.turn;
mountedGameId = store?.gameId ?? "";
mountError = null;
@@ -100,6 +117,25 @@ already manages.
}
}
// handleMapClick translates a renderer click into a planet
// selection. A click that misses every primitive (empty space) is
// a deliberate no-op: the selection rule for Phase 13 is that
// only the explicit close button on the mobile sheet clears the
// current selection.
function handleMapClick(cursorPx: { x: number; y: number }): void {
if (handle === null || store?.report === undefined || store.report === null) {
return;
}
if (selection === undefined) return;
const hit = handle.hitAt(cursorPx);
if (hit === null) return;
if (hit.primitive.kind !== "point") return;
const planetId = hit.primitive.id;
const planet = store.report.planets.find((p) => p.number === planetId);
if (planet === undefined) return;
selection.selectPlanet(planet.number);
}
onMount(() => {
mounted = true;
onResize = (): void => {
@@ -115,6 +151,10 @@ already manages.
window.removeEventListener("resize", onResize);
onResize = null;
}
if (detachClick !== null) {
detachClick();
detachClick = null;
}
if (handle !== null) {
handle.dispose();
handle = null;
+20
View File
@@ -124,6 +124,26 @@ const en = {
"game.bottom_tabs.calc": "calc",
"game.bottom_tabs.order": "order",
"game.bottom_tabs.more": "more",
"game.inspector.planet.kind.local": "your planet",
"game.inspector.planet.kind.other": "other race planet",
"game.inspector.planet.kind.uninhabited": "uninhabited planet",
"game.inspector.planet.kind.unidentified": "unidentified planet",
"game.inspector.planet.field.name": "name",
"game.inspector.planet.field.owner": "owner",
"game.inspector.planet.field.coordinates": "coordinates",
"game.inspector.planet.field.size": "size",
"game.inspector.planet.field.population": "population",
"game.inspector.planet.field.colonists": "colonists",
"game.inspector.planet.field.industry": "industry",
"game.inspector.planet.field.industry_stockpile": "industry stockpile ($)",
"game.inspector.planet.field.materials_stockpile": "materials stockpile (M)",
"game.inspector.planet.field.natural_resources": "natural resources",
"game.inspector.planet.field.production": "current production",
"game.inspector.planet.field.free_industry": "free production",
"game.inspector.planet.production_none": "none",
"game.inspector.planet.unidentified_no_data": "no data — only the location is known",
"game.inspector.sheet_close": "close",
} as const;
export default en;
+20
View File
@@ -125,6 +125,26 @@ const ru: Record<keyof typeof en, string> = {
"game.bottom_tabs.calc": "калк",
"game.bottom_tabs.order": "приказ",
"game.bottom_tabs.more": "ещё",
"game.inspector.planet.kind.local": "ваша планета",
"game.inspector.planet.kind.other": "планета другой расы",
"game.inspector.planet.kind.uninhabited": "необитаемая планета",
"game.inspector.planet.kind.unidentified": "неопознанная планета",
"game.inspector.planet.field.name": "название",
"game.inspector.planet.field.owner": "владелец",
"game.inspector.planet.field.coordinates": "координаты",
"game.inspector.planet.field.size": "размер",
"game.inspector.planet.field.population": "население",
"game.inspector.planet.field.colonists": "колонисты",
"game.inspector.planet.field.industry": "промышленность",
"game.inspector.planet.field.industry_stockpile": "запасы промышленности ($)",
"game.inspector.planet.field.materials_stockpile": "запасы сырья (M)",
"game.inspector.planet.field.natural_resources": "природные ресурсы",
"game.inspector.planet.field.production": "текущее производство",
"game.inspector.planet.field.free_industry": "свободные мощности",
"game.inspector.planet.production_none": "не задано",
"game.inspector.planet.unidentified_no_data": "нет данных — известно только местоположение",
"game.inspector.sheet_close": "закрыть",
};
export default ru;
@@ -0,0 +1,82 @@
<!--
Phase 13 mobile bottom-sheet that hosts the planet inspector when
the user is on the map view on a small screen. Desktop and tablet
breakpoints already render the inspector inside the sidebar, so
the sheet is hidden via a media query above 768 px and the
component is only mounted while the active tool is `map` (so it
does not stack on top of the calc / order overlays).
Phase 13 ships the minimal dismissal surface: a close button (`✕`)
that clears the selection. Swipe-to-dismiss and tap-outside-to-
dismiss from the IA section §6 land in Phase 35 polish.
-->
<script lang="ts">
import type { ReportPlanet } from "../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte";
import Planet from "./planet.svelte";
type Props = {
planet: ReportPlanet | null;
onMap: boolean;
onClose: () => void;
};
let { planet, onMap, onClose }: Props = $props();
</script>
{#if planet !== null && onMap}
<section
class="sheet"
aria-label={i18n.t("game.sidebar.tab.inspector")}
data-testid="inspector-planet-sheet"
>
<button
type="button"
class="close"
data-testid="inspector-planet-sheet-close"
aria-label={i18n.t("game.inspector.sheet_close")}
onclick={onClose}
>
</button>
<Planet {planet} />
</section>
{/if}
<style>
.sheet {
display: none;
}
@media (max-width: 767.98px) {
.sheet {
display: block;
position: fixed;
left: 0;
right: 0;
bottom: 3.25rem;
max-height: calc(100vh - 6rem);
overflow-y: auto;
background: #14182a;
color: #e8eaf6;
border-top: 1px solid #2a3150;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
z-index: 40;
}
}
.close {
position: absolute;
top: 0.4rem;
right: 0.4rem;
font: inherit;
font-size: 1rem;
padding: 0.2rem 0.55rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.close:hover {
color: #e8eaf6;
border-color: #6d8cff;
}
</style>
@@ -0,0 +1,197 @@
<!--
Phase 13 read-only planet inspector. Renders the documented field
set for the planet kind in question:
- `local` / `other` carry the full economy: name, owner (other only),
coordinates, size, population, colonists, industry, both stockpiles,
natural resources, current production, free production potential.
- `uninhabited` keeps name, coordinates, size, both stockpiles, and
natural resources — the engine does not project industry or
population for unowned planets.
- `unidentified` is reduced to coordinates plus a no-data hint.
The component is purely presentational: the parent supplies a
`ReportPlanet` snapshot resolved from `GameStateStore`, no store
lookups happen here. Phase 14 will extend the same component with a
`Rename` action; the read-only layout stays the structural baseline.
-->
<script lang="ts">
import type { ReportPlanet } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
type Props = {
planet: ReportPlanet;
};
let { planet }: Props = $props();
const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
local: "game.inspector.planet.kind.local",
other: "game.inspector.planet.kind.other",
uninhabited: "game.inspector.planet.kind.uninhabited",
unidentified: "game.inspector.planet.kind.unidentified",
};
const kindLabel = $derived(i18n.t(kindKeyMap[planet.kind]));
const coordinates = $derived(
`(${formatNumber(planet.x)}, ${formatNumber(planet.y)})`,
);
const productionLabel = $derived(productionDisplay(planet.production));
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function productionDisplay(value: string | null): string {
if (value === null || value === "") {
return i18n.t("game.inspector.planet.production_none");
}
return value;
}
</script>
<section
class="inspector"
data-testid="inspector-planet"
data-planet-id={planet.number}
data-planet-kind={planet.kind}
>
<header>
<p class="kind" data-testid="inspector-planet-kind">{kindLabel}</p>
{#if planet.kind !== "unidentified"}
<h3 class="name" data-testid="inspector-planet-name">{planet.name}</h3>
{/if}
</header>
<dl class="fields">
{#if planet.kind === "other" && planet.owner !== null}
<div class="field" data-testid="inspector-planet-field-owner">
<dt>{i18n.t("game.inspector.planet.field.owner")}</dt>
<dd>{planet.owner}</dd>
</div>
{/if}
<div class="field" data-testid="inspector-planet-field-coordinates">
<dt>{i18n.t("game.inspector.planet.field.coordinates")}</dt>
<dd>{coordinates}</dd>
</div>
{#if planet.size !== null}
<div class="field" data-testid="inspector-planet-field-size">
<dt>{i18n.t("game.inspector.planet.field.size")}</dt>
<dd>{formatNumber(planet.size)}</dd>
</div>
{/if}
{#if planet.resources !== null}
<div class="field" data-testid="inspector-planet-field-natural_resources">
<dt>{i18n.t("game.inspector.planet.field.natural_resources")}</dt>
<dd>{formatNumber(planet.resources)}</dd>
</div>
{/if}
{#if planet.population !== null}
<div class="field" data-testid="inspector-planet-field-population">
<dt>{i18n.t("game.inspector.planet.field.population")}</dt>
<dd>{formatNumber(planet.population)}</dd>
</div>
{/if}
{#if planet.colonists !== null}
<div class="field" data-testid="inspector-planet-field-colonists">
<dt>{i18n.t("game.inspector.planet.field.colonists")}</dt>
<dd>{formatNumber(planet.colonists)}</dd>
</div>
{/if}
{#if planet.industry !== null}
<div class="field" data-testid="inspector-planet-field-industry">
<dt>{i18n.t("game.inspector.planet.field.industry")}</dt>
<dd>{formatNumber(planet.industry)}</dd>
</div>
{/if}
{#if planet.industryStockpile !== null}
<div class="field" data-testid="inspector-planet-field-industry_stockpile">
<dt>{i18n.t("game.inspector.planet.field.industry_stockpile")}</dt>
<dd>{formatNumber(planet.industryStockpile)}</dd>
</div>
{/if}
{#if planet.materialsStockpile !== null}
<div class="field" data-testid="inspector-planet-field-materials_stockpile">
<dt>{i18n.t("game.inspector.planet.field.materials_stockpile")}</dt>
<dd>{formatNumber(planet.materialsStockpile)}</dd>
</div>
{/if}
{#if planet.production !== null}
<div class="field" data-testid="inspector-planet-field-production">
<dt>{i18n.t("game.inspector.planet.field.production")}</dt>
<dd>{productionLabel}</dd>
</div>
{/if}
{#if planet.freeIndustry !== null}
<div class="field" data-testid="inspector-planet-field-free_industry">
<dt>{i18n.t("game.inspector.planet.field.free_industry")}</dt>
<dd>{formatNumber(planet.freeIndustry)}</dd>
</div>
{/if}
</dl>
{#if planet.kind === "unidentified"}
<p class="hint" data-testid="inspector-planet-no-data">
{i18n.t("game.inspector.planet.unidentified_no_data")}
</p>
{/if}
</section>
<style>
.inspector {
padding: 1rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.kind {
margin: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #aab;
}
.name {
margin: 0;
font-size: 1.05rem;
}
.fields {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.25rem;
column-gap: 0.75rem;
}
.field {
display: contents;
}
.field dt {
color: #aab;
font-size: 0.85rem;
}
.field dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.hint {
margin: 0;
color: #888;
font-size: 0.85rem;
}
</style>
+66
View File
@@ -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;
}
}
@@ -1,29 +1,66 @@
<!--
Phase 10 stub for the Inspector sidebar tool. The empty-state copy
matches the IA section verbatim — `select an object on the map` —
so the user understands the intended interaction before Phase 13
wires real planet selection.
Inspector sidebar tool. Reads the per-game `SelectionStore` and the
`GameStateStore` from context (both set by the in-game shell layout).
When a planet selection resolves to a live `ReportPlanet` in the
current report, the tab swaps the empty-state copy for the read-
only planet inspector. A selection that points at a planet missing
from the current report (e.g. visibility lost between turns) falls
back to the empty state instead of holding stale data.
The empty-state copy still matches the IA section verbatim — `select
an object on the map` — so the no-selection experience is unchanged
from the Phase 10 stub.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
import Planet from "$lib/inspectors/planet.svelte";
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
const selection = getContext<SelectionStore | undefined>(
SELECTION_CONTEXT_KEY,
);
const selectedPlanet = $derived.by(() => {
const sel = selection?.selected;
if (sel === undefined || sel === null || sel.kind !== "planet") return null;
const report = gameState?.report;
if (report === undefined || report === null) return null;
return report.planets.find((p) => p.number === sel.id) ?? null;
});
</script>
<section class="tool" data-testid="sidebar-tool-inspector">
<h3>{i18n.t("game.sidebar.tab.inspector")}</h3>
<p>{i18n.t("game.sidebar.empty.inspector")}</p>
{#if selectedPlanet !== null}
<Planet planet={selectedPlanet} />
{:else}
<h3>{i18n.t("game.sidebar.tab.inspector")}</h3>
<p>{i18n.t("game.sidebar.empty.inspector")}</p>
{/if}
</section>
<style>
.tool {
padding: 1rem;
font-family: system-ui, sans-serif;
}
.tool h3 {
.tool > h3 {
margin: 0 0 0.5rem;
padding: 1rem 1rem 0;
font-size: 1rem;
}
.tool p {
.tool > p {
margin: 0;
padding: 0 1rem 1rem;
color: #888;
}
</style>
+13 -3
View File
@@ -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.
-->
<script lang="ts">
import { onMount } from "svelte";
@@ -29,10 +35,14 @@ constant `false`; Phase 26 flips it on for past-turn snapshots.
open: boolean;
onClose: () => void;
historyMode?: boolean;
activeTab?: SidebarTab;
};
let { open, onClose, historyMode = false }: Props = $props();
let activeTab: SidebarTab = $state("inspector");
let {
open,
onClose,
historyMode = false,
activeTab = $bindable<SidebarTab>("inspector"),
}: Props = $props();
function readUrlSeed(): SidebarTab | null {
const v = page.url.searchParams.get("sidebar");
+21
View File
@@ -58,6 +58,18 @@ export interface RendererHandle {
getViewport(): Viewport;
getBackend(): "webgl" | "webgpu" | "canvas";
hitAt(cursorPx: { x: number; y: number }): Hit | null;
/**
* onClick subscribes `cb` to a click on the map (a pointer-down /
* pointer-up pair without enough drag to trigger pan). The cursor
* is reported in canvas pixel coordinates so callers can hand it
* straight to `hitAt`. Returns a function that detaches the
* listener; the returned disposer is idempotent.
*
* Built on `pixi-viewport`'s `clicked` event, which already
* applies the same drag threshold the pan plugin uses, so a
* click here will not race a pan gesture.
*/
onClick(cb: (cursorPx: { x: number; y: number }) => void): () => void;
resize(widthPx: number, heightPx: number): void;
dispose(): void;
}
@@ -222,6 +234,15 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
getBackend: () => rendererBackendName(app.renderer),
hitAt: (cursorPx) =>
hitTest(opts.world, handle.getCamera(), handle.getViewport(), cursorPx, mode),
onClick: (cb) => {
const handler = (e: { screen: { x: number; y: number } }): void => {
cb({ x: e.screen.x, y: e.screen.y });
};
viewport.on("clicked", handler);
return () => {
viewport.off("clicked", handler);
};
},
resize: (w, h) => {
app.renderer.resize(w, h);
viewport.resize(w, h, opts.world.width, opts.world.height);
@@ -11,18 +11,36 @@ layout owns:
so navigating to any other view through the More drawer or the
header view-menu naturally drops the overlay even if `mobileTool`
was set on a previous tap.
- `activeTab` — current sidebar tool (`calculator` / `inspector` /
`order`). Held here, bound into the sidebar so a planet click on
the map can flip it to `inspector` from the outside (Phase 13).
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
Phase 13 `SelectionStore`. All three are exposed to descendants
via Svelte context; their lifetimes match the layout instance,
which itself stays mounted across active-view switches inside
`/games/:id/*`.
Phase 11 adds the per-game `GameStateStore` instance owned by this
Phase 11 added the per-game `GameStateStore` instance owned by this
layout: it constructs the `GalaxyClient`, fetches the matching lobby
record to discover `current_turn`, then loads the report. The store
is shared with descendants via `setContext("gameState", ...)` so the
header turn counter, the map view, and later inspector tabs all read
header turn counter, the map view, and the inspector tab all read
from the same snapshot.
Phase 13 adds the planet inspector. The layout watches the selection
store and, on the null → planet transition, flips `activeTab` to
`inspector` and `sidebarOpen` to `true` so the inspector becomes
visible regardless of breakpoint (desktop already has the sidebar
pinned; tablet needs the drawer to surface). On mobile the
`<PlanetSheet />` overlay reads the same selection and displays a
read-only sheet over the map; closing the sheet clears the
selection.
State preservation across active-view switches works for free
because SvelteKit keeps this layout instance mounted while children
swap; navigating between games unmounts and remounts the layout, so
the next game's snapshot is loaded fresh.
the next game's snapshot — and the next game's selection — start
fresh.
-->
<script lang="ts">
import { onDestroy, onMount, setContext } from "svelte";
@@ -32,8 +50,13 @@ the next game's snapshot is loaded fresh.
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
import Calculator from "$lib/sidebar/calculator-tab.svelte";
import Order from "$lib/sidebar/order-tab.svelte";
import type { MobileTool } from "$lib/sidebar/types";
import PlanetSheet from "$lib/inspectors/planet-sheet.svelte";
import type { MobileTool, SidebarTab } from "$lib/sidebar/types";
import { GameStateStore, GAME_STATE_CONTEXT_KEY } from "$lib/game-state.svelte";
import {
SelectionStore,
SELECTION_CONTEXT_KEY,
} from "$lib/selection.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
@@ -49,6 +72,7 @@ the next game's snapshot is loaded fresh.
let sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map");
let activeTab: SidebarTab = $state("inspector");
// Phase 12 ships the prop wiring; Phase 26 replaces this constant
// with the real history-mode signal from `lib/history-mode.ts`.
const historyMode = false;
@@ -63,6 +87,33 @@ the next game's snapshot is loaded fresh.
setContext(GAME_STATE_CONTEXT_KEY, gameState);
const orderDraft = new OrderDraftStore();
setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft);
const selection = new SelectionStore();
setContext(SELECTION_CONTEXT_KEY, selection);
// selectedPlanet resolves the current selection against the live
// report so both the desktop sidebar and the mobile sheet display
// the same snapshot. A selection that points at a planet missing
// from the current report (e.g. visibility lost between turns)
// reads as `null` here, which collapses the inspector and the
// sheet without surfacing a stale row.
const selectedPlanet = $derived.by(() => {
const sel = selection.selected;
if (sel === null || sel.kind !== "planet") return null;
const report = gameState.report;
if (report === null) return null;
return report.planets.find((p) => p.number === sel.id) ?? null;
});
// Reveal the inspector whenever a new planet selection lands.
// Reading `selection.selected` once outside the effect keeps the
// effect dependent on the rune transition and not on the derived
// `selectedPlanet`, which can flicker as the report refreshes.
$effect(() => {
const sel = selection.selected;
if (sel === null) return;
activeTab = "inspector";
sidebarOpen = true;
});
function toggleSidebar(): void {
sidebarOpen = !sidebarOpen;
@@ -107,6 +158,7 @@ the next game's snapshot is loaded fresh.
onDestroy(() => {
gameState.dispose();
orderDraft.dispose();
selection.dispose();
});
function describeBootstrapError(err: unknown): string {
@@ -135,6 +187,7 @@ the next game's snapshot is loaded fresh.
open={sidebarOpen}
onClose={() => (sidebarOpen = false)}
{historyMode}
bind:activeTab
/>
</div>
<BottomTabs
@@ -143,6 +196,11 @@ the next game's snapshot is loaded fresh.
onSelectTool={(tool) => (mobileTool = tool)}
hideOrder={historyMode}
/>
<PlanetSheet
planet={selectedPlanet}
onMap={effectiveTool === "map"}
onClose={() => selection.clear()}
/>
</div>
<style>