Files
space-game/apps/viewer/src/viewerSelection.ts

623 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CelestialSnapshot, ShipSnapshot, SystemSnapshot } from "./contracts";
import type {
CameraMode,
OrbitalAnchor,
Selectable,
SelectionGroup,
WorldState,
} from "./viewerTypes";
import { formatGalaxyDistance } from "./viewerMath";
import { getShipBehaviorLabel, getShipOrderLabel } from "./shipAutomationPresentation";
export function describeSelectable(world: WorldState | undefined, item: Selectable): string {
if (!world) {
return item.kind;
}
if (item.kind === "ship") {
return world.ships.get(item.id)?.name ?? item.id;
}
if (item.kind === "station") {
return world.stations.get(item.id)?.label ?? item.id;
}
if (item.kind === "node") {
const node = world.nodes.get(item.id);
return node ? `${node.itemId} source` : item.id;
}
if (item.kind === "celestial") {
const celestial = world.celestials.get(item.id);
if (!celestial) {
return item.id;
}
return describeCelestialPathWithinSystem(world, celestial.systemId, celestial.id)
?? `${world.systems.get(celestial.systemId)?.label ?? celestial.systemId} / ${celestial.kind}`;
}
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}`;
}
if (item.kind === "moon") {
const planet = world.systems.get(item.systemId)?.planets[item.planetIndex];
return planet?.moons[item.moonIndex]?.label ?? `moon ${item.moonIndex + 1}`;
}
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") {
const ship = world.ships.get(item.id);
if (!ship) {
return item.id;
}
const lines = [
ship.name,
`Behavior ${describeShipBehavior(ship)}`,
`State ${describeShipState(world, ship)}`,
`Order ${describeShipOrder(ship)}`,
];
return lines.join("\n");
}
if (item.kind === "station") {
return world.stations.get(item.id)?.label ?? item.id;
}
if (item.kind === "system") {
const system = world.systems.get(item.id);
if (!system) {
return item.id;
}
const starLabel = system.stars.length > 1 ? `${system.stars.length}× ${system.stars[0]?.kind}` : (system.stars[0]?.kind ?? "unknown");
const planetCount = system.planets.length;
const shipCount = [...world.ships.values()].filter((s) => s.systemId === item.id).length;
const stationCount = [...world.stations.values()].filter((s) => s.systemId === item.id).length;
const lines = [
system.label,
`${starLabel} · ${planetCount} planet${planetCount !== 1 ? "s" : ""}`,
];
const parts: string[] = [];
if (shipCount > 0) {
parts.push(`${shipCount} ship${shipCount !== 1 ? "s" : ""}`);
}
if (stationCount > 0) {
parts.push(`${stationCount} station${stationCount !== 1 ? "s" : ""}`);
}
if (parts.length > 0) {
lines.push(parts.join(" · "));
}
return lines.join("\n");
}
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 === "moon") {
const system = world.systems.get(item.systemId);
const planet = system?.planets[item.planetIndex];
const moon = planet?.moons[item.moonIndex];
if (moon) {
return `${system?.label ?? item.systemId} / ${planet?.label ?? `planet ${item.planetIndex + 1}`} / ${moon.label}`;
}
return `${item.systemId} / planet ${item.planetIndex + 1} / moon ${item.moonIndex + 1}`;
}
if (item.kind === "node") {
const node = world.nodes.get(item.id);
if (!node) {
return item.id;
}
const anchorPath = describeAnchorPathWithinSystem(world, node.systemId, node.anchorId);
return anchorPath ? `${anchorPath} / ${node.itemId}` : `${node.systemId} / ${node.itemId}`;
}
if (item.kind === "celestial") {
const celestial = world.celestials.get(item.id);
if (!celestial) {
return item.id;
}
if (celestial.kind === "star") {
const system = world.systems.get(celestial.systemId);
return system ? `${system.label} star` : `${celestial.systemId} star`;
}
return describeCelestialPathWithinSystem(world, celestial.systemId, celestial.id) ?? `${celestial.systemId} / ${celestial.kind}`;
}
if (item.kind === "claim") {
const claim = world.claims.get(item.id);
const anchorPath = claim
? describeAnchorPathWithinSystem(world, claim.systemId, claim.anchorId)
: undefined;
return anchorPath ? `${anchorPath} claim` : `Claim ${item.id}`;
}
if (item.kind === "construction-site") {
const site = world.constructionSites.get(item.id);
const anchorPath = site
? describeAnchorPathWithinSystem(world, site.systemId, site.anchorId)
: 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 === "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 === "celestial") {
return world.celestials.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;
}
if (selection.kind === "moon") {
return selection.systemId;
}
return selection.id;
}
export function resolveFocusedCelestialId(world: WorldState | undefined, selectedItems: Selectable[]): string | undefined {
if (!world || selectedItems.length !== 1) {
return undefined;
}
const selected = selectedItems[0];
if (selected.kind === "celestial") {
return selected.id;
}
if (selected.kind === "ship") {
const ship = world.ships.get(selected.id);
return ship?.spatialState.currentAnchorId && world.celestials.has(ship.spatialState.currentAnchorId)
? ship.spatialState.currentAnchorId
: (ship?.anchorId && world.celestials.has(ship.anchorId) ? ship.anchorId : undefined);
}
if (selected.kind === "station") {
const station = world.stations.get(selected.id);
return station?.anchorId && world.celestials.has(station.anchorId) ? station.anchorId : undefined;
}
if (selected.kind === "claim") {
const claim = world.claims.get(selected.id);
return claim && world.celestials.has(claim.anchorId) ? claim.anchorId : undefined;
}
if (selected.kind === "construction-site") {
const site = world.constructionSites.get(selected.id);
return site && world.celestials.has(site.anchorId) ? site.anchorId : undefined;
}
return undefined;
}
export function resolveFocusedAnchorId(world: WorldState | undefined, selectedItems: Selectable[]): string | undefined {
if (!world || selectedItems.length !== 1) {
return undefined;
}
const selected = selectedItems[0];
if (selected.kind === "node") {
return world.nodes.get(selected.id)?.anchorId;
}
if (selected.kind === "ship") {
const ship = world.ships.get(selected.id);
return ship?.spatialState.currentAnchorId
?? ship?.anchorId
?? resolveFocusedCelestialId(world, selectedItems);
}
if (selected.kind === "station") {
const station = world.stations.get(selected.id);
return station?.anchorId
?? resolveFocusedCelestialId(world, selectedItems);
}
if (selected.kind === "claim") {
const claim = world.claims.get(selected.id);
return claim?.anchorId
?? resolveFocusedCelestialId(world, selectedItems);
}
if (selected.kind === "construction-site") {
const site = world.constructionSites.get(selected.id);
return site?.anchorId
?? resolveFocusedCelestialId(world, selectedItems);
}
if (selected.kind === "celestial") {
if (world.anchors.has(selected.id)) {
return selected.id;
}
const orbitBackedAnchor = [...world.anchors.values()].find((anchor) => anchor.orbitReferenceId === selected.id);
return orbitBackedAnchor?.id;
}
if (selected.kind === "planet") {
return `node-${selected.systemId}-planet-${selected.planetIndex + 1}`;
}
if (selected.kind === "moon") {
return `node-${selected.systemId}-planet-${selected.planetIndex + 1}-moon-${selected.moonIndex + 1}`;
}
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 celestialCount = 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 celestial of world.celestials.values()) {
if (celestial.systemId === system.id) {
celestialCount += 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.moons.length;
}
const followText = activeContext && cameraMode === "follow" && cameraTargetShipId
? `<p>Camera locked to ${world.ships.get(cameraTargetShipId)?.name ?? cameraTargetShipId}</p>`
: "";
return `
<p>${system.id}${activeContext ? " · active system" : ""}</p>
<p>${system.stars[0]?.kind ?? "unknown"} · ${system.stars.length} star${system.stars.length > 1 ? "s" : ""}</p>
<p>Planets ${system.planets.length}<br>Moons ${moonCount}<br>Ships ${shipCount}<br>Stations ${stationCount}</p>
<p>Celestials ${celestialCount}<br>Resource nodes ${nodeCount}</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;
const currentSubTask = ship.activeSubTasks[0];
if (baseState === "capacitor-starved") {
return currentSubTask ? `${baseState} while ${titleTask(currentSubTask.kind)}` : baseState;
}
if (!world || (baseState !== "ftl" && baseState !== "spooling-ftl" && baseState !== "warping" && baseState !== "spooling-warp")) {
return baseState;
}
const destinationAnchorId = ship.spatialState.destinationAnchorId ?? ship.spatialState.transit?.destinationAnchorId;
if (!destinationAnchorId) {
return baseState;
}
const destinationAnchor = destinationAnchorId ? world.anchors.get(destinationAnchorId) : undefined;
if (baseState === "warping" || baseState === "spooling-warp") {
const destinationSystemId = destinationAnchor?.systemId ?? ship.spatialState.currentSystemId ?? ship.systemId;
const destinationPath = describeAnchorPathWithinSystem(
world,
destinationSystemId,
destinationAnchorId,
);
return `${baseState} -> ${destinationPath ?? destinationAnchorId}`;
}
const destinationSystemId = destinationAnchor?.systemId
?? ship.spatialState.currentSystemId
?? ship.systemId;
const destinationSystem = world.systems.get(destinationSystemId);
if (!destinationSystem) {
return `${baseState} -> ${destinationAnchorId}`;
}
return `${baseState} -> ${destinationSystem.label}`;
}
export function describeShipObjective(objective: string): string {
return objective.replace(/[-_]+/g, " ");
}
export function describeShipBehavior(ship: ShipSnapshot): string {
const parts = [getShipBehaviorLabel(ship.defaultBehavior.kind)];
if (ship.assignment?.kind) {
parts.push(ship.assignment.kind);
}
return parts.join(" · ");
}
export function describeShipOrder(ship: ShipSnapshot): string {
const activeOrder = ship.orderQueue.find((order) => order.status === "queued" || order.status === "active");
if (activeOrder) {
return activeOrder.label ?? getShipOrderLabel(activeOrder.kind);
}
if (ship.assignment?.kind) {
return describeShipObjective(ship.assignment.kind);
}
return getShipBehaviorLabel(ship.defaultBehavior.kind);
}
export function describeShipCurrentAction(ship: ShipSnapshot): { label: string; progress: number } | undefined {
const subTask = ship.activeSubTasks[0];
if (!subTask) {
return undefined;
}
return {
label: subTask.summary || titleTask(subTask.kind),
progress: Math.max(0, Math.min(subTask.progress, 1)),
};
}
function titleTask(value: string): string {
return value.replace(/[-_]+/g, " ");
}
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.anchorId
? describeAnchorPathWithinSystem(world, station.systemId, station.anchorId)
: undefined;
return {
system: systemLabel,
local: anchorPath ? `${anchorPath}/${station.label}` : station.label,
};
}
}
const currentAnchorId = ship.spatialState.currentAnchorId ?? ship.anchorId;
if (currentAnchorId) {
const anchorPath = describeAnchorPathWithinSystem(world, systemId, currentAnchorId);
if (anchorPath) {
return { system: systemLabel, local: anchorPath };
}
}
return { system: systemLabel };
}
export function describeActiveSpace(
world: WorldState | undefined,
povLevel: "local" | "system" | "galaxy",
activeSystemId: string | undefined,
selectedItems: Selectable[],
): string {
if (!world || povLevel === "galaxy") {
return "deep-space";
}
const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined;
if (!activeSystem) {
return "deep-space";
}
if (povLevel !== "local") {
return activeSystem.label;
}
const anchorId = resolveFocusedAnchorId(world, selectedItems);
if (anchorId) {
const localPath = describeAnchorPathWithinSystem(world, activeSystem.id, anchorId);
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 describeCelestialPathWithinSystem(world: WorldState, systemId: string, celestialId: string): string | undefined {
const celestial = world.celestials.get(celestialId);
const system = world.systems.get(systemId);
if (!celestial || !system) {
return undefined;
}
const anchorId = resolveAnchorIdForCelestial(world, celestialId);
if (anchorId) {
return describeAnchorPathWithinSystem(world, systemId, anchorId);
}
if (celestial.kind === "star") {
return undefined;
}
return describeCelestialSegment(system, celestial);
}
export function describeAnchorPathWithinSystem(world: WorldState, systemId: string, anchorId: string, celestialId?: string | null): string | undefined {
const anchor = world.anchors.get(anchorId);
if (anchor?.parentAnchorId) {
const parentPath = describeAnchorPathWithinSystem(world, systemId, anchor.parentAnchorId);
const segment = describeAnchorSegment(anchor);
return parentPath ? `${parentPath}/${segment}` : segment;
}
if (celestialId) {
return describeCelestialPathWithinSystem(world, systemId, celestialId);
}
if (!anchor) {
return undefined;
}
return describeAnchorSegment(anchor);
}
function describeAnchorSegment(anchor: { kind: string; id: string; orbitReferenceId?: string | null }): string {
if (anchor.orbitReferenceId) {
return describeAnchorOrbitReference(anchor.orbitReferenceId);
}
if (anchor.kind === "resource-node") {
return anchor.id;
}
return anchor.kind.replace(/-/g, " ");
}
function resolveAnchorIdForCelestial(world: WorldState, celestialId: string): string | undefined {
return world.anchors.has(celestialId) ? celestialId : undefined;
}
function describeAnchorOrbitReference(referenceId: string): string {
const lagrangeMatch = referenceId.match(/(l[1-5])$/i);
if (lagrangeMatch) {
return lagrangeMatch[1].toUpperCase();
}
const moonMatch = referenceId.match(/moon-(\d+)$/i);
if (moonMatch) {
return `Moon ${moonMatch[1]}`;
}
const planetMatch = referenceId.match(/planet-(\d+)$/i);
if (planetMatch) {
return `Planet ${planetMatch[1]}`;
}
return referenceId;
}
function describeCelestialSegment(system: SystemSnapshot, celestial: CelestialSnapshot): string {
const moonMatch = celestial.id.match(/-planet-(\d+)-moon-(\d+)$/);
if (moonMatch) {
const moonIndex = Number.parseInt(moonMatch[2], 10);
return `Moon ${moonIndex}`;
}
const lagrangeMatch = celestial.id.match(/-planet-\d+-(l[1-5])$/);
if (lagrangeMatch) {
return lagrangeMatch[1].toUpperCase();
}
const planetMatch = celestial.id.match(/-planet-(\d+)$/);
if (planetMatch) {
const planetIndex = Number.parseInt(planetMatch[1], 10) - 1;
return system.planets[planetIndex]?.label ?? `Planet ${planetMatch[1]}`;
}
return celestial.orbitReferenceId ?? celestial.kind;
}