From f7109af55c8ba47a68525d849658495eee05a5b3 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 10 May 2026 15:08:41 +0200 Subject: [PATCH] ui/phase-19: torus-aware incoming track + on-planet groups in inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up fixes after the initial Phase 19 landing: 1. The IncomingGroup dashed trajectory was drawn between raw (x1, y1) and (x2, y2) world coordinates. On torus wrap mode this took the long way around when origin and destination sat near opposite seams. The line now picks endpoints via `torusShortestDelta` so the segment crosses the seam when that's the shorter visual path. The interpolated clickable point follows the same unwrapped vector. The same helper fixes the in-hyperspace position for local / foreign groups. 2. On-planet local and foreign groups previously rendered as small offset points next to every populated planet, which turned the canvas into noise as soon as a player held more than a handful of planets. The map no longer renders any in-orbit group; the planet inspector grows a compact "stationed ship groups" subsection (`lib/inspectors/planet/ship-groups.svelte`) that lists each in-orbit group as a row of ` · · ships · `. Race attribution: LocalGroup → the player's race, OtherGroup on a foreign-owned planet → the planet's owner, OtherGroup elsewhere → "foreign" placeholder. Rows are non-interactive in Phase 19; Phase 21+ will deep-link into the ship-groups table view with a (planet, race) filter. Tests: - `state-binding-groups.test.ts` swaps the on-planet rendering expectation for the new "no map primitive" rule, and adds a regression that asserts the incoming line crosses the torus seam via `torusShortestDelta`. - new `inspector-planet-ship-groups.test.ts` covers row composition, the destination-mismatch filter, the in-hyperspace exclusion, the foreign-planet owner fallback, and the empty-state collapse. - `inspector-planet.test.ts` and `inspector-ship-group.spec.ts` pick up the new prop chain (`localShipGroups`, `otherShipGroups`, `localRace`). Co-Authored-By: Claude Opus 4.7 --- ui/frontend/src/lib/i18n/locales/en.ts | 6 + ui/frontend/src/lib/i18n/locales/ru.ts | 6 + .../src/lib/inspectors/planet-sheet.svelte | 11 ++ ui/frontend/src/lib/inspectors/planet.svelte | 16 ++ .../lib/inspectors/planet/ship-groups.svelte | 144 ++++++++++++++ .../src/lib/sidebar/inspector-tab.svelte | 10 + ui/frontend/src/map/ship-groups.ts | 103 ++++++----- .../src/routes/games/[id]/+layout.svelte | 10 + .../tests/e2e/inspector-ship-group.spec.ts | 32 ++-- .../inspector-planet-ship-groups.test.ts | 175 ++++++++++++++++++ ui/frontend/tests/inspector-planet.test.ts | 24 +++ .../tests/state-binding-groups.test.ts | 51 +++-- 12 files changed, 511 insertions(+), 77 deletions(-) create mode 100644 ui/frontend/src/lib/inspectors/planet/ship-groups.svelte create mode 100644 ui/frontend/tests/inspector-planet-ship-groups.test.ts diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index e1366a9..8c37f75 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -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; diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index fbc4f6a..2f2512d 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -276,6 +276,12 @@ const ru: Record = { "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; diff --git a/ui/frontend/src/lib/inspectors/planet-sheet.svelte b/ui/frontend/src/lib/inspectors/planet-sheet.svelte index fe17df8..dd08979 100644 --- a/ui/frontend/src/lib/inspectors/planet-sheet.svelte +++ b/ui/frontend/src/lib/inspectors/planet-sheet.svelte @@ -12,6 +12,8 @@ dismiss from the IA section §6 land in Phase 35 polish. --> + +{#if stationedRows.length > 0} +
+

{i18n.t("game.inspector.planet.ship_groups.title")}

