Behavior
{{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}
-
Orders {{ selectedShip.orderQueue.length }} · Plan {{ selectedShip.activePlan?.kind ?? "none" }}
+
Orders {{ selectedShip.orderQueue.length }} · Tasks {{ selectedShip.activeSubTasks.length }}
Command {{ titleCase(selectedShip.controlSourceKind) }} · {{ selectedShip.controlReason }}
Replan {{ selectedShip.lastReplanReason }}
Access {{ selectedShip.lastAccessFailureReason }}
diff --git a/apps/viewer/src/contractsShips.ts b/apps/viewer/src/contractsShips.ts
index 97a5496..8669ea0 100644
--- a/apps/viewer/src/contractsShips.ts
+++ b/apps/viewer/src/contractsShips.ts
@@ -114,31 +114,6 @@ export interface ShipSubTaskSnapshot {
blockingReason?: string | null;
}
-export interface ShipPlanStepSnapshot {
- id: string;
- kind: string;
- status: string;
- summary: string;
- blockingReason?: string | null;
- currentSubTaskIndex: number;
- subTasks: ShipSubTaskSnapshot[];
-}
-
-export interface ShipPlanSnapshot {
- id: string;
- sourceKind: string;
- sourceId: string;
- kind: string;
- status: string;
- summary: string;
- currentStepIndex: number;
- createdAtUtc: string;
- updatedAtUtc: string;
- interruptReason?: string | null;
- failureReason?: string | null;
- steps: ShipPlanStepSnapshot[];
-}
-
export interface ShipSnapshot {
id: string;
name: string;
@@ -154,8 +129,6 @@ export interface ShipSnapshot {
defaultBehavior: DefaultBehaviorSnapshot;
assignment?: ShipAssignmentSnapshot | null;
skills: ShipSkillProfileSnapshot;
- activePlan?: ShipPlanSnapshot | null;
- currentStepId?: string | null;
activeSubTasks: ShipSubTaskSnapshot[];
controlSourceKind: string;
controlSourceId?: string | null;
diff --git a/apps/viewer/src/shipCommands.ts b/apps/viewer/src/shipCommands.ts
index e45ffde..5042b14 100644
--- a/apps/viewer/src/shipCommands.ts
+++ b/apps/viewer/src/shipCommands.ts
@@ -21,6 +21,26 @@ export interface ShipOrderCommandRequest {
knownStationsOnly?: boolean | null;
}
+export interface ShipOrderUpdateCommandRequest {
+ kind: string;
+ priority: number;
+ interruptCurrentPlan: boolean;
+ label?: string | null;
+ targetEntityId?: string | null;
+ targetSystemId?: string | null;
+ targetPosition?: Vector3Dto | null;
+ sourceStationId?: string | null;
+ destinationStationId?: string | null;
+ itemId?: string | null;
+ anchorId?: string | null;
+ constructionSiteId?: string | null;
+ moduleId?: string | null;
+ waitSeconds?: number | null;
+ radius?: number | null;
+ maxSystemRange?: number | null;
+ knownStationsOnly?: boolean | null;
+}
+
export interface ShipDefaultBehaviorCommandRequest {
kind: string;
homeSystemId?: string | null;
diff --git a/apps/viewer/src/styles/viewer.css b/apps/viewer/src/styles/viewer.css
index c9bae06..248141c 100644
--- a/apps/viewer/src/styles/viewer.css
+++ b/apps/viewer/src/styles/viewer.css
@@ -669,112 +669,6 @@ canvas {
gap: 8px;
}
-.ops-strip {
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- width: 50vw;
- min-height: 128px;
- display: flex;
- align-items: stretch;
- gap: 0;
- overflow-x: auto;
- overflow-y: hidden;
- pointer-events: auto;
- scrollbar-width: thin;
- background: linear-gradient(180deg, rgba(5, 10, 18, 0), rgba(5, 10, 18, 0.92) 28%);
-}
-
-.ship-card {
- border-top: 1px solid rgba(127, 214, 255, 0.14);
- border-right: 1px solid rgba(127, 214, 255, 0.1);
- background: linear-gradient(180deg, rgba(10, 20, 36, 0.96), rgba(6, 12, 22, 0.98));
- padding: 10px 12px 12px;
- min-width: 208px;
- max-width: 208px;
- display: flex;
- flex-direction: column;
- gap: 6px;
- color: var(--viewer-text);
- cursor: pointer;
- transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
-}
-
-.ship-card:hover {
- transform: translateY(-2px);
- border-color: rgba(127, 214, 255, 0.38);
- box-shadow: 0 16px 32px rgba(0, 0, 0, 0.28);
-}
-
-.ship-card.is-selected {
- border-top-color: rgba(255, 191, 105, 0.82);
- background: linear-gradient(180deg, rgba(31, 33, 20, 0.9), rgba(20, 18, 10, 0.92));
-}
-
-.ship-card.is-followed {
- box-shadow: inset 0 0 0 1px rgba(127, 214, 255, 0.34);
-}
-
-.ship-card-header {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- gap: 10px;
-}
-
-.ship-card h3 {
- margin: 0;
- font-size: 0.82rem;
- line-height: 1.15;
- letter-spacing: 0.04em;
-}
-
-.ship-card p {
- margin: 2px 0 0;
- color: var(--viewer-muted);
- line-height: 1.35;
- font-size: 0.72rem;
- white-space: pre-line;
-}
-
-.ship-card-header + p {
- font-size: 0.62rem;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.ship-card-meta {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: 6px;
-}
-
-.ship-card-badge {
- padding: 3px 8px;
- border-radius: 999px;
- background: rgba(127, 214, 255, 0.12);
- color: var(--viewer-accent);
- font-size: 0.64rem;
- letter-spacing: 0.12em;
- text-transform: uppercase;
-}
-
-.ship-card-ai {
- margin-top: 2px;
- padding-top: 6px;
- border-top: 1px solid rgba(127, 214, 255, 0.08);
-}
-
-.ship-card-section-title {
- margin: 0;
- color: var(--viewer-accent);
- letter-spacing: 0.14em;
- text-transform: uppercase;
-}
-
-.ship-card-history-button,
.history-window-copy,
.history-window-close {
border: 1px solid rgba(127, 214, 255, 0.22);
@@ -785,64 +679,15 @@ canvas {
cursor: pointer;
}
-.ship-card-history-button {
- width: 24px;
- height: 24px;
- padding: 0;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- align-self: flex-end;
- font-size: 0.78rem;
- line-height: 1;
-}
-
.history-window-copy,
.history-window-close {
padding: 8px 12px;
}
-.faction-card {
- border-top-color: rgba(180, 130, 255, 0.3);
- cursor: default;
-}
-
-.faction-card:hover {
- transform: none;
- border-color: rgba(180, 130, 255, 0.5);
-}
-
-.station-card {
- border-top-color: rgba(127, 255, 180, 0.22);
-}
-
-.station-card:hover {
- border-color: rgba(127, 255, 180, 0.5);
-}
-
-.ship-card-split-line {
- display: flex;
- justify-content: space-between;
- gap: 12px;
-}
-
.selection-action-button {
pointer-events: auto;
}
-@media (max-width: 1080px) {
- .ops-strip {
- width: 100vw;
- }
-}
-
-@media (max-width: 760px) {
- .ops-strip {
- width: 100vw;
- min-height: 120px;
- }
-}
-
/* ── GM Windows ──────────────────────────────────────────────────────────── */
.gm-window {
@@ -1859,6 +1704,65 @@ canvas {
align-items: center;
}
+.entity-inspector-order-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.entity-inspector-order-card {
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 1rem;
+ background: rgba(255, 255, 255, 0.03);
+ padding: 0.8rem 0.9rem;
+}
+
+.entity-inspector-order-card__header,
+.entity-inspector-order-card__actions,
+.entity-inspector-order-card__summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.7rem;
+}
+
+.entity-inspector-order-card__toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ border: none;
+ background: transparent;
+ color: var(--viewer-text);
+ padding: 0;
+ font: inherit;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.entity-inspector-order-card__status {
+ font-size: 0.72rem;
+ color: rgba(173, 220, 255, 0.78);
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+}
+
+.entity-inspector-order-card__summary {
+ margin-top: 0.55rem;
+ align-items: flex-start;
+ color: var(--viewer-muted);
+ font-size: 0.76rem;
+}
+
+.entity-inspector-order-card__summary span:last-child {
+ text-align: right;
+}
+
+.entity-inspector-order-editor {
+ margin-top: 0.8rem;
+ padding-top: 0.8rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+}
+
.entity-inspector-field {
display: flex;
flex-direction: column;
@@ -1870,6 +1774,10 @@ canvas {
flex: 1 1 auto;
}
+.entity-inspector-field--checkbox {
+ justify-content: flex-end;
+}
+
.entity-inspector-field span {
font-size: 0.72rem;
color: var(--viewer-muted);
@@ -1893,6 +1801,14 @@ canvas {
border-color: rgba(173, 220, 255, 0.4);
}
+.entity-inspector-field--checkbox input {
+ width: 1rem;
+ height: 1rem;
+ min-width: 1rem;
+ padding: 0;
+ accent-color: #7fd6ff;
+}
+
.entity-inspector-note {
margin-top: 0.9rem;
color: var(--viewer-muted);
diff --git a/apps/viewer/src/ui/stores/viewerOrderContextMenu.ts b/apps/viewer/src/ui/stores/viewerOrderContextMenu.ts
index a5d3627..9e42cad 100644
--- a/apps/viewer/src/ui/stores/viewerOrderContextMenu.ts
+++ b/apps/viewer/src/ui/stores/viewerOrderContextMenu.ts
@@ -2,8 +2,13 @@ import { defineStore } from "pinia";
import type { Vector3Dto } from "../../contractsCommon";
import type { Selectable } from "../../viewerTypes";
+export interface ViewerOrderContextMenuPointSelection {
+ kind: "point";
+ id: "local-point";
+}
+
export interface ViewerOrderContextMenuTarget {
- selection: Selectable;
+ selection: Selectable | ViewerOrderContextMenuPointSelection;
label: string;
systemId?: string | null;
anchorId?: string | null;
diff --git a/apps/viewer/src/viewerCamera.ts b/apps/viewer/src/viewerCamera.ts
index bcbc32b..51deceb 100644
--- a/apps/viewer/src/viewerCamera.ts
+++ b/apps/viewer/src/viewerCamera.ts
@@ -92,10 +92,10 @@ export function updatePanFromKeyboard(
const right = new THREE.Vector3(-forward.z, 0, forward.x);
const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z));
if (activeSystemId) {
- const speedKilometers = povLevel === "system"
+ const panSpeed = povLevel === "system"
? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.002, KILOMETERS_PER_AU * 0.35)
- : THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 40, 180000);
- systemAnchor.addScaledVector(pan, speedKilometers * delta);
+ : THREE.MathUtils.mapLinear(currentDistance, Math.max(minimumDistance, 4), 4000, 8, 6000);
+ systemAnchor.addScaledVector(pan, panSpeed * delta);
return;
}
@@ -133,6 +133,11 @@ export function applyPanFromScreenDelta(
const pan = right.multiplyScalar(horizontalDistance).add(forward.multiplyScalar(verticalDistance));
if (activeSystemId) {
+ if (povLevel === "local") {
+ systemAnchor.add(pan);
+ return;
+ }
+
const systemDisplayToKilometers = 1 / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
systemAnchor.addScaledVector(pan, systemDisplayToKilometers);
return;
@@ -353,7 +358,7 @@ export function getCameraFocusWorldPosition(params: CameraFocusParams): THREE.Ve
}
/**
- * Convert a local km position to system-scene display coordinates.
+ * Convert a system-space kilometer position to system-scene display coordinates.
* System scene coordinate system: star at origin, all positions scaled by
* DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE.
*/
diff --git a/apps/viewer/src/viewerConstants.ts b/apps/viewer/src/viewerConstants.ts
index df65ad9..756ae47 100644
--- a/apps/viewer/src/viewerConstants.ts
+++ b/apps/viewer/src/viewerConstants.ts
@@ -1,7 +1,7 @@
import type { PovLevel } from "./viewerTypes";
export const NAV_DISTANCE: Record
= {
- local: 18,
+ local: 180,
system: 3200,
galaxy: 32000,
};
@@ -21,6 +21,11 @@ export const MOON_RENDER_SCALE = 1.1;
// 0.00005 units = ~3 km — allows scrolling very close to ships and structures.
export const MIN_CAMERA_DISTANCE = 0.00005;
export const MAX_CAMERA_DISTANCE = 150000;
+export const MIN_LOCAL_CAMERA_DISTANCE = 4;
+export const MAX_LOCAL_CAMERA_DISTANCE = 120000;
+export const LOCAL_SYSTEM_BACKDROP_DISTANCE = 650;
+export const LOCAL_CAMERA_DISTANCE_AT_TRANSITION = 100000;
+export const LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM = 40;
export interface ZoomBlend {
localWeight: number;
diff --git a/apps/viewer/src/viewerControllerFactory.ts b/apps/viewer/src/viewerControllerFactory.ts
index 0b503a7..289c0d1 100644
--- a/apps/viewer/src/viewerControllerFactory.ts
+++ b/apps/viewer/src/viewerControllerFactory.ts
@@ -112,6 +112,7 @@ export function createViewerControllers(host: any) {
getCameraMode: () => host.cameraMode,
getCameraTargetShipId: () => host.cameraTargetShipId,
getPovLevel: () => host.povLevel,
+ getFocusedAnchorId: () => host.resolveFocusedAnchorId(),
getSelectedItems: () => host.selectedItems,
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getCurrentDistance: () => host.currentDistance,
@@ -251,6 +252,8 @@ export function createViewerControllers(host: any) {
},
getFollowCameraPosition: () => host.followCameraPosition,
getFollowCameraFocus: () => host.followCameraFocus,
+ getLocalRootPosition: () => host.localLayer.localRoot.position.clone(),
+ getFocusedAnchorId: () => host.resolveFocusedAnchorId(),
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
applyPanDelta: (delta: THREE.Vector2) => {
const bounds = host.renderer.domElement.getBoundingClientRect();
diff --git a/apps/viewer/src/viewerControls.ts b/apps/viewer/src/viewerControls.ts
index fd0ba6d..ca64c38 100644
--- a/apps/viewer/src/viewerControls.ts
+++ b/apps/viewer/src/viewerControls.ts
@@ -1,11 +1,12 @@
import * as THREE from "three";
-import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewerConstants";
+import { MAX_CAMERA_DISTANCE, MAX_LOCAL_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, MIN_LOCAL_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewerConstants";
import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
import { rawObject } from "./viewerScenePrimitives";
import { resolveShipWorldPosition } from "./viewerWorldPresentation";
import type { StatsOverlayMode } from "./viewerHudState";
import type {
CameraMode,
+ PovLevel,
Selectable,
ShipVisual,
SystemVisual,
@@ -212,10 +213,12 @@ export function setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opa
material.needsUpdate = true;
}
-export function navigateFromWheel(desiredDistance: number, deltaY: number) {
+export function navigateFromWheel(desiredDistance: number, deltaY: number, povLevel: PovLevel) {
const clampedDelta = THREE.MathUtils.clamp(deltaY, -180, 180);
const zoomFactor = Math.exp(clampedDelta * 0.00135);
- return THREE.MathUtils.clamp(desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE);
+ const minimumDistance = povLevel === "local" ? MIN_LOCAL_CAMERA_DISTANCE : MIN_CAMERA_DISTANCE;
+ const maximumDistance = povLevel === "local" ? MAX_LOCAL_CAMERA_DISTANCE : MAX_CAMERA_DISTANCE;
+ return THREE.MathUtils.clamp(desiredDistance * zoomFactor, minimumDistance, maximumDistance);
}
export function applyKeyboardControl(params: {
diff --git a/apps/viewer/src/viewerHudState.ts b/apps/viewer/src/viewerHudState.ts
index 311217b..af8ec0a 100644
--- a/apps/viewer/src/viewerHudState.ts
+++ b/apps/viewer/src/viewerHudState.ts
@@ -32,43 +32,6 @@ export interface HudProgressBar {
progress: number;
}
-export interface OpsFactionCardState {
- kind: "faction";
- id: string;
- label: string;
- stateLines: string[];
- priorities: { label: string; value: string }[];
-}
-
-export interface OpsStationCardState {
- kind: "station";
- id: string;
- label: string;
- badge: string;
- selected: boolean;
- lines: string[];
- processes: HudProgressBar[];
-}
-
-export interface OpsShipCardState {
- kind: "ship";
- id: string;
- label: string;
- badge: string;
- selected: boolean;
- followed: boolean;
- locationLines: string[];
- lines: string[];
- action?: HudProgressBar;
- aiLines: string[];
-}
-
-export interface OpsStripState {
- factions: OpsFactionCardState[];
- stations: OpsStationCardState[];
- ships: OpsShipCardState[];
-}
-
export interface HistoryWindowState {
id: string;
target: Selectable;
@@ -111,7 +74,6 @@ export interface ViewerHudState {
systemPanel: HudHtmlPanelState;
detailPanel: HudHtmlPanelState;
error: HudErrorState;
- opsStrip: OpsStripState;
historyWindows: HistoryWindowState[];
hoverLabel: HoverLabelState;
marquee: MarqueeState;
@@ -161,11 +123,6 @@ export function createViewerHudState(): ViewerHudState {
hidden: true,
message: "",
},
- opsStrip: {
- factions: [],
- stations: [],
- ships: [],
- },
historyWindows: [],
hoverLabel: {
hidden: true,
diff --git a/apps/viewer/src/viewerInteraction.ts b/apps/viewer/src/viewerInteraction.ts
index 35848ad..31229aa 100644
--- a/apps/viewer/src/viewerInteraction.ts
+++ b/apps/viewer/src/viewerInteraction.ts
@@ -1,7 +1,7 @@
import * as THREE from "three";
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
-import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath";
+import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, METERS_PER_KILOMETER, formatAdaptiveDistanceFromKilometers, formatAdaptiveDistanceFromMeters, formatSystemDistance } from "./viewerMath";
import type { HoverLabelState, MarqueeState } from "./viewerHudState";
import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes";
@@ -169,13 +169,17 @@ function formatHoverDistance(
|| selection.kind === "construction-site";
if (inActiveSystem && activeSystemId) {
+ if (povLevel === "local") {
+ return formatAdaptiveDistanceFromMeters(displayDistance);
+ }
+
const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
return povLevel === "system"
? formatSystemDistance(kilometers / KILOMETERS_PER_AU)
: formatAdaptiveDistanceFromKilometers(kilometers);
}
- return formatAdaptiveDistanceFromKilometers(displayDistance / DISPLAY_UNITS_PER_KILOMETER);
+ return formatAdaptiveDistanceFromKilometers((displayDistance / DISPLAY_UNITS_PER_KILOMETER) / METERS_PER_KILOMETER);
}
export function updateMarqueeBox(
diff --git a/apps/viewer/src/viewerInteractionController.ts b/apps/viewer/src/viewerInteractionController.ts
index a1e4279..c772cff 100644
--- a/apps/viewer/src/viewerInteractionController.ts
+++ b/apps/viewer/src/viewerInteractionController.ts
@@ -20,7 +20,10 @@ import type {
WorldState,
PovLevel,
} from "./viewerTypes";
-import type { ViewerOrderContextMenuTarget } from "./ui/stores/viewerOrderContextMenu";
+import type {
+ ViewerOrderContextMenuPointSelection,
+ ViewerOrderContextMenuTarget,
+} from "./ui/stores/viewerOrderContextMenu";
export interface ViewerInteractionContext {
renderer: THREE.WebGLRenderer;
@@ -60,6 +63,8 @@ export interface ViewerInteractionContext {
setCameraTargetShipId: (value: string | undefined) => void;
getFollowCameraPosition: () => THREE.Vector3;
getFollowCameraFocus: () => THREE.Vector3;
+ getLocalRootPosition: () => THREE.Vector3;
+ getFocusedAnchorId: () => string | undefined;
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
applyPanDelta: (delta: THREE.Vector2) => void;
syncFollowStateFromSelection: () => void;
@@ -206,11 +211,9 @@ export class ViewerInteractionController {
this.context.closeOrderContextMenu();
const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
- if (!picked) {
- return;
- }
-
- const target = this.buildOrderContextTarget(picked);
+ const target = picked
+ ? this.buildOrderContextTarget(picked)
+ : this.buildLocalPointContextTarget(event.clientX, event.clientY);
if (!target) {
return;
}
@@ -218,71 +221,6 @@ export class ViewerInteractionController {
this.context.openOrderContextMenu(event.clientX, event.clientY, target);
};
- readonly onOpsStripClick = (event: MouseEvent) => {
- const target = event.target;
- if (!(target instanceof HTMLElement)) {
- return;
- }
-
- const historyButton = target.closest("[data-history-ship-id]");
- const historyShipId = historyButton?.dataset.historyShipId;
- if (historyShipId) {
- this.context.historyController.openHistoryWindow({ kind: "ship", id: historyShipId });
- return;
- }
-
- const shipCard = target.closest("[data-ship-id]");
- const shipId = shipCard?.dataset.shipId;
- if (shipId) {
- this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
- this.context.syncFollowStateFromSelection();
- this.context.updatePanels();
- return;
- }
-
- const stationCard = target.closest("[data-station-id]");
- const stationId = stationCard?.dataset.stationId;
- if (stationId) {
- this.context.setSelectedItems([{ kind: "station", id: stationId }]);
- this.context.syncFollowStateFromSelection();
- this.context.updatePanels();
- }
- };
-
- readonly onOpsStripDoubleClick = (event: MouseEvent) => {
- const target = event.target;
- if (!(target instanceof HTMLElement)) {
- return;
- }
-
- if (target.closest("[data-history-ship-id]")) {
- return;
- }
-
- const shipCard = target.closest("[data-ship-id]");
- const shipId = shipCard?.dataset.shipId;
- if (shipId) {
- this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
- this.context.syncFollowStateFromSelection();
- this.context.focusOnSelection({ kind: "ship", id: shipId });
- this.toggleCameraMode("tactical");
- this.context.updatePanels();
- this.context.updateGamePanel("Live");
- return;
- }
-
- const stationCard = target.closest("[data-station-id]");
- const stationId = stationCard?.dataset.stationId;
- if (stationId) {
- this.context.setSelectedItems([{ kind: "station", id: stationId }]);
- this.context.syncFollowStateFromSelection();
- this.toggleCameraMode("tactical");
- this.context.focusOnSelection({ kind: "station", id: stationId });
- this.context.updatePanels();
- this.context.updateGamePanel("Live");
- }
- };
-
readonly onHistoryLayerClick = (event: MouseEvent) => this.context.historyController.onHistoryLayerClick(event);
readonly onHistoryLayerPointerDown = (event: PointerEvent) => this.context.historyController.onHistoryLayerPointerDown(event);
@@ -316,7 +254,7 @@ export class ViewerInteractionController {
readonly onWheel = (event: WheelEvent) => {
event.preventDefault();
- this.context.setDesiredDistance(navigateFromWheel(this.context.getDesiredDistance(), event.deltaY));
+ this.context.setDesiredDistance(navigateFromWheel(this.context.getDesiredDistance(), event.deltaY, this.context.getPovLevel()));
this.context.updateGamePanel("Live");
};
@@ -508,4 +446,45 @@ export class ViewerInteractionController {
return null;
}
}
+
+ private buildLocalPointContextTarget(clientX: number, clientY: number): ViewerOrderContextMenuTarget | null {
+ if (this.context.getPovLevel() !== "local") {
+ return null;
+ }
+
+ const world = this.context.getWorld();
+ const systemId = this.context.getActiveSystemId();
+ const anchorId = this.context.getFocusedAnchorId();
+ if (!world || !systemId || !anchorId || !world.anchors.has(anchorId)) {
+ return null;
+ }
+
+ const bounds = this.context.renderer.domElement.getBoundingClientRect();
+ this.context.mouse.x = ((clientX - bounds.left) / bounds.width) * 2 - 1;
+ this.context.mouse.y = -((clientY - bounds.top) / bounds.height) * 2 + 1;
+ this.context.raycaster.setFromCamera(this.context.mouse, this.context.localCamera);
+
+ const localRootPosition = this.context.getLocalRootPosition();
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -localRootPosition.y);
+ const worldIntersection = new THREE.Vector3();
+ if (!this.context.raycaster.ray.intersectPlane(plane, worldIntersection)) {
+ return null;
+ }
+
+ const localPosition = worldIntersection.sub(localRootPosition);
+ const rounded = localPosition.clone().round();
+ const selection: ViewerOrderContextMenuPointSelection = { kind: "point", id: "local-point" };
+
+ return {
+ selection,
+ label: `Point ${rounded.x}m, ${rounded.y}m, ${rounded.z}m`,
+ systemId,
+ anchorId,
+ targetPosition: {
+ x: rounded.x,
+ y: rounded.y,
+ z: rounded.z,
+ },
+ };
+ }
}
diff --git a/apps/viewer/src/viewerLocalLayer.ts b/apps/viewer/src/viewerLocalLayer.ts
index 45cfb05..41ad5f2 100644
--- a/apps/viewer/src/viewerLocalLayer.ts
+++ b/apps/viewer/src/viewerLocalLayer.ts
@@ -14,8 +14,12 @@ import type {
* Camera far plane covers immediate surroundings.
*/
export class LocalLayer {
+ readonly localRoot = new THREE.Group();
+ readonly fineGrid = createLocalGrid(1000, 10, 0x35506d, 0x233449, 0.42);
+ readonly majorGrid = createLocalGrid(10000, 100, 0x6d88a3, 0x4b6078, 0.42);
+ readonly outerGrid = createLocalGrid(80000, 1000, 0x7e98b2, 0x55687f, 0.26);
readonly scene = new THREE.Scene();
- readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 2000);
+ readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200000);
readonly nodeGroup = new THREE.Group();
readonly stationGroup = new THREE.Group();
readonly claimGroup = new THREE.Group();
@@ -36,18 +40,24 @@ export class LocalLayer {
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.4);
keyLight.position.set(180, 220, 140);
this.scene.add(keyLight);
- this.scene.add(
+ this.localRoot.add(
+ this.fineGrid,
+ this.majorGrid,
+ this.outerGrid,
this.nodeGroup,
this.stationGroup,
this.claimGroup,
this.constructionSiteGroup,
this.shipGroup,
);
+ this.scene.add(this.localRoot);
}
- updateCamera(orbitOffset: THREE.Vector3) {
- this.camera.position.copy(orbitOffset);
- this.camera.lookAt(LocalLayer.ORIGIN);
+ updateCamera(localFocus: THREE.Vector3, orbitOffset: THREE.Vector3, anchorOffset: THREE.Vector3) {
+ const worldFocus = localFocus.clone().add(anchorOffset);
+ this.localRoot.position.copy(anchorOffset);
+ this.camera.position.copy(worldFocus).add(orbitOffset);
+ this.camera.lookAt(worldFocus);
}
onResize(aspect: number) {
@@ -59,3 +69,13 @@ export class LocalLayer {
renderer.render(this.scene, this.camera);
}
}
+
+function createLocalGrid(sizeMeters: number, stepMeters: number, majorColor: number, minorColor: number, opacity: number) {
+ const divisions = Math.max(1, Math.round(sizeMeters / stepMeters));
+ const grid = new THREE.GridHelper(sizeMeters, divisions, majorColor, minorColor);
+ const material = grid.material as THREE.Material & { opacity: number; transparent: boolean };
+ material.transparent = true;
+ material.opacity = opacity;
+ grid.position.y = -0.04;
+ return grid;
+}
diff --git a/apps/viewer/src/viewerMath.ts b/apps/viewer/src/viewerMath.ts
index a60a8b0..1ff1275 100644
--- a/apps/viewer/src/viewerMath.ts
+++ b/apps/viewer/src/viewerMath.ts
@@ -15,6 +15,7 @@ import type {
import type { ZoomBlend } from "./viewerConstants";
export const KILOMETERS_PER_AU = 149_597_870.7;
+export const METERS_PER_KILOMETER = 1000;
export const DISPLAY_UNITS_PER_KILOMETER = 0.0000015;
export const DISPLAY_UNITS_PER_LIGHT_YEAR = 2600;
@@ -44,7 +45,7 @@ function formatNumber(value: number, fractionDigits: number) {
}
export function formatLocalDistance(value: number): string {
- return `${formatNumber(value, 0)} km`;
+ return `${formatNumber(value, value >= 100 ? 0 : 1)} m`;
}
export function formatSystemDistance(value: number): string {
@@ -76,6 +77,16 @@ export function formatAdaptiveDistanceFromKilometers(kilometers: number): string
return `${formatNumber(meters, meters >= 100 ? 0 : 1)} m`;
}
+export function formatAdaptiveDistanceFromMeters(meters: number): string {
+ const absoluteMeters = Math.max(0, meters);
+ if (absoluteMeters >= METERS_PER_KILOMETER) {
+ const kilometers = absoluteMeters / METERS_PER_KILOMETER;
+ return `${formatNumber(kilometers, kilometers >= 100 ? 0 : 2)} km`;
+ }
+
+ return `${formatNumber(absoluteMeters, absoluteMeters >= 100 ? 0 : 1)} m`;
+}
+
export function formatShipSpeed(ship: ShipSnapshot): string {
const speed = Math.max(0, ship.travelSpeed);
const unit = ship.travelSpeedUnit;
@@ -107,7 +118,7 @@ export function smoothBand(value: number, start: number, end: number): number {
}
export function computeZoomBlend(distance: number): ZoomBlend {
- const localToSystem = smoothBand(distance, 1200, 5200);
+ const localToSystem = smoothBand(distance, 120, 650);
const systemToUniverse = smoothBand(distance, 9000, 22000);
return {
diff --git a/apps/viewer/src/viewerNavigationController.ts b/apps/viewer/src/viewerNavigationController.ts
index b3eed55..b759274 100644
--- a/apps/viewer/src/viewerNavigationController.ts
+++ b/apps/viewer/src/viewerNavigationController.ts
@@ -196,7 +196,7 @@ export class ViewerNavigationController {
return toDisplayLocalPosition(localPosition);
}
- /** Returns a display position for the system camera, derived from a raw local position in km. */
+ /** Returns a display position for the system camera, derived from a raw local position in meters. */
toSystemDisplayPosition(localPosition: THREE.Vector3) {
return toDisplayLocalPosition(localPosition);
}
diff --git a/apps/viewer/src/viewerOpsStrip.ts b/apps/viewer/src/viewerOpsStrip.ts
deleted file mode 100644
index 93903a9..0000000
--- a/apps/viewer/src/viewerOpsStrip.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-import type { StationSnapshot } from "./contractsInfrastructure";
-import type { FactionSnapshot } from "./contractsFactions";
-import type {
- HudProgressBar,
- OpsFactionCardState,
- OpsShipCardState,
- OpsStationCardState,
- OpsStripState,
-} from "./viewerHudState";
-import { describeShipCurrentAction, describeShipLocation, describeShipState } from "./viewerSelection";
-import { getShipBehaviorLabel, getShipOrderLabel } from "./shipAutomationPresentation";
-import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
-import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
-import { viewerPinia } from "./ui/stores/pinia";
-
-function buildFactionCard(world: WorldState, faction: FactionSnapshot): OpsFactionCardState {
- const playerFaction = usePlayerFactionStore(viewerPinia).playerFaction;
- if (playerFaction && playerFaction.sovereignFactionId === faction.id) {
- const selectedDirective = playerFaction.directives[0];
- return {
- kind: "faction",
- id: faction.id,
- label: `${faction.label} Command`,
- stateLines: [
- `Player ${playerFaction.assetRegistry.shipIds.length} ships · ${playerFaction.assetRegistry.stationIds.length} stations`,
- `Groups ${playerFaction.fleets.length + playerFaction.taskForces.length + playerFaction.stationGroups.length + playerFaction.economicRegions.length + playerFaction.fronts.length + playerFaction.reserves.length}`,
- `Intent ${playerFaction.strategicIntent.strategicPosture} · ${playerFaction.strategicIntent.economicPosture}`,
- `Alerts ${playerFaction.alerts.length} · Decisions ${playerFaction.decisionLog.length}`,
- `Lead ${selectedDirective ? `${selectedDirective.behaviorKind} · ${selectedDirective.scopeKind}` : "no active directives"}`,
- ],
- priorities: [
- { label: "Reserve", value: `${Math.round(playerFaction.strategicIntent.desiredReserveRatio * 100)}%` },
- { label: "Auto", value: `${Number(playerFaction.strategicIntent.allowDelegatedEconomicAutomation)}/${Number(playerFaction.strategicIntent.allowDelegatedCombatAutomation)}` },
- ],
- };
- }
-
- const strategicState = faction.strategicState;
- const economic = strategicState.economicAssessment;
- const activeCampaigns = strategicState.campaigns.filter((campaign) => campaign.status === "active");
- const activeTheaters = strategicState.theaters.filter((theater) => theater.status === "active");
- const activeWars = world.geopolitics?.diplomacy.wars.filter((war) => war.factionAId === faction.id || war.factionBId === faction.id).length ?? 0;
- const contestedSystems = world.geopolitics?.territory.controlStates.filter((state) =>
- state.isContested && (state.controllerFactionId === faction.id || state.primaryClaimantFactionId === faction.id || state.claimantFactionIds.includes(faction.id))).length ?? 0;
- const leadCampaign = [...strategicState.campaigns]
- .sort((left, right) => right.priority - left.priority)[0];
- const leadTheater = [...strategicState.theaters]
- .sort((left, right) => right.priority - left.priority)[0];
- const latestDecision = [...faction.decisionLog]
- .sort((left, right) => right.occurredAtUtc.localeCompare(left.occurredAtUtc))[0];
- return {
- kind: "faction",
- id: faction.id,
- label: faction.label,
- stateLines: [
- `Posture ${faction.doctrine.strategicPosture} · ${faction.doctrine.militaryPosture}`,
- `Campaigns ${activeCampaigns.length} · Fronts ${activeTheaters.length} · Wars ${activeWars}`,
- `Commit ${economic.militaryShipCount}/${economic.targetMilitaryShipCount} mil · ${economic.minerShipCount}/${economic.targetMinerShipCount} min`,
- `Reserve ${strategicState.budget.reservedMilitaryAssets} mil · ${strategicState.budget.reservedLogisticsAssets} log`,
- `Bottleneck ${economic.industrialBottleneckItemId ?? "none"} · Contested ${contestedSystems}${latestDecision ? ` · ${latestDecision.kind}` : ""}`,
- ],
- priorities: [
- ...(leadCampaign ? [{ label: leadCampaign.kind, value: leadCampaign.priority.toFixed(0) }] : []),
- ...(leadTheater ? [{ label: leadTheater.kind, value: leadTheater.priority.toFixed(0) }] : []),
- ],
- };
-}
-
-function buildProgressBar(label: string, progress: number): HudProgressBar {
- return {
- label,
- valueLabel: `${Math.round(progress * 100)}%`,
- progress: Number((progress * 100).toFixed(1)),
- };
-}
-
-function buildStationCard(station: StationSnapshot, isSelected: boolean): OpsStationCardState {
- const cargo = station.inventory.reduce((sum, entry) => sum + entry.amount, 0);
- return {
- kind: "station",
- id: station.id,
- label: station.label,
- badge: station.category,
- selected: isSelected,
- lines: [
- station.systemId,
- `Docked ${station.dockedShips} / ${station.dockingPads}`,
- `Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}`,
- `Modules ${station.installedModules.length}`,
- ],
- processes: station.currentProcesses.map((process) => buildProgressBar(process.label, process.progress)),
- };
-}
-
-function buildShipCard(
- world: WorldState,
- ship: WorldState["ships"] extends Map ? Ship : never,
- isSelected: boolean,
- isFollowed: boolean,
-): OpsShipCardState {
- const cargo = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
- const shipLocation = describeShipLocation(world, ship);
- const shipState = describeShipState(world, ship);
- const shipAction = describeShipCurrentAction(ship);
- const currentStep = ship.activePlan?.steps[ship.activePlan.currentStepIndex];
- const topOrder = [...ship.orderQueue]
- .sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
-
- return {
- kind: "ship",
- id: ship.id,
- label: ship.name,
- badge: ship.type,
- selected: isSelected,
- followed: isFollowed,
- locationLines: [shipLocation.system, ...(shipLocation.local ? [shipLocation.local] : [])],
- lines: [
- `Cargo ${cargo.toFixed(0)}`,
- `State ${shipState}`,
- ],
- action: shipAction ? buildProgressBar(shipAction.label, shipAction.progress) : undefined,
- aiLines: [
- `Assignment ${ship.assignment?.kind ?? "unassigned"}`,
- `Behavior ${getShipBehaviorLabel(ship.defaultBehavior.kind)}`,
- `Plan ${ship.activePlan ? `${ship.activePlan.kind}${currentStep ? ` · ${currentStep.kind}` : ""}` : "none"}`,
- `Orders ${topOrder ? `${getShipOrderLabel(topOrder.kind)} +${Math.max(0, ship.orderQueue.length - 1)}` : "none"}`,
- ],
- };
-}
-
-export function buildOpsStripState(
- world: WorldState | undefined,
- selectedItems: Selectable[],
- cameraMode: CameraMode,
- cameraTargetShipId?: string,
- povLevel?: PovLevel,
- activeSystemId?: string,
-): OpsStripState {
- if (!world) {
- return {
- factions: [],
- stations: [],
- ships: [],
- };
- }
-
- const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null;
-
- const factions = [...world.factions.values()]
- .sort((left, right) => left.label.localeCompare(right.label))
- .map((faction) => buildFactionCard(world, faction));
-
- const stations = [...world.stations.values()]
- .filter((station) => !isSystemFiltered || station.systemId === activeSystemId)
- .sort((left, right) => left.label.localeCompare(right.label))
- .map((station) => buildStationCard(
- station,
- selectedItems.length === 1 && selectedItems[0].kind === "station" && selectedItems[0].id === station.id,
- ));
-
- const ships = [...world.ships.values()]
- .filter((ship) => !isSystemFiltered || ship.systemId === activeSystemId)
- .sort((left, right) => left.name.localeCompare(right.name))
- .map((ship) => buildShipCard(
- world,
- ship,
- selectedItems.length === 1 && selectedItems[0].kind === "ship" && selectedItems[0].id === ship.id,
- cameraMode === "follow" && cameraTargetShipId === ship.id,
- ));
-
- return { factions, stations, ships };
-}
diff --git a/apps/viewer/src/viewerPanels.ts b/apps/viewer/src/viewerPanels.ts
index b25a846..e3b25f4 100644
--- a/apps/viewer/src/viewerPanels.ts
+++ b/apps/viewer/src/viewerPanels.ts
@@ -346,7 +346,6 @@ export function buildDetailPanelState(params: DetailPanelParams) {
const shipBehavior = describeShipBehavior(ship);
const shipOrder = describeShipOrder(ship);
const shipAction = describeShipCurrentAction(ship);
- const currentStep = ship.activePlan?.steps[ship.activePlan.currentStepIndex];
const orderQueue = ship.orderQueue.length > 0
? ship.orderQueue.slice(0, 4).map((order) => `${getShipOrderLabel(order.kind)} [${order.status}]`).join("
")
: "none";
@@ -369,7 +368,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
State ${shipState}
Order ${shipOrder}
Queue ${orderQueue}
- Plan ${ship.activePlan ? `${ship.activePlan.kind} · ${ship.activePlan.status}` : "none"}${currentStep ? `
Step ${currentStep.kind} · ${currentStep.status}` : ""}
+ Activity ${subTaskList}
Subtasks ${subTaskList}
${ship.lastReplanReason ? `Last replan ${ship.lastReplanReason}
` : ""}
${ship.lastAccessFailureReason ? `Access ${ship.lastAccessFailureReason}
` : ""}
@@ -463,7 +462,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
${celestial.systemId}
Parent ${celestial.parentAnchorId ?? "none"}
Orbit ref ${celestial.orbitReferenceId ?? "none"}
Occupying structure ${celestial.occupyingStructureId ?? "none"}
- Local space radius ${celestial.localSpaceRadius.toFixed(0)} km
+ Local space radius ${celestial.localSpaceRadius.toFixed(0)} m
`,
};
}
diff --git a/apps/viewer/src/viewerPresentationController.ts b/apps/viewer/src/viewerPresentationController.ts
index 84696b3..97f51f0 100644
--- a/apps/viewer/src/viewerPresentationController.ts
+++ b/apps/viewer/src/viewerPresentationController.ts
@@ -30,6 +30,7 @@ export interface ViewerPresentationContext {
getCameraMode: () => any;
getCameraTargetShipId: () => string | undefined;
getPovLevel: () => any;
+ getFocusedAnchorId: () => string | undefined;
getSelectedItems: () => Selectable[];
getWorldTimeSyncMs: () => number;
getCurrentDistance: () => number;
@@ -106,12 +107,52 @@ export class ViewerPresentationController {
applyZoomPresentation() {
const povLevel = this.context.getPovLevel();
+ const world = this.context.getWorld();
+ const focusedAnchorId = this.context.getFocusedAnchorId();
+ const focusedAnchor = focusedAnchorId ? world?.anchors.get(focusedAnchorId) : undefined;
+ const focusedPlanetMatch = focusedAnchorId?.match(/^node-[^-]+-planet-(\d+)$/);
+ const focusedMoonMatch = focusedAnchorId?.match(/^node-[^-]+-planet-(\d+)-moon-(\d+)$/);
+ const focusedPlanetIndex = focusedMoonMatch
+ ? Number.parseInt(focusedMoonMatch[1], 10) - 1
+ : (focusedPlanetMatch ? Number.parseInt(focusedPlanetMatch[1], 10) - 1 : undefined);
+ const focusedMoonIndex = focusedMoonMatch ? Number.parseInt(focusedMoonMatch[2], 10) - 1 : undefined;
this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035);
const showPlanetIcons = povLevel !== "local";
- for (const visual of this.context.planetVisuals) {
+ for (const [planetIndex, visual] of this.context.planetVisuals.entries()) {
visual.icon.setVisible(showPlanetIcons);
+ if (povLevel === "local") {
+ const showPlanetMesh = focusedAnchor?.kind === "planet"
+ ? planetIndex === focusedPlanetIndex
+ : focusedAnchor?.kind === "moon"
+ ? planetIndex === focusedPlanetIndex
+ : false;
+ visual.mesh.setVisible(showPlanetMesh);
+ visual.orbit.setVisible(false);
+ if (visual.ring) {
+ visual.ring.setVisible(showPlanetMesh);
+ }
+ for (const [moonIndex, moon] of visual.moons.entries()) {
+ const showMoonMesh = focusedAnchor?.kind === "moon"
+ && planetIndex === focusedPlanetIndex
+ && moonIndex === focusedMoonIndex;
+ moon.mesh.setVisible(showMoonMesh);
+ moon.icon.setVisible(false);
+ moon.orbit.setVisible(false);
+ }
+ continue;
+ }
+
+ visual.mesh.setVisible(true);
+ visual.orbit.setVisible(true);
+ if (visual.ring) {
+ visual.ring.setVisible(true);
+ }
+ }
+
+ for (const systemVisual of this.context.systemVisuals.values()) {
+ systemVisual.starCluster.setVisible(povLevel !== "local" || focusedAnchor?.kind === "star");
}
}
diff --git a/apps/viewer/src/viewerSceneAppearance.ts b/apps/viewer/src/viewerSceneAppearance.ts
index fe23c9f..02e57b4 100644
--- a/apps/viewer/src/viewerSceneAppearance.ts
+++ b/apps/viewer/src/viewerSceneAppearance.ts
@@ -4,20 +4,20 @@ import type { ShipSnapshot } from "./contracts";
export function shipSize(ship: ShipSnapshot) {
switch (ship.type) {
case "carrier":
- return 0.018;
+ return 18;
case "battleship":
- return 0.012;
+ return 12;
case "destroyer":
- return 0.009;
+ return 9;
case "builder":
case "freighter":
case "transporter":
case "resupplier":
case "miner":
case "largeminer":
- return 0.01;
+ return 10;
default:
- return 0.007;
+ return 7;
}
}
diff --git a/apps/viewer/src/viewerSceneDataController.ts b/apps/viewer/src/viewerSceneDataController.ts
index 88b17d8..ee27722 100644
--- a/apps/viewer/src/viewerSceneDataController.ts
+++ b/apps/viewer/src/viewerSceneDataController.ts
@@ -17,6 +17,8 @@ import {
import {
createClaimMesh,
createConstructionSiteMesh,
+ createLocalResourceDepositMesh,
+ createLocalResourceNodeMesh,
createNodeMesh,
createResourceDepositMesh,
createShipMesh,
@@ -182,7 +184,7 @@ export class ViewerSceneDataController {
continue;
}
- const mesh = createNodeMesh(node);
+ const mesh = createLocalResourceNodeMesh(node);
const icon = createTacticalIcon(this.context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 100);
const localPosition = new THREE.Vector3(0, 0, 0);
mesh.setPosition(localPosition);
@@ -201,7 +203,7 @@ export class ViewerSceneDataController {
});
this.context.localNodeGroup.add(rawObject(mesh), rawObject(icon));
for (const deposit of node.deposits) {
- const depositMesh = createResourceDepositMesh(deposit, node);
+ const depositMesh = createLocalResourceDepositMesh(deposit, node);
this.context.localNodeGroup.add(rawObject(depositMesh));
}
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "node", id: node.id });
diff --git a/apps/viewer/src/viewerSceneFactory.ts b/apps/viewer/src/viewerSceneFactory.ts
index 939c151..d089cbb 100644
--- a/apps/viewer/src/viewerSceneFactory.ts
+++ b/apps/viewer/src/viewerSceneFactory.ts
@@ -64,6 +64,47 @@ export function createResourceDepositMesh(deposit: ResourceDepositSnapshot, node
return createSceneNode(mesh);
}
+export function createLocalResourceNodeMesh(node: ResourceNodeSnapshot): SceneNode {
+ const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
+ const oreRatio = node.maxOre <= 0.01 ? 0 : node.oreRemaining / node.maxOre;
+ const radius = isGas
+ ? 120 + (oreRatio * 30)
+ : 55 + (oreRatio * 20);
+ const mesh = new THREE.Mesh(
+ new THREE.SphereGeometry(radius, isGas ? 18 : 16, isGas ? 18 : 16),
+ new THREE.MeshStandardMaterial({
+ color: isGas ? 0x7fd6ff : 0xb28b59,
+ roughness: isGas ? 0.4 : 0.92,
+ metalness: isGas ? 0.06 : 0.08,
+ transparent: isGas,
+ opacity: isGas ? 0.5 : 1,
+ emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xb28b59).multiplyScalar(isGas ? 0.18 : 0.03),
+ }),
+ );
+ return createSceneNode(mesh);
+}
+
+export function createLocalResourceDepositMesh(deposit: ResourceDepositSnapshot, node: ResourceNodeSnapshot): SceneNode {
+ const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
+ const oreRatio = deposit.maxOre <= 0.01 ? 0 : deposit.oreRemaining / deposit.maxOre;
+ const radius = isGas
+ ? 8 + (oreRatio * 4)
+ : 3 + (oreRatio * 4);
+ const mesh = new THREE.Mesh(
+ new THREE.SphereGeometry(radius, 12, 12),
+ new THREE.MeshStandardMaterial({
+ color: isGas ? 0x92deff : 0xd0ad77,
+ roughness: isGas ? 0.36 : 0.95,
+ metalness: isGas ? 0.04 : 0.02,
+ transparent: isGas,
+ opacity: isGas ? 0.58 : 1,
+ emissive: new THREE.Color(isGas ? 0x92deff : 0xd0ad77).multiplyScalar(isGas ? 0.16 : 0.025),
+ }),
+ );
+ mesh.position.copy(toThreeVector(deposit.localPosition));
+ return createSceneNode(mesh);
+}
+
export function createCelestialMesh(node: CelestialSnapshot, celestialColor: (kind: string) => string): SceneNode {
const color = celestialColor(node.kind);
return createSceneNode(new THREE.Mesh(
@@ -229,17 +270,31 @@ export function createStationMesh(station: StationSnapshot): SceneNode {
}
export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): SceneNode {
- const geometry = new THREE.ConeGeometry(size, length, 7);
- geometry.rotateX(Math.PI / 2);
- const mesh = new THREE.Mesh(
- geometry,
- new THREE.MeshStandardMaterial({
- color,
- emissive: new THREE.Color(color).multiplyScalar(0.18),
- }),
+ const root = new THREE.Group();
+ const material = new THREE.MeshStandardMaterial({
+ color,
+ emissive: new THREE.Color(color).multiplyScalar(0.28),
+ });
+
+ const bodyRadius = Math.max(size * 0.48, 2.4);
+ const bodyLength = Math.max(length - (bodyRadius * 1.8), bodyRadius * 1.2);
+ const body = new THREE.Mesh(
+ new THREE.CapsuleGeometry(bodyRadius, bodyLength, 6, 12),
+ material,
);
- mesh.position.copy(toThreeVector(ship.localPosition));
- return createSceneNode(mesh);
+ body.rotation.x = Math.PI / 2;
+ root.add(body);
+
+ const nose = new THREE.Mesh(
+ new THREE.ConeGeometry(Math.max(bodyRadius * 0.72, 1.8), Math.max(bodyRadius * 1.4, 3.2), 8),
+ material,
+ );
+ nose.rotation.x = Math.PI / 2;
+ nose.position.z = (bodyLength * 0.5) + (bodyRadius * 0.55);
+ root.add(nose);
+
+ root.position.copy(toThreeVector(ship.localPosition));
+ return createSceneNode(root);
}
function createStarGlowTexture(documentRef: Document): THREE.CanvasTexture {
diff --git a/apps/viewer/src/viewerSceneSync.ts b/apps/viewer/src/viewerSceneSync.ts
index 4b7d9db..bed3335 100644
--- a/apps/viewer/src/viewerSceneSync.ts
+++ b/apps/viewer/src/viewerSceneSync.ts
@@ -65,7 +65,7 @@ import {
} from "./viewerScenePrimitives";
import type { SceneNode } from "./viewerScenePrimitives";
-/** Scale a local km position to system-scene display coordinates. */
+/** Scale a system-space kilometer position to system-scene display coordinates. */
function toSystemPos(localPosition: THREE.Vector3): THREE.Vector3 {
return localPosition.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
}
diff --git a/apps/viewer/src/viewerSelection.ts b/apps/viewer/src/viewerSelection.ts
index 5518240..dd9c99b 100644
--- a/apps/viewer/src/viewerSelection.ts
+++ b/apps/viewer/src/viewerSelection.ts
@@ -274,10 +274,10 @@ export function resolveFocusedAnchorId(world: WorldState | undefined, selectedIt
return orbitBackedAnchor?.id;
}
if (selected.kind === "planet") {
- return `${selected.systemId}-planet-${selected.planetIndex + 1}`;
+ return `node-${selected.systemId}-planet-${selected.planetIndex + 1}`;
}
if (selected.kind === "moon") {
- return `${selected.systemId}-planet-${selected.planetIndex + 1}-moon-${selected.moonIndex + 1}`;
+ return `node-${selected.systemId}-planet-${selected.planetIndex + 1}-moon-${selected.moonIndex + 1}`;
}
return undefined;
@@ -429,8 +429,7 @@ export function describeShipBehavior(ship: ShipSnapshot): string {
}
export function describeShipOrder(ship: ShipSnapshot): string {
- const activeOrder = [...ship.orderQueue]
- .sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
+ const activeOrder = ship.orderQueue.find((order) => order.status === "queued" || order.status === "active");
if (activeOrder) {
return activeOrder.label ?? getShipOrderLabel(activeOrder.kind);
}
@@ -439,10 +438,6 @@ export function describeShipOrder(ship: ShipSnapshot): string {
return describeShipObjective(ship.assignment.kind);
}
- if (ship.activePlan) {
- return ship.activePlan.summary || ship.activePlan.kind;
- }
-
return getShipBehaviorLabel(ship.defaultBehavior.kind);
}
diff --git a/apps/viewer/src/viewerWorldLifecycle.ts b/apps/viewer/src/viewerWorldLifecycle.ts
index 9ab7023..aadf151 100644
--- a/apps/viewer/src/viewerWorldLifecycle.ts
+++ b/apps/viewer/src/viewerWorldLifecycle.ts
@@ -1,6 +1,5 @@
import { fetchWorldSnapshot, openWorldStream } from "./api";
import type { ViewerHudState } from "./viewerHudState";
-import { buildOpsStripState } from "./viewerOpsStrip";
import { useGmStore } from "./ui/stores/gmStore";
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
import { viewerPinia } from "./ui/stores/pinia";
@@ -193,15 +192,6 @@ export class ViewerWorldLifecycle {
}
rebuildFactions(_factions: FactionSnapshot[]) {
- this.context.hudState.opsStrip = buildOpsStripState(
- this.context.getWorld(),
- this.context.getSelectedItems(),
- this.context.getCameraMode(),
- this.context.getCameraTargetShipId(),
- this.context.getPovLevel(),
- this.context.getActiveSystemId(),
- );
-
const world = this.context.getWorld();
if (world) {
useGmStore(viewerPinia).updateWorld(
diff --git a/apps/viewer/src/viewerWorldPresentation.ts b/apps/viewer/src/viewerWorldPresentation.ts
index ff2175e..631fa82 100644
--- a/apps/viewer/src/viewerWorldPresentation.ts
+++ b/apps/viewer/src/viewerWorldPresentation.ts
@@ -3,6 +3,7 @@ import {
DISPLAY_UNITS_PER_KILOMETER,
DISPLAY_UNITS_PER_LIGHT_YEAR,
KILOMETERS_PER_AU,
+ METERS_PER_KILOMETER,
computeMoonLocalPosition,
computePlanetLocalPosition,
currentWorldTimeSeconds,
@@ -137,8 +138,7 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
}
const localPosition = getAnimatedShipLocalPosition(visual, now);
- const displayPosition = context.toDisplayLocalPosition(localPosition);
- visual.mesh.setPosition(displayPosition);
+ visual.mesh.setPosition(localPosition);
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
@@ -201,29 +201,28 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
}
for (const visual of context.localNodeVisuals.values()) {
- visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
+ visual.mesh.setPosition(visual.localPosition.clone());
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.localStationVisuals.values()) {
- const displayPosition = context.toDisplayLocalPosition(visual.localPosition.clone());
- visual.mesh.setPosition(displayPosition);
+ visual.mesh.setPosition(visual.localPosition.clone());
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.localClaimVisuals.values()) {
- visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
+ visual.mesh.setPosition(visual.localPosition.clone());
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.localConstructionSiteVisuals.values()) {
- visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
+ visual.mesh.setPosition(visual.localPosition.clone());
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
@@ -409,16 +408,12 @@ export function describeGameStatus(params: GameStatusParams) {
? `gal pos: ${fmtVec(galaxyAnchor.clone().divideScalar(DISPLAY_UNITS_PER_LIGHT_YEAR), 2)} ly`
: "";
// System space: systemAnchor in AU — changes only during system navigation
- const sysPos = systemAnchor
+ const sysPos = povLevel !== "local" && systemAnchor
? `sys pos: ${fmtVec(systemAnchor.clone().divideScalar(KILOMETERS_PER_AU), 3)} AU`
: "";
- // Local space: position relative to the focused celestial's orbital anchor in km
- const focusedAnchorId = resolveFocusedAnchorId(world, selectedItems);
- const celestialAnchor = focusedAnchorId
- ? (world?.anchors.get(focusedAnchorId)?.systemPosition ?? world?.celestials.get(focusedAnchorId)?.orbitalAnchor)
- : undefined;
- const locPos = systemAnchor && celestialAnchor
- ? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km`
+ // Local space: local focus in meters relative to the focused anchor
+ const locPos = povLevel === "local" && systemAnchor
+ ? `loc pos: ${fmtVec(systemAnchor, 0)} m`
: "";
return {
@@ -445,6 +440,33 @@ export function updateGameStatus(params: GameStatusParams & { statusEl: HTMLDivE
}
}
+export function resolveLocalAnchorOffset(world: WorldState | undefined, focusedAnchorId?: string): THREE.Vector3 {
+ if (!world || !focusedAnchorId) {
+ return new THREE.Vector3(0, 0, 0);
+ }
+
+ const anchor = world.anchors.get(focusedAnchorId);
+ if (!anchor) {
+ return new THREE.Vector3(0, 0, 0);
+ }
+
+ if (anchor.kind === "lagrange-point" || anchor.kind === "resource-node") {
+ return new THREE.Vector3(0, 0, 0);
+ }
+
+ const bodyRadiusMeters = resolveAnchorBodyRadius(world, anchor) * METERS_PER_KILOMETER;
+ if (bodyRadiusMeters <= 1) {
+ return new THREE.Vector3(0, 0, 0);
+ }
+
+ const safeOffset = Math.min(
+ Math.max(bodyRadiusMeters * 1.08, 120),
+ Math.max(anchor.localSpaceRadius * 0.55, 300),
+ );
+
+ return new THREE.Vector3(-safeOffset, 0, 0);
+}
+
export function deriveNodeOrbital(
context: WorldOrbitalContext,
node: ResourceNodeSnapshot | ResourceNodeDelta,
@@ -536,6 +558,32 @@ export function resolvePointPosition(context: WorldOrbitalContext, _systemId: st
return new THREE.Vector3(0, 0, 0);
}
+function resolveAnchorBodyRadius(world: WorldState, anchor: { id: string; systemId: string; kind: string }) {
+ const system = world.systems.get(anchor.systemId);
+ if (!system) {
+ return 0;
+ }
+
+ if (anchor.kind === "star") {
+ return system.stars[0]?.size ?? 0;
+ }
+
+ const planetMatch = /^node-[^-]+-planet-(\d+)$/.exec(anchor.id);
+ if (planetMatch) {
+ const planetIndex = Number.parseInt(planetMatch[1], 10) - 1;
+ return system.planets[planetIndex]?.size ?? 0;
+ }
+
+ const moonMatch = /^node-[^-]+-planet-(\d+)-moon-(\d+)$/.exec(anchor.id);
+ if (moonMatch) {
+ const planetIndex = Number.parseInt(moonMatch[1], 10) - 1;
+ const moonIndex = Number.parseInt(moonMatch[2], 10) - 1;
+ return system.planets[planetIndex]?.moons[moonIndex]?.size ?? 0;
+ }
+
+ return 0;
+}
+
export function computeCelestialLocalPosition(context: WorldOrbitalContext, visual: CelestialVisual, timeSeconds: number) {
return computeCelestialLocalPositionById(context, visual.id, timeSeconds) ?? visual.orbitalAnchor.clone();
}
diff --git a/docs/VALIDATION-WORKSHEET.md b/docs/VALIDATION-WORKSHEET.md
new file mode 100644
index 0000000..7ced629
--- /dev/null
+++ b/docs/VALIDATION-WORKSHEET.md
@@ -0,0 +1,26 @@
+# Backlog
+
+## Improve CreateFactionForm
+
+Confirm whether GM faction creation is limited to predefined faction ids or is incorrectly restricted to `terran`. Improve the GM faction creation flow so it presents valid faction choices clearly instead of relying on unclear freeform id edits.
+From: V-010 validation
+
+## Improve GM Ship Spawn Options
+
+Extend the GM ship spawn form so it allows choosing what kind of ship to create instead of always spawning the same default result. At minimum, support a few clear presets such as hauler, fighter, miner, and builder. Longer term, allow selecting the ship type directly and optionally configuring modules before spawn.
+From: V-011 validation, V-013 validation
+
+## Spawn GM Ships In A Neutral Starting State
+
+Spawn GM ships with a minimal default behavior such as `hold-position` instead of immediately assigning `local-auto-mine`. Newly created ships should start in a predictable manual-control state unless the GM explicitly asks for another behavior.
+From: V-011 validation
+
+## Spawn GM Ships At A Chosen Anchor
+
+Extend GM ship spawning so the GM can choose an anchor, not just a system. Ships should spawn into the selected anchor's localspace at a safe non-colliding position.
+From: V-011 validation
+
+## Improve GM Station Spawn Options
+
+Add a proper station spawn flow or form so the GM can configure the station before creating it. The spawn flow should allow choosing the station role or preset and selecting its intended location before it appears in the world.
+From: V-012 validation
diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md
new file mode 100644
index 0000000..070392b
--- /dev/null
+++ b/docs/VALIDATION.md
@@ -0,0 +1,713 @@
+# Manual Validation Plan
+
+This document defines the manual validation passes to run against the current game basis.
+
+It is intentionally focused on behavior validation, not implementation details.
+
+The goal is to verify that the simulation can perform the core actions of the game correctly before writing deeper automated simulation tests.
+
+## Purpose
+
+This validation plan answers the following questions:
+
+- does the world boot cleanly and reproducibly
+- can we create the minimum actors needed to exercise gameplay
+- can ships receive and complete direct orders
+- can ships run supported default behaviors without getting stuck
+- do movement, mining, docking, and combat work at the simulation level
+- does the viewer reflect the same state the backend is executing
+
+This document is the manual test source of truth for the current phase.
+
+Later, these same checks should become simulation-first tests running directly against the real runtime.
+
+## Scope
+
+This phase is intentionally centered on `empty.json`.
+
+That is correct for now.
+
+The purpose of `empty.json` is to validate primitive actions and control behavior with minimal scenario noise.
+
+It is not yet the basis for validating full economy, expansion, or long-horizon faction behavior.
+
+Those should be validated later using richer scenarios after the primitives are trustworthy.
+
+## Current Baseline
+
+Development startup currently loads:
+
+- [`shared/data/scenarios/empty.json`](/home/jbourdon/repos/space-game/shared/data/scenarios/empty.json)
+
+The backend startup path is defined in:
+
+- [`apps/backend/Program.cs`](/home/jbourdon/repos/space-game/apps/backend/Program.cs)
+
+World reset returns to the startup scenario through:
+
+- [`apps/backend/Universe/Api/ResetWorldHandler.cs`](/home/jbourdon/repos/space-game/apps/backend/Universe/Api/ResetWorldHandler.cs)
+
+## Environment
+
+Manual runs should use a reproducible local development setup.
+
+Suggested startup:
+
+1. Start postgres with `./scripts/start-postgres.sh`
+2. Start backend in development mode
+3. Start the viewer
+4. Log in as a GM user
+5. Reset the world before each test pass
+
+Relevant files:
+
+- [`scripts/start-postgres.sh`](/home/jbourdon/repos/space-game/scripts/start-postgres.sh)
+- [`apps/backend/appsettings.Development.json`](/home/jbourdon/repos/space-game/apps/backend/appsettings.Development.json)
+- [`apps/viewer/package.json`](/home/jbourdon/repos/space-game/apps/viewer/package.json)
+
+Development GM credentials currently include:
+
+- `gm` / `gm`
+- `admin` / `admin`
+
+## Test Method
+
+Each manual test should record:
+
+- setup
+- action
+- expected result
+- observed result
+- pass or fail
+- notes
+
+Recommended rule:
+
+- if a test leaves the world in a noisy or questionable state, reset before the next test
+
+Recommended evidence to capture:
+
+- ship state
+- ship spatial state
+- active plan and subtasks
+- order queue
+- inventory changes
+- station docking state
+- viewer selection and inspector state
+
+## Phase 1: Boot And Baseline
+
+These tests must pass before behavior testing has value.
+
+### V-001 Backend boots cleanly
+
+Setup:
+
+- start backend in development mode
+
+Expected:
+
+- startup succeeds
+- auth schema initializes
+- dev users seed
+- world loads from `empty.json`
+- no startup exception is thrown
+
+### V-002 Viewer connects and renders world
+
+Setup:
+
+- start viewer and open the app
+
+Expected:
+
+- world snapshot loads
+- live delta stream connects
+- no obvious contract mismatch or rendering crash appears
+
+### V-003 Reset returns world to clean baseline
+
+Setup:
+
+- use the GM reset action
+
+Expected:
+
+- world returns to startup scenario
+- previously spawned factions, ships, and stations are gone
+- sequence and snapshot refresh behave cleanly
+
+### V-004 Empty world is actually minimal
+
+Setup:
+
+- inspect the world after reset
+
+Expected:
+
+- systems, celestials, anchors, and resource nodes exist
+- no initial factions, stations, or ships exist unless intentionally seeded later
+
+## Phase 2: Minimal Actor Creation
+
+These tests prove the empty world can be turned into a controlled validation sandbox.
+
+### V-010 Create a faction
+
+Method:
+
+- use the GM faction creation flow
+
+Relevant API:
+
+- `POST /api/gm/factions`
+
+Expected:
+
+- the faction appears in the world
+- it is visible in the GM UI
+- no duplicate or invalid-creation error occurs for a valid faction id
+
+### V-011 Spawn a ship
+
+Method:
+
+- spawn a ship for the created faction in a known system
+
+Relevant API:
+
+- `POST /api/gm/ships`
+
+Expected:
+
+- the ship appears in the selected system
+- the ship has a valid id, faction, system, and spatial state
+- the viewer can select and inspect it
+
+### V-012 Spawn a station
+
+Method:
+
+- spawn a station for the created faction in a known system
+
+Relevant API:
+
+- `POST /api/gm/stations`
+
+Expected:
+
+- the station appears in the world
+- the station has a valid anchor association or valid placement according to current runtime rules
+- the viewer can focus and inspect it
+
+### V-013 Spawn multiple ships of different roles
+
+Method:
+
+- create at least:
+ - one miner-capable ship
+ - one combat-capable ship
+ - one generic utility or trader if available
+
+Expected:
+
+- each ship spawns without corrupting world state
+- each ship reports sensible movement, cargo, and behavior fields
+
+## Phase 3: Direct Order Validation
+
+This phase validates immediate control and plan execution.
+
+Relevant backend surface:
+
+- [`apps/backend/Ships/Contracts/ShipCommands.cs`](/home/jbourdon/repos/space-game/apps/backend/Ships/Contracts/ShipCommands.cs)
+- [`apps/backend/Ships/Contracts/Ships.cs`](/home/jbourdon/repos/space-game/apps/backend/Ships/Contracts/Ships.cs)
+- [`apps/backend/Ships/Api/EnqueueShipOrderHandler.cs`](/home/jbourdon/repos/space-game/apps/backend/Ships/Api/EnqueueShipOrderHandler.cs)
+
+### V-020 Queue a move or fly order
+
+Method:
+
+- issue a direct move-style order to a ship
+- prefer `fly-and-wait` through the current viewer flow
+
+Expected:
+
+- the order appears in the queue
+- an active plan is created
+- subtasks are coherent
+- the ship moves toward the target
+- the order eventually completes
+
+Watch for:
+
+- ship never leaving idle
+- plan created but no subtask progress
+- target position mismatch
+- order stays executing forever
+
+### V-021 Queue follow ship
+
+Method:
+
+- spawn two ships
+- issue `follow-ship` from one to the other
+
+Expected:
+
+- the follower tracks the target ship
+- the follower updates position as the target moves
+- no oscillation or runaway drift appears
+
+### V-022 Queue attack target
+
+Method:
+
+- spawn two ships from opposing factions if required by current hostility logic
+- issue `attack-target`
+
+Expected:
+
+- order is accepted
+- attacker closes to engagement range
+- combat state transitions occur
+- health changes on the target if combat is functioning
+
+Watch for:
+
+- invalid target acceptance
+- attacker never approaching
+- attacker stuck in transit or wait state
+- combat order silently failing
+
+### V-023 Queue mine resource
+
+Method:
+
+- issue `mine-and-deliver` against a valid resource in the current system
+
+Expected:
+
+- ship selects a valid resource node or deposit
+- ship reaches the mining location
+- mining progress occurs
+- cargo increases
+- delivery or post-mining behavior is coherent
+
+Watch for:
+
+- no valid mining target selected
+- ship arrives but never mines
+- cargo remains unchanged
+- order fails with missing target when a target exists
+
+### V-024 Queue dock and wait if station exists
+
+Method:
+
+- spawn a station
+- issue a docking-capable order path
+
+Expected:
+
+- ship requests or performs docking
+- docked state is visible
+- station dock count updates
+- undocking or wait completion works
+
+### V-025 Remove an order
+
+Method:
+
+- queue an order, then remove it before completion
+
+Expected:
+
+- the order is removed cleanly
+- the ship replans safely
+- the ship returns to default behavior or idle state
+- no orphan active subtasks remain
+
+## Phase 4: Default Behavior Validation
+
+This phase validates autonomous ship control rather than one-shot direct orders.
+
+Relevant backend surface:
+
+- [`apps/backend/Shared/Runtime/ShipAutomationCatalog.cs`](/home/jbourdon/repos/space-game/apps/backend/Shared/Runtime/ShipAutomationCatalog.cs)
+- [`apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs`](/home/jbourdon/repos/space-game/apps/backend/Ships/Api/UpdateShipDefaultBehaviorHandler.cs)
+
+Relevant viewer surfaces:
+
+- [`apps/viewer/src/components/ViewerEntityInspectorPanel.vue`](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerEntityInspectorPanel.vue)
+- [`apps/viewer/src/components/ViewerShipOrderContextMenu.vue`](/home/jbourdon/repos/space-game/apps/viewer/src/components/ViewerShipOrderContextMenu.vue)
+
+### V-030 Hold position
+
+Method:
+
+- set default behavior to `hold-position`
+
+Expected:
+
+- ship remains stable
+- behavior is reflected in inspector state
+- no unintended autonomous orders are generated
+
+### V-031 Fly and wait behavior
+
+Method:
+
+- set default behavior to `fly-and-wait`
+- provide a valid target position or object
+
+Expected:
+
+- behavior-backed order is synthesized
+- ship moves to the target
+- ship waits as configured
+- behavior continues to own control after completion
+
+### V-032 Follow ship behavior
+
+Method:
+
+- set default behavior to `follow-ship`
+
+Expected:
+
+- managed follow behavior is generated
+- the ship stays near its target within reasonable tolerance
+
+### V-033 Patrol behavior
+
+Method:
+
+- configure patrol points if supported through current UI flow
+
+Expected:
+
+- patrol orders are generated from the behavior
+- ship cycles patrol movement cleanly
+- if a threat appears, patrol can interrupt into attack behavior as intended
+
+### V-034 Local auto mine
+
+Method:
+
+- set `local-auto-mine` on a miner in a system with mineable nodes
+
+Expected:
+
+- if a valid local mining target exists, the ship mines it
+- if no valid target exists, failure is readable and not destructive
+
+Note:
+
+- current catalog marks this as partially supported
+
+### V-035 Advanced or expert auto mine
+
+Method:
+
+- set `advanced-auto-mine` or `expert-auto-mine`
+
+Expected:
+
+- behavior synthesizes a mine-and-deliver run
+- ship selects a resource source and delivery path
+- behavior can repeat after completion
+
+### V-036 Combat guard behaviors
+
+Method:
+
+- validate one or more of:
+ - `protect-position`
+ - `protect-ship`
+ - `protect-station`
+ - `police`
+
+Expected:
+
+- behavior creates managed guard or intercept orders
+- threat response is coherent
+- ship returns to guarding behavior after engagement if still valid
+
+## Phase 5: Spatial And Transit Validation
+
+This phase validates the new universe-model runtime behavior.
+
+Primary concern:
+
+- ships should behave as anchor-aware entities rather than generic free-flying system dots
+
+### V-040 Spatial state is coherent at rest
+
+Expected:
+
+- a resting ship reports a sensible `SpatialState`
+- `SpaceLayer`, `CurrentSystemId`, `CurrentAnchorId`, and `MovementRegime` agree with the visible world state
+
+### V-041 Local movement remains local
+
+Expected:
+
+- local movement updates local position coherently
+- the ship does not accidentally enter invalid transit state
+
+### V-042 Intra-system transit is explicit
+
+Method:
+
+- send a ship between distant anchors if the current order flow supports it
+
+Expected:
+
+- movement regime transitions are explicit
+- transit state reports origin, destination, and progress
+- arrival returns the ship to a valid anchor-local state
+
+### V-043 Inter-system travel if available
+
+Method:
+
+- attempt a cross-system route through current supported mechanics
+
+Expected:
+
+- system change happens through a coherent transit path
+- no entity duplication or dropped ship occurs
+
+## Phase 6: Docking, Cargo, And Station Interaction
+
+These tests prove basic station interaction works.
+
+### V-050 Docking updates both sides
+
+Expected:
+
+- ship shows docked station id
+- station docked ship list updates
+- dock count changes are visible in the viewer
+
+### V-051 Cargo transfer changes inventory
+
+Method:
+
+- use a mining or delivery flow involving a station
+
+Expected:
+
+- ship inventory changes
+- station inventory changes
+- transfer is not purely cosmetic
+
+### V-052 Invalid docking fails cleanly
+
+Method:
+
+- attempt docking or a delivery path with a ship or station that should not support it
+
+Expected:
+
+- failure is visible and readable
+- ship does not become stuck in permanent docking state
+
+## Phase 7: Combat Validation
+
+These tests are still primitive in the empty-world phase.
+
+The goal is not full tactical balance.
+
+The goal is to prove the combat loop exists and behaves coherently.
+
+### V-060 Attack order enters engagement
+
+Expected:
+
+- attacker closes on target
+- attack state appears
+- target health changes if weapons and hostility permit combat
+
+### V-061 Combat resolves to a stable end state
+
+Expected:
+
+- one of the following happens cleanly:
+ - target destroyed
+ - attacker disengages
+ - order fails with a readable reason
+
+No permanent broken state should remain.
+
+### V-062 Non-combat ship does not behave like a combat ship
+
+Method:
+
+- issue combat pressure to a non-combat or civilian ship if possible
+
+Expected:
+
+- behavior is limited, defensive, or clearly incapable
+- it should not unrealistically perform like a dedicated combat hull unless current design says it can
+
+## Phase 8: Invalid And Edge Cases
+
+These are mandatory because many simulation regressions hide in failure handling rather than happy paths.
+
+### V-070 Invalid target order
+
+Method:
+
+- send an order with a missing or invalid target
+
+Expected:
+
+- backend rejects the order or marks it failed cleanly
+- no corrupted plan remains
+
+### V-071 Remove target during execution
+
+Method:
+
+- destroy or invalidate the target context while a ship is executing an order
+
+Expected:
+
+- ship replans or fails safely
+- no null-state or endless execution loop appears
+
+### V-072 Reset during active simulation
+
+Method:
+
+- reset the world while ships are active
+
+Expected:
+
+- viewer refreshes cleanly
+- no stale selected entity state causes crashes
+- world stream recovers to fresh baseline
+
+### V-073 Behavior with impossible prerequisites
+
+Method:
+
+- assign a behavior that requires a target, station, or ware that is not available
+
+Expected:
+
+- failure is readable
+- ship falls back safely
+- behavior does not create runaway order spam
+
+## Phase 9: Viewer-State Validation
+
+The simulation may be correct while the viewer is misleading.
+
+That is still a failure.
+
+### V-080 Inspector reflects real ship state
+
+Expected:
+
+- order queue, active plan, subtasks, inventory, health, and spatial state match observed behavior
+
+### V-081 Selection survives world updates
+
+Expected:
+
+- selecting a ship or station remains stable through normal delta updates
+
+### V-082 Focus and follow modes remain usable
+
+Expected:
+
+- camera focus and tracking do not break during movement, docking, or combat
+
+### V-083 Context actions target the intended entity
+
+Method:
+
+- use context menu actions such as:
+ - mine resource
+ - fly to and wait
+ - follow ship
+ - attack
+
+Expected:
+
+- the generated order matches the selected target
+- the resulting ship action matches the command label
+
+## Recommended Manual Run Order
+
+Run in this order:
+
+1. boot and reset validation
+2. faction creation
+3. ship spawn
+4. station spawn
+5. direct navigation order
+6. direct mining order
+7. docking and delivery
+8. direct attack order
+9. default behavior checks
+10. edge and failure checks
+11. viewer consistency pass
+
+## Minimum Pass Criteria
+
+The current basis of the game should be considered working only if all of the following are true:
+
+- world startup and reset are reliable
+- actors can be spawned into an empty baseline
+- at least one ship can move successfully
+- at least one ship can mine successfully
+- at least one ship can attack successfully
+- at least one ship can dock and transfer inventory successfully
+- direct orders can be added and removed cleanly
+- default behaviors can control ships without obvious stuck states
+- viewer state remains trustworthy during all of the above
+
+## Failure Reporting
+
+When a test fails, record:
+
+- test id
+- exact setup
+- exact action taken
+- whether failure happened in backend, simulation behavior, or viewer representation
+- whether reset recovers the world cleanly
+- likely regression area if visible from inspector or logs
+
+Suggested failure categories:
+
+- startup
+- API contract
+- planning
+- subtask execution
+- movement
+- docking
+- mining
+- combat
+- inventory
+- viewer sync
+- reset or stream lifecycle
+
+## Follow-Up
+
+After this manual pass stabilizes:
+
+1. turn the most important Phase 1 through Phase 4 checks into runtime-level simulation tests
+2. prefer real simulation execution over mocked unit tests
+3. add richer scenario validation only after primitive behavior passes consistently
+
+That next phase should validate composed loops:
+
+- mine -> dock -> unload
+- trade route -> station inventory update
+- construction support
+- guard and intercept response
+- longer-run autonomous behavior without manual intervention
diff --git a/shared/data/scenarios/minimal.json b/shared/data/scenarios/minimal.json
new file mode 100644
index 0000000..81f1c46
--- /dev/null
+++ b/shared/data/scenarios/minimal.json
@@ -0,0 +1,66 @@
+{
+ "worldGeneration": {
+ "seed": 1,
+ "targetSystemCount": 1,
+ "useKnownSystems": false,
+ "aiControllerFactionCount": 0,
+ "generatePlayerFaction": false
+ },
+ "systems": [
+ {
+ "id": "minimal",
+ "label": "Minimal Test System",
+ "position": [0, 0, 0],
+ "stars": [
+ {
+ "kind": "main-sequence",
+ "color": "#fff1b8",
+ "glow": "#ffd35a",
+ "size": 420000,
+ "orbitRadius": 0,
+ "orbitSpeed": 0,
+ "orbitPhaseAtEpoch": 0
+ }
+ ],
+ "asteroidField": {
+ "decorationCount": 0,
+ "radiusOffset": 0,
+ "radiusVariance": 0,
+ "heightVariance": 0
+ },
+ "resourceNodes": [
+ {
+ "sourceKind": "asteroid-belt",
+ "angle": 0.6,
+ "radiusOffset": 180000,
+ "inclinationDegrees": 3,
+ "oreAmount": 12000,
+ "itemId": "ore",
+ "shardCount": 9
+ }
+ ],
+ "planets": [
+ {
+ "label": "Primer",
+ "planetType": "terrestrial",
+ "shape": "sphere",
+ "moons": [],
+ "orbitRadius": 0.8,
+ "orbitSpeed": 0.14,
+ "orbitEccentricity": 0.01,
+ "orbitInclination": 0,
+ "orbitLongitudeOfAscendingNode": 0,
+ "orbitArgumentOfPeriapsis": 0,
+ "orbitPhaseAtEpoch": 0,
+ "size": 6200,
+ "color": "#6ea7d4",
+ "tilt": 0,
+ "hasRing": false
+ }
+ ]
+ }
+ ],
+ "initialStations": [],
+ "shipFormations": [],
+ "patrolRoutes": []
+}
diff --git a/tests/backend/ShipAiServiceExecutionTests.cs b/tests/backend/ShipAiServiceExecutionTests.cs
new file mode 100644
index 0000000..1f42df8
--- /dev/null
+++ b/tests/backend/ShipAiServiceExecutionTests.cs
@@ -0,0 +1,153 @@
+using FluentAssertions;
+using SpaceGame.Api.Definitions;
+using SpaceGame.Api.Industry.Planning;
+using SpaceGame.Api.Shared.Runtime;
+using SpaceGame.Api.Ships.AI;
+using SpaceGame.Api.Ships.Runtime;
+using SpaceGame.Api.Universe.Contracts;
+using SpaceGame.Api.Universe.Runtime;
+using SpaceGame.Api.Universe.Simulation;
+using Xunit;
+
+namespace SpaceGame.Api.Tests;
+
+public sealed class ShipAiServiceExecutionTests
+{
+ [Fact]
+ public void MoveOrder_CompletesAndIsRemovedAfterArrival()
+ {
+ var ship = new ShipRuntime
+ {
+ Id = "ship-1",
+ SystemId = "sys-1",
+ Definition = CreateShipDefinition(),
+ FactionId = "player",
+ Position = Vector3.Zero,
+ TargetPosition = Vector3.Zero,
+ SpatialState = new ShipSpatialStateRuntime
+ {
+ CurrentSystemId = "sys-1",
+ CurrentAnchorId = null,
+ LocalPosition = Vector3.Zero,
+ SystemPosition = Vector3.Zero,
+ },
+ DefaultBehavior = new DefaultBehaviorRuntime
+ {
+ Kind = ShipBehaviorKinds.Idle,
+ },
+ Skills = new ShipSkillProfileRuntime
+ {
+ Navigation = 3,
+ Trade = 3,
+ Mining = 3,
+ Combat = 3,
+ Construction = 3,
+ },
+ Health = 100f,
+ };
+
+ ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime
+ {
+ Id = "move-1",
+ Kind = ShipOrderKinds.Move,
+ SourceKind = ShipOrderSourceKind.Player,
+ SourceId = "test",
+ Label = "Fly to point",
+ TargetSystemId = "sys-1",
+ TargetPosition = new Vector3(5f, 0f, 0f),
+ });
+
+ var world = CreateWorld(ship);
+ var service = new ShipAiService(new TestBalanceService());
+ var events = new List();
+
+ service.UpdateShip(world, ship, 1f, events);
+ service.UpdateShip(world, ship, 0.01f, events);
+
+ ship.Position.Should().Be(new Vector3(5f, 0f, 0f));
+ ship.OrderQueue.Should().BeEmpty();
+ ship.ActiveOrderId.Should().BeNull();
+ }
+
+ private static SimulationWorld CreateWorld(ShipRuntime ship)
+ {
+ return new SimulationWorld
+ {
+ Label = "test",
+ Seed = 1,
+ Systems =
+ [
+ new SystemRuntime
+ {
+ Definition = new SolarSystemDefinition
+ {
+ Id = "sys-1",
+ Label = "System 1",
+ Position = [0f, 0f, 0f],
+ Stars = [],
+ AsteroidField = new AsteroidFieldDefinition(),
+ ResourceNodes = [],
+ Planets = [],
+ },
+ Position = Vector3.Zero,
+ }
+ ],
+ Anchors = [],
+ Nodes = [],
+ Celestials = [],
+ Wrecks = [],
+ Stations = [],
+ Ships = [ship],
+ Factions = [],
+ Commanders = [],
+ Claims = [],
+ ConstructionSites = [],
+ MarketOrders = [],
+ Policies = [],
+ ShipDefinitions = new Dictionary(StringComparer.Ordinal),
+ ItemDefinitions = new Dictionary(StringComparer.Ordinal),
+ ModuleDefinitions = new Dictionary(StringComparer.Ordinal),
+ ModuleRecipes = new Dictionary(StringComparer.Ordinal),
+ Recipes = new Dictionary(StringComparer.Ordinal),
+ ProductionGraph = new ProductionGraph
+ {
+ Commodities = new Dictionary(StringComparer.Ordinal),
+ Processes = new Dictionary(StringComparer.Ordinal),
+ ProcessesByOutputId = new Dictionary>(StringComparer.Ordinal),
+ ProcessesByInputId = new Dictionary>(StringComparer.Ordinal),
+ OutputsByModuleId = new Dictionary>(StringComparer.Ordinal),
+ },
+ GeneratedAtUtc = DateTimeOffset.UtcNow,
+ };
+ }
+
+ private static ShipDefinition CreateShipDefinition()
+ {
+ return new ShipDefinition
+ {
+ Id = "ship-def",
+ Name = "Test Ship",
+ Size = "small",
+ Hull = 100f,
+ Purpose = ShipPurpose.Trade,
+ Type = ShipType.Courier,
+ };
+ }
+
+ private sealed class TestBalanceService : IBalanceService
+ {
+ public float SimulationSpeedMultiplier => 1f;
+ public float YPlane => 0f;
+ public float ArrivalThreshold => 1f;
+ public float MiningRate => 10f;
+ public float MiningCycleSeconds => 1f;
+ public float TransferRate => 10f;
+ public float DockingDuration => 0.1f;
+ public float UndockingDuration => 0.1f;
+ public float UndockDistance => 10f;
+
+ public BalanceOptions GetCurrent() => new();
+
+ public BalanceOptions Update(BalanceOptions candidate) => candidate;
+ }
+}
diff --git a/tests/backend/ShipOrderQueueTests.cs b/tests/backend/ShipOrderQueueTests.cs
new file mode 100644
index 0000000..bce8d4a
--- /dev/null
+++ b/tests/backend/ShipOrderQueueTests.cs
@@ -0,0 +1,140 @@
+using FluentAssertions;
+using SpaceGame.Api.Shared.Runtime;
+using SpaceGame.Api.Ships.Runtime;
+using Xunit;
+
+namespace SpaceGame.Api.Tests;
+
+public sealed class ShipOrderQueueTests
+{
+ [Fact]
+ public void Enqueue_Throws_WhenQueueIsFull()
+ {
+ var queue = new ShipOrderQueue();
+ foreach (var index in Enumerable.Range(0, ShipOrderQueue.MaxOrders))
+ {
+ queue.Enqueue(CreateOrder($"order-{index}", ShipOrderSourceKind.Player, priority: index));
+ }
+
+ var act = () => queue.Enqueue(CreateOrder("overflow", ShipOrderSourceKind.Player));
+
+ act.Should().Throw()
+ .WithMessage("Order queue is full.");
+ }
+
+ [Fact]
+ public void GetCurrentOrder_UsesQueueOrder()
+ {
+ var queue = new ShipOrderQueue();
+ queue.EnqueuePlayerOrder(CreateOrder("player-first", ShipOrderSourceKind.Player));
+ queue.EnqueueManagedOrder(CreateOrder("commander-second", ShipOrderSourceKind.Commander, priority: 100));
+ queue.EnqueueManagedOrder(CreateOrder("behavior-third", ShipOrderSourceKind.Behavior, priority: 999));
+
+ var currentOrder = queue.GetCurrentOrder();
+
+ currentOrder.Should().NotBeNull();
+ currentOrder!.Id.Should().Be("player-first");
+ }
+
+ [Fact]
+ public void GetCurrentOrder_IgnoresTerminalStatuses()
+ {
+ var queue = new ShipOrderQueue();
+ queue.EnqueuePlayerOrder(CreateOrder("completed-player", ShipOrderSourceKind.Player, priority: 100, status: OrderStatus.Completed));
+ queue.EnqueueManagedOrder(CreateOrder("active-commander", ShipOrderSourceKind.Commander, priority: 1, status: OrderStatus.Active));
+
+ var currentOrder = queue.GetCurrentOrder();
+
+ currentOrder.Should().NotBeNull();
+ currentOrder!.Id.Should().Be("active-commander");
+ }
+
+ [Fact]
+ public void EnqueuePlayerOrder_InsertsBeforeManagedOrders()
+ {
+ var queue = new ShipOrderQueue();
+ queue.EnqueueManagedOrder(CreateOrder("behavior-1", ShipOrderSourceKind.Behavior));
+ queue.EnqueuePlayerOrder(CreateOrder("player-1", ShipOrderSourceKind.Player));
+ queue.EnqueueManagedOrder(CreateOrder("behavior-2", ShipOrderSourceKind.Commander));
+ queue.EnqueuePlayerOrder(CreateOrder("player-2", ShipOrderSourceKind.Player));
+
+ queue.Select(order => order.Id).Should().Equal("player-1", "player-2", "behavior-1", "behavior-2");
+ queue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)!.Id.Should().Be("player-1");
+ }
+
+ [Fact]
+ public void AddOrReplaceManagedOrder_ReplacesMatchingOrderWithoutGrowingQueue()
+ {
+ var queue = new ShipOrderQueue();
+ queue.EnqueueManagedOrder(CreateOrder("order-1", ShipOrderSourceKind.Behavior, label: "Initial label"));
+
+ queue.AddOrReplaceManagedOrder(CreateOrder("order-1", ShipOrderSourceKind.Behavior, label: "Updated label", priority: 7));
+
+ queue.Count.Should().Be(1);
+ queue.FindById("order-1").Should().NotBeNull();
+ queue.FindById("order-1")!.Label.Should().Be("Updated label");
+ queue.FindById("order-1")!.Priority.Should().Be(7);
+ }
+
+ [Fact]
+ public void RemoveById_RemovesMatchingOrder()
+ {
+ var queue = new ShipOrderQueue();
+ queue.EnqueuePlayerOrder(CreateOrder("remove-me", ShipOrderSourceKind.Player));
+ queue.EnqueuePlayerOrder(CreateOrder("keep-me", ShipOrderSourceKind.Player));
+
+ var removed = queue.RemoveById("remove-me");
+
+ removed.Should().BeTrue();
+ queue.Select(order => order.Id).Should().ContainSingle().Which.Should().Be("keep-me");
+ }
+
+ [Fact]
+ public void TryMovePlayerOrder_ReordersWithinPlayerSegment()
+ {
+ var queue = new ShipOrderQueue();
+ queue.EnqueuePlayerOrder(CreateOrder("player-1", ShipOrderSourceKind.Player));
+ queue.EnqueuePlayerOrder(CreateOrder("player-2", ShipOrderSourceKind.Player));
+ queue.EnqueueManagedOrder(CreateOrder("behavior-1", ShipOrderSourceKind.Behavior));
+
+ var moved = queue.TryMovePlayerOrder("player-2", 0);
+
+ moved.Should().BeTrue();
+ queue.Select(order => order.Id).Should().Equal("player-2", "player-1", "behavior-1");
+ }
+
+ [Fact]
+ public void TryCompleteOrder_RemovesCompletedDirectOrderFromQueue()
+ {
+ var queue = new ShipOrderQueue();
+ queue.EnqueuePlayerOrder(CreateOrder("direct-order", ShipOrderSourceKind.Player, status: OrderStatus.Active));
+ queue.EnqueueManagedOrder(CreateOrder("behavior-order", ShipOrderSourceKind.Behavior));
+
+ var completed = queue.TryCompleteOrder("direct-order");
+
+ completed.Should().BeTrue();
+ queue.FindById("direct-order").Should().BeNull();
+ queue.GetCurrentOrder()!.Id.Should().Be("behavior-order");
+ }
+
+ private static ShipOrderRuntime CreateOrder(
+ string id,
+ ShipOrderSourceKind sourceKind,
+ int priority = 0,
+ OrderStatus status = OrderStatus.Queued,
+ string? label = null,
+ DateTimeOffset? createdAtUtc = null)
+ {
+ return new ShipOrderRuntime
+ {
+ Id = id,
+ Kind = "test-order",
+ SourceKind = sourceKind,
+ SourceId = $"{sourceKind}-source",
+ Priority = priority,
+ Status = status,
+ Label = label,
+ CreatedAtUtc = createdAtUtc ?? new DateTimeOffset(2026, 4, 8, 12, 0, 0, TimeSpan.Zero),
+ };
+ }
+}
diff --git a/tests/backend/SpaceGame.Api.Tests.csproj b/tests/backend/SpaceGame.Api.Tests.csproj
new file mode 100644
index 0000000..6253fe8
--- /dev/null
+++ b/tests/backend/SpaceGame.Api.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+