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

508 lines
16 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") {
return item.id;
}
if (item.kind === "celestial") {
return `${world.celestials.get(item.id)?.kind ?? "celestial"} ${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}`;
}
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 = node.celestialId
? describeCelestialPathWithinSystem(world, node.systemId, node.celestialId)
: undefined;
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?.celestialId
? describeCelestialPathWithinSystem(world, claim.systemId, claim.celestialId)
: undefined;
return anchorPath ? `${anchorPath} claim` : `Claim ${item.id}`;
}
if (item.kind === "construction-site") {
const site = world.constructionSites.get(item.id);
const anchorPath = site?.celestialId
? describeCelestialPathWithinSystem(world, site.systemId, site.celestialId)
: 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") {
return world.ships.get(selected.id)?.spatialState.currentCelestialId ?? world.ships.get(selected.id)?.celestialId ?? undefined;
}
if (selected.kind === "station") {
return world.stations.get(selected.id)?.celestialId ?? undefined;
}
if (selected.kind === "claim") {
return world.claims.get(selected.id)?.celestialId ?? undefined;
}
if (selected.kind === "construction-site") {
return world.constructionSites.get(selected.id)?.celestialId ?? 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 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 destinationNodeId = ship.spatialState.destinationNodeId ?? ship.spatialState.transit?.destinationNodeId;
if (!destinationNodeId) {
return baseState;
}
const destinationCelestial = world.celestials.get(destinationNodeId);
if (!destinationCelestial) {
return `${baseState} -> ${destinationNodeId}`;
}
if (baseState === "warping" || baseState === "spooling-warp") {
const destinationPath = describeCelestialPathWithinSystem(world, destinationCelestial.systemId, destinationNodeId);
return `${baseState} -> ${destinationPath ?? destinationNodeId}`;
}
const destinationSystem = world.systems.get(destinationCelestial.systemId);
return `${baseState} -> ${destinationSystem?.label ?? destinationCelestial.systemId}`;
}
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]
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
if (activeOrder) {
return activeOrder.label ?? getShipOrderLabel(activeOrder.kind);
}
if (ship.assignment?.kind) {
return describeShipObjective(ship.assignment.kind);
}
if (ship.activePlan) {
return ship.activePlan.summary || ship.activePlan.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.celestialId
? describeCelestialPathWithinSystem(world, station.systemId, station.celestialId)
: undefined;
return {
system: systemLabel,
local: anchorPath ? `${anchorPath}/${station.label}` : station.label,
};
}
}
const currentCelestialId = ship.spatialState.currentCelestialId ?? ship.celestialId;
if (currentCelestialId) {
const celestialPath = describeCelestialPathWithinSystem(world, systemId, currentCelestialId);
if (celestialPath) {
return { system: systemLabel, local: celestialPath };
}
}
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 celestialId = resolveFocusedCelestialId(world, selectedItems);
if (celestialId) {
const localPath = describeCelestialPathWithinSystem(world, activeSystem.id, celestialId);
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;
}
if (celestial.parentNodeId) {
const parentPath = describeCelestialPathWithinSystem(world, systemId, celestial.parentNodeId);
const segment = describeCelestialSegment(system, celestial);
return parentPath ? `${parentPath}/${segment}` : segment;
}
if (celestial.kind === "star") {
return undefined;
}
return describeCelestialSegment(system, celestial);
}
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;
}