+
    + {#each stationedRows as row (row.key)} +
  • + + {row.race} + + {row.class} + + {i18n.t("game.inspector.planet.ship_groups.row.count", { + count: String(row.count), + })} + + + {i18n.t("game.inspector.planet.ship_groups.row.mass", { + mass: formatNumber(row.mass), + })} + +
  • + {/each} +
+
+{/if} + + diff --git a/ui/frontend/src/lib/sidebar/inspector-tab.svelte b/ui/frontend/src/lib/sidebar/inspector-tab.svelte index aa9a291..50be659 100644 --- a/ui/frontend/src/lib/sidebar/inspector-tab.svelte +++ b/ui/frontend/src/lib/sidebar/inspector-tab.svelte @@ -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 ?? "");
@@ -101,6 +108,9 @@ from the Phase 10 stub. {mapWidth} {mapHeight} {localPlayerDrive} + {localShipGroups} + {otherShipGroups} + {localRace} /> {:else if selectedShipGroup !== null} diff --git a/ui/frontend/src/map/ship-groups.ts b/ui/frontend/src/map/ship-groups.ts index b1ff8be..3bcc132 100644 --- a/ui/frontend/src/map/ship-groups.ts +++ b/ui/frontend/src/map/ship-groups.ts @@ -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, + 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 }; } /** diff --git a/ui/frontend/src/routes/games/[id]/+layout.svelte b/ui/frontend/src/routes/games/[id]/+layout.svelte index 0f103d5..1c84ef4 100644 --- a/ui/frontend/src/routes/games/[id]/+layout.svelte +++ b/ui/frontend/src/routes/games/[id]/+layout.svelte @@ -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()} /> diff --git a/ui/frontend/tests/e2e/inspector-ship-group.spec.ts b/ui/frontend/tests/e2e/inspector-ship-group.spec.ts index f655fcf..56b1f26 100644 --- a/ui/frontend/tests/e2e/inspector-ship-group.spec.ts +++ b/ui/frontend/tests/e2e/inspector-ship-group.spec.ts @@ -10,31 +10,27 @@ import { expect, test, type Page } from "@playwright/test"; -interface DebugSurface { - ready: true; - loadSession(): Promise; - setDeviceSessionId(id: string): Promise; -} - -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 { await page.goto("/__debug/store"); await expect(page.getByTestId("debug-store-ready")).toBeVisible(); - await page.waitForFunction(() => window.__galaxyDebug?.ready === true); + await page.waitForFunction( + () => (window as unknown as { __galaxyDebug?: { ready?: boolean } }).__galaxyDebug?.ready === true, + ); await page.evaluate(async () => { - await window.__galaxyDebug!.loadSession(); - await window.__galaxyDebug!.setDeviceSessionId( - "phase-19-synthetic-session", - ); + const debug = (window as unknown as { + __galaxyDebug: { + loadSession(): Promise; + setDeviceSessionId(id: string): Promise; + }; + }).__galaxyDebug; + await debug.loadSession(); + await debug.setDeviceSessionId("phase-19-synthetic-session"); }); } diff --git a/ui/frontend/tests/inspector-planet-ship-groups.test.ts b/ui/frontend/tests/inspector-planet-ship-groups.test.ts new file mode 100644 index 0000000..a61061a --- /dev/null +++ b/ui/frontend/tests/inspector-planet-ship-groups.test.ts @@ -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 { + 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 { + 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(); + }); +}); diff --git a/ui/frontend/tests/inspector-planet.test.ts b/ui/frontend/tests/inspector-planet.test.ts index 4d0d318..cea418e 100644 --- a/ui/frontend/tests/inspector-planet.test.ts +++ b/ui/frontend/tests/inspector-planet.test.ts @@ -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" diff --git a/ui/frontend/tests/state-binding-groups.test.ts b/ui/frontend/tests/state-binding-groups.test.ts index 9172db5..3ddf19a 100644 --- a/ui/frontend/tests/state-binding-groups.test.ts +++ b/ui/frontend/tests/state-binding-groups.test.ts @@ -58,7 +58,7 @@ function makeReport(overrides: Partial = {}): 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 });