ui/phase-19: read-only ship-group inspector + sheet + tab dispatch

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 <ShipGroup /> 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 <ShipGroupSheet /> 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 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 13:24:17 +02:00
parent 676556db4e
commit 86e77efe39
8 changed files with 843 additions and 0 deletions
@@ -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()}
/>
<ShipGroupSheet
selection={selectedShipGroup}
planets={inspectorPlanets}
onMap={effectiveTool === "map"}
onClose={() => selection.clear()}
/>
</div>
<style>