ui/phase-19: torus-aware incoming track + on-planet groups in inspector
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 `<race> · <class> · <count>
ships · <mass>`. 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 <noreply@anthropic.com>
This commit is contained in:
@@ -275,6 +275,12 @@ const en = {
|
|||||||
"game.inspector.ship_group.location.in_hyperspace": "in hyperspace",
|
"game.inspector.ship_group.location.in_hyperspace": "in hyperspace",
|
||||||
"game.inspector.ship_group.fleet.none": "—",
|
"game.inspector.ship_group.fleet.none": "—",
|
||||||
"game.inspector.ship_group.unidentified_no_data": "no data — only the radar blip is known",
|
"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;
|
} as const;
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|||||||
@@ -276,6 +276,12 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.inspector.ship_group.location.in_hyperspace": "в гиперпространстве",
|
"game.inspector.ship_group.location.in_hyperspace": "в гиперпространстве",
|
||||||
"game.inspector.ship_group.fleet.none": "—",
|
"game.inspector.ship_group.fleet.none": "—",
|
||||||
"game.inspector.ship_group.unidentified_no_data": "данных нет — известны только координаты",
|
"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;
|
export default ru;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ dismiss from the IA section §6 land in Phase 35 polish.
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {
|
import type {
|
||||||
|
ReportLocalShipGroup,
|
||||||
|
ReportOtherShipGroup,
|
||||||
ReportPlanet,
|
ReportPlanet,
|
||||||
ReportRoute,
|
ReportRoute,
|
||||||
ShipClassSummary,
|
ShipClassSummary,
|
||||||
@@ -27,6 +29,9 @@ dismiss from the IA section §6 land in Phase 35 polish.
|
|||||||
mapWidth: number;
|
mapWidth: number;
|
||||||
mapHeight: number;
|
mapHeight: number;
|
||||||
localPlayerDrive: number;
|
localPlayerDrive: number;
|
||||||
|
localShipGroups: ReportLocalShipGroup[];
|
||||||
|
otherShipGroups: ReportOtherShipGroup[];
|
||||||
|
localRace: string;
|
||||||
onMap: boolean;
|
onMap: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
@@ -38,6 +43,9 @@ dismiss from the IA section §6 land in Phase 35 polish.
|
|||||||
mapWidth,
|
mapWidth,
|
||||||
mapHeight,
|
mapHeight,
|
||||||
localPlayerDrive,
|
localPlayerDrive,
|
||||||
|
localShipGroups,
|
||||||
|
otherShipGroups,
|
||||||
|
localRace,
|
||||||
onMap,
|
onMap,
|
||||||
onClose,
|
onClose,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -66,6 +74,9 @@ dismiss from the IA section §6 land in Phase 35 polish.
|
|||||||
{mapWidth}
|
{mapWidth}
|
||||||
{mapHeight}
|
{mapHeight}
|
||||||
{localPlayerDrive}
|
{localPlayerDrive}
|
||||||
|
{localShipGroups}
|
||||||
|
{otherShipGroups}
|
||||||
|
{localRace}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ field with five buttons.
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext, tick } from "svelte";
|
import { getContext, tick } from "svelte";
|
||||||
import type {
|
import type {
|
||||||
|
ReportLocalShipGroup,
|
||||||
|
ReportOtherShipGroup,
|
||||||
ReportPlanet,
|
ReportPlanet,
|
||||||
ReportRoute,
|
ReportRoute,
|
||||||
ShipClassSummary,
|
ShipClassSummary,
|
||||||
@@ -30,6 +32,7 @@ field with five buttons.
|
|||||||
} from "$lib/util/entity-name";
|
} from "$lib/util/entity-name";
|
||||||
import CargoRoutes from "./planet/cargo-routes.svelte";
|
import CargoRoutes from "./planet/cargo-routes.svelte";
|
||||||
import Production from "./planet/production.svelte";
|
import Production from "./planet/production.svelte";
|
||||||
|
import ShipGroups from "./planet/ship-groups.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
planet: ReportPlanet;
|
planet: ReportPlanet;
|
||||||
@@ -39,6 +42,9 @@ field with five buttons.
|
|||||||
mapWidth: number;
|
mapWidth: number;
|
||||||
mapHeight: number;
|
mapHeight: number;
|
||||||
localPlayerDrive: number;
|
localPlayerDrive: number;
|
||||||
|
localShipGroups: ReportLocalShipGroup[];
|
||||||
|
otherShipGroups: ReportOtherShipGroup[];
|
||||||
|
localRace: string;
|
||||||
};
|
};
|
||||||
let {
|
let {
|
||||||
planet,
|
planet,
|
||||||
@@ -48,6 +54,9 @@ field with five buttons.
|
|||||||
mapWidth,
|
mapWidth,
|
||||||
mapHeight,
|
mapHeight,
|
||||||
localPlayerDrive,
|
localPlayerDrive,
|
||||||
|
localShipGroups,
|
||||||
|
otherShipGroups,
|
||||||
|
localRace,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
|
const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
|
||||||
@@ -223,6 +232,13 @@ field with five buttons.
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ShipGroups
|
||||||
|
{planet}
|
||||||
|
{localShipGroups}
|
||||||
|
{otherShipGroups}
|
||||||
|
{localRace}
|
||||||
|
/>
|
||||||
|
|
||||||
<dl class="fields">
|
<dl class="fields">
|
||||||
{#if planet.kind === "other" && planet.owner !== null}
|
{#if planet.kind === "other" && planet.owner !== null}
|
||||||
<div class="field" data-testid="inspector-planet-field-owner">
|
<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(
|
const localPlayerDrive = $derived(
|
||||||
renderedReport?.report?.localPlayerDrive ?? 0,
|
renderedReport?.report?.localPlayerDrive ?? 0,
|
||||||
);
|
);
|
||||||
|
const localShipGroups = $derived(
|
||||||
|
renderedReport?.report?.localShipGroups ?? [],
|
||||||
|
);
|
||||||
|
const otherShipGroups = $derived(
|
||||||
|
renderedReport?.report?.otherShipGroups ?? [],
|
||||||
|
);
|
||||||
|
const localRace = $derived(renderedReport?.report?.race ?? "");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="tool" data-testid="sidebar-tool-inspector">
|
<section class="tool" data-testid="sidebar-tool-inspector">
|
||||||
@@ -101,6 +108,9 @@ from the Phase 10 stub.
|
|||||||
{mapWidth}
|
{mapWidth}
|
||||||
{mapHeight}
|
{mapHeight}
|
||||||
{localPlayerDrive}
|
{localPlayerDrive}
|
||||||
|
{localShipGroups}
|
||||||
|
{otherShipGroups}
|
||||||
|
{localRace}
|
||||||
/>
|
/>
|
||||||
{:else if selectedShipGroup !== null}
|
{:else if selectedShipGroup !== null}
|
||||||
<ShipGroup selection={selectedShipGroup} planets={allPlanets} />
|
<ShipGroup selection={selectedShipGroup} planets={allPlanets} />
|
||||||
|
|||||||
@@ -4,22 +4,28 @@
|
|||||||
// incoming-trajectory lines) lives here.
|
// incoming-trajectory lines) lives here.
|
||||||
//
|
//
|
||||||
// Position rules:
|
// Position rules:
|
||||||
// - On-planet local / other groups (origin === null) — drawn next
|
// - On-planet local / other groups (origin === null, range === null)
|
||||||
// to the destination planet, slightly offset so the group has its
|
// are NOT rendered on the map. Stationed groups would otherwise
|
||||||
// own hit-target distinct from the planet pixel. Multiple groups
|
// pile up next to every populated planet and turn the canvas
|
||||||
// stationed at the same planet share the offset (Phase 19
|
// into noise; the planet inspector lists them instead
|
||||||
// limitation; a future phase fans them out or lists them in the
|
// (see `lib/inspectors/planet/ship-groups.svelte`).
|
||||||
// planet inspector).
|
|
||||||
// - In-hyperspace local / other groups (origin / range set) —
|
// - In-hyperspace local / other groups (origin / range set) —
|
||||||
// interpolated along the origin → destination line at `range`
|
// 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;
|
// - Incoming groups — origin and destination are always present;
|
||||||
// emit a dashed red trajectory line between the two and a
|
// emit a dashed red trajectory line from origin to a wrap-aware
|
||||||
// clickable point at the interpolated position (range = the
|
// destination plus a clickable point at the interpolated
|
||||||
// `distance` field).
|
// position (range = the `distance` field).
|
||||||
// - Unidentified groups — drawn at the absolute (x, y) the radar
|
// - Unidentified groups — drawn at the absolute (x, y) the radar
|
||||||
// reports.
|
// 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
|
// PrimitiveIDs are partitioned via large per-variant offsets so they
|
||||||
// never collide with planet ids (which run in `[0, planetCount)`).
|
// never collide with planet ids (which run in `[0, planetCount)`).
|
||||||
|
|
||||||
@@ -32,6 +38,7 @@ import type {
|
|||||||
ReportUnidentifiedShipGroup,
|
ReportUnidentifiedShipGroup,
|
||||||
} from "../api/game-state";
|
} from "../api/game-state";
|
||||||
import type { ShipGroupRef } from "../lib/selection.svelte";
|
import type { ShipGroupRef } from "../lib/selection.svelte";
|
||||||
|
import { torusShortestDelta } from "./math";
|
||||||
import type { LinePrim, PointPrim, PrimitiveID, Style } from "./world";
|
import type { LinePrim, PointPrim, PrimitiveID, Style } from "./world";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,12 +56,6 @@ export const SHIP_GROUP_ID_OFFSETS = {
|
|||||||
unidentified: 400_000_000,
|
unidentified: 400_000_000,
|
||||||
} as const;
|
} 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 = {
|
const STYLE_LOCAL_GROUP: Style = {
|
||||||
fillColor: 0xfff176,
|
fillColor: 0xfff176,
|
||||||
fillAlpha: 0.95,
|
fillAlpha: 0.95,
|
||||||
@@ -88,9 +89,9 @@ const STYLE_UNIDENTIFIED_GROUP: Style = {
|
|||||||
|
|
||||||
// Priority order inside `hit-test`: ship groups outrank planets so a
|
// Priority order inside `hit-test`: ship groups outrank planets so a
|
||||||
// hyperspace group landing on top of an unidentified planet is
|
// hyperspace group landing on top of an unidentified planet is
|
||||||
// selectable. On-planet groups stay below the planet so clicks on a
|
// selectable. The trajectory line itself is given the lowest priority
|
||||||
// planet still resolve to the planet itself (the offset gives the
|
// so a click on the dashed segment never "wins" over the clickable
|
||||||
// group its own un-overlapped hit area).
|
// point at the interpolated position.
|
||||||
const PRIORITY_LOCAL = 5;
|
const PRIORITY_LOCAL = 5;
|
||||||
const PRIORITY_OTHER = 5;
|
const PRIORITY_OTHER = 5;
|
||||||
const PRIORITY_INCOMING_POINT = 6;
|
const PRIORITY_INCOMING_POINT = 6;
|
||||||
@@ -109,10 +110,12 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
|
|||||||
for (const planet of report.planets) {
|
for (const planet of report.planets) {
|
||||||
planetIndex.set(planet.number, planet);
|
planetIndex.set(planet.number, planet);
|
||||||
}
|
}
|
||||||
|
const w = report.mapWidth;
|
||||||
|
const h = report.mapHeight;
|
||||||
|
|
||||||
for (let i = 0; i < report.localShipGroups.length; i++) {
|
for (let i = 0; i < report.localShipGroups.length; i++) {
|
||||||
const group = report.localShipGroups[i]!;
|
const group = report.localShipGroups[i]!;
|
||||||
const pos = computeGroupPosition(group, planetIndex);
|
const pos = computeInSpacePosition(group, planetIndex, w, h);
|
||||||
if (pos === null) continue;
|
if (pos === null) continue;
|
||||||
const id = SHIP_GROUP_ID_OFFSETS.local + i;
|
const id = SHIP_GROUP_ID_OFFSETS.local + i;
|
||||||
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, STYLE_LOCAL_GROUP));
|
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++) {
|
for (let i = 0; i < report.otherShipGroups.length; i++) {
|
||||||
const group = report.otherShipGroups[i]!;
|
const group = report.otherShipGroups[i]!;
|
||||||
const pos = computeGroupPosition(group, planetIndex);
|
const pos = computeInSpacePosition(group, planetIndex, w, h);
|
||||||
if (pos === null) continue;
|
if (pos === null) continue;
|
||||||
const id = SHIP_GROUP_ID_OFFSETS.other + i;
|
const id = SHIP_GROUP_ID_OFFSETS.other + i;
|
||||||
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_OTHER, STYLE_OTHER_GROUP));
|
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 origin = planetIndex.get(group.origin);
|
||||||
const destination = planetIndex.get(group.destination);
|
const destination = planetIndex.get(group.destination);
|
||||||
if (origin === undefined || destination === undefined) continue;
|
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;
|
const lineId = SHIP_GROUP_ID_OFFSETS.incomingLine + i;
|
||||||
primitives.push({
|
primitives.push({
|
||||||
kind: "line",
|
kind: "line",
|
||||||
@@ -142,16 +154,10 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
|
|||||||
hitSlopPx: 0,
|
hitSlopPx: 0,
|
||||||
x1: origin.x,
|
x1: origin.x,
|
||||||
y1: origin.y,
|
y1: origin.y,
|
||||||
x2: destination.x,
|
x2: destX,
|
||||||
y2: destination.y,
|
y2: destY,
|
||||||
});
|
});
|
||||||
const pos = interpolateAlongLine(
|
const pos = interpolateAlongLine(destX, destY, origin.x, origin.y, group.distance);
|
||||||
destination.x,
|
|
||||||
destination.y,
|
|
||||||
origin.x,
|
|
||||||
origin.y,
|
|
||||||
group.distance,
|
|
||||||
);
|
|
||||||
const pointId = SHIP_GROUP_ID_OFFSETS.incoming + i;
|
const pointId = SHIP_GROUP_ID_OFFSETS.incoming + i;
|
||||||
primitives.push(
|
primitives.push(
|
||||||
makePoint(
|
makePoint(
|
||||||
@@ -185,29 +191,34 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
|
|||||||
return { primitives, lookup };
|
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,
|
group: ReportLocalShipGroup | ReportOtherShipGroup,
|
||||||
planetIndex: Map<number, ReportPlanet>,
|
planetIndex: Map<number, ReportPlanet>,
|
||||||
|
mapWidth: number,
|
||||||
|
mapHeight: number,
|
||||||
): { x: number; y: number } | null {
|
): { x: number; y: number } | null {
|
||||||
|
if (group.origin === null || group.range === null) return null;
|
||||||
const destination = planetIndex.get(group.destination);
|
const destination = planetIndex.get(group.destination);
|
||||||
if (destination === undefined) return null;
|
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);
|
const origin = planetIndex.get(group.origin);
|
||||||
if (origin === undefined) return null;
|
if (origin === undefined) return null;
|
||||||
return interpolateAlongLine(
|
const dx = torusShortestDelta(destination.x, origin.x, mapWidth);
|
||||||
destination.x,
|
const dy = torusShortestDelta(destination.y, origin.y, mapHeight);
|
||||||
destination.y,
|
const total = Math.hypot(dx, dy);
|
||||||
origin.x,
|
if (total === 0 || group.range <= 0) {
|
||||||
origin.y,
|
return { x: destination.x, y: destination.y };
|
||||||
group.range,
|
}
|
||||||
);
|
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(
|
const inspectorLocalDrive = $derived(
|
||||||
renderedReport.report?.localPlayerDrive ?? 0,
|
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.
|
// Reveal the inspector whenever a new planet selection lands.
|
||||||
// Reading `selection.selected` once outside the effect keeps the
|
// Reading `selection.selected` once outside the effect keeps the
|
||||||
@@ -324,6 +331,9 @@ fresh.
|
|||||||
mapWidth={inspectorMapWidth}
|
mapWidth={inspectorMapWidth}
|
||||||
mapHeight={inspectorMapHeight}
|
mapHeight={inspectorMapHeight}
|
||||||
localPlayerDrive={inspectorLocalDrive}
|
localPlayerDrive={inspectorLocalDrive}
|
||||||
|
localShipGroups={inspectorLocalShipGroups}
|
||||||
|
otherShipGroups={inspectorOtherShipGroups}
|
||||||
|
localRace={inspectorLocalRace}
|
||||||
onMap={effectiveTool === "map"}
|
onMap={effectiveTool === "map"}
|
||||||
onClose={() => selection.clear()}
|
onClose={() => selection.clear()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,31 +10,27 @@
|
|||||||
|
|
||||||
import { expect, test, type Page } from "@playwright/test";
|
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
|
// Seed an authenticated session through `/__debug/store` so the
|
||||||
// root layout's redirect-to-login guard passes. The synthetic flow
|
// root layout's redirect-to-login guard passes. The synthetic flow
|
||||||
// itself does not talk to the gateway, but the session check still
|
// 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> {
|
async function seedSession(page: Page): Promise<void> {
|
||||||
await page.goto("/__debug/store");
|
await page.goto("/__debug/store");
|
||||||
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
|
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 page.evaluate(async () => {
|
||||||
await window.__galaxyDebug!.loadSession();
|
const debug = (window as unknown as {
|
||||||
await window.__galaxyDebug!.setDeviceSessionId(
|
__galaxyDebug: {
|
||||||
"phase-19-synthetic-session",
|
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,
|
mapWidth: 1,
|
||||||
mapHeight: 1,
|
mapHeight: 1,
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localShipGroups: [],
|
||||||
|
otherShipGroups: [],
|
||||||
|
localRace: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const section = ui.getByTestId("inspector-planet");
|
const section = ui.getByTestId("inspector-planet");
|
||||||
@@ -140,6 +143,9 @@ describe("planet inspector", () => {
|
|||||||
mapWidth: 1,
|
mapWidth: 1,
|
||||||
mapHeight: 1,
|
mapHeight: 1,
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localShipGroups: [],
|
||||||
|
otherShipGroups: [],
|
||||||
|
localRace: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
|
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
|
||||||
@@ -176,6 +182,9 @@ describe("planet inspector", () => {
|
|||||||
mapWidth: 1,
|
mapWidth: 1,
|
||||||
mapHeight: 1,
|
mapHeight: 1,
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localShipGroups: [],
|
||||||
|
otherShipGroups: [],
|
||||||
|
localRace: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
|
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
|
||||||
@@ -213,6 +222,9 @@ describe("planet inspector", () => {
|
|||||||
mapWidth: 1,
|
mapWidth: 1,
|
||||||
mapHeight: 1,
|
mapHeight: 1,
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localShipGroups: [],
|
||||||
|
otherShipGroups: [],
|
||||||
|
localRace: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
|
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
|
||||||
@@ -246,6 +258,9 @@ describe("planet inspector", () => {
|
|||||||
mapWidth: 1,
|
mapWidth: 1,
|
||||||
mapHeight: 1,
|
mapHeight: 1,
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localShipGroups: [],
|
||||||
|
otherShipGroups: [],
|
||||||
|
localRace: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(ui.queryByTestId("inspector-planet-rename-action")).toBeNull();
|
expect(ui.queryByTestId("inspector-planet-rename-action")).toBeNull();
|
||||||
@@ -283,6 +298,9 @@ describe("planet inspector", () => {
|
|||||||
mapWidth: 1,
|
mapWidth: 1,
|
||||||
mapHeight: 1,
|
mapHeight: 1,
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localShipGroups: [],
|
||||||
|
otherShipGroups: [],
|
||||||
|
localRace: "",
|
||||||
},
|
},
|
||||||
context,
|
context,
|
||||||
});
|
});
|
||||||
@@ -351,6 +369,9 @@ describe("planet inspector", () => {
|
|||||||
mapWidth: 1,
|
mapWidth: 1,
|
||||||
mapHeight: 1,
|
mapHeight: 1,
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localShipGroups: [],
|
||||||
|
otherShipGroups: [],
|
||||||
|
localRace: "",
|
||||||
},
|
},
|
||||||
context,
|
context,
|
||||||
});
|
});
|
||||||
@@ -386,6 +407,9 @@ describe("planet inspector", () => {
|
|||||||
mapWidth: 1,
|
mapWidth: 1,
|
||||||
mapHeight: 1,
|
mapHeight: 1,
|
||||||
localPlayerDrive: 0,
|
localPlayerDrive: 0,
|
||||||
|
localShipGroups: [],
|
||||||
|
otherShipGroups: [],
|
||||||
|
localRace: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Empty production strings collapse to the localised "none"
|
// Empty production strings collapse to the localised "none"
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("reportToWorld — ship groups", () => {
|
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 home = planet({ number: 17, x: 100, y: 100, kind: "local" });
|
||||||
const { world, hitLookup } = reportToWorld(
|
const { world, hitLookup } = reportToWorld(
|
||||||
makeReport({
|
makeReport({
|
||||||
@@ -82,18 +82,13 @@ describe("reportToWorld — ship groups", () => {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// 1 planet point + 1 ship-group point.
|
// Only the planet itself contributes a primitive; the on-planet
|
||||||
expect(world.primitives.length).toBe(2);
|
// group is intentionally invisible on the map. Phase 19's
|
||||||
const groupPrimId = SHIP_GROUP_ID_OFFSETS.local + 0;
|
// `lib/inspectors/planet/ship-groups.svelte` lists it inside the
|
||||||
const group = world.primitives.find((p) => p.id === groupPrimId);
|
// planet inspector instead.
|
||||||
expect(group).toBeDefined();
|
expect(world.primitives.length).toBe(1);
|
||||||
if (group?.kind !== "point") throw new Error("expected point");
|
expect(hitLookup.has(SHIP_GROUP_ID_OFFSETS.local + 0)).toBe(false);
|
||||||
// Off-planet rendering: not exactly on (100, 100).
|
expect(hitLookup.get(17)).toEqual({ kind: "planet", number: 17 });
|
||||||
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" },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("in-hyperspace local group renders at the interpolated position", () => {
|
test("in-hyperspace local group renders at the interpolated position", () => {
|
||||||
@@ -130,6 +125,36 @@ describe("reportToWorld — ship groups", () => {
|
|||||||
expect(group.y).toBe(0);
|
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", () => {
|
test("incoming group emits one dashed line + one clickable point", () => {
|
||||||
const dest = planet({ number: 1, x: 0, y: 0 });
|
const dest = planet({ number: 1, x: 0, y: 0 });
|
||||||
const orig = planet({ number: 9, x: 100, y: 0 });
|
const orig = planet({ number: 9, x: 100, y: 0 });
|
||||||
|
|||||||
Reference in New Issue
Block a user