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:
+61
-19
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
{#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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user