From 86e77efe39144b27643df969620179f571a114b7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 10 May 2026 13:24:17 +0200 Subject: [PATCH] ui/phase-19: read-only ship-group inspector + sheet + tab dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Phase 19's UI surface. The inspector dispatches on the selection variant: local / other groups render class, count, the four tech levels, mass, cargo (type + amount when loaded), location (planet name on-orbit, from/to/distance in hyperspace), and — for local groups only — fleet membership + state. Incoming groups surface origin / destination / distance / speed and the inline ETA = ceil(distance / speed); zero speed collapses to the designer's existing "—" placeholder. Unidentified groups render just the (x, y) coordinates and the no-data hint, mirroring the unidentified planet treatment. Layout / inspector-tab plumbing: - inspector-tab.svelte derives selectedShipGroup against the rendered report and mounts when the planet branch doesn't match. Stale refs (an index that no longer resolves after a turn refresh) collapse cleanly to the empty state. - +layout.svelte mounts alongside the existing planet sheet on mobile; both share the `effectiveTool === "map"` guard and clear-on-close. i18n: en + ru both grow ~30 keys under `game.inspector.ship_group.*`. Adding a key to one without the other is a TS error (TranslationKey is `keyof typeof en`), so the Russian mirror stays mandatory. Tests: - inspector-ship-group.test.ts exercises every variant — on-planet local, in-hyperspace local, cargo-loaded local, foreign, incoming with ETA, incoming with zero speed, unidentified, plus the missing-planet `#NN` fallback. - tests/e2e/inspector-ship-group.spec.ts is a smoke spec that drives the DEV-only synthetic-report loader from /lobby through navigation to /games/synthetic-XXX/map. Co-Authored-By: Claude Opus 4.7 --- ui/frontend/src/lib/i18n/locales/en.ts | 30 ++ ui/frontend/src/lib/i18n/locales/ru.ts | 30 ++ .../lib/inspectors/ship-group-sheet.svelte | 78 ++++++ .../src/lib/inspectors/ship-group.svelte | 260 ++++++++++++++++++ .../src/lib/sidebar/inspector-tab.svelte | 43 +++ .../src/routes/games/[id]/+layout.svelte | 37 +++ .../tests/e2e/inspector-ship-group.spec.ts | 134 +++++++++ .../tests/inspector-ship-group.test.ts | 231 ++++++++++++++++ 8 files changed, 843 insertions(+) create mode 100644 ui/frontend/src/lib/inspectors/ship-group-sheet.svelte create mode 100644 ui/frontend/src/lib/inspectors/ship-group.svelte create mode 100644 ui/frontend/tests/e2e/inspector-ship-group.spec.ts create mode 100644 ui/frontend/tests/inspector-ship-group.test.ts diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index 410b90b..e1366a9 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -245,6 +245,36 @@ const en = { "game.designer.ship_class.preview.range": "range at full load (ly/turn)", "game.designer.ship_class.preview.cargo_capacity": "cargo capacity per ship", "game.designer.ship_class.preview.unavailable": "—", + + "game.inspector.ship_group.kind.local": "your group", + "game.inspector.ship_group.kind.other": "other race group", + "game.inspector.ship_group.kind.incoming": "incoming group", + "game.inspector.ship_group.kind.unidentified": "unidentified group", + "game.inspector.ship_group.field.class": "class", + "game.inspector.ship_group.field.count": "ships", + "game.inspector.ship_group.field.drive": "drive", + "game.inspector.ship_group.field.weapons": "weapons", + "game.inspector.ship_group.field.shields": "shields", + "game.inspector.ship_group.field.cargo_tech": "cargo", + "game.inspector.ship_group.field.mass": "mass", + "game.inspector.ship_group.field.cargo_load": "cargo aboard", + "game.inspector.ship_group.field.location": "location", + "game.inspector.ship_group.field.from": "from", + "game.inspector.ship_group.field.to": "to", + "game.inspector.ship_group.field.distance": "distance remaining", + "game.inspector.ship_group.field.speed": "speed (ly/turn)", + "game.inspector.ship_group.field.eta": "ETA (turns)", + "game.inspector.ship_group.field.fleet": "fleet", + "game.inspector.ship_group.field.state": "state", + "game.inspector.ship_group.field.coordinates": "coordinates", + "game.inspector.ship_group.cargo.col": "colonists", + "game.inspector.ship_group.cargo.cap": "industry", + "game.inspector.ship_group.cargo.mat": "materials", + "game.inspector.ship_group.cargo.emp": "empty", + "game.inspector.ship_group.cargo.none": "none", + "game.inspector.ship_group.location.in_hyperspace": "in hyperspace", + "game.inspector.ship_group.fleet.none": "—", + "game.inspector.ship_group.unidentified_no_data": "no data — only the radar blip is known", } as const; export default en; diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index 601ec99..fbc4f6a 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -246,6 +246,36 @@ const ru: Record = { "game.designer.ship_class.preview.range": "дальность при полной загрузке (св.лет/ход)", "game.designer.ship_class.preview.cargo_capacity": "грузоподъёмность одного корабля", "game.designer.ship_class.preview.unavailable": "—", + + "game.inspector.ship_group.kind.local": "ваша группа", + "game.inspector.ship_group.kind.other": "группа другой расы", + "game.inspector.ship_group.kind.incoming": "входящая группа", + "game.inspector.ship_group.kind.unidentified": "неопознанная группа", + "game.inspector.ship_group.field.class": "класс", + "game.inspector.ship_group.field.count": "кораблей", + "game.inspector.ship_group.field.drive": "двигатели", + "game.inspector.ship_group.field.weapons": "оружие", + "game.inspector.ship_group.field.shields": "защита", + "game.inspector.ship_group.field.cargo_tech": "грузоперевозки", + "game.inspector.ship_group.field.mass": "масса", + "game.inspector.ship_group.field.cargo_load": "груз на борту", + "game.inspector.ship_group.field.location": "расположение", + "game.inspector.ship_group.field.from": "из", + "game.inspector.ship_group.field.to": "в", + "game.inspector.ship_group.field.distance": "оставшееся расстояние", + "game.inspector.ship_group.field.speed": "скорость (св.лет/ход)", + "game.inspector.ship_group.field.eta": "прибытие (ходов)", + "game.inspector.ship_group.field.fleet": "флот", + "game.inspector.ship_group.field.state": "состояние", + "game.inspector.ship_group.field.coordinates": "координаты", + "game.inspector.ship_group.cargo.col": "колонисты", + "game.inspector.ship_group.cargo.cap": "промышленность", + "game.inspector.ship_group.cargo.mat": "сырьё", + "game.inspector.ship_group.cargo.emp": "пусто", + "game.inspector.ship_group.cargo.none": "нет", + "game.inspector.ship_group.location.in_hyperspace": "в гиперпространстве", + "game.inspector.ship_group.fleet.none": "—", + "game.inspector.ship_group.unidentified_no_data": "данных нет — известны только координаты", }; export default ru; diff --git a/ui/frontend/src/lib/inspectors/ship-group-sheet.svelte b/ui/frontend/src/lib/inspectors/ship-group-sheet.svelte new file mode 100644 index 0000000..314785c --- /dev/null +++ b/ui/frontend/src/lib/inspectors/ship-group-sheet.svelte @@ -0,0 +1,78 @@ + + + +{#if selection !== null && onMap} +
+ + +
+{/if} + + diff --git a/ui/frontend/src/lib/inspectors/ship-group.svelte b/ui/frontend/src/lib/inspectors/ship-group.svelte new file mode 100644 index 0000000..dc2d40a --- /dev/null +++ b/ui/frontend/src/lib/inspectors/ship-group.svelte @@ -0,0 +1,260 @@ + + + +
+
+

