ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
12 changed files with 511 additions and 77 deletions
Showing only changes of commit f7109af55c - Show all commits
+6
View File
@@ -275,6 +275,12 @@ const en = {
"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",
"game.inspector.planet.ship_groups.title": "stationed ship groups",
"game.inspector.planet.ship_groups.row.count": "{count} ships",
"game.inspector.planet.ship_groups.row.mass": "mass {mass}",
"game.inspector.planet.ship_groups.race.unknown": "unknown",
"game.inspector.planet.ship_groups.race.foreign": "foreign",
} as const;
export default en;
+6
View File
@@ -276,6 +276,12 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.ship_group.location.in_hyperspace": "в гиперпространстве",
"game.inspector.ship_group.fleet.none": "—",
"game.inspector.ship_group.unidentified_no_data": "данных нет — известны только координаты",
"game.inspector.planet.ship_groups.title": "корабли на орбите",
"game.inspector.planet.ship_groups.row.count": "{count} кораблей",
"game.inspector.planet.ship_groups.row.mass": "масса {mass}",
"game.inspector.planet.ship_groups.race.unknown": "неизвестно",
"game.inspector.planet.ship_groups.race.foreign": "чужие",
};
export default ru;
@@ -12,6 +12,8 @@ dismiss from the IA section §6 land in Phase 35 polish.
-->
<script lang="ts">
import type {
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
ReportRoute,
ShipClassSummary,
@@ -27,6 +29,9 @@ dismiss from the IA section §6 land in Phase 35 polish.
mapWidth: number;
mapHeight: number;
localPlayerDrive: number;
localShipGroups: ReportLocalShipGroup[];
otherShipGroups: ReportOtherShipGroup[];
localRace: string;
onMap: boolean;
onClose: () => void;
};
@@ -38,6 +43,9 @@ dismiss from the IA section §6 land in Phase 35 polish.
mapWidth,
mapHeight,
localPlayerDrive,
localShipGroups,
otherShipGroups,
localRace,
onMap,
onClose,
}: Props = $props();
@@ -66,6 +74,9 @@ dismiss from the IA section §6 land in Phase 35 polish.
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localShipGroups}
{otherShipGroups}
{localRace}
/>
</section>
{/if}
@@ -15,6 +15,8 @@ field with five buttons.
<script lang="ts">
import { getContext, tick } from "svelte";
import type {
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
ReportRoute,
ShipClassSummary,
@@ -30,6 +32,7 @@ field with five buttons.
} from "$lib/util/entity-name";
import CargoRoutes from "./planet/cargo-routes.svelte";
import Production from "./planet/production.svelte";
import ShipGroups from "./planet/ship-groups.svelte";
type Props = {
planet: ReportPlanet;
@@ -39,6 +42,9 @@ field with five buttons.
mapWidth: number;
mapHeight: number;
localPlayerDrive: number;
localShipGroups: ReportLocalShipGroup[];
otherShipGroups: ReportOtherShipGroup[];
localRace: string;
};
let {
planet,
@@ -48,6 +54,9 @@ field with five buttons.
mapWidth,
mapHeight,
localPlayerDrive,
localShipGroups,
otherShipGroups,
localRace,
}: Props = $props();
const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
@@ -223,6 +232,13 @@ field with five buttons.
/>
{/if}
<ShipGroups
{planet}
{localShipGroups}
{otherShipGroups}
{localRace}
/>
<dl class="fields">
{#if planet.kind === "other" && planet.owner !== null}
<div class="field" data-testid="inspector-planet-field-owner">
@@ -0,0 +1,144 @@
<!--
Phase 19 read-only "ship groups stationed here" subsection of the
planet inspector. Phase 19 originally rendered every in-orbit group
as an offset point near its planet on the map, which crowded the
canvas to the point of unreadability. The map now hides on-planet
groups and the planet inspector lists them instead — one row per
group, showing its race, class, ship count, and mass.
Race attribution is best-effort:
- LocalGroup → the player's own race (`localRace` prop).
- OtherGroup on an `other`-kind planet → the planet's owner.
- OtherGroup elsewhere → "foreign" placeholder; the engine's
typed contract does not carry per-group ownership outside
battle rosters.
Rows are intentionally non-interactive in Phase 19. Phase 21+ will
deliver a full ship-groups table view; clicking a row will then
deep-link into that table with a `(planet, race)` filter pre-applied.
-->
<script lang="ts">
import type {
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
} from "../../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte";
type Props = {
planet: ReportPlanet;
localShipGroups: ReportLocalShipGroup[];
otherShipGroups: ReportOtherShipGroup[];
localRace: string;
};
let { planet, localShipGroups, otherShipGroups, localRace }: Props = $props();
interface StationedRow {
key: string;
race: string;
class: string;
count: number;
mass: number;
}
const stationedRows: StationedRow[] = $derived.by(() => {
const rows: StationedRow[] = [];
for (const g of localShipGroups) {
if (g.destination !== planet.number) continue;
if (g.origin !== null || g.range !== null) continue;
rows.push({
key: `local:${g.id}`,
race: localRace || i18n.t("game.inspector.planet.ship_groups.race.unknown"),
class: g.class,
count: g.count,
mass: g.mass,
});
}
const foreignRace =
planet.owner ??
i18n.t("game.inspector.planet.ship_groups.race.foreign");
for (let i = 0; i < otherShipGroups.length; i++) {
const g = otherShipGroups[i]!;
if (g.destination !== planet.number) continue;
if (g.origin !== null || g.range !== null) continue;
rows.push({
key: `other:${i}`,
race: foreignRace,
class: g.class,
count: g.count,
mass: g.mass,
});
}
return rows;
});
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
</script>
{#if stationedRows.length > 0}
<section class="ship-groups" data-testid="inspector-planet-ship-groups">
<h4>{i18n.t("game.inspector.planet.ship_groups.title")}</h4>
<ul class="rows">
{#each stationedRows as row (row.key)}
<li class="row" data-testid="inspector-planet-ship-groups-row">
<span class="race" data-testid="inspector-planet-ship-groups-race">
{row.race}
</span>
<span class="class">{row.class}</span>
<span class="count">
{i18n.t("game.inspector.planet.ship_groups.row.count", {
count: String(row.count),
})}
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatNumber(row.mass),
})}
</span>
</li>
{/each}
</ul>
</section>
{/if}
<style>
.ship-groups {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
h4 {
margin: 0;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #aab;
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr auto auto;
gap: 0.5rem;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.race {
font-weight: 600;
}
.class {
color: #cdd;
}
.count,
.mass {
color: #aab;
}
</style>
@@ -89,6 +89,13 @@ from the Phase 10 stub.
const localPlayerDrive = $derived(
renderedReport?.report?.localPlayerDrive ?? 0,
);
const localShipGroups = $derived(
renderedReport?.report?.localShipGroups ?? [],
);
const otherShipGroups = $derived(
renderedReport?.report?.otherShipGroups ?? [],
);
const localRace = $derived(renderedReport?.report?.race ?? "");
</script>
<section class="tool" data-testid="sidebar-tool-inspector">
@@ -101,6 +108,9 @@ from the Phase 10 stub.
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localShipGroups}
{otherShipGroups}
{localRace}
/>
{:else if selectedShipGroup !== null}
<ShipGroup selection={selectedShipGroup} planets={allPlanets} />
+57 -46
View File
@@ -4,22 +4,28 @@
// incoming-trajectory lines) lives here.
//
// Position rules:
// - On-planet local / other groups (origin === null) — drawn next
// to the destination planet, slightly offset so the group has its
// own hit-target distinct from the planet pixel. Multiple groups
// stationed at the same planet share the offset (Phase 19
// limitation; a future phase fans them out or lists them in the
// planet inspector).
// - On-planet local / other groups (origin === null, range === null)
// are NOT rendered on the map. Stationed groups would otherwise
// pile up next to every populated planet and turn the canvas
// into noise; the planet inspector lists them instead
// (see `lib/inspectors/planet/ship-groups.svelte`).
// - In-hyperspace local / other groups (origin / range set) —
// interpolated along the origin → destination line at `range`
// world units from the destination.
// world units from the destination. The line is the wrap-aware
// shortest path on a torus.
// - Incoming groups — origin and destination are always present;
// emit a dashed red trajectory line between the two and a
// clickable point at the interpolated position (range = the
// `distance` field).
// emit a dashed red trajectory line from origin to a wrap-aware
// destination plus a clickable point at the interpolated
// position (range = the `distance` field).
// - Unidentified groups — drawn at the absolute (x, y) the radar
// reports.
//
// Torus-shortest deltas come from `map/math.torusShortestDelta`. The
// canonical Go-side equivalent is `pkg/calc.ShortestDelta`; the TS
// helper duplicates the formula because the renderer's hot path
// avoids the WASM boundary cost. Both implementations agree on the
// half-circumference tie-break.
//
// PrimitiveIDs are partitioned via large per-variant offsets so they
// never collide with planet ids (which run in `[0, planetCount)`).
@@ -32,6 +38,7 @@ import type {
ReportUnidentifiedShipGroup,
} from "../api/game-state";
import type { ShipGroupRef } from "../lib/selection.svelte";
import { torusShortestDelta } from "./math";
import type { LinePrim, PointPrim, PrimitiveID, Style } from "./world";
/**
@@ -49,12 +56,6 @@ export const SHIP_GROUP_ID_OFFSETS = {
unidentified: 400_000_000,
} as const;
/** ON_PLANET_OFFSET is the (dx, dy) world-unit shift applied to a
* group point that sits on a planet, so the group has a distinct
* click target from the planet itself. The offset is small enough
* that the visual association with the planet stays clear. */
const ON_PLANET_OFFSET = { dx: 6, dy: -6 };
const STYLE_LOCAL_GROUP: Style = {
fillColor: 0xfff176,
fillAlpha: 0.95,
@@ -88,9 +89,9 @@ const STYLE_UNIDENTIFIED_GROUP: Style = {
// Priority order inside `hit-test`: ship groups outrank planets so a
// hyperspace group landing on top of an unidentified planet is
// selectable. On-planet groups stay below the planet so clicks on a
// planet still resolve to the planet itself (the offset gives the
// group its own un-overlapped hit area).
// selectable. The trajectory line itself is given the lowest priority
// so a click on the dashed segment never "wins" over the clickable
// point at the interpolated position.
const PRIORITY_LOCAL = 5;
const PRIORITY_OTHER = 5;
const PRIORITY_INCOMING_POINT = 6;
@@ -109,10 +110,12 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
for (const planet of report.planets) {
planetIndex.set(planet.number, planet);
}
const w = report.mapWidth;
const h = report.mapHeight;
for (let i = 0; i < report.localShipGroups.length; i++) {
const group = report.localShipGroups[i]!;
const pos = computeGroupPosition(group, planetIndex);
const pos = computeInSpacePosition(group, planetIndex, w, h);
if (pos === null) continue;
const id = SHIP_GROUP_ID_OFFSETS.local + i;
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, STYLE_LOCAL_GROUP));
@@ -121,7 +124,7 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
for (let i = 0; i < report.otherShipGroups.length; i++) {
const group = report.otherShipGroups[i]!;
const pos = computeGroupPosition(group, planetIndex);
const pos = computeInSpacePosition(group, planetIndex, w, h);
if (pos === null) continue;
const id = SHIP_GROUP_ID_OFFSETS.other + i;
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_OTHER, STYLE_OTHER_GROUP));
@@ -133,6 +136,15 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
const origin = planetIndex.get(group.origin);
const destination = planetIndex.get(group.destination);
if (origin === undefined || destination === undefined) continue;
// Unwrap the destination relative to origin so the line crosses
// the torus seam when that is the shorter path. Renderer-side
// we draw the segment in a single tile; in torus mode PixiJS
// repeats the world so the line still appears continuous on
// the visible side of the seam.
const dx = torusShortestDelta(origin.x, destination.x, w);
const dy = torusShortestDelta(origin.y, destination.y, h);
const destX = origin.x + dx;
const destY = origin.y + dy;
const lineId = SHIP_GROUP_ID_OFFSETS.incomingLine + i;
primitives.push({
kind: "line",
@@ -142,16 +154,10 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
hitSlopPx: 0,
x1: origin.x,
y1: origin.y,
x2: destination.x,
y2: destination.y,
x2: destX,
y2: destY,
});
const pos = interpolateAlongLine(
destination.x,
destination.y,
origin.x,
origin.y,
group.distance,
);
const pos = interpolateAlongLine(destX, destY, origin.x, origin.y, group.distance);
const pointId = SHIP_GROUP_ID_OFFSETS.incoming + i;
primitives.push(
makePoint(
@@ -185,29 +191,34 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
return { primitives, lookup };
}
function computeGroupPosition(
/**
* computeInSpacePosition returns the renderer-side (x, y) of a local
* or foreign group that is currently in hyperspace. On-planet groups
* (origin === null || range === null) are intentionally skipped so the
* map does not pile dozens of primitives onto every populated planet
* — the planet inspector lists them instead. Returns null when either
* the group is on-planet, or the origin / destination planets are
* not visible to the local player.
*/
function computeInSpacePosition(
group: ReportLocalShipGroup | ReportOtherShipGroup,
planetIndex: Map<number, ReportPlanet>,
mapWidth: number,
mapHeight: number,
): { x: number; y: number } | null {
if (group.origin === null || group.range === null) return null;
const destination = planetIndex.get(group.destination);
if (destination === undefined) return null;
if (group.origin === null || group.range === null) {
// Stationed on the destination planet; offset slightly so the
// group is distinct from the planet's own hit target.
return {
x: destination.x + ON_PLANET_OFFSET.dx,
y: destination.y + ON_PLANET_OFFSET.dy,
};
}
const origin = planetIndex.get(group.origin);
if (origin === undefined) return null;
return interpolateAlongLine(
destination.x,
destination.y,
origin.x,
origin.y,
group.range,
);
const dx = torusShortestDelta(destination.x, origin.x, mapWidth);
const dy = torusShortestDelta(destination.y, origin.y, mapHeight);
const total = Math.hypot(dx, dy);
if (total === 0 || group.range <= 0) {
return { x: destination.x, y: destination.y };
}
const t = Math.min(1, group.range / total);
return { x: destination.x + t * dx, y: destination.y + t * dy };
}
/**
@@ -180,6 +180,13 @@ fresh.
const inspectorLocalDrive = $derived(
renderedReport.report?.localPlayerDrive ?? 0,
);
const inspectorLocalShipGroups = $derived(
renderedReport.report?.localShipGroups ?? [],
);
const inspectorOtherShipGroups = $derived(
renderedReport.report?.otherShipGroups ?? [],
);
const inspectorLocalRace = $derived(renderedReport.report?.race ?? "");
// Reveal the inspector whenever a new planet selection lands.
// Reading `selection.selected` once outside the effect keeps the
@@ -324,6 +331,9 @@ fresh.
mapWidth={inspectorMapWidth}
mapHeight={inspectorMapHeight}
localPlayerDrive={inspectorLocalDrive}
localShipGroups={inspectorLocalShipGroups}
otherShipGroups={inspectorOtherShipGroups}
localRace={inspectorLocalRace}
onMap={effectiveTool === "map"}
onClose={() => selection.clear()}
/>
@@ -10,31 +10,27 @@
import { expect, test, type Page } from "@playwright/test";
interface DebugSurface {
ready: true;
loadSession(): Promise<unknown>;
setDeviceSessionId(id: string): Promise<void>;
}
declare global {
interface Window {
__galaxyDebug?: DebugSurface;
}
}
// Seed an authenticated session through `/__debug/store` so the
// root layout's redirect-to-login guard passes. The synthetic flow
// itself does not talk to the gateway, but the session check still
// runs at every navigation.
// runs at every navigation. The full `__galaxyDebug` shape is
// declared globally in `tests/e2e/storage-keypair-persistence.spec.ts`;
// here we only need `loadSession` + `setDeviceSessionId`.
async function seedSession(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(async () => {
await window.__galaxyDebug!.loadSession();
await window.__galaxyDebug!.setDeviceSessionId(
"phase-19-synthetic-session",
await page.waitForFunction(
() => (window as unknown as { __galaxyDebug?: { ready?: boolean } }).__galaxyDebug?.ready === true,
);
await page.evaluate(async () => {
const debug = (window as unknown as {
__galaxyDebug: {
loadSession(): Promise<unknown>;
setDeviceSessionId(id: string): Promise<void>;
};
}).__galaxyDebug;
await debug.loadSession();
await debug.setDeviceSessionId("phase-19-synthetic-session");
});
}
@@ -0,0 +1,175 @@
// Vitest coverage for the Phase 19 follow-up "stationed ship groups"
// subsection of the planet inspector. Phase 19 originally rendered
// every in-orbit group as a small offset point on the map; the
// resulting visual noise pushed the listing into this subsection
// (`lib/inspectors/planet/ship-groups.svelte`) instead.
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 {
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
} from "../src/api/game-state";
import ShipGroups from "../src/lib/inspectors/planet/ship-groups.svelte";
beforeEach(() => {
i18n.resetForTests("en");
});
const HOME_PLANET: ReportPlanet = {
number: 17,
name: "Castle",
x: 100,
y: 100,
kind: "local",
owner: null,
size: 1000,
resources: 10,
industryStockpile: 0,
materialsStockpile: 0,
industry: 1000,
population: 1000,
colonists: 100,
production: "Capital",
freeIndustry: 1000,
};
const FOREIGN_PLANET: ReportPlanet = {
...HOME_PLANET,
number: 99,
name: "Outpost",
kind: "other",
owner: "Klingons",
};
function localGroup(
overrides: Partial<ReportLocalShipGroup> = {},
): ReportLocalShipGroup {
return {
id: "uuid-1",
count: 1,
class: "Frontier",
tech: { drive: 5, weapons: 0, shields: 0, cargo: 1 },
cargo: "NONE",
load: 0,
destination: 17,
origin: null,
range: null,
speed: 0,
mass: 12,
state: "In_Orbit",
fleet: null,
...overrides,
};
}
function otherGroup(
overrides: Partial<ReportOtherShipGroup> = {},
): ReportOtherShipGroup {
return {
count: 3,
class: "Bird-of-Prey",
tech: { drive: 6, weapons: 4, shields: 3, cargo: 0 },
cargo: "NONE",
load: 0,
destination: 99,
origin: null,
range: null,
speed: 0,
mass: 25,
...overrides,
};
}
describe("planet inspector — stationed ship groups", () => {
test("renders one row per in-orbit local group with the player's race", () => {
const ui = render(ShipGroups, {
props: {
planet: HOME_PLANET,
localShipGroups: [
localGroup({ id: "g1", count: 2, class: "Frontier", mass: 24 }),
localGroup({ id: "g2", count: 7, class: "Furgon", mass: 173.25 }),
],
otherShipGroups: [],
localRace: "Earthlings",
},
});
const rows = ui.getAllByTestId("inspector-planet-ship-groups-row");
expect(rows.length).toBe(2);
expect(rows[0]).toHaveTextContent("Earthlings");
expect(rows[0]).toHaveTextContent("Frontier");
expect(rows[0]).toHaveTextContent("2");
expect(rows[0]).toHaveTextContent("24");
expect(rows[1]).toHaveTextContent("Furgon");
expect(rows[1]).toHaveTextContent("173.25");
});
test("filters out groups stationed on a different planet", () => {
const ui = render(ShipGroups, {
props: {
planet: HOME_PLANET,
localShipGroups: [
localGroup({ id: "g1", destination: 17 }),
localGroup({ id: "g2", destination: 99 }),
],
otherShipGroups: [],
localRace: "Earthlings",
},
});
expect(ui.getAllByTestId("inspector-planet-ship-groups-row").length).toBe(
1,
);
});
test("excludes in-hyperspace groups even when destination matches", () => {
const ui = render(ShipGroups, {
props: {
planet: HOME_PLANET,
localShipGroups: [
localGroup({ id: "stationed", destination: 17 }),
localGroup({
id: "fleeing",
destination: 17,
origin: 99,
range: 5,
}),
],
otherShipGroups: [],
localRace: "Earthlings",
},
});
expect(ui.getAllByTestId("inspector-planet-ship-groups-row").length).toBe(
1,
);
});
test("foreign-planet visitors fall back to the planet owner's race", () => {
const ui = render(ShipGroups, {
props: {
planet: FOREIGN_PLANET,
localShipGroups: [],
otherShipGroups: [otherGroup({ destination: 99 })],
localRace: "Earthlings",
},
});
const row = ui.getByTestId("inspector-planet-ship-groups-row");
expect(row).toHaveTextContent("Klingons");
expect(row).toHaveTextContent("Bird-of-Prey");
});
test("subsection collapses entirely when nothing is stationed", () => {
const ui = render(ShipGroups, {
props: {
planet: HOME_PLANET,
localShipGroups: [],
otherShipGroups: [],
localRace: "Earthlings",
},
});
expect(ui.queryByTestId("inspector-planet-ship-groups")).toBeNull();
});
});
@@ -70,6 +70,9 @@ describe("planet inspector", () => {
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
localShipGroups: [],
otherShipGroups: [],
localRace: "",
},
});
const section = ui.getByTestId("inspector-planet");
@@ -140,6 +143,9 @@ describe("planet inspector", () => {
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
localShipGroups: [],
otherShipGroups: [],
localRace: "",
},
});
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
@@ -176,6 +182,9 @@ describe("planet inspector", () => {
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
localShipGroups: [],
otherShipGroups: [],
localRace: "",
},
});
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
@@ -213,6 +222,9 @@ describe("planet inspector", () => {
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
localShipGroups: [],
otherShipGroups: [],
localRace: "",
},
});
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
@@ -246,6 +258,9 @@ describe("planet inspector", () => {
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
localShipGroups: [],
otherShipGroups: [],
localRace: "",
},
});
expect(ui.queryByTestId("inspector-planet-rename-action")).toBeNull();
@@ -283,6 +298,9 @@ describe("planet inspector", () => {
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
localShipGroups: [],
otherShipGroups: [],
localRace: "",
},
context,
});
@@ -351,6 +369,9 @@ describe("planet inspector", () => {
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
localShipGroups: [],
otherShipGroups: [],
localRace: "",
},
context,
});
@@ -386,6 +407,9 @@ describe("planet inspector", () => {
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
localShipGroups: [],
otherShipGroups: [],
localRace: "",
},
});
// Empty production strings collapse to the localised "none"
+38 -13
View File
@@ -58,7 +58,7 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
}
describe("reportToWorld — ship groups", () => {
test("on-planet local group renders a clickable point near the planet", () => {
test("on-planet local group is NOT rendered on the map (planet inspector hosts it)", () => {
const home = planet({ number: 17, x: 100, y: 100, kind: "local" });
const { world, hitLookup } = reportToWorld(
makeReport({
@@ -82,18 +82,13 @@ describe("reportToWorld — ship groups", () => {
],
}),
);
// 1 planet point + 1 ship-group point.
expect(world.primitives.length).toBe(2);
const groupPrimId = SHIP_GROUP_ID_OFFSETS.local + 0;
const group = world.primitives.find((p) => p.id === groupPrimId);
expect(group).toBeDefined();
if (group?.kind !== "point") throw new Error("expected point");
// Off-planet rendering: not exactly on (100, 100).
expect(group.x === home.x && group.y === home.y).toBe(false);
expect(hitLookup.get(groupPrimId)).toEqual({
kind: "shipGroup",
ref: { variant: "local", id: "uuid-local-1" },
});
// Only the planet itself contributes a primitive; the on-planet
// group is intentionally invisible on the map. Phase 19's
// `lib/inspectors/planet/ship-groups.svelte` lists it inside the
// planet inspector instead.
expect(world.primitives.length).toBe(1);
expect(hitLookup.has(SHIP_GROUP_ID_OFFSETS.local + 0)).toBe(false);
expect(hitLookup.get(17)).toEqual({ kind: "planet", number: 17 });
});
test("in-hyperspace local group renders at the interpolated position", () => {
@@ -130,6 +125,36 @@ describe("reportToWorld — ship groups", () => {
expect(group.y).toBe(0);
});
test("incoming-group line crosses the torus seam via the shortest path", () => {
const dest = planet({ number: 1, x: 5, y: 50 });
const orig = planet({ number: 9, x: 95, y: 50 });
const { world } = reportToWorld(
makeReport({
mapWidth: 100,
mapHeight: 100,
planets: [dest, orig],
incomingShipGroups: [
{
origin: 9,
destination: 1,
distance: 5,
speed: 5,
mass: 1,
},
],
}),
);
const line = world.primitives.find(
(p) => p.id === SHIP_GROUP_ID_OFFSETS.incomingLine + 0,
);
if (line?.kind !== "line") throw new Error("expected line");
// Origin (95) → unwrapped destination at 105 (origin.x + (-10) is
// the no-wrap path). The shortest delta from 95 to 5 on width 100
// is +10, so we expect line.x2 = 95 + 10 = 105.
expect(line.x1).toBe(95);
expect(line.x2).toBe(105);
});
test("incoming group emits one dashed line + one clickable point", () => {
const dest = planet({ number: 1, x: 0, y: 0 });
const orig = planet({ number: 9, x: 100, y: 0 });