ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
19 changed files with 1440 additions and 75 deletions
Showing only changes of commit 6364bba6fd - Show all commits
+61 -19
View File
@@ -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.
+71 -10
View File
@@ -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 `<BottomTabs />` 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
+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>
+50 -10
View File
@@ -4,8 +4,12 @@
// engine container.
//
// Phase 11 only renders planets, so the helpers keep the report shape
// minimal (turn / dimensions / planet vectors). Later phases extend
// the helper as ships, fleets, sciences, etc. land.
// minimal (turn / dimensions / planet vectors). Phase 13 extended the
// fixture with the optional rich planet fields (size, resources,
// stockpiles, population, industry, colonists, production, free
// industry) so the inspector e2e can drive the read-only display
// against realistic values. Later phases extend the helper as ships,
// fleets, sciences, etc. land.
import { Builder } from "flatbuffers";
@@ -22,9 +26,21 @@ export interface PlanetFixture {
name: string;
x: number;
y: number;
size?: number;
resources?: number;
capital?: number;
material?: number;
}
export interface OtherPlanetFixture extends PlanetFixture {
export interface InhabitedFixture extends PlanetFixture {
population?: number;
colonists?: number;
industry?: number;
production?: string;
freeIndustry?: number;
}
export interface OtherPlanetFixture extends InhabitedFixture {
owner: string;
}
@@ -32,7 +48,7 @@ export interface ReportFixture {
turn: number;
mapWidth?: number;
mapHeight?: number;
localPlanets?: PlanetFixture[];
localPlanets?: InhabitedFixture[];
otherPlanets?: OtherPlanetFixture[];
uninhabitedPlanets?: PlanetFixture[];
unidentifiedPlanets?: { number: number; x: number; y: number }[];
@@ -43,28 +59,49 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
const localOffsets = (fixture.localPlanets ?? []).map((planet) => {
const name = builder.createString(planet.name);
const production =
planet.production !== undefined
? builder.createString(planet.production)
: null;
LocalPlanet.startLocalPlanet(builder);
LocalPlanet.addNumber(builder, BigInt(planet.number));
LocalPlanet.addX(builder, planet.x);
LocalPlanet.addY(builder, planet.y);
LocalPlanet.addName(builder, name);
LocalPlanet.addSize(builder, 10);
LocalPlanet.addResources(builder, 0.5);
LocalPlanet.addPopulation(builder, 0);
LocalPlanet.addIndustry(builder, 0);
LocalPlanet.addSize(builder, planet.size ?? 10);
LocalPlanet.addResources(builder, planet.resources ?? 0.5);
LocalPlanet.addCapital(builder, planet.capital ?? 0);
LocalPlanet.addMaterial(builder, planet.material ?? 0);
LocalPlanet.addPopulation(builder, planet.population ?? 0);
LocalPlanet.addIndustry(builder, planet.industry ?? 0);
LocalPlanet.addColonists(builder, planet.colonists ?? 0);
if (production !== null) LocalPlanet.addProduction(builder, production);
LocalPlanet.addFreeIndustry(builder, planet.freeIndustry ?? 0);
return LocalPlanet.endLocalPlanet(builder);
});
const otherOffsets = (fixture.otherPlanets ?? []).map((planet) => {
const name = builder.createString(planet.name);
const owner = builder.createString(planet.owner);
const production =
planet.production !== undefined
? builder.createString(planet.production)
: null;
OtherPlanet.startOtherPlanet(builder);
OtherPlanet.addNumber(builder, BigInt(planet.number));
OtherPlanet.addX(builder, planet.x);
OtherPlanet.addY(builder, planet.y);
OtherPlanet.addName(builder, name);
OtherPlanet.addOwner(builder, owner);
OtherPlanet.addSize(builder, 9);
OtherPlanet.addSize(builder, planet.size ?? 9);
OtherPlanet.addResources(builder, planet.resources ?? 0.5);
OtherPlanet.addCapital(builder, planet.capital ?? 0);
OtherPlanet.addMaterial(builder, planet.material ?? 0);
OtherPlanet.addPopulation(builder, planet.population ?? 0);
OtherPlanet.addIndustry(builder, planet.industry ?? 0);
OtherPlanet.addColonists(builder, planet.colonists ?? 0);
if (production !== null) OtherPlanet.addProduction(builder, production);
OtherPlanet.addFreeIndustry(builder, planet.freeIndustry ?? 0);
return OtherPlanet.endOtherPlanet(builder);
});
@@ -76,7 +113,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
UninhabitedPlanet.addX(builder, planet.x);
UninhabitedPlanet.addY(builder, planet.y);
UninhabitedPlanet.addName(builder, name);
UninhabitedPlanet.addSize(builder, 6);
UninhabitedPlanet.addSize(builder, planet.size ?? 6);
UninhabitedPlanet.addResources(builder, planet.resources ?? 0.5);
UninhabitedPlanet.addCapital(builder, planet.capital ?? 0);
UninhabitedPlanet.addMaterial(builder, planet.material ?? 0);
return UninhabitedPlanet.endUninhabitedPlanet(builder);
},
);
@@ -0,0 +1,223 @@
// Phase 13 end-to-end coverage for the planet inspector. Boots an
// authenticated session and a mocked gateway with a single local
// planet placed at the world centre, navigates to the map view, and
// drives a real canvas click into the renderer's `clicked` event.
// On desktop the sidebar inspector tab swaps from the empty state to
// the planet view; on the mobile project the bottom-sheet appears
// and the close button clears it.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ByteBuffer } from "flatbuffers";
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
import { UUID } from "../../src/proto/galaxy/fbs/common";
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildMyGamesListPayload,
type GameFixture,
} from "./fixtures/lobby-fbs";
import { buildReportPayload } from "./fixtures/report-fbs";
const SESSION_ID = "phase-13-inspector-session";
const GAME_ID = "13131313-1313-1313-1313-131313131313";
const WORLD = 4000;
const CENTRE = WORLD / 2;
interface MockOpts {
currentTurn: number;
report: Parameters<typeof buildReportPayload>[0];
}
async function mockGateway(page: Page, opts: MockOpts): Promise<void> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Phase 13 Game",
gameType: "private",
status: "running",
ownerUserId: "user-1",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
createdAtMs: BigInt(Date.now() - 86_400_000),
updatedAtMs: BigInt(Date.now()),
currentTurn: opts.currentTurn,
};
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let resultCode = "ok";
let payload: Uint8Array;
switch (req.messageType) {
case "lobby.my.games.list":
payload = buildMyGamesListPayload([game]);
break;
case "user.games.report": {
// Drain the request to keep the decoder happy even though
// we ignore the turn — the fixture serves a single snapshot.
GameReportRequest.getRootAsGameReportRequest(
new ByteBuffer(req.payloadBytes),
).gameId(new UUID());
payload = buildReportPayload(opts.report);
break;
}
default:
resultCode = "internal_error";
payload = new Uint8Array();
}
const body = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode,
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body,
});
},
);
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
async () => {
await new Promise<void>(() => {});
},
);
}
async function bootSession(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
}
async function setupShell(page: Page): Promise<void> {
await mockGateway(page, {
currentTurn: 4,
report: {
turn: 4,
mapWidth: WORLD,
mapHeight: WORLD,
localPlanets: [
{
number: 17,
name: "Galactica",
x: CENTRE,
y: CENTRE,
size: 1000,
resources: 10,
capital: 0,
material: 0,
population: 850,
colonists: 25,
industry: 700,
production: "drive",
freeIndustry: 175,
},
],
},
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
"data-planet-count",
"1",
);
}
async function clickCanvasCentre(page: Page): Promise<void> {
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (box === null) throw new Error("canvas has no bounding box");
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
await page.mouse.click(cx, cy);
}
test("clicking a planet on the map shows it in the desktop inspector tab", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"sidebar is hidden on mobile breakpoint",
);
await setupShell(page);
// Empty state before any selection.
const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar).toContainText("select an object on the map");
await clickCanvasCentre(page);
// Both the sidebar inspector and the bottom-sheet receive the
// same selection — the sheet is hidden by CSS at the desktop
// breakpoint but still mounted in the DOM, so the assertions
// scope explicitly to the sidebar to avoid the strict-mode
// duplicate-locator trap.
const inspector = sidebar.getByTestId("inspector-planet");
await expect(inspector).toBeVisible();
await expect(inspector).toHaveAttribute("data-planet-id", "17");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
"Galactica",
);
await expect(
sidebar.getByTestId("inspector-planet-field-population"),
).toContainText("850");
await expect(
sidebar.getByTestId("inspector-planet-field-industry"),
).toContainText("700");
await expect(
sidebar.getByTestId("inspector-planet-field-production"),
).toContainText("drive");
});
test("clicking a planet on mobile raises the bottom-sheet, close clears it", async ({
page,
}, testInfo) => {
test.skip(
!testInfo.project.name.startsWith("chromium-mobile"),
"sheet is mobile-only",
);
await setupShell(page);
// No sheet before the click.
await expect(page.getByTestId("inspector-planet-sheet")).toHaveCount(0);
await clickCanvasCentre(page);
const sheet = page.getByTestId("inspector-planet-sheet");
await expect(sheet).toBeVisible();
const inspector = sheet.getByTestId("inspector-planet");
await expect(inspector).toHaveAttribute("data-planet-id", "17");
await expect(sheet.getByTestId("inspector-planet-name")).toHaveText(
"Galactica",
);
await page.getByTestId("inspector-planet-sheet-close").click();
await expect(page.getByTestId("inspector-planet-sheet")).toHaveCount(0);
});
+118 -2
View File
@@ -1,7 +1,8 @@
// Component tests for the Phase 10 in-game shell sidebar. Validates
// the default selected tab, the Calculator / Inspector / Order
// switching, the empty-state copy that matches the IA section, and
// the `?sidebar=` URL seed convention used by the mobile bottom-tabs.
// switching, the empty-state copy that matches the IA section, the
// `?sidebar=` URL seed convention used by the mobile bottom-tabs,
// and the Phase 13 selection-driven planet inspector content.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
@@ -14,6 +15,15 @@ import {
} from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import {
GAME_STATE_CONTEXT_KEY,
GameStateStore,
} from "../src/lib/game-state.svelte";
import {
SELECTION_CONTEXT_KEY,
SelectionStore,
} from "../src/lib/selection.svelte";
import type { GameReport, ReportPlanet } from "../src/api/game-state";
const pageMock = vi.hoisted(() => ({
url: new URL("http://localhost/games/g1/map"),
@@ -26,6 +36,53 @@ vi.mock("$app/state", () => ({
import Sidebar from "../src/lib/sidebar/sidebar.svelte";
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
return {
number: 0,
name: "",
x: 0,
y: 0,
kind: "local",
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
...overrides,
};
}
function makeReport(planets: ReportPlanet[]): GameReport {
return {
turn: 1,
mapWidth: 1000,
mapHeight: 1000,
planetCount: planets.length,
planets,
};
}
function withStores(report: GameReport | null): {
gameState: GameStateStore;
selection: SelectionStore;
context: Map<unknown, unknown>;
} {
const gameState = new GameStateStore();
gameState.report = report;
gameState.status = report === null ? "idle" : "ready";
const selection = new SelectionStore();
const context = new Map<unknown, unknown>([
[GAME_STATE_CONTEXT_KEY, gameState],
[SELECTION_CONTEXT_KEY, selection],
]);
return { gameState, selection, context };
}
beforeEach(() => {
i18n.resetForTests("en");
pageMock.url = new URL("http://localhost/games/g1/map");
@@ -95,4 +152,63 @@ describe("game-shell sidebar", () => {
await fireEvent.click(ui.getByTestId("sidebar-close"));
expect(onClose).toHaveBeenCalledTimes(1);
});
test("inspector tab swaps to the planet view when a planet is selected", async () => {
const planet = makePlanet({
number: 17,
name: "Galactica",
kind: "local",
x: 50,
y: 75,
size: 1000,
resources: 10,
population: 800,
colonists: 0,
industry: 600,
industryStockpile: 0,
materialsStockpile: 0,
production: "drive",
freeIndustry: 200,
});
const { selection, context } = withStores(makeReport([planet]));
const ui = render(
Sidebar,
{
props: { open: false, onClose: () => {} },
context,
},
);
expect(ui.getByTestId("sidebar-tool-inspector")).toHaveTextContent(
"select an object on the map",
);
selection.selectPlanet(17);
await Promise.resolve();
expect(ui.queryByText("select an object on the map")).toBeNull();
expect(ui.getByTestId("inspector-planet")).toHaveAttribute(
"data-planet-id",
"17",
);
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
"Galactica",
);
});
test("selection that points at a missing planet falls back to the empty state", () => {
const { selection, context } = withStores(
makeReport([makePlanet({ number: 1, name: "Visible", kind: "local" })]),
);
selection.selectPlanet(999);
const ui = render(
Sidebar,
{
props: { open: false, onClose: () => {} },
context,
},
);
expect(ui.queryByTestId("inspector-planet")).toBeNull();
expect(ui.getByTestId("sidebar-tool-inspector")).toHaveTextContent(
"select an object on the map",
);
});
});
+218
View File
@@ -0,0 +1,218 @@
// Vitest component coverage for the read-only planet inspector.
// Each kind has a dedicated case so the per-kind field gating
// (which fields are present, which are hidden) is verified
// explicitly. The component is purely presentational, so the tests
// drive it with synthetic `ReportPlanet` literals — no store.
import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/svelte";
import { beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type { ReportPlanet } from "../src/api/game-state";
import Planet from "../src/lib/inspectors/planet.svelte";
beforeEach(() => {
i18n.resetForTests("en");
});
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
return {
number: 0,
name: "",
x: 0,
y: 0,
kind: "local",
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
...overrides,
};
}
describe("planet inspector", () => {
test("local planet renders the full economy field set", () => {
const ui = render(Planet, {
props: {
planet: makePlanet({
number: 7,
name: "Home World",
kind: "local",
x: 100.25,
y: 200,
size: 1000,
resources: 10,
population: 950,
colonists: 50,
industry: 800,
industryStockpile: 12.5,
materialsStockpile: 30,
production: "drive",
freeIndustry: 187.5,
}),
},
});
const section = ui.getByTestId("inspector-planet");
expect(section).toHaveAttribute("data-planet-id", "7");
expect(section).toHaveAttribute("data-planet-kind", "local");
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
"Home World",
);
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
"your planet",
);
expect(
ui.getByTestId("inspector-planet-field-coordinates"),
).toHaveTextContent("(100.25, 200)");
expect(ui.getByTestId("inspector-planet-field-size")).toHaveTextContent(
"size",
);
expect(
ui.getByTestId("inspector-planet-field-natural_resources"),
).toHaveTextContent("10");
expect(
ui.getByTestId("inspector-planet-field-population"),
).toHaveTextContent("950");
expect(
ui.getByTestId("inspector-planet-field-colonists"),
).toHaveTextContent("50");
expect(
ui.getByTestId("inspector-planet-field-industry"),
).toHaveTextContent("800");
expect(
ui.getByTestId("inspector-planet-field-industry_stockpile"),
).toHaveTextContent("12.5");
expect(
ui.getByTestId("inspector-planet-field-materials_stockpile"),
).toHaveTextContent("30");
expect(
ui.getByTestId("inspector-planet-field-production"),
).toHaveTextContent("drive");
expect(
ui.getByTestId("inspector-planet-field-free_industry"),
).toHaveTextContent("187.5");
expect(ui.queryByTestId("inspector-planet-field-owner")).toBeNull();
expect(ui.queryByTestId("inspector-planet-no-data")).toBeNull();
});
test("other-race planet shows the owner row", () => {
const ui = render(Planet, {
props: {
planet: makePlanet({
number: 9,
name: "Far Away",
kind: "other",
owner: "Federation",
size: 700,
resources: 5,
population: 500,
colonists: 12,
industry: 400,
industryStockpile: 5,
materialsStockpile: 8,
production: "weapons",
freeIndustry: 75,
}),
},
});
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
"other race planet",
);
expect(ui.getByTestId("inspector-planet-field-owner")).toHaveTextContent(
"Federation",
);
expect(
ui.getByTestId("inspector-planet-field-population"),
).toHaveTextContent("500");
});
test("uninhabited planet hides population, industry, and production rows", () => {
const ui = render(Planet, {
props: {
planet: makePlanet({
number: 3,
name: "Bare Rock",
kind: "uninhabited",
size: 250,
resources: 1.5,
industryStockpile: 0,
materialsStockpile: 0,
}),
},
});
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
"uninhabited planet",
);
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
"Bare Rock",
);
expect(ui.getByTestId("inspector-planet-field-size")).toHaveTextContent(
"250",
);
expect(
ui.getByTestId("inspector-planet-field-natural_resources"),
).toHaveTextContent("1.5");
expect(ui.queryByTestId("inspector-planet-field-population")).toBeNull();
expect(ui.queryByTestId("inspector-planet-field-colonists")).toBeNull();
expect(ui.queryByTestId("inspector-planet-field-industry")).toBeNull();
expect(ui.queryByTestId("inspector-planet-field-production")).toBeNull();
expect(ui.queryByTestId("inspector-planet-field-free_industry")).toBeNull();
expect(ui.queryByTestId("inspector-planet-field-owner")).toBeNull();
});
test("unidentified planet shows the no-data hint and only coordinates", () => {
const ui = render(Planet, {
props: {
planet: makePlanet({
number: 42,
kind: "unidentified",
x: 1234,
y: -5,
}),
},
});
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
"unidentified planet",
);
expect(ui.queryByTestId("inspector-planet-name")).toBeNull();
expect(ui.getByTestId("inspector-planet-no-data")).toHaveTextContent(
"no data",
);
expect(
ui.getByTestId("inspector-planet-field-coordinates"),
).toHaveTextContent("(1,234, -5)");
expect(ui.queryByTestId("inspector-planet-field-size")).toBeNull();
expect(ui.queryByTestId("inspector-planet-field-natural_resources")).toBeNull();
});
test("missing production string falls back to the localised placeholder", () => {
const ui = render(Planet, {
props: {
planet: makePlanet({
number: 5,
name: "Idle",
kind: "local",
size: 800,
resources: 1,
population: 1,
colonists: 0,
industry: 0,
industryStockpile: 0,
materialsStockpile: 0,
production: "",
freeIndustry: 0,
}),
},
});
expect(
ui.getByTestId("inspector-planet-field-production"),
).toHaveTextContent("none");
});
});
+47
View File
@@ -0,0 +1,47 @@
// SelectionStore unit tests. The store is in-memory only and carries
// no async lifecycle, so the cases focus on the rune transitions and
// the post-`dispose` no-op contract.
import { describe, expect, test } from "vitest";
import { SelectionStore } from "../src/lib/selection.svelte";
describe("SelectionStore", () => {
test("initial state has no selection", () => {
const store = new SelectionStore();
expect(store.selected).toBeNull();
});
test("selectPlanet records the planet id", () => {
const store = new SelectionStore();
store.selectPlanet(42);
expect(store.selected).toEqual({ kind: "planet", id: 42 });
});
test("selectPlanet replaces the previous selection", () => {
const store = new SelectionStore();
store.selectPlanet(1);
store.selectPlanet(2);
expect(store.selected).toEqual({ kind: "planet", id: 2 });
});
test("clear resets the selection to null", () => {
const store = new SelectionStore();
store.selectPlanet(7);
store.clear();
expect(store.selected).toBeNull();
});
test("dispose blocks subsequent mutations", () => {
const store = new SelectionStore();
store.selectPlanet(3);
store.dispose();
expect(store.selected).toBeNull();
store.selectPlanet(4);
expect(store.selected).toBeNull();
store.clear();
expect(store.selected).toBeNull();
});
});
+35 -12
View File
@@ -9,7 +9,7 @@
import "@testing-library/jest-dom/vitest";
import { describe, expect, test } from "vitest";
import type { GameReport } from "../src/api/game-state";
import type { GameReport, ReportPlanet } from "../src/api/game-state";
import { reportToWorld } from "../src/map/state-binding";
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
@@ -23,6 +23,29 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
};
}
// makePlanet fills the rich-projection fields the binding does not
// inspect with `null`s so the binding-focused tests stay readable.
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
return {
number: 0,
name: "",
x: 0,
y: 0,
kind: "local",
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
...overrides,
};
}
describe("reportToWorld", () => {
test("uses report dimensions for the World", () => {
const world = reportToWorld(makeReport({ mapWidth: 3200, mapHeight: 1600 }));
@@ -34,10 +57,10 @@ describe("reportToWorld", () => {
const world = reportToWorld(
makeReport({
planets: [
{ number: 1, name: "Home", x: 100, y: 100, kind: "local", owner: null, size: 12, resources: 0.5 },
{ number: 2, name: "Alpha", x: 200, y: 100, kind: "other", owner: "Federation", size: 8, resources: 0.3 },
{ number: 3, name: "Rock", x: 100, y: 200, kind: "uninhabited", owner: null, size: 4, resources: 0.1 },
{ number: 4, name: "", x: 200, y: 200, kind: "unidentified", owner: null, size: null, resources: null },
makePlanet({ number: 1, name: "Home", x: 100, y: 100, kind: "local", size: 12, resources: 0.5 }),
makePlanet({ number: 2, name: "Alpha", x: 200, y: 100, kind: "other", owner: "Federation", size: 8, resources: 0.3 }),
makePlanet({ number: 3, name: "Rock", x: 100, y: 200, kind: "uninhabited", size: 4, resources: 0.1 }),
makePlanet({ number: 4, name: "", x: 200, y: 200, kind: "unidentified" }),
],
}),
);
@@ -51,7 +74,7 @@ describe("reportToWorld", () => {
const world = reportToWorld(
makeReport({
planets: [
{ number: 42, name: "Home", x: 123.5, y: 456.25, kind: "local", owner: null, size: 10, resources: 0.5 },
makePlanet({ number: 42, name: "Home", x: 123.5, y: 456.25, kind: "local", size: 10, resources: 0.5 }),
],
}),
);
@@ -68,10 +91,10 @@ describe("reportToWorld", () => {
const world = reportToWorld(
makeReport({
planets: [
{ number: 1, name: "L", x: 0, y: 0, kind: "local", owner: null, size: 1, resources: 0 },
{ number: 2, name: "O", x: 1, y: 0, kind: "other", owner: "Foe", size: 1, resources: 0 },
{ number: 3, name: "U", x: 2, y: 0, kind: "uninhabited", owner: null, size: 1, resources: 0 },
{ number: 4, name: "?", x: 3, y: 0, kind: "unidentified", owner: null, size: null, resources: null },
makePlanet({ number: 1, name: "L", kind: "local", size: 1, resources: 0 }),
makePlanet({ number: 2, name: "O", x: 1, kind: "other", owner: "Foe", size: 1, resources: 0 }),
makePlanet({ number: 3, name: "U", x: 2, kind: "uninhabited", size: 1, resources: 0 }),
makePlanet({ number: 4, name: "?", x: 3, kind: "unidentified" }),
],
}),
);
@@ -102,8 +125,8 @@ describe("reportToWorld", () => {
const world = reportToWorld(
makeReport({
planets: [
{ number: 1, name: "Home", x: 0, y: 0, kind: "local", owner: null, size: 1, resources: 0 },
{ number: 2, name: "?", x: 0, y: 0, kind: "unidentified", owner: null, size: null, resources: null },
makePlanet({ number: 1, name: "Home", kind: "local", size: 1, resources: 0 }),
makePlanet({ number: 2, name: "?", kind: "unidentified" }),
],
}),
);