{kindLabel}

+ {#if selection.variant === "local" || selection.variant === "other"} +

+ {selection.group.class} +

+ {/if} +
+ + {#if selection.variant === "local" || selection.variant === "other"} + {@const g = selection.group} + {@const onPlanet = g.origin === null || g.range === null} +
+
+
{i18n.t("game.inspector.ship_group.field.count")}
+
{g.count}
+
+
+
{i18n.t("game.inspector.ship_group.field.drive")}
+
{formatNumber(g.tech.drive)}
+
+
+
{i18n.t("game.inspector.ship_group.field.weapons")}
+
{formatNumber(g.tech.weapons)}
+
+
+
{i18n.t("game.inspector.ship_group.field.shields")}
+
{formatNumber(g.tech.shields)}
+
+
+
{i18n.t("game.inspector.ship_group.field.cargo_tech")}
+
{formatNumber(g.tech.cargo)}
+
+
+
{i18n.t("game.inspector.ship_group.field.mass")}
+
{formatNumber(g.mass)}
+
+
+
{i18n.t("game.inspector.ship_group.field.cargo_load")}
+
+ {#if g.cargo === "NONE"} + {cargoLabel(g.cargo)} + {:else} + {cargoLabel(g.cargo)} × {formatNumber(g.load)} + {/if} +
+
+ + {#if onPlanet} +
+
{i18n.t("game.inspector.ship_group.field.location")}
+
{planetLabel(g.destination)}
+
+ {:else} +
+
{i18n.t("game.inspector.ship_group.field.from")}
+
{planetLabel(g.origin!)}
+
+
+
{i18n.t("game.inspector.ship_group.field.to")}
+
{planetLabel(g.destination)}
+
+
+
{i18n.t("game.inspector.ship_group.field.distance")}
+
{formatNumber(g.range!)}
+
+ {/if} + + {#if selection.variant === "local"} +
+
{i18n.t("game.inspector.ship_group.field.fleet")}
+
+ {selection.group.fleet ?? i18n.t("game.inspector.ship_group.fleet.none")} +
+
+
+
{i18n.t("game.inspector.ship_group.field.state")}
+
{selection.group.state}
+
+ {/if} +
+ {:else if selection.variant === "incoming"} + {@const g = selection.group} + {@const eta = g.speed > 0 ? Math.ceil(g.distance / g.speed) : null} +
+
+
{i18n.t("game.inspector.ship_group.field.from")}
+
{planetLabel(g.origin)}
+
+
+
{i18n.t("game.inspector.ship_group.field.to")}
+
{planetLabel(g.destination)}
+
+
+
{i18n.t("game.inspector.ship_group.field.distance")}
+
{formatNumber(g.distance)}
+
+
+
{i18n.t("game.inspector.ship_group.field.speed")}
+
{formatNumber(g.speed)}
+
+
+
{i18n.t("game.inspector.ship_group.field.eta")}
+
+ {eta === null + ? i18n.t("game.designer.ship_class.preview.unavailable") + : eta} +
+
+
+
{i18n.t("game.inspector.ship_group.field.mass")}
+
{formatNumber(g.mass)}
+
+
+ {:else} +
+
+
{i18n.t("game.inspector.ship_group.field.coordinates")}
+
+ ({formatNumber(selection.group.x)}, {formatNumber(selection.group.y)}) +
+
+
+

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

+ {/if} +
+ + diff --git a/ui/frontend/src/lib/sidebar/inspector-tab.svelte b/ui/frontend/src/lib/sidebar/inspector-tab.svelte index 00e05ed..aa9a291 100644 --- a/ui/frontend/src/lib/sidebar/inspector-tab.svelte +++ b/ui/frontend/src/lib/sidebar/inspector-tab.svelte @@ -7,6 +7,12 @@ 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. +Phase 19 widens the dispatch: a `kind === "shipGroup"` selection +resolves against the matching report array and mounts the read-only +ship-group inspector. Unresolvable refs (e.g. the chosen index has +fallen out of the new turn's report) cleanly collapse to the empty +state — same fallback as a stale planet selection. + 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. @@ -23,6 +29,9 @@ from the Phase 10 stub. type RenderedReportSource, } from "$lib/rendered-report.svelte"; import Planet from "$lib/inspectors/planet.svelte"; + import ShipGroup, { + type ShipGroupSelection, + } from "$lib/inspectors/ship-group.svelte"; const renderedReport = getContext( RENDERED_REPORT_CONTEXT_KEY, @@ -38,6 +47,38 @@ from the Phase 10 stub. if (report === undefined || report === null) return null; return report.planets.find((p) => p.number === sel.id) ?? null; }); + const selectedShipGroup: ShipGroupSelection | null = $derived.by(() => { + const sel = selection?.selected; + if (sel === undefined || sel === null || sel.kind !== "shipGroup") { + return null; + } + const report = renderedReport?.report; + if (report === undefined || report === null) return null; + const ref = sel.ref; + switch (ref.variant) { + case "local": { + const group = report.localShipGroups.find((g) => g.id === ref.id); + if (group === undefined) return null; + return { variant: "local", group }; + } + case "other": { + const group = report.otherShipGroups[ref.index]; + if (group === undefined) return null; + return { variant: "other", group }; + } + case "incoming": { + const group = report.incomingShipGroups[ref.index]; + if (group === undefined) return null; + return { variant: "incoming", group }; + } + case "unidentified": { + const group = report.unidentifiedShipGroups[ref.index]; + if (group === undefined) return null; + return { variant: "unidentified", group }; + } + } + }); + const localShipClass = $derived( renderedReport?.report?.localShipClass ?? [], ); @@ -61,6 +102,8 @@ from the Phase 10 stub. {mapHeight} {localPlayerDrive} /> + {:else if selectedShipGroup !== null} + {:else}

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

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

diff --git a/ui/frontend/src/routes/games/[id]/+layout.svelte b/ui/frontend/src/routes/games/[id]/+layout.svelte index 88ab268..0f103d5 100644 --- a/ui/frontend/src/routes/games/[id]/+layout.svelte +++ b/ui/frontend/src/routes/games/[id]/+layout.svelte @@ -52,6 +52,8 @@ fresh. import Calculator from "$lib/sidebar/calculator-tab.svelte"; import Order from "$lib/sidebar/order-tab.svelte"; import PlanetSheet from "$lib/inspectors/planet-sheet.svelte"; + import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte"; + import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte"; import type { MobileTool, SidebarTab } from "$lib/sidebar/types"; import { GameStateStore, GAME_STATE_CONTEXT_KEY } from "$lib/game-state.svelte"; import { @@ -139,6 +141,35 @@ fresh. if (report === null) return null; return report.planets.find((p) => p.number === sel.id) ?? null; }); + const selectedShipGroup: ShipGroupSelection | null = $derived.by(() => { + const sel = selection.selected; + if (sel === null || sel.kind !== "shipGroup") return null; + const report = renderedReport.report; + if (report === null) return null; + const ref = sel.ref; + switch (ref.variant) { + case "local": { + const group = report.localShipGroups.find((g) => g.id === ref.id); + if (group === undefined) return null; + return { variant: "local", group }; + } + case "other": { + const group = report.otherShipGroups[ref.index]; + if (group === undefined) return null; + return { variant: "other", group }; + } + case "incoming": { + const group = report.incomingShipGroups[ref.index]; + if (group === undefined) return null; + return { variant: "incoming", group }; + } + case "unidentified": { + const group = report.unidentifiedShipGroups[ref.index]; + if (group === undefined) return null; + return { variant: "unidentified", group }; + } + } + }); const localShipClass = $derived( renderedReport.report?.localShipClass ?? [], ); @@ -296,6 +327,12 @@ fresh. onMap={effectiveTool === "map"} onClose={() => selection.clear()} /> + selection.clear()} + />