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")}

+ +
+{/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 });