Files
space-game/apps/viewer/src/viewerSelection.ts
Jonathan Bourdon 5df5111463 feat: migrate simulation to physically-based unit system
Replace arbitrary game units with real-world measurements throughout
the simulation and viewer: planet orbits in AU, sizes in km, galaxy
positions in light-years. Add SimulationUnits helpers for conversions,
separate WarpSpeed from FtlSpeed for ships, fix FTL transit progress
to use galaxy-space distances, overhaul Lagrange point placement with
Hill sphere approximation, and update the viewer to scale and format
all distances correctly. Ships in FTL transit now render in galaxy view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 14:21:20 -04:00

497 lines
15 KiB
TypeScript

import type { ShipSnapshot, SpatialNodeSnapshot, SystemSnapshot } from "./contracts";
import type {
CameraMode,
OrbitalAnchor,
Selectable,
SelectionGroup,
WorldState,
} from "./viewerTypes";
import { formatGalaxyDistance } from "./viewerMath";
export function describeSelectable(world: WorldState | undefined, item: Selectable): string {
if (!world) {
return item.kind;
}
if (item.kind === "ship") {
return world.ships.get(item.id)?.label ?? item.id;
}
if (item.kind === "station") {
return world.stations.get(item.id)?.label ?? item.id;
}
if (item.kind === "node") {
return item.id;
}
if (item.kind === "spatial-node") {
return `${world.spatialNodes.get(item.id)?.kind ?? "node"} ${item.id}`;
}
if (item.kind === "bubble") {
return `bubble ${item.id}`;
}
if (item.kind === "claim") {
return `claim ${item.id}`;
}
if (item.kind === "construction-site") {
return `construction ${item.id}`;
}
if (item.kind === "planet") {
return world.systems.get(item.systemId)?.planets[item.planetIndex]?.label ?? `${item.systemId}:${item.planetIndex}`;
}
return world.systems.get(item.id)?.label ?? item.id;
}
export function describeHoverLabel(world: WorldState | undefined, item: Selectable): string | undefined {
if (!world) {
return undefined;
}
if (item.kind === "ship") {
return world.ships.get(item.id)?.label ?? item.id;
}
if (item.kind === "station") {
return world.stations.get(item.id)?.label ?? item.id;
}
if (item.kind === "system") {
return world.systems.get(item.id)?.label ?? item.id;
}
if (item.kind === "planet") {
const system = world.systems.get(item.systemId);
const planet = system?.planets[item.planetIndex];
return planet ? `${system?.label ?? item.systemId} / ${planet.label}` : `${item.systemId} / planet ${item.planetIndex + 1}`;
}
if (item.kind === "node") {
const node = world.nodes.get(item.id);
if (!node) {
return item.id;
}
const anchorPath = node.anchorNodeId
? describeSpatialNodePathWithinSystem(world, node.systemId, node.anchorNodeId)
: undefined;
return anchorPath ? `${anchorPath} / ${node.itemId}` : `${node.systemId} / ${node.itemId}`;
}
if (item.kind === "spatial-node") {
const node = world.spatialNodes.get(item.id);
if (!node) {
return item.id;
}
if (node.kind === "star") {
const system = world.systems.get(node.systemId);
return system ? `${system.label} star` : `${node.systemId} star`;
}
return describeSpatialNodePathWithinSystem(world, node.systemId, node.id) ?? `${node.systemId} / ${node.kind}`;
}
if (item.kind === "bubble") {
const bubble = world.localBubbles.get(item.id);
const anchorPath = bubble?.nodeId
? describeSpatialNodePathWithinSystem(world, bubble.systemId, bubble.nodeId)
: undefined;
return anchorPath ? `${anchorPath} bubble` : `Bubble ${item.id}`;
}
if (item.kind === "claim") {
const claim = world.claims.get(item.id);
const anchorPath = claim?.nodeId
? describeSpatialNodePathWithinSystem(world, claim.systemId, claim.nodeId)
: undefined;
return anchorPath ? `${anchorPath} claim` : `Claim ${item.id}`;
}
if (item.kind === "construction-site") {
const site = world.constructionSites.get(item.id);
const anchorPath = site?.nodeId
? describeSpatialNodePathWithinSystem(world, site.systemId, site.nodeId)
: undefined;
const siteLabel = site ? (site.blueprintId ?? site.targetDefinitionId) : item.id;
return anchorPath ? `${anchorPath} / ${siteLabel}` : `Construction ${siteLabel}`;
}
return describeSelectable(world, item);
}
export function getSelectionGroup(item: Selectable): SelectionGroup {
if (item.kind === "ship") {
return "ships";
}
if (
item.kind === "station"
|| item.kind === "node"
|| item.kind === "spatial-node"
|| item.kind === "bubble"
|| item.kind === "claim"
|| item.kind === "construction-site"
) {
return "structures";
}
return "celestials";
}
export function resolveSelectableSystemId(world: WorldState | undefined, selection: Selectable): string | undefined {
if (!world) {
return undefined;
}
if (selection.kind === "ship") {
return world.ships.get(selection.id)?.systemId;
}
if (selection.kind === "station") {
return world.stations.get(selection.id)?.systemId;
}
if (selection.kind === "node") {
return world.nodes.get(selection.id)?.systemId;
}
if (selection.kind === "spatial-node") {
return world.spatialNodes.get(selection.id)?.systemId;
}
if (selection.kind === "bubble") {
return world.localBubbles.get(selection.id)?.systemId;
}
if (selection.kind === "claim") {
return world.claims.get(selection.id)?.systemId;
}
if (selection.kind === "construction-site") {
return world.constructionSites.get(selection.id)?.systemId;
}
if (selection.kind === "planet") {
return selection.systemId;
}
return selection.id;
}
export function resolveFocusedBubbleId(world: WorldState | undefined, selectedItems: Selectable[]): string | undefined {
if (!world || selectedItems.length !== 1) {
return undefined;
}
const selected = selectedItems[0];
if (selected.kind === "bubble") {
return selected.id;
}
if (selected.kind === "ship") {
return world.ships.get(selected.id)?.bubbleId ?? world.ships.get(selected.id)?.spatialState.currentBubbleId ?? undefined;
}
if (selected.kind === "station") {
return world.stations.get(selected.id)?.bubbleId ?? undefined;
}
if (selected.kind === "spatial-node") {
return world.spatialNodes.get(selected.id)?.bubbleId ?? undefined;
}
if (selected.kind === "claim") {
return world.claims.get(selected.id)?.bubbleId ?? undefined;
}
if (selected.kind === "construction-site") {
return world.constructionSites.get(selected.id)?.bubbleId ?? undefined;
}
return undefined;
}
export function describeOrbitalParent(world: WorldState | undefined, systemId?: string, anchor?: OrbitalAnchor): string {
if (!world || !systemId) {
return "unknown";
}
const system = world.systems.get(systemId);
if (!system) {
return systemId;
}
if (!anchor || anchor.kind === "star") {
return `${system.label} star`;
}
const planet = system.planets[anchor.planetIndex];
if (!planet) {
return `${system.label} star`;
}
if (anchor.kind === "planet") {
return planet.label;
}
return `${planet.label} moon ${anchor.moonIndex + 1}`;
}
export function renderSystemDetails(
world: WorldState | undefined,
system: SystemSnapshot,
activeContext: boolean,
cameraMode: CameraMode,
cameraTargetShipId?: string,
): string {
if (!world) {
return "";
}
let shipCount = 0;
let stationCount = 0;
let nodeCount = 0;
let spatialNodeCount = 0;
let bubbleCount = 0;
let claimCount = 0;
let constructionCount = 0;
let moonCount = 0;
for (const ship of world.ships.values()) {
if (ship.systemId === system.id) {
shipCount += 1;
}
}
for (const station of world.stations.values()) {
if (station.systemId === system.id) {
stationCount += 1;
}
}
for (const node of world.nodes.values()) {
if (node.systemId === system.id) {
nodeCount += 1;
}
}
for (const node of world.spatialNodes.values()) {
if (node.systemId === system.id) {
spatialNodeCount += 1;
}
}
for (const bubble of world.localBubbles.values()) {
if (bubble.systemId === system.id) {
bubbleCount += 1;
}
}
for (const claim of world.claims.values()) {
if (claim.systemId === system.id) {
claimCount += 1;
}
}
for (const site of world.constructionSites.values()) {
if (site.systemId === system.id) {
constructionCount += 1;
}
}
for (const planet of system.planets) {
moonCount += planet.moonCount;
}
const followText = activeContext && cameraMode === "follow" && cameraTargetShipId
? `<p>Camera locked to ${world.ships.get(cameraTargetShipId)?.label ?? cameraTargetShipId}</p>`
: "";
return `
<p>${system.id}${activeContext ? " · active system" : ""}</p>
<p>${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}</p>
<p>Planets ${system.planets.length}<br>Moons ${moonCount}<br>Ships ${shipCount}<br>Stations ${stationCount}</p>
<p>Spatial nodes ${spatialNodeCount}<br>Resource nodes ${nodeCount}<br>Bubbles ${bubbleCount}</p>
<p>Claims ${claimCount}<br>Construction sites ${constructionCount}</p>
<p>Height ${formatGalaxyDistance(system.galaxyPosition.y)}</p>
<p>${system.planets.slice(0, 8).map((planet) => `${planet.label} (${planet.planetType})`).join("<br>")}</p>
${followText}
`;
}
export function describeShipState(world: WorldState | undefined, ship: ShipSnapshot): string {
const baseState = ship.state;
if (baseState === "capacitor-starved") {
return `${baseState} while ${describeControllerTask(ship.controllerTaskKind)}`;
}
if (!world || (baseState !== "ftl" && baseState !== "spooling-ftl" && baseState !== "warping" && baseState !== "spooling-warp")) {
return baseState;
}
const destinationNodeId = ship.spatialState.destinationNodeId ?? ship.spatialState.transit?.destinationNodeId;
if (!destinationNodeId) {
return baseState;
}
const destinationNode = world.spatialNodes.get(destinationNodeId);
if (!destinationNode) {
return `${baseState} -> ${destinationNodeId}`;
}
if (baseState === "warping" || baseState === "spooling-warp") {
const destinationPath = describeSpatialNodePathWithinSystem(world, destinationNode.systemId, destinationNodeId);
return `${baseState} -> ${destinationPath ?? destinationNodeId}`;
}
const destinationSystem = world.systems.get(destinationNode.systemId);
return `${baseState} -> ${destinationSystem?.label ?? destinationNode.systemId}`;
}
function describeControllerTask(taskKind: string): string {
switch (taskKind) {
case "travel":
return "travel";
case "extract":
return "mining";
case "dock":
return "docking";
case "unload":
return "transfer";
case "refuel":
return "refuel";
case "deliver-construction":
return "material delivery";
case "build-construction-site":
return "site construction";
case "construct-module":
return "module construction";
case "undock":
return "undocking";
case "load-workers":
return "worker loading";
case "unload-workers":
return "worker unloading";
default:
return taskKind;
}
}
export function describeShipCurrentAction(ship: ShipSnapshot): { label: string; progress: number } | undefined {
if (!ship.currentAction) {
return undefined;
}
return {
label: ship.currentAction.label,
progress: Math.max(0, Math.min(ship.currentAction.progress, 1)),
};
}
export function describeShipLocation(world: WorldState | undefined, ship: ShipSnapshot): { system: string; local?: string } {
const systemId = ship.spatialState.currentSystemId || ship.systemId;
const system = world?.systems.get(systemId);
const systemLabel = system?.label ?? systemId;
if (!world || !system) {
return { system: systemLabel };
}
if (ship.dockedStationId) {
const station = world.stations.get(ship.dockedStationId);
if (station) {
const anchorPath = station.anchorNodeId
? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId)
: undefined;
return {
system: systemLabel,
local: anchorPath ? `${anchorPath}/${station.label}` : station.label,
};
}
}
const currentNodeId = ship.spatialState.currentNodeId ?? ship.nodeId;
if (currentNodeId) {
const nodePath = describeSpatialNodePathWithinSystem(world, systemId, currentNodeId);
if (nodePath) {
return { system: systemLabel, local: nodePath };
}
}
const currentBubbleId = ship.spatialState.currentBubbleId ?? ship.bubbleId;
if (currentBubbleId) {
const bubble = world.localBubbles.get(currentBubbleId);
if (bubble?.nodeId) {
const nodePath = describeSpatialNodePathWithinSystem(world, systemId, bubble.nodeId);
if (nodePath) {
return { system: systemLabel, local: nodePath };
}
}
}
return { system: systemLabel };
}
export function describeActiveSpace(
world: WorldState | undefined,
zoomLevel: "local" | "system" | "universe",
activeSystemId: string | undefined,
selectedItems: Selectable[],
): string {
if (!world || zoomLevel === "universe") {
return "deep-space";
}
const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined;
if (!activeSystem) {
return "deep-space";
}
if (zoomLevel !== "local") {
return activeSystem.label;
}
const bubbleId = resolveFocusedBubbleId(world, selectedItems);
if (bubbleId) {
const bubble = world.localBubbles.get(bubbleId);
const localPath = bubble?.nodeId
? describeSpatialNodePathWithinSystem(world, activeSystem.id, bubble.nodeId)
: undefined;
return localPath
? `${activeSystem.label} / ${localPath}`
: activeSystem.label;
}
const selected = selectedItems.length === 1 ? selectedItems[0] : undefined;
if (selected?.kind === "planet" && selected.systemId === activeSystem.id) {
const planet = activeSystem.planets[selected.planetIndex];
return planet
? `${activeSystem.label} / ${planet.label}`
: activeSystem.label;
}
return activeSystem.label;
}
export function describeSpatialNodePathWithinSystem(world: WorldState, systemId: string, nodeId: string): string | undefined {
const node = world.spatialNodes.get(nodeId);
const system = world.systems.get(systemId);
if (!node || !system) {
return undefined;
}
if (node.parentNodeId) {
const parentPath = describeSpatialNodePathWithinSystem(world, systemId, node.parentNodeId);
const segment = describeSpatialNodeSegment(world, system, node);
return parentPath ? `${parentPath}/${segment}` : segment;
}
if (node.kind === "star") {
return undefined;
}
return describeSpatialNodeSegment(world, system, node);
}
function describeSpatialNodeSegment(world: WorldState, system: SystemSnapshot, node: SpatialNodeSnapshot): string {
const moonMatch = node.id.match(/-planet-(\d+)-moon-(\d+)$/);
if (moonMatch) {
const moonIndex = Number.parseInt(moonMatch[2], 10);
return `Moon ${moonIndex}`;
}
const lagrangeMatch = node.id.match(/-planet-\d+-(l[1-5])$/);
if (lagrangeMatch) {
return lagrangeMatch[1].toUpperCase();
}
const planetMatch = node.id.match(/-planet-(\d+)$/);
if (planetMatch) {
const planetIndex = Number.parseInt(planetMatch[1], 10) - 1;
return system.planets[planetIndex]?.label ?? `Planet ${planetMatch[1]}`;
}
if (node.kind === "station" && node.occupyingStructureId) {
return world.stations.get(node.occupyingStructureId)?.label ?? node.occupyingStructureId;
}
if (node.kind === "resource-site") {
return node.orbitReferenceId ?? "Resource Site";
}
return node.orbitReferenceId ?? node.kind;
}