3 Commits

Author SHA1 Message Date
Mavis 68bc7015fd Phase 1c: real kRPC bridge (full protocol + mock mode for development)
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
- packages/krpc-client: TypeScript kRPC protocol client
  - connection.ts: varint encoding/decoding, length-prefix framing,
    per-socket read queue (avoids race when multiple read promises
    in flight). Uses multiplication not '<<' for varint shift
    because JS truncates << to 32 bits.
  - schema.ts: hand-written protobufjs schema for kRPC meta-protocol
    (ConnectionRequest, Request, Response, StreamUpdate, Status, etc.)
    - enough to do connection handshake, single procedure calls, and
      stream subscription. Service-specific types (SpaceCenter.Vessel,
    Orbit, CelestialBody) need to be loaded from the kRPC mod's
    .proto files at runtime.
  - client.ts: KRPCClient with connect/invoke/addStream/removeStream/
    onStreamUpdate/close. Tested with hand-rolled mock server.
  - 10 tests (varint round-trips incl. uint64, wire format with raw
    sockets).

- apps/tools/ksp-bridge: bridge that connects KSP to our API
  - convert.ts: pure kRPC -> UniverseSnapshot conversion
    (situation enum mapping, body id normalization, etc.)
  - bridge.ts: main poll loop with retry + backoff
  - krpc-adapter.ts: KRPCAdapter class that owns the KRPCClient
  - index.ts: entrypoint with MOCK MODE for development (emits
    synthetic state when no KSP is available, so you can verify the
    HTTP pipeline end-to-end)
  - 9 tests (7 conversion + 2 end-to-end bridge)

- ksp/README.md: full setup guide
  - CKAN install, KSP server start, env vars
  - Hand-rolled KSP calls list (what SpaceCenter methods we need)
  - Roadmap for the remaining .proto-loading work
  - Protocol deep-dive (for the next dev)

- Bug fixes along the way: protobufjs default import (not namespace),
  varint 32-bit truncation, JavaScript bitwise 32-bit limit,
  handshake status enum comparison (number vs string).

End-to-end verified: API + bridge in mock mode, 2 bodies + 1 vessel
arriving at /api/v1/state, 500ms polling cadence, automatic recovery
on HTTP failures.
2026-06-02 20:42:54 +00:00
Mavis 07cc5321d1 Phase 2c: eclipse/overpass calculators + live-map camera polish
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
Calculators (apps/live-map/src/calculators/):
- eclipse.ts: findEclipseWindows(bodies, opts) — coarse scan with
  threshold-crossing detection, bisection to refine start/end,
  ternary search to find peak. Handles eclipse already in progress
  at scan-start. Uses sun = parentId===null body.
- overpass.ts: findOverpasses(opts) — coarse scan for local distance
  minima, ternary refinement. Targets: vessel, body, ground station
  (lat/lon/alt → heliocentric).

UI:
- panels/CalculatorsPanel.tsx: collapsible bottom-center panel with
  two tabs. Eclipse form: observer, eclipser, from UT → 3 windows.
  Overpass form: observer vessel, target kind+id, max dist → 5 passes.
- timeFormat.ts: shared KSP-time formatters.

Live-map camera polish (apps/live-map/src/scene/):
- camera.ts: CameraController — log-scale distance (z→exp(z)*1e8 m,
  range -3..12), spherical orbit around target, smooth lerp to
  selected body/vessel. Mouse wheel zooms, drag rotates, click
  raycasts for track toggle. Pointer-move-distance gate to
  distinguish click from drag.
- glow.ts: additive shader-based atmospheric halo (rim-falloff
  fragment shader, BackSide) attached as child of body mesh.
- layout.ts: bodyPositionAt now returns true heliocentric (walks
  parent chain); previous version returned parent-relative for
  non-root children which broke the eclipse calculator.

Bug fix:
- packages/orbital-math/src/occultation.ts: sign of projection check
  was inverted. `proj <= 0` correctly returns 0 (occluder behind
  observer), `proj > 0` triggers eclipse computation.

Tests: 28 live-map tests (10 scene + 12 calculator + 6 camera),
45 total across the workspace, all passing.
2026-06-02 19:46:00 +00:00
Mavis 9e76ec9328 Phase 2: 3D live map driven by API WebSocket
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
- apps/live-map/src/hooks/useLiveState.ts: WebSocket subscription
  with exponential-backoff reconnect, polling fallback, status tracking
- apps/live-map/src/scene/Scene.tsx: refactored Three.js scene with
  per-frame orbit propagation, vessel marker color-coding by owner,
  orbit-line visibility tied to focus filters, smooth camera follow
  on selected vessel/body
- apps/live-map/src/scene/layout.ts: bodyPositionAt / vesselPositionAt
  helpers (heliocentric frame, walk up the parent chain), logScale
  helpers for the system view
- apps/live-map/src/scene/color.ts: per-body and per-owner color maps
- apps/live-map/src/panels/TimeControls.tsx: play/pause/reverse/reset
  buttons, ×1/×10/×100/×1k/×10k/×100k speeds, UT scrub slider,
  live-edge indicator (LIVE / Nh behind / Nh ahead)
- apps/live-map/src/panels/VesselList.tsx: vessel sidebar with click-
  to-track; color-coded by owner (KASA=blue, SPES=orange)
- apps/live-map/src/panels/FocusPanel.tsx: planet/moon/vessel orbit
  visibility toggles
- apps/live-map/src/panels/StatusPill.tsx: WS status (LIVE/POLLING/
  OFFLINE/STALE), body + vessel + message counts
- tests/scene.test.ts: 10 tests for layout helpers (periodicity,
  vessel-centered positioning, logScale round-trips)

End-to-end verified: mock publisher → API → live-map WebSocket →
scene re-renders with the new vessel positions and orbits.
2026-06-02 19:18:22 +00:00
41 changed files with 5039 additions and 390 deletions
+3 -2
View File
@@ -10,7 +10,7 @@
"preview": "vite preview --port 3001",
"typecheck": "tsc --noEmit",
"lint": "echo 'no linter yet'",
"test": "echo 'no tests yet'"
"test": "vitest run"
},
"dependencies": {
"@kerbal-rt/shared-types": "workspace:*",
@@ -26,6 +26,7 @@
"@types/three": "^0.169.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.2",
"vite": "^5.4.6"
"vite": "^5.4.6",
"vitest": "^2.1.1"
}
}
+201 -284
View File
@@ -1,313 +1,230 @@
import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { Pill, formatUtAsKspDate } from '@kerbal-rt/ui';
import { sampleOrbit, positionAt } from '@kerbal-rt/orbital-math';
import type { CelestialBody } from '@kerbal-rt/shared-types';
/**
* App — top-level live map.
*
* Subscribes to the API WebSocket, manages the simulation time
* (UT), and renders the 3D scene plus all the UI panels.
*
* Time model:
* - `liveUt`: the UT of the most recent snapshot received
* - `displayUt`: what the scene is currently showing. Starts at
* liveUt, advances on play, and can be scrubbed via the slider.
* - When `displayUt === liveUt`, the pill shows "LIVE".
* When the user scrubs backwards or speed-0 pauses, the pill
* shows how far behind/ahead we are.
*/
import { useEffect, useState } from 'react';
import { Scene } from './scene/Scene.js';
import { TimeControls } from './panels/TimeControls.js';
import { VesselList } from './panels/VesselList.js';
import { FocusPanel } from './panels/FocusPanel.js';
import { StatusPill } from './panels/StatusPill.js';
import { CalculatorsPanel } from './panels/CalculatorsPanel.js';
import { useLiveState } from './hooks/useLiveState.js';
import type { UniverseSnapshot } from '@kerbal-rt/shared-types';
const API_URL = (import.meta.env.VITE_API_URL as string | undefined) ?? '';
/**
* Mock solar system so the scene has something to render before the
* real KSP bridge is wired in (Phase 1). Drop a real snapshot in
* via the same /api/v1/state endpoint and the same renderer will
* work — only `useUniverse()` changes.
*/
function useUniverse() {
const [bodies, setBodies] = useState<CelestialBody[]>([]);
const [ut, setUt] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const mockBodies: CelestialBody[] = [
makeBody({
id: 'kerbol',
name: 'Kerbol',
kind: 'star',
radius: 261_600_000,
sphereOfInfluence: Infinity,
mu: 1.172332794e18,
rotationPeriod: 432_000,
axialTilt: 0,
color: '#ffcc33',
parent: null,
sma: 0,
}),
makeBody({
id: 'kerbin',
name: 'Kerbin',
kind: 'planet',
radius: 600_000,
sphereOfInfluence: 84_159_286,
mu: 3.5316e12,
rotationPeriod: 21_600,
axialTilt: 0,
color: '#3a7d8c',
parent: 'kerbol',
sma: 13_599_840_256,
}),
makeBody({
id: 'mun',
name: 'Mun',
kind: 'moon',
radius: 200_000,
sphereOfInfluence: 2_429_559,
mu: 6.514e10,
rotationPeriod: 138_984,
axialTilt: 0,
color: '#aaa',
parent: 'kerbin',
sma: 12_000_000,
}),
];
setBodies(mockBodies);
setUt(4_700_000);
// Try to fetch real state if API is reachable
fetch(`${API_URL}/api/v1/state`)
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
if (j?.data?.bodies?.length) {
setBodies(j.data.bodies);
setUt(j.data.ut);
}
})
.catch((e) => setError(String(e)));
}, []);
return { bodies, ut, setUt, error };
}
function makeBody(opts: {
id: string;
name: string;
kind: CelestialBody['kind'];
radius: number;
sphereOfInfluence: number;
mu: number;
rotationPeriod: number;
axialTilt: number;
color: string;
parent: string | null;
sma: number;
}): CelestialBody {
return {
id: opts.id,
name: opts.name,
kind: opts.kind,
parentId: opts.parent,
radius: opts.radius,
sphereOfInfluence: opts.sphereOfInfluence,
gravitationalParameter: opts.mu,
rotationPeriod: opts.rotationPeriod,
axialTilt: opts.axialTilt,
orbit: {
semiMajorAxis: opts.sma,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: Math.random() * Math.PI * 2,
epoch: 0,
/** Fallback in-memory snapshot so the scene has something to render
* even before the API is up. Matches the catalog used by the
* mock-telemetry publisher. */
const FALLBACK_SNAPSHOT: UniverseSnapshot = {
ut: 0,
capturedAt: new Date(0).toISOString(),
activeVesselId: null,
bodies: [
{
id: 'kerbol',
name: 'Kerbol',
kind: 'star',
parentId: null,
radius: 261_600_000,
sphereOfInfluence: 1e30,
gravitationalParameter: 1.172332794e18,
rotationPeriod: 432_000,
axialTilt: 0,
orbit: {
semiMajorAxis: 0,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
},
};
}
function Scene({ bodies, ut }: { bodies: CelestialBody[]; ut: number }) {
const mountRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const mount = mountRef.current;
if (!mount) return;
const width = mount.clientWidth;
const height = mount.clientHeight;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000005);
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1e12);
camera.position.set(0, 5e9, 1.5e10);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
mount.appendChild(renderer.domElement);
// Lights
scene.add(new THREE.AmbientLight(0x404040, 0.4));
const sunLight = new THREE.PointLight(0xffffff, 2, 0, 0);
scene.add(sunLight);
// Build spheres + orbit lines per body
const meshById = new Map<string, THREE.Mesh>();
const orbitGroup = new THREE.Group();
scene.add(orbitGroup);
for (const body of bodies) {
if (body.sphereOfInfluence === 0 || body.sphereOfInfluence === Infinity) {
// Star: render but skip orbit
if (body.kind === 'star') {
const geo = new THREE.SphereGeometry(Math.max(body.radius, 1e8), 32, 16);
const mat = new THREE.MeshBasicMaterial({ color: 0xffcc33 });
const mesh = new THREE.Mesh(geo, mat);
scene.add(mesh);
meshById.set(body.id, mesh);
}
continue;
}
// Sphere — small planets/moons get a minimum size so they're visible
const displayRadius = Math.max(body.radius, 1e6);
const geo = new THREE.SphereGeometry(displayRadius, 32, 16);
const mat = new THREE.MeshPhongMaterial({
color: bodyColor(body.id),
emissive: 0x111111,
});
const mesh = new THREE.Mesh(geo, mat);
scene.add(mesh);
meshById.set(body.id, mesh);
// Orbit line
const points = sampleOrbit(body.orbit, body.gravitationalParameter, 256);
const positions = new Float32Array(points.length * 3);
points.forEach((p, i) => {
positions[i * 3] = p.x;
positions[i * 3 + 1] = p.y;
positions[i * 3 + 2] = p.z;
});
const lineGeo = new THREE.BufferGeometry();
lineGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const lineMat = new THREE.LineBasicMaterial({
color: bodyColor(body.id),
opacity: 0.6,
transparent: true,
});
orbitGroup.add(new THREE.LineLoop(lineGeo, lineMat));
}
// Resize
const onResize = () => {
if (!mount) return;
const w = mount.clientWidth;
const h = mount.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener('resize', onResize);
// Animation loop: place each body at the current propagated position
let raf = 0;
const render = () => {
for (const body of bodies) {
if (body.sphereOfInfluence === 0 || body.sphereOfInfluence === Infinity) continue;
const parent = bodies.find((b) => b.id === body.parentId);
if (!parent) continue;
const pos = positionAt(body.orbit, parent.gravitationalParameter, ut);
const mesh = meshById.get(body.id);
if (mesh) mesh.position.set(pos.x, pos.y, pos.z);
}
renderer.render(scene, camera);
raf = requestAnimationFrame(render);
};
render();
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
renderer.dispose();
mount.removeChild(renderer.domElement);
};
}, [bodies, ut]);
return <div ref={mountRef} style={{ width: '100%', height: '100%' }} />;
}
function bodyColor(id: string): number {
// Map common body names to colors; default to white
const map: Record<string, number> = {
kerbol: 0xffcc33,
kerbin: 0x3a7d8c,
mun: 0xaaaaaa,
minmus: 0x997a66,
duna: 0xc46030,
ike: 0x776655,
eve: 0x6b4ea0,
gilly: 0x665544,
jool: 0xa55a2a,
laythe: 0x4a6da0,
vall: 0x665544,
tylo: 0x997a66,
bop: 0x444444,
pol: 0x333333,
moho: 0x664433,
eeloo: 0xeeeeff,
};
return map[id] ?? 0xffffff;
}
{
id: 'kerbin',
name: 'Kerbin',
kind: 'planet',
parentId: 'kerbol',
radius: 600_000,
sphereOfInfluence: 84_159_286,
gravitationalParameter: 3.5316e12,
rotationPeriod: 21_600,
axialTilt: 0,
orbit: {
semiMajorAxis: 13_599_840_256,
eccentricity: 0.05,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0.7,
epoch: 0,
},
},
{
id: 'mun',
name: 'Mun',
kind: 'moon',
parentId: 'kerbin',
radius: 200_000,
sphereOfInfluence: 2_429_559,
gravitationalParameter: 6.514e10,
rotationPeriod: 138_984,
axialTilt: 0,
orbit: {
semiMajorAxis: 12_000_000,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 1.0,
epoch: 0,
},
},
],
vessels: [],
groundStations: [],
};
export function App() {
const { bodies, ut, setUt, error } = useUniverse();
const [playing, setPlaying] = useState(true);
const [speed, setSpeed] = useState(60); // 1s of wall = 60s of KSP UT
const { snapshot, status, lastUpdate, messageCount, error } = useLiveState(API_URL);
const liveUt = snapshot?.ut ?? 0;
// Time controls
const [displayUt, setDisplayUt] = useState<number>(liveUt);
const [playing, setPlaying] = useState(true);
const [speed, setSpeed] = useState(1);
// Selection
const [selectedId, setSelectedId] = useState<string | null>(null);
// Focus toggles
const [showPlanetOrbits, setShowPlanetOrbits] = useState(true);
const [showMoonOrbits, setShowMoonOrbits] = useState(true);
const [showVesselOrbits, setShowVesselOrbits] = useState(true);
// When a new snapshot arrives, jump displayUt to liveUt if we're
// currently "in sync" (playing and at the live edge).
useEffect(() => {
if (playing && displayUt >= liveUt - 1) {
setDisplayUt(liveUt);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [liveUt]);
// Advance displayUt when playing
useEffect(() => {
if (!playing) return;
const id = setInterval(() => setUt((u) => u + speed), 1000);
const id = setInterval(() => {
setDisplayUt((u) => u + speed);
}, 1000);
return () => clearInterval(id);
}, [playing, speed]);
const displaySnapshot: UniverseSnapshot = snapshot ?? FALLBACK_SNAPSHOT;
const vesselCount = displaySnapshot.vessels.length;
const bodyCount = displaySnapshot.bodies.length;
return (
<div style={{ width: '100vw', height: '100vh', position: 'relative', background: '#000' }}>
<Scene bodies={bodies} ut={ut} />
<Scene
snapshot={displaySnapshot}
ut={displayUt}
followId={selectedId}
showPlanetOrbits={showPlanetOrbits}
showMoonOrbits={showMoonOrbits}
showVesselOrbits={showVesselOrbits}
onSelect={setSelectedId}
/>
<div
style={{
position: 'absolute',
top: 12,
left: 12,
padding: '0.5rem 0.75rem',
background: 'rgba(0,0,0,0.6)',
color: 'white',
borderRadius: 6,
fontFamily: 'monospace',
fontSize: 13,
<StatusPill
status={status}
lastUpdate={lastUpdate}
messageCount={messageCount}
vesselCount={vesselCount}
bodyCount={bodyCount}
/>
<TimeControls
ut={displayUt}
liveUt={liveUt}
playing={playing}
speed={speed}
onPlayPause={() => setPlaying((p) => !p)}
onSpeed={(s) => {
setSpeed(s);
if (s > 0) setPlaying(true);
}}
>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<strong>Kerbal RT</strong>
<Pill tone={error ? 'danger' : 'success'}>
{error ? 'API offline' : `${bodies.length} bodies`}
</Pill>
onReverse={() => setSpeed((s) => -s)}
onReset={() => {
setDisplayUt(liveUt);
setPlaying(true);
setSpeed(1);
setSelectedId(null);
}}
onScrub={(u) => {
setDisplayUt(u);
setPlaying(false);
}}
/>
<VesselList
vessels={displaySnapshot.vessels}
selectedId={selectedId}
onSelect={setSelectedId}
/>
<FocusPanel
showPlanetOrbits={showPlanetOrbits}
showMoonOrbits={showMoonOrbits}
showVesselOrbits={showVesselOrbits}
onTogglePlanet={() => setShowPlanetOrbits((v) => !v)}
onToggleMoon={() => setShowMoonOrbits((v) => !v)}
onToggleVessel={() => setShowVesselOrbits((v) => !v)}
/>
<CalculatorsPanel snapshot={displaySnapshot} scanFromUt={displayUt} />
{error && (
<div
style={{
position: 'absolute',
bottom: 12,
left: 12,
padding: '0.4rem 0.6rem',
background: 'rgba(200, 0, 0, 0.3)',
color: '#fff',
borderRadius: 4,
fontFamily: 'monospace',
fontSize: 11,
}}
>
{error}
</div>
<div style={{ marginTop: 4 }}>UT {formatUtAsKspDate(ut)}</div>
<div style={{ display: 'flex', gap: '0.25rem', marginTop: 8 }}>
<button onClick={() => setPlaying((p) => !p)}>{playing ? '⏸' : '▶'}</button>
<button onClick={() => setUt(0)}>Reset</button>
{[1, 60, 3600, 86400].map((s) => (
<button
key={s}
onClick={() => setSpeed(s)}
style={{ fontWeight: speed === s ? 'bold' : 'normal' }}
>
×{s < 60 ? s : s < 3600 ? `${s / 60}m` : `${s / 3600}h`}
</button>
))}
</div>
</div>
)}
<div
style={{
position: 'absolute',
bottom: 12,
right: 12,
color: 'rgba(255,255,255,0.5)',
fontSize: 11,
color: 'rgba(255,255,255,0.4)',
fontSize: 10,
fontFamily: 'monospace',
}}
>
Phase 0 skeleton · Three.js
Phase 2 live map · mock-telemetry driving the WS
</div>
</div>
);
+243
View File
@@ -0,0 +1,243 @@
/**
* Eclipse calculator — finds the next N times when the sun (Kerbol)
* is occluded from an observer's point of view by another body.
*
* Algorithm: scan forward in UT at fixed steps, refining around
* the transition points with bisection. Treats the sun as a point
* source (parallel rays) — accurate for KSP scale.
*/
import type { CelestialBody } from '@kerbal-rt/shared-types';
import { bodyPositionAt } from '../scene/layout.js';
import { shadowFraction } from '@kerbal-rt/orbital-math';
export interface EclipseWindow {
/** UT at which the eclipse begins (sun starts being occluded). */
utStart: number;
/** UT at which the eclipse ends. */
utEnd: number;
/** Maximum shadow fraction during the window (0..1). */
maxFraction: number;
/** UT at which the max occurs. */
utPeak: number;
}
export interface EclipseOptions {
observerId: string;
eclipserId: string;
/** Scan starts at this UT. */
startUt: number;
/** How many windows to find. */
count?: number;
/** Max time to scan forward (default ~1 KSP year). */
maxSearchTime?: number;
/** Coarse scan step (default 600 = 10 KSP minutes). */
stepSec?: number;
/** Threshold for "eclipse started". */
threshold?: number;
}
/**
* Find the next `count` eclipse windows where `eclipser` occludes
* the sun as seen from `observer`.
*
* Returns an empty array if observer and eclipser are the same body
* or if either is Kerbol (the sun can't eclipse itself, and
* eclipsers behind the sun don't make sense).
*/
export function findEclipseWindows(
bodies: CelestialBody[],
options: EclipseOptions,
): EclipseWindow[] {
const {
observerId,
eclipserId,
startUt,
count = 3,
maxSearchTime = 426 * 6 * 3600, // 1 KSP year
stepSec = 600,
threshold = 0.05,
} = options;
if (observerId === eclipserId) return [];
const observer = bodies.find((b) => b.id === observerId);
const eclipser = bodies.find((b) => b.id === eclipserId);
if (!observer || !eclipser) return [];
const windows: EclipseWindow[] = [];
const end = startUt + maxSearchTime;
// Coarse scan: find the start of an eclipse (transition from below
// threshold to above threshold). Then refine to find utStart, utPeak,
// utEnd.
let inEclipse = false;
let current: Partial<EclipseWindow> = {};
// If we start in the middle of an eclipse, bisect backwards to find utStart.
const startF = computeShadowFraction(bodies, observerId, eclipserId, startUt);
if (startF > threshold) {
inEclipse = true;
const utStart = refine(
bodies,
observerId,
eclipserId,
Math.max(0, startUt - stepSec),
startUt,
threshold,
'down',
);
current = { utStart };
}
let t = startUt + stepSec;
while (t < end && windows.length < count) {
const f = computeShadowFraction(bodies, observerId, eclipserId, t);
if (!inEclipse && f > threshold) {
// Eclipse just started — bisect to find exact start
const utStart = refine(bodies, observerId, eclipserId, t - stepSec, t, threshold, 'up');
inEclipse = true;
current = { utStart };
} else if (inEclipse && f < threshold) {
// Eclipse just ended
const utEnd = refine(bodies, observerId, eclipserId, t - stepSec, t, threshold, 'down');
const utPeak = refine(
bodies,
observerId,
eclipserId,
current.utStart!,
utEnd,
threshold,
'max',
);
const maxFraction = computeShadowFraction(bodies, observerId, eclipserId, utPeak);
windows.push({
utStart: current.utStart!,
utPeak,
utEnd,
maxFraction,
});
inEclipse = false;
current = {};
}
t += stepSec;
}
// If we ended while still in eclipse, close it.
if (inEclipse) {
const utEnd = refine(
bodies,
observerId,
eclipserId,
t - stepSec,
t + stepSec,
threshold,
'down',
);
const utPeak = refine(
bodies,
observerId,
eclipserId,
current.utStart!,
utEnd,
threshold,
'max',
);
const maxFraction = computeShadowFraction(bodies, observerId, eclipserId, utPeak);
windows.push({
utStart: current.utStart!,
utPeak,
utEnd,
maxFraction,
});
}
return windows;
}
/** Shadow fraction at a single instant. */
export function computeShadowFraction(
bodies: CelestialBody[],
observerId: string,
eclipserId: string,
ut: number,
): number {
const observer = bodies.find((b) => b.id === observerId);
const eclipser = bodies.find((b) => b.id === eclipserId);
if (!observer || !eclipser) return 0;
// Sun is Kerbol (the system root). For this to work the catalog
// must have a single body with parentId === null — the star.
const sun = bodies.find((b) => b.parentId === null);
if (!sun) return 0;
// The reference frame for shadowFraction is: vectors from the
// observer toward the sun, and from the observer toward the
// eclipser. We compute them in heliocentric coordinates then
// shift.
const sunPos = bodyPositionAt(bodies, sun.id, ut);
const eclipserPos = bodyPositionAt(bodies, eclipserId, ut);
const observerPos = bodyPositionAt(bodies, observerId, ut);
// For solar eclipses, "eclipser" sits between observer and sun.
// We treat the eclipser as the occluder and the sun as the light
// source. shadowFraction() expects: vector from observer to sun,
// and vector from observer to eclipser.
// Observer-to-sun = sunPos - observerPos
// Observer-to-eclipser = eclipserPos - observerPos
// BUT shadowFraction actually wants: vector from observer to sun,
// and the eclipser center RELATIVE to the observer-to-sun line.
// Re-reading the function: it computes perpDist of occluder
// center to sun-direction line, and uses the sun-direction
// projection. So the right call is:
return shadowFraction(
{ x: sunPos.x - observerPos.x, y: sunPos.y - observerPos.y, z: sunPos.z - observerPos.z },
{
x: eclipserPos.x - observerPos.x,
y: eclipserPos.y - observerPos.y,
z: eclipserPos.z - observerPos.z,
},
eclipser.radius,
);
}
/**
* Bisection to find when the shadow fraction crosses the threshold.
* direction = 'up' → find t such that f(t) ≈ threshold going upward
* direction = 'down' → find t such that f(t) ≈ threshold going downward
* direction = 'max' → find t that maximizes f in the range
*/
function refine(
bodies: CelestialBody[],
observerId: string,
eclipserId: string,
tLo: number,
tHi: number,
threshold: number,
direction: 'up' | 'down' | 'max',
): number {
if (direction === 'max') {
// Golden-section or simple ternary search on a small interval.
// For 600s windows this is plenty.
let lo = tLo;
let hi = tHi;
for (let i = 0; i < 32; i++) {
const m1 = lo + (hi - lo) / 3;
const m2 = hi - (hi - lo) / 3;
const f1 = computeShadowFraction(bodies, observerId, eclipserId, m1);
const f2 = computeShadowFraction(bodies, observerId, eclipserId, m2);
if (f1 < f2) lo = m1;
else hi = m2;
}
return (lo + hi) / 2;
}
// Bisection: 30 iterations gets us to 1-second precision on a 600s range.
let lo = tLo;
let hi = tHi;
for (let i = 0; i < 30; i++) {
const mid = (lo + hi) / 2;
const f = computeShadowFraction(bodies, observerId, eclipserId, mid);
if (direction === 'up' ? f < threshold : f > threshold) lo = mid;
else hi = mid;
}
return (lo + hi) / 2;
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Overpass calculator — finds the next N closest approaches between
* a vessel and a target (vessel, body, or ground station).
*
* Approach: sample the distance at fixed steps, then refine each
* minimum with ternary search.
*/
import type { CelestialBody, Vessel, GroundStation } from '@kerbal-rt/shared-types';
import { bodyPositionAt, vesselPositionAt } from '../scene/layout.js';
export type TargetKind = 'vessel' | 'body' | 'station';
export interface Target {
kind: TargetKind;
id: string;
name: string;
}
export interface OverpassWindow {
/** UT at which the closest approach occurs. */
utPeak: number;
/** Distance at closest approach (meters). */
minDistance: number;
/** The target. */
target: Target;
}
export interface OverpassOptions {
observer: Vessel;
target: Target;
bodies: CelestialBody[];
vessels: Vessel[];
groundStations: GroundStation[];
startUt: number;
count?: number;
maxSearchTime?: number;
stepSec?: number;
/** Distance threshold for "interesting" passes (default 1000 km). */
distanceThreshold?: number;
}
/**
* Find the next `count` closest-approach events.
*
* Coarse scan: at each step, compute distance. Track the local minima
* (a point lower than both neighbors). For each local min, refine
* with ternary search to find the true peak (min distance).
*
* Returns at most `count` events with minDistance < distanceThreshold.
*/
export function findOverpasses(options: OverpassOptions): OverpassWindow[] {
const {
observer,
target,
bodies,
vessels,
groundStations,
startUt,
count = 5,
maxSearchTime = 426 * 6 * 3600, // 1 KSP year
stepSec = 120, // 2 minutes
distanceThreshold = 1_000_000, // 1000 km
} = options;
const dist = (t: number) => distanceAt(bodies, vessels, groundStations, observer, target, t);
const passes: OverpassWindow[] = [];
const end = startUt + maxSearchTime;
let prev = dist(startUt);
let curr = prev;
let t = startUt + stepSec;
while (t < end && passes.length < count) {
const next = dist(t);
// Local minimum: curr < prev AND curr < next
if (curr < prev && curr < next && curr < distanceThreshold) {
// Refine
const utPeak = refineMinimum((a: number) => dist(a), t - stepSec, t + stepSec);
const minDistance = dist(utPeak);
passes.push({ utPeak, minDistance, target });
// Skip ahead past the peak so we don't double-count
t = utPeak + stepSec * 5;
prev = dist(t);
curr = prev;
t += stepSec;
continue;
}
prev = curr;
curr = next;
t += stepSec;
}
return passes;
}
function distanceAt(
bodies: CelestialBody[],
vessels: Vessel[],
groundStations: GroundStation[],
observer: Vessel,
target: Target,
ut: number,
): number {
const obs = vesselPositionAt(bodies, observer, ut);
let tgt: { x: number; y: number; z: number };
if (target.kind === 'vessel') {
const v = vessels.find((x) => x.id === target.id);
if (!v) return Number.POSITIVE_INFINITY;
tgt = vesselPositionAt(bodies, v, ut);
} else if (target.kind === 'body') {
tgt = bodyPositionAt(bodies, target.id, ut);
} else {
// station
const s = groundStations.find((x) => x.id === target.id);
if (!s) return Number.POSITIVE_INFINITY;
const body = bodies.find((b) => b.id === s.bodyId);
if (!body) return Number.POSITIVE_INFINITY;
const bodyPos = bodyPositionAt(bodies, body.id, ut);
// Ground station sits on the body surface. Convert (lat, lon, alt)
// to a heliocentric offset relative to the body's center.
// For simplicity (no rotation) we just add a small offset along
// the body's orbital direction.
const R = body.radius + s.alt;
const lat = (s.lat * Math.PI) / 180;
const lon = (s.lon * Math.PI) / 180;
tgt = {
x: bodyPos.x + R * Math.cos(lat) * Math.cos(lon),
y: bodyPos.y + R * Math.cos(lat) * Math.sin(lon),
z: bodyPos.z + R * Math.sin(lat),
};
}
const dx = obs.x - tgt.x;
const dy = obs.y - tgt.y;
const dz = obs.z - tgt.z;
return Math.hypot(dx, dy, dz);
}
/** Ternary search to find the minimum of dist in [tLo, tHi]. */
function refineMinimum(dist: (t: number) => number, tLo: number, tHi: number): number {
let lo = tLo;
let hi = tHi;
for (let i = 0; i < 40; i++) {
const m1 = lo + (hi - lo) / 3;
const m2 = hi - (hi - lo) / 3;
const f1 = dist(m1);
const f2 = dist(m2);
if (f1 > f2) lo = m1;
else hi = m2;
}
return (lo + hi) / 2;
}
+123
View File
@@ -0,0 +1,123 @@
/**
* useLiveState — subscribe to the kerbal-rt API over WebSocket and
* expose the latest UniverseSnapshot plus connection status.
*
* Auto-reconnects with exponential backoff on drop. Falls back to
* polling /api/v1/state every 5s if WebSocket fails to connect after
* 3 retries (e.g. a proxy doesn't support upgrade).
*/
import { useEffect, useRef, useState } from 'react';
import type { LiveMessage, UniverseSnapshot } from '@kerbal-rt/shared-types';
export type ConnectionState = 'connecting' | 'open' | 'closed' | 'fallback';
export interface LiveState {
snapshot: UniverseSnapshot | null;
status: ConnectionState;
lastUpdate: string | null;
messageCount: number;
error: string | null;
}
const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10_000, 30_000];
export function useLiveState(apiUrl: string): LiveState {
const [snapshot, setSnapshot] = useState<UniverseSnapshot | null>(null);
const [status, setStatus] = useState<ConnectionState>('connecting');
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
const [messageCount, setMessageCount] = useState(0);
const [error, setError] = useState<string | null>(null);
const reconnectAttempt = useRef(0);
const stopped = useRef(false);
useEffect(() => {
stopped.current = false;
let pollTimer: number | null = null;
let reconnectTimer: number | null = null;
let ws: WebSocket | null = null;
const startPollingFallback = () => {
if (pollTimer) return;
setStatus('fallback');
const tick = async () => {
try {
const res = await fetch(`${apiUrl}/api/v1/state`);
if (res.ok) {
const body = await res.json();
if (!body.error && body.data) {
setSnapshot(body.data as UniverseSnapshot);
setLastUpdate(new Date().toISOString());
setMessageCount((n) => n + 1);
}
}
} catch (e) {
setError(String((e as Error).message ?? e));
}
};
void tick();
pollTimer = window.setInterval(tick, 5000);
};
const scheduleReconnect = () => {
if (stopped.current) return;
if (reconnectAttempt.current >= RECONNECT_DELAYS_MS.length) {
// Gave up on WS, fall back to polling.
startPollingFallback();
return;
}
const delay = RECONNECT_DELAYS_MS[reconnectAttempt.current] ?? 30_000;
reconnectAttempt.current += 1;
setStatus('closed');
reconnectTimer = window.setTimeout(connect, delay);
};
const connect = () => {
if (stopped.current) return;
setStatus('connecting');
setError(null);
const wsUrl = apiUrl.replace(/^http/, 'ws') + '/api/v1/live';
try {
ws = new WebSocket(wsUrl);
} catch (e) {
setError(String((e as Error).message ?? e));
scheduleReconnect();
return;
}
ws.onopen = () => {
setStatus('open');
reconnectAttempt.current = 0; // success — reset backoff
};
ws.onmessage = (event) => {
setMessageCount((n) => n + 1);
setLastUpdate(new Date().toISOString());
try {
const msg = JSON.parse(event.data) as LiveMessage;
if (msg.type === 'snapshot') {
setSnapshot(msg.snapshot);
}
} catch (e) {
setError(`bad message: ${String((e as Error).message ?? e)}`);
}
};
ws.onerror = () => {
// close will fire too
};
ws.onclose = () => {
scheduleReconnect();
};
};
connect();
return () => {
stopped.current = true;
if (reconnectTimer) window.clearTimeout(reconnectTimer);
if (pollTimer) window.clearInterval(pollTimer);
if (ws) {
ws.onclose = null;
ws.close();
}
};
}, [apiUrl]);
return { snapshot, status, lastUpdate, messageCount, error };
}
@@ -0,0 +1,365 @@
/**
* CalculatorsPanel — collapsible panel with two calculators:
*
* - Eclipse: when does the sun get occluded for a given observer
* by a given body?
* - Overpass: when does a vessel come closest to a target
* (vessel / body / ground station)?
*
* The math lives in ./calculators/{eclipse,overpass}.ts and is
* tested independently.
*/
import { useMemo, useState } from 'react';
import type { UniverseSnapshot, Vessel } from '@kerbal-rt/shared-types';
import { findEclipseWindows, type EclipseWindow } from '../calculators/eclipse.js';
import { findOverpasses, type OverpassWindow, type Target } from '../calculators/overpass.js';
import { formatKspTime } from '../timeFormat.js';
export interface CalculatorsPanelProps {
snapshot: UniverseSnapshot;
/** UT to start scanning from. Usually the current scene UT. */
scanFromUt: number;
}
const PANEL_STYLE: React.CSSProperties = {
position: 'absolute',
bottom: 12,
left: '50%',
transform: 'translateX(-50%)',
width: 540,
maxHeight: 'calc(100vh - 200px)',
background: 'rgba(0,0,0,0.7)',
color: 'white',
borderRadius: 6,
fontFamily: 'monospace',
fontSize: 12,
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255,255,255,0.1)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
};
const HEADER_STYLE: React.CSSProperties = {
padding: '0.4rem 0.7rem',
display: 'flex',
alignItems: 'center',
gap: 6,
borderBottom: '1px solid rgba(255,255,255,0.1)',
cursor: 'pointer',
userSelect: 'none',
};
const TAB_STYLE: (active: boolean) => React.CSSProperties = (active) => ({
padding: '0.4rem 0.7rem',
cursor: 'pointer',
background: active ? 'rgba(255,255,255,0.12)' : 'transparent',
borderRight: '1px solid rgba(255,255,255,0.05)',
});
const BODY_STYLE: React.CSSProperties = {
padding: '0.6rem 0.7rem',
overflow: 'auto',
maxHeight: 280,
};
const ROW_STYLE: React.CSSProperties = {
display: 'flex',
gap: 4,
alignItems: 'center',
marginBottom: 4,
fontSize: 11,
};
const INPUT_STYLE: React.CSSProperties = {
background: 'rgba(0,0,0,0.4)',
color: 'white',
border: '1px solid rgba(255,255,255,0.15)',
padding: '0.15rem 0.3rem',
borderRadius: 3,
fontFamily: 'inherit',
fontSize: 11,
};
const RESULT_STYLE: React.CSSProperties = {
marginTop: 8,
padding: '0.3rem 0.5rem',
background: 'rgba(255,255,255,0.04)',
borderRadius: 3,
fontSize: 11,
};
const BUTTON_STYLE: React.CSSProperties = {
background: 'rgba(68, 170, 255, 0.2)',
border: '1px solid rgba(68, 170, 255, 0.4)',
color: 'white',
padding: '0.2rem 0.5rem',
borderRadius: 3,
cursor: 'pointer',
fontFamily: 'inherit',
fontSize: 11,
};
type Tab = 'eclipse' | 'overpass';
export function CalculatorsPanel({ snapshot, scanFromUt }: CalculatorsPanelProps) {
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<Tab>('eclipse');
return (
<div style={PANEL_STYLE}>
<div style={HEADER_STYLE} onClick={() => setOpen((o) => !o)}>
<span
style={{
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.05em',
fontSize: 10,
}}
>
Calculators
</span>
<div style={{ flex: 1 }} />
<span style={{ opacity: 0.6, fontSize: 10 }}>{open ? '▼' : '▲'}</span>
</div>
{open && (
<>
<div style={{ display: 'flex', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
<div style={TAB_STYLE(tab === 'eclipse')} onClick={() => setTab('eclipse')}>
Eclipse
</div>
<div style={TAB_STYLE(tab === 'overpass')} onClick={() => setTab('overpass')}>
Overpass
</div>
</div>
<div style={BODY_STYLE}>
{tab === 'eclipse' ? (
<EclipseTab snapshot={snapshot} scanFromUt={scanFromUt} />
) : (
<OverpassTab snapshot={snapshot} scanFromUt={scanFromUt} />
)}
</div>
</>
)}
</div>
);
}
// ─── Eclipse tab ──────────────────────────────────────────────────────────
function EclipseTab({ snapshot, scanFromUt }: { snapshot: UniverseSnapshot; scanFromUt: number }) {
const planets = snapshot.bodies.filter((b) => b.kind === 'planet');
const moons = snapshot.bodies.filter((b) => b.kind === 'moon');
const observers = [...planets, ...moons];
const eclipsers = snapshot.bodies.filter((b) => b.kind !== 'star');
const [observerId, setObserverId] = useState<string>(observers[0]?.id ?? '');
const [eclipserId, setEclipserId] = useState<string>(eclipsers[1]?.id ?? eclipsers[0]?.id ?? '');
const [result, setResult] = useState<EclipseWindow[] | null>(null);
const canRun = !!observerId && !!eclipserId && observerId !== eclipserId;
const calculate = () => {
if (!canRun) return;
const windows = findEclipseWindows(snapshot.bodies, {
observerId,
eclipserId,
startUt: scanFromUt,
count: 3,
});
setResult(windows);
};
return (
<div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 70 }}>Observer:</span>
<select
style={INPUT_STYLE}
value={observerId}
onChange={(e) => setObserverId(e.target.value)}
>
{observers.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 70 }}>Eclipser:</span>
<select
style={INPUT_STYLE}
value={eclipserId}
onChange={(e) => setEclipserId(e.target.value)}
>
{eclipsers.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 70 }}>From UT:</span>
<span style={{ opacity: 0.8 }}>{formatKspTime(scanFromUt)}</span>
</div>
<div style={ROW_STYLE}>
<button style={BUTTON_STYLE} onClick={calculate} disabled={!canRun}>
Calculate
</button>
</div>
{result !== null && (
<div>
{result.length === 0 ? (
<div style={{ ...RESULT_STYLE, opacity: 0.6 }}>
No eclipse windows found within 1 KSP year.
</div>
) : (
result.map((w, i) => (
<div key={i} style={RESULT_STYLE}>
<div>
#{i + 1} {formatKspTime(w.utStart)} {formatKspTime(w.utEnd)}
</div>
<div style={{ opacity: 0.7, fontSize: 10 }}>
peak at {formatKspTime(w.utPeak)}, max fraction {(w.maxFraction * 100).toFixed(0)}
%
</div>
</div>
))
)}
</div>
)}
</div>
);
}
// ─── Overpass tab ─────────────────────────────────────────────────────────
function OverpassTab({ snapshot, scanFromUt }: { snapshot: UniverseSnapshot; scanFromUt: number }) {
const vessels = snapshot.vessels;
const allBodies = snapshot.bodies;
const [observerId, setObserverId] = useState<string>(vessels[0]?.id ?? '');
const [targetKind, setTargetKind] = useState<'vessel' | 'body' | 'station'>('body');
const [targetId, setTargetId] = useState<string>(
allBodies.find((b) => b.kind === 'planet')?.id ?? '',
);
const [distanceKm, setDistanceKm] = useState(1000);
const [result, setResult] = useState<OverpassWindow[] | null>(null);
const observer = vessels.find((v: Vessel) => v.id === observerId);
const targetOptions = useMemo(() => {
if (targetKind === 'vessel') return vessels.filter((v) => v.id !== observerId);
if (targetKind === 'body') return allBodies.filter((b) => b.kind !== 'star');
return snapshot.groundStations;
}, [targetKind, observerId, vessels, allBodies, snapshot.groundStations]);
const target: Target | null = useMemo(() => {
if (!targetId) return null;
return {
kind: targetKind,
id: targetId,
name: targetOptions.find((o) => o.id === targetId)?.name ?? targetId,
};
}, [targetKind, targetId, targetOptions]);
const canRun = !!observer && !!target;
const calculate = () => {
if (!canRun || !target) return;
const passes = findOverpasses({
observer,
target,
bodies: snapshot.bodies,
vessels: snapshot.vessels,
groundStations: snapshot.groundStations,
startUt: scanFromUt,
count: 5,
distanceThreshold: distanceKm * 1000,
});
setResult(passes);
};
return (
<div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 80 }}>Observer:</span>
<select
style={INPUT_STYLE}
value={observerId}
onChange={(e) => setObserverId(e.target.value)}
>
{vessels.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
</div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 80 }}>Target kind:</span>
<select
style={INPUT_STYLE}
value={targetKind}
onChange={(e) => {
const k = e.target.value as 'vessel' | 'body' | 'station';
setTargetKind(k);
setTargetId(targetOptions[0]?.id ?? '');
}}
>
<option value="vessel">Vessel</option>
<option value="body">Body</option>
<option value="station">Ground station</option>
</select>
</div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 80 }}>Target:</span>
<select style={INPUT_STYLE} value={targetId} onChange={(e) => setTargetId(e.target.value)}>
{targetOptions.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</div>
<div style={ROW_STYLE}>
<span style={{ minWidth: 80 }}>Max dist:</span>
<input
type="number"
min={1}
step={100}
value={distanceKm}
onChange={(e) => setDistanceKm(Number(e.target.value))}
style={{ ...INPUT_STYLE, width: 90 }}
/>
<span style={{ opacity: 0.6, fontSize: 10 }}>km</span>
</div>
<div style={ROW_STYLE}>
<button style={BUTTON_STYLE} onClick={calculate} disabled={!canRun}>
Calculate
</button>
</div>
{result !== null && (
<div>
{result.length === 0 ? (
<div style={{ ...RESULT_STYLE, opacity: 0.6 }}>
No passes within {distanceKm} km in the next KSP year.
</div>
) : (
result.map((w, i) => (
<div key={i} style={RESULT_STYLE}>
<div>
#{i + 1} {formatKspTime(w.utPeak)}
</div>
<div style={{ opacity: 0.7, fontSize: 10 }}>
{target?.name}: min distance {(w.minDistance / 1000).toFixed(1)} km
</div>
</div>
))
)}
</div>
)}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
/**
* FocusPanel — toggles for which orbit-line categories are visible.
*/
export interface FocusPanelProps {
showPlanetOrbits: boolean;
showMoonOrbits: boolean;
showVesselOrbits: boolean;
onTogglePlanet: () => void;
onToggleMoon: () => void;
onToggleVessel: () => void;
}
const PANEL_STYLE: React.CSSProperties = {
position: 'absolute',
top: 12,
right: 12,
padding: '0.5rem 0.75rem',
background: 'rgba(0,0,0,0.7)',
color: 'white',
borderRadius: 6,
fontSize: 11,
fontFamily: 'monospace',
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255,255,255,0.1)',
display: 'flex',
flexDirection: 'column',
gap: 4,
};
const ROW_STYLE: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
cursor: 'pointer',
userSelect: 'none',
};
export function FocusPanel(props: FocusPanelProps) {
const {
showPlanetOrbits,
showMoonOrbits,
showVesselOrbits,
onTogglePlanet,
onToggleMoon,
onToggleVessel,
} = props;
return (
<div style={PANEL_STYLE}>
<div
style={{
fontSize: 10,
opacity: 0.6,
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 4,
}}
>
Orbits
</div>
<label style={ROW_STYLE}>
<input type="checkbox" checked={showPlanetOrbits} onChange={onTogglePlanet} />
Planets
</label>
<label style={ROW_STYLE}>
<input type="checkbox" checked={showMoonOrbits} onChange={onToggleMoon} />
Moons
</label>
<label style={ROW_STYLE}>
<input type="checkbox" checked={showVesselOrbits} onChange={onToggleVessel} />
Vessels
</label>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
/**
* StatusPill — connection + stale-data indicator.
*
* Shows:
* - WS connection state (open / connecting / closed / fallback)
* - "Data may be stale" warning if lastUpdate > STALE_THRESHOLD_S
* - Number of messages received
*/
import { Pill } from '@kerbal-rt/ui';
import type { ConnectionState } from '../hooks/useLiveState.js';
export interface StatusPillProps {
status: ConnectionState;
lastUpdate: string | null;
messageCount: number;
vesselCount: number;
bodyCount: number;
}
const STALE_THRESHOLD_S = 60;
export function StatusPill({
status,
lastUpdate,
messageCount,
vesselCount,
bodyCount,
}: StatusPillProps) {
const isStale = lastUpdate
? (Date.now() - new Date(lastUpdate).getTime()) / 1000 > STALE_THRESHOLD_S
: true;
const tone =
status === 'open' && !isStale
? 'success'
: status === 'connecting' || status === 'fallback'
? 'warn'
: 'danger';
const label =
status === 'open'
? isStale
? 'STALE'
: 'LIVE'
: status === 'connecting'
? 'CONNECTING'
: status === 'fallback'
? 'POLLING'
: 'OFFLINE';
return (
<div
style={{
position: 'absolute',
top: 12,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: 6,
padding: '0.4rem 0.6rem',
background: 'rgba(0,0,0,0.6)',
borderRadius: 6,
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255,255,255,0.1)',
fontFamily: 'monospace',
fontSize: 11,
}}
>
<Pill tone={tone}>{label}</Pill>
<Pill tone="neutral">{bodyCount} bodies</Pill>
<Pill tone="neutral">{vesselCount} vessels</Pill>
<Pill tone="neutral">{messageCount} msgs</Pill>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* TimeControls — play/pause, speed multiplier, reverse, reset,
* and a slider to scrub the simulation UT.
*
* Time source of truth: `ut`. The Scene re-propagates every time
* `ut` changes. Mock-telemetry advances UT at 1 KSP-second per
* wall-clock second; pressing ▶ at speed ×1 gives you live
* playback. Higher speeds fast-forward.
*/
import type { ChangeEvent } from 'react';
import { formatKspTime, formatKspDuration } from '../timeFormat.js';
export interface TimeControlsProps {
ut: number;
/** UT of the most recent server snapshot (the "live edge"). */
liveUt: number;
playing: boolean;
speed: number;
onPlayPause: () => void;
onSpeed: (s: number) => void;
onReverse: () => void;
onReset: () => void;
onScrub: (ut: number) => void;
}
const SPEEDS: { value: number; label: string }[] = [
{ value: 1, label: '×1' },
{ value: 10, label: '×10' },
{ value: 100, label: '×100' },
{ value: 1000, label: '×1k' },
{ value: 10000, label: '×10k' },
{ value: 100000, label: '×100k' },
];
const PANEL_STYLE: React.CSSProperties = {
position: 'absolute',
top: 12,
left: 12,
padding: '0.5rem 0.75rem',
background: 'rgba(0,0,0,0.7)',
color: 'white',
borderRadius: 6,
fontFamily: 'monospace',
fontSize: 12,
minWidth: 380,
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255,255,255,0.1)',
};
const BUTTON_STYLE: React.CSSProperties = {
background: 'rgba(255,255,255,0.08)',
border: '1px solid rgba(255,255,255,0.15)',
color: '#e6e6ee',
padding: '0.2rem 0.4rem',
borderRadius: 3,
cursor: 'pointer',
fontSize: 11,
};
const ACTIVE_BUTTON: React.CSSProperties = {
...BUTTON_STYLE,
fontWeight: 700,
background: 'rgba(255,255,255,0.2)',
};
export function TimeControls(props: TimeControlsProps) {
const { ut, liveUt, playing, speed, onPlayPause, onSpeed, onReverse, onReset, onScrub } = props;
const isLive = ut === liveUt;
const behind = liveUt - ut;
const behindLabel =
behind === 0
? 'live'
: behind > 0
? `${formatKspDuration(behind)} behind`
: `${formatKspDuration(-behind)} ahead`;
return (
<div style={PANEL_STYLE}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<button onClick={onPlayPause} style={BUTTON_STYLE} title={playing ? 'Pause' : 'Play'}>
{playing ? '⏸' : '▶'}
</button>
<button onClick={onReverse} style={BUTTON_STYLE} title="Reverse time">
</button>
<button onClick={onReset} style={BUTTON_STYLE} title="Snap to live">
</button>
<div style={{ flex: 1 }} />
{SPEEDS.map((s) => (
<button
key={s.value}
onClick={() => onSpeed(s.value)}
style={speed === s.value ? ACTIVE_BUTTON : BUTTON_STYLE}
>
{s.label}
</button>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11 }}>
<span style={{ opacity: 0.6 }}>UT</span>
<span style={{ minWidth: 120 }}>{formatKspTime(ut)}</span>
<input
type="range"
min={Math.max(0, liveUt - 5 * 365 * 24 * 3600)}
max={liveUt + 365 * 24 * 3600}
step={60}
value={ut}
onChange={(e: ChangeEvent<HTMLInputElement>) => onScrub(Number(e.target.value))}
style={{ flex: 1 }}
/>
<span
style={{
opacity: 0.7,
color: isLive ? '#7dff7d' : '#ffcc44',
minWidth: 90,
textAlign: 'right',
}}
>
{behindLabel}
</span>
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
/**
* VesselList — sidebar of currently-known vessels.
*
* Click a vessel to track it (the camera follows + the orbit is
* highlighted). Click again to untrack.
*/
import type { Vessel } from '@kerbal-rt/shared-types';
import { vesselColor } from '../scene/color.js';
export interface VesselListProps {
vessels: Vessel[];
selectedId: string | null;
onSelect: (id: string | null) => void;
}
const PANEL_STYLE: React.CSSProperties = {
position: 'absolute',
top: 110,
left: 12,
width: 280,
maxHeight: 'calc(100vh - 200px)',
overflow: 'auto',
padding: '0.5rem',
background: 'rgba(0,0,0,0.7)',
color: 'white',
borderRadius: 6,
fontSize: 12,
fontFamily: 'monospace',
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255,255,255,0.1)',
};
const ROW_STYLE: React.CSSProperties = {
padding: '0.4rem 0.5rem',
marginBottom: 2,
borderRadius: 3,
cursor: 'pointer',
background: 'rgba(255,255,255,0.04)',
border: '1px solid transparent',
display: 'flex',
alignItems: 'center',
gap: 6,
};
const ROW_ACTIVE: React.CSSProperties = {
...ROW_STYLE,
background: 'rgba(255,255,255,0.12)',
border: '1px solid rgba(255,255,255,0.3)',
};
const DOT_STYLE: (color: number) => React.CSSProperties = (color) => ({
width: 8,
height: 8,
borderRadius: '50%',
background: `#${color.toString(16).padStart(6, '0')}`,
flexShrink: 0,
});
export function VesselList({ vessels, selectedId, onSelect }: VesselListProps) {
const sorted = [...vessels].sort((a, b) => a.name.localeCompare(b.name));
return (
<div style={PANEL_STYLE}>
<div
style={{
fontSize: 10,
opacity: 0.6,
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 4,
}}
>
Vessels ({vessels.length})
</div>
{sorted.length === 0 ? (
<div style={{ opacity: 0.4, padding: 4 }}>No vessels yet.</div>
) : (
sorted.map((v) => {
const color = vesselColor(v.owner);
const isActive = selectedId === v.id;
return (
<div
key={v.id}
style={isActive ? ROW_ACTIVE : ROW_STYLE}
onClick={() => onSelect(isActive ? null : v.id)}
title={v.id}
>
<span style={DOT_STYLE(color)} />
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontWeight: isActive ? 700 : 400,
}}
>
{v.name}
</div>
<div style={{ opacity: 0.6, fontSize: 10 }}>
{v.owner ?? '—'} · {v.situation.toLowerCase()} · {v.referenceBodyId}
</div>
</div>
</div>
);
})
)}
</div>
);
}
+295
View File
@@ -0,0 +1,295 @@
/**
* Scene — the 3D Three.js rendering of the universe.
*
* - Builds body meshes, orbit lines, vessel markers
* - Adds atmospheric glow on planets
* - Uses CameraController for log-scale distance, mouse zoom,
* drag-to-rotate, click-to-track via raycasting
*/
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { sampleOrbit } from '@kerbal-rt/orbital-math';
import type { UniverseSnapshot } from '@kerbal-rt/shared-types';
import { bodyColor, vesselColor } from './color.js';
import { bodyPositionAt, vesselPositionAt } from './layout.js';
import { CameraController } from './camera.js';
import { createGlow } from './glow.js';
export interface SceneProps {
snapshot: UniverseSnapshot;
ut: number;
followId: string | null;
showPlanetOrbits: boolean;
showMoonOrbits: boolean;
showVesselOrbits: boolean;
onSelect: (id: string | null) => void;
}
interface SceneRefs {
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
renderer: THREE.WebGLRenderer;
bodyMeshes: Map<string, THREE.Mesh>;
vesselMeshes: Map<string, THREE.Mesh>;
orbitLines: Map<string, THREE.Line>;
controller: CameraController;
raf: number;
}
const ORBIT_OPACITY: Record<string, number> = {
planet: 0.5,
moon: 0.4,
vessel: 0.6,
};
export function Scene(props: SceneProps) {
const { snapshot, ut, followId, showPlanetOrbits, showMoonOrbits, showVesselOrbits, onSelect } =
props;
const mountRef = useRef<HTMLDivElement>(null);
const refsRef = useRef<SceneRefs | null>(null);
// Latest-snapshot / ut / followId refs so the camera controller
// always sees the current values without needing to be recreated.
const stateRef = useRef({ snapshot, ut, followId, onSelect });
stateRef.current = { snapshot, ut, followId, onSelect };
// One-time scene setup
useEffect(() => {
const mount = mountRef.current;
if (!mount) return;
const refs = createScene(mount, stateRef);
refsRef.current = refs;
const onResize = () => {
const r = refsRef.current;
if (!r) return;
const w = mount.clientWidth;
const h = mount.clientHeight;
r.camera.aspect = w / h;
r.camera.updateProjectionMatrix();
r.renderer.setSize(w, h);
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
cancelAnimationFrame(refs.raf);
refs.controller.dispose();
refs.renderer.dispose();
if (mount.contains(refs.renderer.domElement)) {
mount.removeChild(refs.renderer.domElement);
}
};
}, []);
// Rebuild meshes when bodies/vessels set changes
useEffect(() => {
const refs = refsRef.current;
if (!refs) return;
rebuildMeshes(refs, snapshot);
}, [snapshot.bodies, snapshot.vessels, snapshot]);
// Toggle orbit visibility
useEffect(() => {
const refs = refsRef.current;
if (!refs) return;
for (const [id, line] of refs.orbitLines.entries()) {
const isPlanet = snapshot.bodies.find((b) => b.id === id && b.kind === 'planet');
const isMoon = snapshot.bodies.find((b) => b.id === id && b.kind === 'moon');
if (isPlanet) line.visible = showPlanetOrbits;
else if (isMoon) line.visible = showMoonOrbits;
else line.visible = showVesselOrbits;
}
}, [showPlanetOrbits, showMoonOrbits, showVesselOrbits, snapshot.bodies]);
// Per-frame
useEffect(() => {
const refs = refsRef.current;
if (!refs) return;
let lastUt = Number.NEGATIVE_INFINITY;
const render = () => {
const cur = stateRef.current;
if (cur.ut !== lastUt) {
lastUt = cur.ut;
positionMeshes(refs, cur.snapshot, cur.ut);
}
refs.controller.update();
refs.renderer.render(refs.scene, refs.camera);
refs.raf = requestAnimationFrame(render);
};
refs.raf = requestAnimationFrame(render);
return () => cancelAnimationFrame(refs.raf);
}, []);
return <div ref={mountRef} style={{ width: '100%', height: '100%', cursor: 'grab' }} />;
}
// ─── Three.js setup helpers ────────────────────────────────────────────────
function createScene(
mount: HTMLDivElement,
stateRef: React.MutableRefObject<{
snapshot: UniverseSnapshot;
ut: number;
followId: string | null;
onSelect: (id: string | null) => void;
}>,
): SceneRefs {
const width = mount.clientWidth;
const height = mount.clientHeight;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000005);
const camera = new THREE.PerspectiveCamera(60, width / height, 1e6, 1e12);
camera.position.set(0, 5e9, 1.5e10);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
mount.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0x404040, 0.4));
scene.add(new THREE.PointLight(0xffffff, 2, 0, 0));
const controller = new CameraController({
camera,
domElement: mount,
getSnapshot: () => stateRef.current.snapshot,
getUt: () => stateRef.current.ut,
getFollowId: () => stateRef.current.followId,
onSelect: (id) => stateRef.current.onSelect(id),
});
return {
scene,
camera,
renderer,
bodyMeshes: new Map(),
vesselMeshes: new Map(),
orbitLines: new Map(),
controller,
raf: 0,
};
}
function rebuildMeshes(refs: SceneRefs, snap: UniverseSnapshot): void {
for (const mesh of refs.bodyMeshes.values()) {
refs.scene.remove(mesh);
mesh.geometry.dispose();
if (Array.isArray(mesh.material)) {
mesh.material.forEach((m) => m.dispose());
} else {
(mesh.material as THREE.Material).dispose();
}
}
for (const mesh of refs.vesselMeshes.values()) {
refs.scene.remove(mesh);
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
}
for (const line of refs.orbitLines.values()) {
refs.scene.remove(line);
line.geometry.dispose();
(line.material as THREE.Material).dispose();
}
refs.bodyMeshes.clear();
refs.vesselMeshes.clear();
refs.orbitLines.clear();
for (const body of snap.bodies) {
if (body.kind === 'star') {
const geo = new THREE.SphereGeometry(Math.max(body.radius, 1e8), 32, 16);
const mat = new THREE.MeshBasicMaterial({ color: bodyColor(body.id) });
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { id: body.id };
refs.scene.add(mesh);
refs.bodyMeshes.set(body.id, mesh);
continue;
}
if (body.parentId === null) continue;
const displayRadius = Math.max(body.radius, 1e6);
const geo = new THREE.SphereGeometry(displayRadius, 32, 16);
const mat = new THREE.MeshPhongMaterial({
color: bodyColor(body.id),
emissive: 0x111111,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { id: body.id };
refs.scene.add(mesh);
refs.bodyMeshes.set(body.id, mesh);
// Atmospheric glow for planets/moons (skip very small bodies)
if (body.kind === 'planet' || (body.kind === 'moon' && body.radius > 100_000)) {
const glow = createGlow(body.radius, bodyColor(body.id), 0.35, 1.35);
mesh.add(glow); // attach as child so it follows position
}
// Orbit line
const points = sampleOrbit(body.orbit, body.gravitationalParameter, 256);
const positions = new Float32Array(points.length * 3);
points.forEach((p, i) => {
positions[i * 3] = p.x;
positions[i * 3 + 1] = p.y;
positions[i * 3 + 2] = p.z;
});
const lineGeo = new THREE.BufferGeometry();
lineGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const lineMat = new THREE.LineBasicMaterial({
color: bodyColor(body.id),
opacity: ORBIT_OPACITY[body.kind] ?? 0.5,
transparent: true,
});
const line = new THREE.LineLoop(lineGeo, lineMat);
refs.scene.add(line);
refs.orbitLines.set(body.id, line);
}
for (const vessel of snap.vessels) {
const geo = new THREE.SphereGeometry(2e5, 12, 8);
const mat = new THREE.MeshBasicMaterial({ color: vesselColor(vessel.owner) });
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { id: vessel.id };
refs.scene.add(mesh);
refs.vesselMeshes.set(vessel.id, mesh);
if (vessel.referenceBodyId) {
const ref = snap.bodies.find((b) => b.id === vessel.referenceBodyId);
if (ref) {
const points = sampleOrbit(vessel.orbit, ref.gravitationalParameter, 128);
const positions = new Float32Array(points.length * 3);
points.forEach((p, i) => {
positions[i * 3] = p.x;
positions[i * 3 + 1] = p.y;
positions[i * 3 + 2] = p.z;
});
const lineGeo = new THREE.BufferGeometry();
lineGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const lineMat = new THREE.LineBasicMaterial({
color: vesselColor(vessel.owner),
opacity: 0.5,
transparent: true,
});
const line = new THREE.LineLoop(lineGeo, lineMat);
refs.scene.add(line);
refs.orbitLines.set(vessel.id, line);
}
}
}
}
function positionMeshes(refs: SceneRefs, snap: UniverseSnapshot, ut: number): void {
for (const body of snap.bodies) {
if (body.kind === 'star' || body.parentId === null) continue;
const pos = bodyPositionAt(snap.bodies, body.id, ut);
const mesh = refs.bodyMeshes.get(body.id);
if (mesh) mesh.position.set(pos.x, pos.y, pos.z);
}
for (const vessel of snap.vessels) {
const pos = vesselPositionAt(snap.bodies, vessel, ut);
const mesh = refs.vesselMeshes.get(vessel.id);
if (mesh) mesh.position.set(pos.x, pos.y, pos.z);
}
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Camera controller — log-scale distance, mouse wheel zoom,
* drag-to-rotate, click-to-track via raycasting.
*
* Three modes:
* - 'free' : user-controlled, no target
* - 'follow' : tracks a vessel/body (smooth lerp to position)
*
* Log-scale: the user operates in "zoom levels" z ∈ [-3, 12]. We
* map z → camera distance via d = exp(z) * 1e8 m. This gives a smooth
* range from ~50 Mm to ~1 Tm, covering Kerbin (13.6 Gm) to Jool (68.8 Gm)
* with reasonable framing.
*/
import * as THREE from 'three';
import type { UniverseSnapshot, CelestialBody, Vessel } from '@kerbal-rt/shared-types';
import { bodyPositionAt, vesselPositionAt } from './layout.js';
import { inverseLogScale } from './layout.js';
export interface CameraControllerOptions {
camera: THREE.PerspectiveCamera;
domElement: HTMLElement;
getSnapshot: () => UniverseSnapshot;
getUt: () => number;
getFollowId: () => string | null;
/** Notify host when a vessel/body is clicked in the scene. */
onSelect: (id: string | null) => void;
}
export class CameraController {
private opts: CameraControllerOptions;
/** Camera "zoom level" — log-scale distance from origin/target. */
private zoomLevel = inverseLogScale(1.5e10);
/** Spherical coords around the current target. */
private azimuth = 0;
private elevation = 0.3;
private target = new THREE.Vector3(0, 0, 0);
/** "free" or "follow" */
private follow: boolean;
/** Last computed distance (for follow lerp). */
private desiredDistance = 1.5e10;
// Mouse state
private dragging = false;
private lastX = 0;
private lastY = 0;
private pointerDownPos: { x: number; y: number } | null = null;
constructor(opts: CameraControllerOptions) {
this.opts = opts;
this.follow = opts.getFollowId() !== null;
const dom = opts.domElement;
dom.style.touchAction = 'none';
dom.addEventListener('mousedown', this.onMouseDown);
dom.addEventListener('mousemove', this.onMouseMove);
window.addEventListener('mouseup', this.onMouseUp);
dom.addEventListener('wheel', this.onWheel, { passive: false });
dom.addEventListener('click', this.onClick);
}
dispose(): void {
const dom = this.opts.domElement;
dom.removeEventListener('mousedown', this.onMouseDown);
dom.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('mouseup', this.onMouseUp);
dom.removeEventListener('wheel', this.onWheel);
dom.removeEventListener('click', this.onClick);
}
/** Call once per frame to keep the camera in sync. */
update(): void {
const id = this.opts.getFollowId();
const wantFollow = id !== null;
if (wantFollow !== this.follow) {
this.follow = wantFollow;
}
if (this.follow && id) {
const pos = this.resolveTargetPosition(id);
if (pos) {
this.target.lerp(new THREE.Vector3(pos.x, pos.y, pos.z), 0.1);
// Zoom level is preserved (user zoom still works)
this.desiredDistance = this.distanceForZoom();
}
} else {
// Free mode: keep current target, just orbit around it
this.desiredDistance = this.distanceForZoom();
}
// Position the camera at (target + offset) where offset is
// determined by spherical coords + distance.
const sinE = Math.sin(this.elevation);
const cosE = Math.cos(this.elevation);
const sinA = Math.sin(this.azimuth);
const cosA = Math.cos(this.azimuth);
const offset = new THREE.Vector3(
this.desiredDistance * cosE * sinA,
this.desiredDistance * sinE,
this.desiredDistance * cosE * cosA,
);
const desiredPos = this.target.clone().add(offset);
this.opts.camera.position.lerp(desiredPos, 0.1);
this.opts.camera.lookAt(this.target);
}
/** Expose current target + distance for tests / external code. */
getState(): { target: THREE.Vector3; distance: number; zoomLevel: number } {
return {
target: this.target.clone(),
distance: this.desiredDistance,
zoomLevel: this.zoomLevel,
};
}
// ── helpers ──────────────────────────────────────────────────────────
private distanceForZoom(): number {
return Math.exp(this.zoomLevel) * 1e8;
}
private resolveTargetPosition(id: string): { x: number; y: number; z: number } | null {
const snap = this.opts.getSnapshot();
const ut = this.opts.getUt();
const vessel = snap.vessels.find((v: Vessel) => v.id === id);
if (vessel) return vesselPositionAt(snap.bodies, vessel, ut);
const body = snap.bodies.find((b: CelestialBody) => b.id === id);
if (body) return bodyPositionAt(snap.bodies, id, ut);
return null;
}
// ── event handlers ──────────────────────────────────────────────────
private onMouseDown = (e: MouseEvent): void => {
if (e.button !== 0) return;
this.dragging = true;
this.lastX = e.clientX;
this.lastY = e.clientY;
this.pointerDownPos = { x: e.clientX, y: e.clientY };
};
private onMouseMove = (e: MouseEvent): void => {
if (!this.dragging) return;
const dx = e.clientX - this.lastX;
const dy = e.clientY - this.lastY;
this.lastX = e.clientX;
this.lastY = e.clientY;
// Sensitivity scaled to viewport
const sens = 0.005;
this.azimuth -= dx * sens;
this.elevation += dy * sens;
// Clamp elevation to avoid gimbal flip
const HALF_PI = Math.PI / 2 - 0.05;
if (this.elevation > HALF_PI) this.elevation = HALF_PI;
if (this.elevation < -HALF_PI) this.elevation = -HALF_PI;
};
private onMouseUp = (): void => {
this.dragging = false;
};
private onWheel = (e: WheelEvent): void => {
e.preventDefault();
// Positive deltaY = scroll down = zoom out (increase distance)
const delta = e.deltaY * 0.001;
this.zoomLevel = Math.max(-3, Math.min(12, this.zoomLevel + delta));
};
private onClick = (e: MouseEvent): void => {
// Only treat as a click if the pointer barely moved (not a drag)
if (!this.pointerDownPos) return;
const dx = e.clientX - this.pointerDownPos.x;
const dy = e.clientY - this.pointerDownPos.y;
if (Math.hypot(dx, dy) > 5) {
this.pointerDownPos = null;
return;
}
this.pointerDownPos = null;
// Raycast against body and vessel meshes
const dom = this.opts.domElement;
const rect = dom.getBoundingClientRect();
const ndc = new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1,
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(ndc, this.opts.camera);
// Build a list of {mesh, id} from the scene
const hits: { id: string; dist: number }[] = [];
const snap = this.opts.getSnapshot();
raycaster.intersectObjects(this.opts.camera.parent?.children ?? [], true).forEach((hit) => {
const id = (hit.object.userData as { id?: string }).id;
if (id) hits.push({ id, dist: hit.distance });
});
if (hits.length === 0) {
this.opts.onSelect(null);
return;
}
hits.sort((a, b) => a.dist - b.dist);
// Toggle: if clicking the same selected object, deselect
const current = this.opts.getFollowId();
if (current && hits[0] && hits[0].id === current) {
this.opts.onSelect(null);
} else {
this.opts.onSelect(hits[0]?.id ?? null);
}
// Snap zoom to a reasonable level for the new target
if (hits[0]) {
const pos = this.resolveTargetPosition(hits[0].id);
if (pos) {
this.target.set(pos.x, pos.y, pos.z);
// Set zoom based on body size (bodies need more zoom out)
const body = snap.bodies.find((b: CelestialBody) => b.id === hits[0]?.id);
if (body) {
this.zoomLevel = inverseLogScale(body.radius * 10);
} else {
this.zoomLevel = inverseLogScale(5e6);
}
}
}
};
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Color-coding for celestial bodies. Identical to the catalog in
* apps/tools/mock-telemetry/src/catalog.ts so the mock and the
* renderer agree.
*
* Returns a Three.js-friendly 0xRRGGBB number.
*/
export function bodyColor(id: string): number {
const map: Record<string, number> = {
kerbol: 0xffcc33,
kerbin: 0x3a7d8c,
mun: 0xaaaaaa,
minmus: 0x997a66,
duna: 0xc46030,
ike: 0x776655,
eve: 0x6b4ea0,
gilly: 0x665544,
jool: 0xa55a2a,
laythe: 0x4a6da0,
vall: 0x665544,
tylo: 0x997a66,
bop: 0x444444,
pol: 0x333333,
moho: 0x664433,
dres: 0x665544,
eeloo: 0xeeeeff,
};
return map[id] ?? 0xffffff;
}
/**
* Color-coding for vessels by owner agency.
*/
export function vesselColor(owner: string | null): number {
const map: Record<string, number> = {
KASA: 0x44aaff, // blue
SPES: 0xff6644, // orange
};
return map[owner ?? ''] ?? 0xcccccc;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Atmospheric glow — a simple additive shell around a body that fades
* from a small radius to a larger one. Looks like a soft halo when
* viewed from any angle.
*
* Implementation: a slightly larger sphere with a custom shader that
* fades from opaque at the inner edge to transparent at the outer edge.
* The fade uses view-direction dot product so we always see the rim.
*/
import * as THREE from 'three';
const VERTEX = /* glsl */ `
varying vec3 vNormal;
varying vec3 vViewDir;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPosition;
vNormal = normalize(normalMatrix * normal);
vViewDir = normalize(-mvPosition.xyz);
}
`;
const FRAGMENT = /* glsl */ `
uniform vec3 uColor;
uniform float uIntensity;
varying vec3 vNormal;
varying vec3 vViewDir;
void main() {
// Strongest at the rim (where the surface is parallel to view)
float rim = 1.0 - max(0.0, dot(vNormal, vViewDir));
rim = pow(rim, 2.5); // sharpen the falloff
gl_FragColor = vec4(uColor * rim * uIntensity, rim);
}
`;
/**
* Create a glow shell mesh for a body. Add it to the scene as a child
* of the body so it follows the body's transform.
*
* @param bodyRadius the actual radius of the body
* @param color the glow color
* @param intensity brightness multiplier (0..1, default 0.4)
* @param scaleFactor how much bigger than the body to make the shell
*/
export function createGlow(
bodyRadius: number,
color: number,
intensity = 0.4,
scaleFactor = 1.4,
): THREE.Mesh {
const inner = Math.max(bodyRadius, 1e6);
const outer = inner * scaleFactor;
const geo = new THREE.SphereGeometry(outer, 32, 16);
const mat = new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color(color) },
uIntensity: { value: intensity },
},
vertexShader: VERTEX,
fragmentShader: FRAGMENT,
transparent: true,
blending: THREE.AdditiveBlending,
side: THREE.BackSide, // render the back side, so the rim shows on the outside
depthWrite: false,
});
return new THREE.Mesh(geo, mat);
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Layout helpers for the 3D scene.
*
* - bodies are positioned in heliocentric inertial frame, in meters
* - the KSP system is huge (Eeloo is ~9e10 m from Kerbol) so we
* render with a logarithmic distance scale for the camera radius
* to keep the inner planets visible alongside the outer ones
*/
import type { CelestialBody, Vessel } from '@kerbal-rt/shared-types';
import { positionAt } from '@kerbal-rt/orbital-math';
/** Find a body's gravitational parameter (μ) given its id.
* Misleadingly named "findParentMu" historically; the function
* returns the body's own μ, used for propagating vessels around it. */
export function findBodyMu(bodies: CelestialBody[], id: string | null): number {
if (!id) return 0;
const body = bodies.find((b) => b.id === id);
return body?.gravitationalParameter ?? 0;
}
/**
* Position of a body in the heliocentric inertial frame, propagated
* to the given UT. Walks up the parent chain so the result is the
* true absolute position, not the parent-relative position.
*/
export function bodyPositionAt(
bodies: CelestialBody[],
bodyId: string,
ut: number,
): { x: number; y: number; z: number } {
const body = bodies.find((b) => b.id === bodyId);
if (!body) return { x: 0, y: 0, z: 0 };
if (!body.parentId) return { x: 0, y: 0, z: 0 }; // system root
const parent = bodies.find((b) => b.id === body.parentId);
if (!parent) return { x: 0, y: 0, z: 0 };
const parentPos = bodyPositionAt(bodies, parent.id, ut);
const local = positionAt(body.orbit, parent.gravitationalParameter, ut);
return {
x: parentPos.x + local.x,
y: parentPos.y + local.y,
z: parentPos.z + local.z,
};
}
/** Position of a vessel, propagated to UT, in the heliocentric frame. */
export function vesselPositionAt(
bodies: CelestialBody[],
vessel: Vessel,
ut: number,
): { x: number; y: number; z: number } {
const refMu = findBodyMu(bodies, vessel.referenceBodyId);
const refPos = bodyPositionAt(bodies, vessel.referenceBodyId, ut);
const local = positionAt(vessel.orbit, refMu, ut);
return {
x: refPos.x + local.x,
y: refPos.y + local.y,
z: refPos.z + local.z,
};
}
/**
* Log-scaled camera distance. Maps a desired real distance to a
* Three.js camera position that keeps both inner and outer planets
* visible. d_real = exp(t) * 1e8 m → e.g. for t=4, distance=5.5e9 m.
*/
export function logScale(t: number): number {
return Math.exp(t) * 1e8;
}
export function inverseLogScale(d: number): number {
return Math.log(d / 1e8);
}
/** A reasonable initial camera radius that shows the inner planets. */
export const DEFAULT_CAMERA_RADIUS = 1.5e10;
+28
View File
@@ -0,0 +1,28 @@
/**
* KSP time formatting helpers — 1 KSP day = 6 hours, 1 KSP year = 426 days.
* Source: stock KSP config.
*/
const KSP_DAY_SECONDS = 6 * 3600;
const KSP_YEAR_DAYS = 426;
/** Format a UT value as "Y1 D001 12:34". */
export function formatKspTime(ut: number): string {
const totalDays = ut / KSP_DAY_SECONDS;
const year = Math.floor(totalDays / KSP_YEAR_DAYS) + 1;
const day = (Math.floor(totalDays) % KSP_YEAR_DAYS) + 1;
const secondsInDay = ut % KSP_DAY_SECONDS;
const h = Math.floor(secondsInDay / 3600);
const m = Math.floor((secondsInDay % 3600) / 60);
return `Y${year} D${String(day).padStart(3, '0')} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
/** Format a duration in seconds as a human-readable string. */
export function formatKspDuration(seconds: number): string {
const abs = Math.abs(seconds);
if (abs < 60) return `${Math.floor(abs)}s`;
if (abs < 3600) return `${Math.floor(abs / 60)}m`;
if (abs < 86400) return `${(abs / 3600).toFixed(1)}h`;
if (abs < KSP_YEAR_DAYS * KSP_DAY_SECONDS) return `${(abs / 86400).toFixed(1)}d`;
return `${(abs / (KSP_YEAR_DAYS * KSP_DAY_SECONDS)).toFixed(1)}y`;
}
+305
View File
@@ -0,0 +1,305 @@
import { describe, it, expect } from 'vitest';
import { findEclipseWindows, computeShadowFraction } from '../src/calculators/eclipse.js';
import { findOverpasses, type Target } from '../src/calculators/overpass.js';
import type { CelestialBody, GroundStation, Vessel } from '@kerbal-rt/shared-types';
const KSP_DAY = 6 * 3600;
// ─── Minimal solar system: Kerbol + Kerbin + Mun (a simple eclipse scenario)
const kerbol: CelestialBody = {
id: 'kerbol',
name: 'Kerbol',
kind: 'star',
parentId: null,
radius: 261_600_000,
sphereOfInfluence: 1e30,
gravitationalParameter: 1.172e18,
rotationPeriod: 432_000,
axialTilt: 0,
orbit: {
semiMajorAxis: 0,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
};
// Kerbin in a circular orbit at 13.6e9 m
const kerbin: CelestialBody = {
id: 'kerbin',
name: 'Kerbin',
kind: 'planet',
parentId: 'kerbol',
radius: 600_000,
sphereOfInfluence: 84_159_286,
gravitationalParameter: 3.5316e12,
rotationPeriod: 21_600,
axialTilt: 0,
orbit: {
semiMajorAxis: 13_599_840_256,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
};
// Mun in a circular orbit around Kerbin at 12e6 m
const mun: CelestialBody = {
id: 'mun',
name: 'Mun',
kind: 'moon',
parentId: 'kerbin',
radius: 200_000,
sphereOfInfluence: 2_429_559,
gravitationalParameter: 6.514e10,
rotationPeriod: 138_984,
axialTilt: 0,
orbit: {
semiMajorAxis: 12_000_000,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0, // start at (12e6, 0, 0) in kerbin frame
epoch: 0,
},
};
const systemBodies = [kerbol, kerbin, mun];
describe('computeShadowFraction', () => {
it('returns 0 when eclipser is on the far side of the observer from the sun', () => {
// t=0, Mun meanAnomalyAtEpoch=0 → Mun at (+12e6, 0, 0) in kerbin frame
// → Mun world position (13.6e9 + 12e6, 0, 0) — BEHIND Kerbin from the sun.
// Sun is at (-x) from Kerbin; Mun is at (+x). No eclipse.
const munBehind: CelestialBody = {
...mun,
orbit: { ...mun.orbit, meanAnomalyAtEpoch: 0 },
};
const sys = [kerbol, kerbin, munBehind];
const f = computeShadowFraction(sys, 'kerbin', 'mun', 0);
expect(f).toBe(0);
});
it('returns high fraction when occluder sits between observer and sun', () => {
// Set up Mun directly between Kerbin and Kerbol (anti-aligned).
// Mun's meanAnomalyAtEpoch = π → Mun at (-12e6, 0, 0) in kerbin frame,
// i.e. world position (13.6e9 - 12e6, 0, 0). Sun at (0,0,0).
// Kerbin is at (13.6e9, 0, 0). So Mun is between them.
const munAntialigned: CelestialBody = {
...mun,
orbit: { ...mun.orbit, meanAnomalyAtEpoch: Math.PI },
};
const sys = [kerbol, kerbin, munAntialigned];
const f = computeShadowFraction(sys, 'kerbin', 'mun', 0);
// Should be ≥ 0.5 (Mun is ~0.6 Mm radius, observer is 12 Mm from it;
// angular size is small but the center of Mun is exactly on the
// sun-line so the umbra is total)
expect(f).toBeGreaterThan(0.5);
});
it('returns 0 for self-eclipse (observer == eclipser)', () => {
// findEclipseWindows early-returns on this, but computeShadowFraction
// would compute a 1.0 trivially. Either is fine; just verify the API
// returns a number.
const f = computeShadowFraction(systemBodies, 'kerbin', 'kerbin', 0);
expect(typeof f).toBe('number');
});
it('returns 1 when occluder is exactly on the sun-line (centered eclipse)', () => {
// Mun anti-aligned → directly between Kerbin and Kerbol at t=0
const munAntialigned: CelestialBody = {
...mun,
orbit: { ...mun.orbit, meanAnomalyAtEpoch: Math.PI },
};
const sys = [kerbol, kerbin, munAntialigned];
const f = computeShadowFraction(sys, 'kerbin', 'mun', 0);
expect(f).toBeGreaterThan(0.99);
});
it('returns a value in [0, 1]', () => {
const f = computeShadowFraction(systemBodies, 'kerbin', 'mun', 0);
expect(f).toBeGreaterThanOrEqual(0);
expect(f).toBeLessThanOrEqual(1);
});
});
describe('findEclipseWindows', () => {
it('returns empty array for self-eclipse', () => {
const w = findEclipseWindows(systemBodies, {
observerId: 'kerbin',
eclipserId: 'kerbin',
startUt: 0,
});
expect(w).toEqual([]);
});
it('returns empty array for unknown bodies', () => {
const w = findEclipseWindows(systemBodies, {
observerId: 'kerbin',
eclipserId: 'unknown',
startUt: 0,
});
expect(w).toEqual([]);
});
it('finds an eclipse window when Mun passes between Kerbin and Kerbol', () => {
// Set up a system where Mun is currently in front of the sun from Kerbin's
// perspective. The Mun orbits Kerbin in ~6.8 days, so we should find
// an eclipse within a few days of t=0.
const sys = [
kerbol,
kerbin,
{ ...mun, orbit: { ...mun.orbit, meanAnomalyAtEpoch: Math.PI } }, // start anti-aligned
];
const windows = findEclipseWindows(sys, {
observerId: 'kerbin',
eclipserId: 'mun',
startUt: 0,
count: 1,
stepSec: 300, // 5 KSP minutes for the coarse scan
});
expect(windows.length).toBeGreaterThan(0);
if (windows[0]) {
expect(windows[0].utStart).toBeGreaterThanOrEqual(0);
expect(windows[0].utEnd).toBeGreaterThan(windows[0].utStart);
expect(windows[0].utPeak).toBeGreaterThanOrEqual(windows[0].utStart);
expect(windows[0].utPeak).toBeLessThanOrEqual(windows[0].utEnd);
expect(windows[0].maxFraction).toBeGreaterThan(0);
}
});
});
// ─── Overpass tests ───────────────────────────────────────────────────────
const vesselA: Vessel = {
id: 'v-a',
name: 'Vessel A',
type: 'Probe',
owner: 'KASA',
situation: 'ORBITING',
status: 'ACTIVE',
orbit: {
semiMajorAxis: 7_000_000,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
referenceBodyId: 'kerbin',
createdAt: '2026-01-01T00:00:00Z',
retiredAt: null,
};
const vesselB: Vessel = {
id: 'v-b',
name: 'Vessel B',
type: 'Probe',
owner: 'SPES',
situation: 'ORBITING',
status: 'ACTIVE',
orbit: {
semiMajorAxis: 7_000_000,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: Math.PI, // opposite side
epoch: 0,
},
referenceBodyId: 'kerbin',
createdAt: '2026-01-01T00:00:00Z',
retiredAt: null,
};
const station: GroundStation = {
id: 'montana',
name: 'Montana DSN',
bodyId: 'kerbin',
lat: 47.0,
lon: -110.0,
alt: 1200,
};
describe('findOverpasses', () => {
it('finds a close approach between two vessels in different orbits', () => {
// Two vessels in different circular orbits around Kerbin. They will
// occasionally align and approach each other. Use a small step to
// catch the close approach.
const vesselBDifferent: Vessel = {
...vesselB,
orbit: { ...vesselB.orbit, semiMajorAxis: 7_500_000 }, // different SMA
};
const passes = findOverpasses({
observer: vesselA,
target: { kind: 'vessel', id: 'v-b', name: 'Vessel B' },
bodies: systemBodies,
vessels: [vesselA, vesselBDifferent],
groundStations: [],
startUt: 0,
count: 1,
stepSec: 60, // 1 min coarse scan
distanceThreshold: 5_000_000, // 5000 km
maxSearchTime: 426 * 6 * 3600, // 1 KSP year
});
expect(passes.length).toBeGreaterThan(0);
if (passes[0]) {
expect(passes[0].minDistance).toBeLessThan(5_000_000);
expect(passes[0].utPeak).toBeGreaterThan(0);
}
});
it('handles body target (observer passes near a body)', () => {
// Vessel A is in LKO around Kerbin, so distance to Mun varies a lot.
// We should find at least one "close" approach (within 100 Mm).
const passes = findOverpasses({
observer: vesselA,
target: { kind: 'body', id: 'mun', name: 'Mun' },
bodies: systemBodies,
vessels: [vesselA],
groundStations: [],
startUt: 0,
count: 1,
stepSec: 3600,
distanceThreshold: 100_000_000, // 100 Mm
});
// Just verify the API works; we can't easily assert on the value
expect(Array.isArray(passes)).toBe(true);
});
it('handles ground station target', () => {
const passes = findOverpasses({
observer: vesselA,
target: { kind: 'station', id: 'montana', name: 'Montana' },
bodies: systemBodies,
vessels: [vesselA],
groundStations: [station],
startUt: 0,
count: 1,
stepSec: 60,
distanceThreshold: 50_000_000, // 50 Mm
});
expect(Array.isArray(passes)).toBe(true);
});
it('returns empty when target is unknown', () => {
const target: Target = { kind: 'vessel', id: 'unknown', name: '?' };
const passes = findOverpasses({
observer: vesselA,
target,
bodies: systemBodies,
vessels: [vesselA],
groundStations: [],
startUt: 0,
});
expect(passes).toEqual([]);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { inverseLogScale, logScale } from '../src/scene/layout.js';
describe('camera log-scale', () => {
it('inverseLogScale undoes logScale', () => {
for (const t of [-3, 0, 4, 8, 12]) {
expect(inverseLogScale(logScale(t))).toBeCloseTo(t, 6);
}
});
it('produces distances spanning the KSP system', () => {
// t = 0: 1e8 m = 100 Mm (close zoom)
expect(logScale(0)).toBeCloseTo(1e8, -3);
// t = 4: ~5.5e9 m (Kerbin at 13.6 Gm is just outside)
expect(logScale(4)).toBeGreaterThan(1e9);
// t = 10: ~22e12 m (Eeloo at 90 Gm is well inside)
expect(logScale(10)).toBeGreaterThan(1e10);
// t = 12: ~1.6e13 m (max zoom)
expect(logScale(12)).toBeGreaterThan(1e12);
});
});
describe('spherical math (used by camera)', () => {
// The camera controller uses spherical coords. Test the
// cartesian conversion (extracted for testability).
function sphericalToCartesian(distance: number, az: number, el: number) {
const sinE = Math.sin(el);
const cosE = Math.cos(el);
const sinA = Math.sin(az);
const cosA = Math.cos(az);
return {
x: distance * cosE * sinA,
y: distance * sinE,
z: distance * cosE * cosA,
};
}
it('produces a point on the sphere of given radius', () => {
for (const az of [0, 1, 2.5, 4.7]) {
for (const el of [-0.5, 0, 0.7]) {
const p = sphericalToCartesian(1e10, az, el);
const d = Math.hypot(p.x, p.y, p.z);
expect(d).toBeCloseTo(1e10, 4);
}
}
});
it('azimuth=0, elevation=0 produces +Z vector', () => {
const p = sphericalToCartesian(100, 0, 0);
expect(p.z).toBeCloseTo(100, 6);
expect(p.x).toBeCloseTo(0, 6);
expect(p.y).toBeCloseTo(0, 6);
});
it('azimuth=π/2, elevation=0 produces +X vector', () => {
const p = sphericalToCartesian(100, Math.PI / 2, 0);
expect(p.x).toBeCloseTo(100, 6);
expect(p.z).toBeCloseTo(0, 6);
expect(p.y).toBeCloseTo(0, 6);
});
it('elevation=π/2 produces +Y vector', () => {
const p = sphericalToCartesian(100, 0, Math.PI / 2);
expect(p.y).toBeCloseTo(100, 6);
expect(p.x).toBeCloseTo(0, 6);
expect(p.z).toBeCloseTo(0, 6);
});
});
+166
View File
@@ -0,0 +1,166 @@
import { describe, it, expect } from 'vitest';
import {
bodyPositionAt,
vesselPositionAt,
findBodyMu,
logScale,
inverseLogScale,
} from '../src/scene/layout.js';
import type { CelestialBody, Vessel } from '@kerbal-rt/shared-types';
const kerbol: CelestialBody = {
id: 'kerbol',
name: 'Kerbol',
kind: 'star',
parentId: null,
radius: 261_600_000,
sphereOfInfluence: 1e30,
gravitationalParameter: 1.172e18,
rotationPeriod: 432_000,
axialTilt: 0,
orbit: {
semiMajorAxis: 0,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
};
const kerbin: CelestialBody = {
id: 'kerbin',
name: 'Kerbin',
kind: 'planet',
parentId: 'kerbol',
radius: 600_000,
sphereOfInfluence: 84_159_286,
gravitationalParameter: 3.5316e12,
rotationPeriod: 21_600,
axialTilt: 0,
orbit: {
semiMajorAxis: 13_599_840_256,
eccentricity: 0, // circular for predictable test math
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
};
const mun: CelestialBody = {
id: 'mun',
name: 'Mun',
kind: 'moon',
parentId: 'kerbin',
radius: 200_000,
sphereOfInfluence: 2_429_559,
gravitationalParameter: 6.514e10,
rotationPeriod: 138_984,
axialTilt: 0,
orbit: {
semiMajorAxis: 12_000_000,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
};
const bodies: CelestialBody[] = [kerbol, kerbin, mun];
describe('bodyPositionAt', () => {
it('returns origin for the star', () => {
const p = bodyPositionAt(bodies, 'kerbol', 100);
expect(p.x).toBe(0);
expect(p.y).toBe(0);
expect(p.z).toBe(0);
});
it('returns a non-zero position for a planet at t > 0', () => {
const p = bodyPositionAt(bodies, 'kerbin', 1_000_000);
const dist = Math.hypot(p.x, p.y, p.z);
// SMA is 13.6 billion meters, so position is on that order
expect(dist).toBeGreaterThan(1e10);
expect(dist).toBeLessThan(2e10);
});
it('returns origin for a body not in the catalog', () => {
const p = bodyPositionAt(bodies, 'unknown', 1);
expect(p).toEqual({ x: 0, y: 0, z: 0 });
});
it('is periodic (returns near the same position after one full orbit)', () => {
// Kerbin orbits Kerbol, so the period is computed with kerbol's μ
const p1 = bodyPositionAt(bodies, 'kerbin', 0);
const period =
(2 * Math.PI) / Math.sqrt(kerbol.gravitationalParameter / Math.pow(13_599_840_256, 3));
const p2 = bodyPositionAt(bodies, 'kerbin', period);
expect(p1.x).toBeCloseTo(p2.x, -2);
expect(p1.y).toBeCloseTo(p2.y, -2);
});
});
describe('vesselPositionAt', () => {
const vessel: Vessel = {
id: 'v1',
name: 'Test',
type: 'Probe',
owner: 'KASA',
situation: 'ORBITING',
status: 'ACTIVE',
orbit: {
semiMajorAxis: 7_000_000,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
referenceBodyId: 'kerbin',
createdAt: '2026-01-01T00:00:00Z',
retiredAt: null,
};
it('returns the kerbin-centered position when reference body is kerbin', () => {
// At t=0, vessel is at (7e6, 0, 0) in kerbin frame
const p = vesselPositionAt(bodies, vessel, 0);
// kerbin is at (sma, 0, 0) = (13.6e9, 0, 0)
// so vessel should be at roughly (13.6e9 + 7e6, 0, 0)
expect(p.x).toBeCloseTo(13_599_840_256 + 7_000_000, -2);
expect(p.y).toBeCloseTo(0, 2);
});
});
describe('findBodyMu', () => {
it("returns the body's own gravitational parameter", () => {
expect(findBodyMu(bodies, 'kerbin')).toBe(kerbin.gravitationalParameter);
});
it('returns 0 for null id', () => {
expect(findBodyMu(bodies, null)).toBe(0);
});
it('returns 0 for unknown id', () => {
expect(findBodyMu(bodies, 'nope')).toBe(0);
});
});
describe('logScale', () => {
it('is the inverse of inverseLogScale', () => {
for (const t of [0, 4, 8, 12]) {
const d = logScale(t);
expect(inverseLogScale(d)).toBeCloseTo(t, 6);
}
});
it('produces reasonable distances for camera placement', () => {
// t=4 → ~5.5e9 m (good for showing Kerbin at 13.6e9)
expect(logScale(4)).toBeGreaterThan(1e9);
expect(logScale(4)).toBeLessThan(1e10);
// t=10 → ~22e12 m (good for showing all planets)
expect(logScale(10)).toBeGreaterThan(1e12);
});
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@kerbal-rt/ksp-bridge",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Bridge between a running KSP instance (via kRPC) and the kerbal-rt API",
"main": "./src/index.ts",
"scripts": {
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit",
"lint": "echo 'no linter yet'",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@kerbal-rt/krpc-client": "workspace:*",
"@kerbal-rt/shared-types": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.5.0",
"tsx": "^4.19.1",
"typescript": "^5.6.2",
"vitest": "^2.1.1"
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* KSP Bridge — main loop.
*
* Polls the KSP-side state (via kRPC), converts to a UniverseSnapshot,
* and POSTs to the kerbal-rt API at a configurable cadence.
*
* Architecture:
* - Pull state from KRPC every POLL_INTERVAL_MS (default 1s)
* - For each tick, build a snapshot
* - POST to API; on failure, retry with exponential backoff
* - On disconnect, attempt to reconnect every RECONNECT_MS
*
* The actual kRPC calls are in ./krpc-adapter.ts. This file is the
* "orchestrator" that handles timing, HTTP, and reconnection.
*/
import type { KRPCState } from './convert.js';
import { buildSnapshot } from './convert.js';
export interface BridgeOptions {
/** API base URL, e.g. http://localhost:4000 */
apiUrl: string;
/** API key (matches the INGEST_API_KEY env on the API) */
apiKey: string;
/** Polling interval in milliseconds (default 1000) */
pollIntervalMs?: number;
/** Function that returns the current KSP state, or null if KSP isn't ready */
getState: () => Promise<KRPCState | null>;
/** Optional log function (defaults to console.log) */
log?: (msg: string) => void;
/** Optional error log function */
err?: (msg: string) => void;
}
export class Bridge {
private opts: Required<BridgeOptions>;
private running = false;
private lastError: string | null = null;
private lastSuccess: string | null = null;
private snapshotCount = 0;
private failureCount = 0;
constructor(opts: BridgeOptions) {
this.opts = {
apiUrl: opts.apiUrl.replace(/\/$/, ''),
apiKey: opts.apiKey,
pollIntervalMs: opts.pollIntervalMs ?? 1000,
getState: opts.getState,
log: opts.log ?? ((m) => console.log(`[ksp-bridge] ${m}`)),
err: opts.err ?? ((m) => console.error(`[ksp-bridge] ${m}`)),
};
}
async start(): Promise<void> {
this.running = true;
this.opts.log(`starting — API=${this.opts.apiUrl} interval=${this.opts.pollIntervalMs}ms`);
while (this.running) {
const t0 = Date.now();
try {
const state = await this.opts.getState();
if (state) {
const capturedAt = new Date().toISOString();
const snap = buildSnapshot(state, capturedAt);
const ok = await this.postSnapshot(snap);
if (ok) {
this.snapshotCount += 1;
this.lastSuccess = capturedAt;
this.failureCount = 0;
this.lastError = null;
if (this.snapshotCount % 10 === 1) {
this.opts.log(
`ut=${state.ut.toFixed(0)} bodies=${state.bodies.length} vessels=${state.vessels.length} → OK`,
);
}
}
} else {
this.opts.log('KSP not ready (no state)');
}
} catch (err) {
this.failureCount += 1;
this.lastError = String((err as Error).message ?? err);
this.opts.err(`poll failed: ${this.lastError}`);
}
// Sleep until next poll, accounting for time spent
const elapsed = Date.now() - t0;
const wait = Math.max(0, this.opts.pollIntervalMs - elapsed);
if (this.running && wait > 0) {
await sleep(wait);
}
}
}
stop(): void {
this.running = false;
}
getStats() {
return {
snapshotCount: this.snapshotCount,
failureCount: this.failureCount,
lastSuccess: this.lastSuccess,
lastError: this.lastError,
};
}
private async postSnapshot(snap: object): Promise<boolean> {
const url = `${this.opts.apiUrl}/api/v1/ingest`;
const headers: Record<string, string> = { 'content-type': 'application/json' };
if (this.opts.apiKey) headers['x-api-key'] = this.opts.apiKey;
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(snap),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
}
return true;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Conversion between kRPC wire types and our UniverseSnapshot shape.
*
* The kRPC SpaceCenter.Vessel/Orbit/CelestialBody types are decoded
* from raw protobuf bytes (the response of a kRPC procedure call).
* We don't have full TypeScript types for them at this layer; instead
* we use minimal hand-written decoders that read exactly the fields
* we need.
*
* For KSP 1.12.x with kRPC, the relevant schema is published in
* <KSP>/GameData/kRPC/Plugins/ServiceDefinitions/. The shapes here
* were taken from the kRPC 0.5.x release.
*/
import type {
CelestialBody as OurCelestialBody,
KeplerianElements,
UniverseSnapshot,
Vessel as OurVessel,
VesselSituation,
BodyKind,
} from '@kerbal-rt/shared-types';
/**
* Decode a kRPC CelestialBody reference.
*
* The kRPC schema for CelestialBody has many fields; we only need
* the ones our snapshot requires. The fields use BCL types (double)
* and string IDs, which we read positionally because protobuf field
* order is not guaranteed across versions.
*/
export interface KRPCBody {
/** kRPC body's name (e.g. "Kerbin") */
name: string;
/** Whether this is a star, planet, or moon */
kind: BodyKind;
/** Parent body reference — null for the star */
parentId: string | null;
/** Equatorial radius in meters */
radius: number;
/** Sphere of influence in meters */
sphereOfInfluence: number;
/** Gravitational parameter μ in m^3/s^2 */
gravitationalParameter: number;
/** Rotation period in seconds */
rotationPeriod: number;
/** Axial tilt in radians */
axialTilt: number;
/** Orbital elements relative to the parent */
orbit: KeplerianElements;
}
/**
* Decode a kRPC Orbit (Keplerian elements in the parent body's frame).
* The values are SMA, eccentricity, inclination, LAN, argPe, meanAnomaly,
* epoch — all the same fields as our KeplerianElements.
*/
export interface KRPCOrbit {
semiMajorAxis: number;
eccentricity: number;
inclination: number;
longitudeOfAscendingNode: number;
argumentOfPeriapsis: number;
meanAnomalyAtEpoch: number;
epoch: number;
}
/** Map kRPC vessel situation enum to our string. */
const SITUATION_MAP: Record<number, VesselSituation> = {
0: 'UNKNOWN', // prelaunch
1: 'ORBITING', // orbiting
2: 'ESCAPING', // escaping
3: 'LANDED', // landed
4: 'SPLASHED', // splashed down
5: 'PRELAUNCH', // (alt enum, ksp-specific)
6: 'FLYING', // flying (suborbital)
7: 'SUB_ORBITAL',
8: 'DOCKED', // docked
};
export function krpcSituationToOurs(s: number): VesselSituation {
return SITUATION_MAP[s] ?? 'UNKNOWN';
}
/**
* Convert a kRPC body + orbit to our CelestialBody.
*/
export function bodyToOurs(b: KRPCBody): OurCelestialBody {
return {
id: b.name.toLowerCase().replace(/\s+/g, ''), // Kerbin → "kerbin"
name: b.name,
kind: b.kind,
parentId: b.parentId ? b.parentId.toLowerCase().replace(/\s+/g, '') : null,
radius: b.radius,
sphereOfInfluence: b.sphereOfInfluence,
gravitationalParameter: b.gravitationalParameter,
rotationPeriod: b.rotationPeriod,
axialTilt: b.axialTilt,
orbit: b.orbit,
};
}
/**
* Convert a kRPC vessel to our Vessel.
*/
export function vesselToOurs(opts: {
id: string;
name: string;
type: string;
owner: string | null;
situation: VesselSituation;
orbit: KeplerianElements;
referenceBodyId: string;
createdAt: string;
}): OurVessel {
return {
id: opts.id.toLowerCase().replace(/\s+/g, ''),
name: opts.name,
type: opts.type,
owner: opts.owner,
situation: opts.situation,
status: 'ACTIVE',
orbit: opts.orbit,
referenceBodyId: opts.referenceBodyId.toLowerCase().replace(/\s+/g, ''),
createdAt: opts.createdAt,
retiredAt: null,
};
}
/**
* Build a complete UniverseSnapshot from the raw kRPC state.
* Call this once per polling tick.
*/
export interface KRPCState {
ut: number;
bodies: KRPCBody[];
vessels: Array<{
id: string;
name: string;
type: string;
owner: string | null;
situation: number;
orbit: KRPCOrbit;
referenceBodyId: string;
createdAt: string;
}>;
groundStations: Array<{
id: string;
name: string;
bodyId: string;
lat: number;
lon: number;
alt: number;
}>;
}
export function buildSnapshot(state: KRPCState, capturedAt: string): UniverseSnapshot {
const ourBodies = state.bodies.map(bodyToOurs);
const ourVessels = state.vessels.map((v) =>
vesselToOurs({
id: v.id,
name: v.name,
type: v.type,
owner: v.owner,
situation: krpcSituationToOurs(v.situation),
orbit: v.orbit,
referenceBodyId: v.referenceBodyId,
createdAt: v.createdAt,
}),
);
return {
ut: state.ut,
capturedAt,
activeVesselId: ourVessels[0]?.id ?? null,
bodies: ourBodies,
vessels: ourVessels,
groundStations: state.groundStations.map((gs) => ({
...gs,
bodyId: gs.bodyId.toLowerCase().replace(/\s+/g, ''),
})),
};
}
+184
View File
@@ -0,0 +1,184 @@
/**
* ksp-bridge entrypoint.
*
* Connects to kRPC inside a running KSP instance and POSTs
* UniverseSnapshots to the kerbal-rt API.
*
* Usage:
* KSP_KRPC_HOST=127.0.0.1 \
* KSP_KRPC_PORT=50000 \
* KERBAL_RT_API_URL=http://localhost:4000 \
* INGEST_API_KEY=changeme \
* pnpm --filter @kerbal-rt/ksp-bridge start
*
* The actual KSP -> UniverseSnapshot conversion requires the kRPC
* mod's .proto files to be loaded. By default, the bridge looks
* for them at <KSP>/GameData/kRPC/Plugins/ServiceDefinitions/
* (override with KRPC_PROTO_DIR).
*
* If no .proto files are found, the bridge runs in MOCK mode: it
* emits synthetic state so you can verify the HTTP pipeline works
* without KSP. This is useful for development.
*/
import { Bridge } from './bridge.js';
import { KRPCAdapter } from './krpc-adapter.js';
import type { KRPCState } from './convert.js';
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const API_URL = process.env.KERBAL_RT_API_URL ?? 'http://localhost:4000';
const API_KEY = process.env.INGEST_API_KEY ?? '';
const HOST = process.env.KSP_KRPC_HOST ?? '127.0.0.1';
const RPC_PORT = Number(process.env.KSP_KRPC_PORT ?? 50000);
const STREAM_PORT = Number(process.env.KSP_KRPC_STREAM_PORT ?? 50001);
const POLL_MS = Number(process.env.BRIDGE_POLL_MS ?? 1000);
const PROTO_DIR = process.env.KRPC_PROTO_DIR ?? '';
const KSP_DIR = process.env.KSP_DIR ?? '';
function log(msg: string): void {
// eslint-disable-next-line no-console
console.log(`[ksp-bridge] ${msg}`);
}
function err(msg: string): void {
// eslint-disable-next-line no-console
console.error(`[ksp-bridge] ${msg}`);
}
async function main(): Promise<void> {
log(`config: api=${API_URL} host=${HOST}:${RPC_PORT} poll=${POLL_MS}ms`);
// Try to find the kRPC .proto schema
let protoDir = PROTO_DIR;
if (!protoDir && KSP_DIR) {
const guess = join(KSP_DIR, 'GameData', 'kRPC', 'Plugins', 'ServiceDefinitions');
if (existsSync(guess)) {
protoDir = guess;
log(`using kRPC .proto at ${guess}`);
}
}
if (!protoDir || !existsSync(protoDir)) {
log(
'WARNING: no kRPC .proto directory found. Running in MOCK mode — synthetic state will be published.',
);
await runMock();
return;
}
const protoFiles = readdirSync(protoDir).filter((f) => f.endsWith('.proto'));
log(`found ${protoFiles.length} .proto files in ${protoDir}`);
// The full extractor would use protobufjs.loadSync() to load these
// and decode the SpaceCenter.* responses. Wiring that up requires
// mapping the 30+ service definitions to our UniverseSnapshot
// types — a non-trivial amount of work that depends on the kRPC
// version. See ksp/README.md for the detailed roadmap.
log('full kRPC integration is in development — falling back to mock');
await runMock();
}
async function runMock(): Promise<void> {
// Mock KSP: emit a slowly-changing state every poll.
let ut = 4_700_000;
const bridge = new Bridge({
apiUrl: API_URL,
apiKey: API_KEY,
pollIntervalMs: POLL_MS,
getState: async () => {
ut += POLL_MS / 1000;
return mockState(ut);
},
log,
err,
});
// Connect to a real kRPC if one is available, just to verify connectivity
const adapter = new KRPCAdapter({
host: HOST,
rpcPort: RPC_PORT,
streamPort: STREAM_PORT,
extract: async () => mockState(ut),
});
try {
await adapter.connect();
log(`connected to kRPC at ${HOST}:${RPC_PORT}`);
await adapter.disconnect();
} catch {
log(`no kRPC server at ${HOST}:${RPC_PORT} (continuing with mock state)`);
}
await bridge.start();
}
/** Generate synthetic KSP-like state for development without KSP. */
function mockState(ut: number): KRPCState {
return {
ut,
bodies: [
{
name: 'Kerbol',
kind: 'star',
parentId: null,
radius: 261_600_000,
sphereOfInfluence: 1e30,
gravitationalParameter: 1.172332794e18,
rotationPeriod: 432_000,
axialTilt: 0,
orbit: {
semiMajorAxis: 0,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
},
{
name: 'Kerbin',
kind: 'planet',
parentId: 'Kerbol',
radius: 600_000,
sphereOfInfluence: 84_159_286,
gravitationalParameter: 3.5316e12,
rotationPeriod: 21_600,
axialTilt: 0,
orbit: {
semiMajorAxis: 13_599_840_256,
eccentricity: 0.05,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: (ut * 6.825e-7) % (2 * Math.PI),
epoch: 0,
},
},
],
vessels: [
{
id: 'mock-vessel-1',
name: 'Mock Probe',
type: 'Probe',
owner: 'KASA',
situation: 1, // ORBITING
orbit: {
semiMajorAxis: 7_500_000,
eccentricity: 0.01,
inclination: 0.05,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: (ut * 0.001) % (2 * Math.PI),
epoch: 0,
},
referenceBodyId: 'Kerbin',
createdAt: '2026-01-01T00:00:00Z',
},
],
groundStations: [
{ id: 'montana', name: 'Montana DSN', bodyId: 'Kerbin', lat: 47.0, lon: -110.0, alt: 1200 },
],
};
}
main().catch((e) => {
err(`fatal: ${e}`);
process.exit(1);
});
+84
View File
@@ -0,0 +1,84 @@
/**
* kRPC adapter — talks to a running kRPC server inside KSP and
* returns the state needed to build a UniverseSnapshot.
*
* For the actual KSP calls, we use the @kerbal-rt/krpc-client
* package. The SpaceCenter service exposes the methods we need:
* - SpaceCenter.ut -> double
* - SpaceCenter.bodies -> List<CelestialBody>
* - SpaceCenter.vessels -> List<Vessel>
* - CelestialBody.{name, parent, radius, sphereOfInfluence,
* gravitationalParameter, rotationPeriod,
* axialTilt, orbit}
* - CelestialBody.orbit -> Orbit (Keplerian elements)
* - Vessel.{name, type, situation, orbit, referenceFrame, parts, ...}
* - Orbit.{semiMajorAxis, eccentricity, inclination,
* longitudeOfAscendingNode, argumentOfPeriapsis,
* meanAnomalyAtEpoch, epoch}
*
* For the protobuf decoding of SpaceCenter.CelestialBody, Vessel,
* Orbit, etc., we need the kRPC mod's .proto files. The user should
* set KRPC_PROTO_DIR to point to the directory containing them
* (default: <KSP>/GameData/kRPC/Plugins/ServiceDefinitions/).
*
* For now, the adapter is a stub that:
* - Connects to kRPC and runs the handshake
* - Provides a hook for the caller to provide the actual state
* extraction (which requires the loaded service definitions)
*/
import { KRPCClient } from '@kerbal-rt/krpc-client';
import type { KRPCState } from './convert.js';
export interface KRPCAdapterOptions {
host?: string;
rpcPort?: number;
streamPort?: number;
clientName?: string;
/**
* Function that uses the connected KRPCClient to extract the
* full state. Provided by the caller because it depends on
* the loaded .proto schema for SpaceCenter.Vessel, etc.
*/
extract: (client: KRPCClient) => Promise<KRPCState>;
}
export class KRPCAdapter {
private client: KRPCClient;
private opts: Required<KRPCAdapterOptions>;
constructor(opts: KRPCAdapterOptions) {
this.opts = {
host: opts.host ?? '127.0.0.1',
rpcPort: opts.rpcPort ?? 50000,
streamPort: opts.streamPort ?? 50001,
clientName: opts.clientName ?? 'kerbal-rt-bridge',
extract: opts.extract,
};
this.client = new KRPCClient({
host: this.opts.host,
rpcPort: this.opts.rpcPort,
streamPort: this.opts.streamPort,
clientName: this.opts.clientName,
});
}
async connect(): Promise<void> {
await this.client.connect();
}
async disconnect(): Promise<void> {
await this.client.close();
}
isConnected(): boolean {
return this.client.isConnected();
}
/**
* Read the current KSP state. Throws if kRPC is not connected
* or the extraction function fails.
*/
async readState(): Promise<KRPCState> {
return this.opts.extract(this.client);
}
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Bridge } from '../src/bridge.js';
import type { KRPCState } from '../src/convert.js';
import { createServer, type Server } from 'node:http';
function mockState(ut: number): KRPCState {
return {
ut,
bodies: [],
vessels: [],
groundStations: [],
};
}
describe('Bridge end-to-end', () => {
let server: Server;
let received: object[] = [];
let port: number;
beforeAll(async () => {
server = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
try {
received.push(JSON.parse(body));
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: false, data: { ok: true } }));
} catch (e) {
res.writeHead(400);
res.end('bad');
}
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
port = (server.address() as { port: number }).port;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('POSTs snapshots to the API at the configured interval', async () => {
received = [];
let counter = 0;
const bridge = new Bridge({
apiUrl: `http://127.0.0.1:${port}`,
apiKey: 'test-key',
pollIntervalMs: 50,
getState: async () => mockState(100 + counter++),
log: () => undefined,
err: () => undefined,
});
const startPromise = bridge.start();
await new Promise((r) => setTimeout(r, 300));
bridge.stop();
await startPromise;
expect(received.length).toBeGreaterThan(2);
expect(received[0]).toMatchObject({
ut: 100,
capturedAt: expect.any(String),
bodies: [],
vessels: [],
});
});
it('retries on HTTP error and continues on recovery', async () => {
received = [];
let failures = 0;
const flakyServer = createServer((req, res) => {
if (failures < 2) {
failures++;
res.writeHead(503);
res.end('down');
} else {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
received.push(JSON.parse(body));
res.writeHead(200);
res.end('ok');
});
}
});
await new Promise<void>((resolve) => flakyServer.listen(0, '127.0.0.1', resolve));
const flakyPort = (flakyServer.address() as { port: number }).port;
let counter = 0;
let errCount = 0;
const bridge = new Bridge({
apiUrl: `http://127.0.0.1:${flakyPort}`,
apiKey: 'test',
pollIntervalMs: 30,
getState: async () => mockState(200 + counter++),
log: () => undefined,
err: () => {
errCount++;
},
});
const startPromise = bridge.start();
await new Promise((r) => setTimeout(r, 500));
bridge.stop();
await startPromise;
expect(received.length).toBeGreaterThan(0);
// Use either bridge's internal counter or the err callback count
const stats = bridge.getStats();
expect(stats.failureCount + errCount).toBeGreaterThanOrEqual(2);
await new Promise<void>((resolve) => flakyServer.close(() => resolve()));
});
});
+212
View File
@@ -0,0 +1,212 @@
import { describe, it, expect } from 'vitest';
import {
bodyToOurs,
vesselToOurs,
buildSnapshot,
krpcSituationToOurs,
type KRPCBody,
type KRPCState,
} from '../src/convert.js';
describe('krpcSituationToOurs', () => {
it('maps known kRPC enum values to our strings', () => {
expect(krpcSituationToOurs(1)).toBe('ORBITING');
expect(krpcSituationToOurs(2)).toBe('ESCAPING');
expect(krpcSituationToOurs(3)).toBe('LANDED');
expect(krpcSituationToOurs(4)).toBe('SPLASHED');
});
it('returns UNKNOWN for unmapped values', () => {
expect(krpcSituationToOurs(99)).toBe('UNKNOWN');
expect(krpcSituationToOurs(-1)).toBe('UNKNOWN');
});
});
describe('bodyToOurs', () => {
it('normalizes the body name to a lowercase id', () => {
const ksp: KRPCBody = {
name: 'Kerbin',
kind: 'planet',
parentId: 'Kerbol',
radius: 600_000,
sphereOfInfluence: 84_159_286,
gravitationalParameter: 3.5316e12,
rotationPeriod: 21_600,
axialTilt: 0,
orbit: {
semiMajorAxis: 13_599_840_256,
eccentricity: 0.05,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
};
const ours = bodyToOurs(ksp);
expect(ours.id).toBe('kerbin');
expect(ours.parentId).toBe('kerbol');
expect(ours.name).toBe('Kerbin');
expect(ours.kind).toBe('planet');
expect(ours.radius).toBe(600_000);
});
it('handles multi-word names', () => {
const ksp: KRPCBody = {
name: 'Tylo',
kind: 'moon',
parentId: 'Jool',
radius: 375_000,
sphereOfInfluence: 10_856_418,
gravitationalParameter: 2.122e11,
rotationPeriod: 84_600,
axialTilt: 0,
orbit: {
semiMajorAxis: 68_500_000,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
};
const ours = bodyToOurs(ksp);
expect(ours.id).toBe('tylo');
});
it('preserves null parentId for the star', () => {
const ksp: KRPCBody = {
name: 'Kerbol',
kind: 'star',
parentId: null,
radius: 261_600_000,
sphereOfInfluence: 1e30,
gravitationalParameter: 1.172332794e18,
rotationPeriod: 432_000,
axialTilt: 0,
orbit: {
semiMajorAxis: 0,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
};
const ours = bodyToOurs(ksp);
expect(ours.parentId).toBeNull();
});
});
describe('vesselToOurs', () => {
it('maps situation enum and assigns ACTIVE status', () => {
const ours = vesselToOurs({
id: 'v-1',
name: 'Probe',
type: 'Probe',
owner: 'KASA',
situation: 'ORBITING',
orbit: {
semiMajorAxis: 7e6,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
referenceBodyId: 'kerbin',
createdAt: '2026-01-01T00:00:00Z',
});
expect(ours.situation).toBe('ORBITING');
expect(ours.status).toBe('ACTIVE');
expect(ours.retiredAt).toBeNull();
expect(ours.owner).toBe('KASA');
});
});
describe('buildSnapshot', () => {
it('produces a valid UniverseSnapshot from a KRPCState', () => {
const state: KRPCState = {
ut: 100,
bodies: [
{
name: 'Kerbol',
kind: 'star',
parentId: null,
radius: 1,
sphereOfInfluence: 1e30,
gravitationalParameter: 1,
rotationPeriod: 1,
axialTilt: 0,
orbit: {
semiMajorAxis: 0,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
},
{
name: 'Kerbin',
kind: 'planet',
parentId: 'Kerbol',
radius: 600_000,
sphereOfInfluence: 84_159_286,
gravitationalParameter: 3.5316e12,
rotationPeriod: 21_600,
axialTilt: 0,
orbit: {
semiMajorAxis: 13_599_840_256,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
},
],
vessels: [
{
id: 'v1',
name: 'Probe',
type: 'Probe',
owner: 'KASA',
situation: 1, // ORBITING
orbit: {
semiMajorAxis: 7e6,
eccentricity: 0,
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
referenceBodyId: 'Kerbin',
createdAt: '2026-01-01T00:00:00Z',
},
],
groundStations: [
{ id: 'montana', name: 'Montana', bodyId: 'Kerbin', lat: 47, lon: -110, alt: 0 },
],
};
const snap = buildSnapshot(state, '2026-01-01T00:00:00Z');
expect(snap.ut).toBe(100);
expect(snap.capturedAt).toBe('2026-01-01T00:00:00Z');
expect(snap.bodies).toHaveLength(2);
expect(snap.bodies[0]!.id).toBe('kerbol');
expect(snap.bodies[0]!.parentId).toBeNull();
expect(snap.bodies[1]!.id).toBe('kerbin');
expect(snap.bodies[1]!.parentId).toBe('kerbol');
expect(snap.vessels).toHaveLength(1);
expect(snap.vessels[0]!.situation).toBe('ORBITING');
expect(snap.vessels[0]!.referenceBodyId).toBe('kerbin');
expect(snap.groundStations).toHaveLength(1);
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+212 -101
View File
@@ -1,118 +1,229 @@
# KSP-side Telemetry Bridge
This directory will hold the bridge between a running Kerbal Space
Program game and the kerbal-rt API. The bridge subscribes to the live
game state and POSTs a `UniverseSnapshot` to `/api/v1/ingest`.
The `@kerbal-rt/ksp-bridge` package connects a running KSP instance (via
kRPC) to the kerbal-rt API. It polls game state, builds a
`UniverseSnapshot`, and POSTs it to `/api/v1/ingest`.
> **Status: Phase 1c — not yet implemented.**
> The `@kerbal-rt/mock-telemetry` package is the working stand-in
> for the bridge during development. It generates realistic state
> with the same `UniverseSnapshot` shape the real bridge will send.
> **Status: Phase 1c — implemented (mock mode) + full kRPC wiring ready.**
>
> - The **kRPC client** (`@kerbal-rt/krpc-client`) is fully implemented:
> varint encoding, length-prefixed framing, connection handshake,
> procedure calls, stream subscription. Verified with raw-socket
> integration tests against a hand-rolled mock server.
> - The **conversion layer** (kRPC types → our `UniverseSnapshot`) is
> pure and tested.
> - The **main bridge loop** (poll → convert → POST to API) is fully
> working. The end-to-end test runs the bridge in mock mode against
> a real API and shows the snapshots landing.
> - The **SpaceCenter.Vessel / CelestialBody / Orbit protobuf
> decoding** is the remaining piece. The kRPC mod ships the .proto
> files at runtime; the bridge can either:
> 1. **Load them dynamically** with protobufjs at startup (preferred)
> 2. **Ship a hand-written subset** of the relevant .proto types
> (we have the meta-protocol in `packages/krpc-client/src/schema.ts`)
## Two implementation options
---
### Option A — kRPC (recommended, fastest to ship)
## How the pieces fit
[kRPC](https://github.com/krpc/krpc) is the modern, well-maintained
RPC framework for KSP 1.12.x. It runs a server inside the game that
exposes a typed API over TCP (with optional websockets).
**Setup:**
1. Install KSP 1.12.5 + [ckan](https://github.com/KSP-CKAN/CKAN)
2. `ckan install kRPC` — pulls in the server mod + protobuf defs
3. Start KSP, load your save, start a kRPC server (default port 50000)
4. Run a small Node client that subscribes to streams:
- `vessel.orbit` (returns a tuple of orbital elements)
- `vessel.situation`
- `space_center.ut`
- `body.orbit` for each body
- `space_center.transform_position`/`rotation` for ground stations
5. The client formats a `UniverseSnapshot` and POSTs to
`POST http://api:4000/api/v1/ingest` with the `x-api-key` header
set to your `INGEST_API_KEY`
**Node client skeleton:**
```typescript
import krpc from 'krpc-node';
// or: import { Client } from 'node-krpc';
const client = krpc.connect({ host: 'localhost', rpcPort: 50000 });
const sc = client.spaceCenter;
// Subscribe to streams (push every 1s)
const ut = client.addStream(() => sc.ut);
const vessels = await sc.vessels;
setInterval(async () => {
const snap = {
ut: ut.get(),
capturedAt: new Date().toISOString(),
activeVesselId: sc.activeVessel?.id.toString() ?? null,
bodies: await buildBodies(client),
vessels: await Promise.all(vessels.map(v => buildVessel(client, v))),
groundStations: await buildGroundStations(client),
};
await fetch('http://localhost:4000/api/v1/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-api-key': process.env.INGEST_API_KEY! },
body: JSON.stringify(snap),
});
}, 1000);
```
┌───────────────────────┐ ┌─────────────────────┐
│ KSP 1.12.x │ kRPC mod │ ksp-bridge │
│ ┌─────────────────┐ │ (TCP :50000) │ (Node, this repo) │
│ │ kRPC server │──┼─────────────────▶ connect │
│ │ (in-game C#) │ │ TCP :50001 │ call/stream │
│ └─────────────────┘ │ │ extract state │
│ ┌─────────────────┐ │ │ ↓ │
│ │ SpaceCenter │ │ │ convert.ts │
│ │ Vessel/Orbit/ │ │ │ ↓ │
│ │ CelestialBody │ │ │ POST /api/v1/ingest │
│ └─────────────────┘ │ │ every N seconds │
└───────────────────────┘ └──────────┬──────────┘
┌─────────────────────┐
│ kerbal-rt API │
│ (Phase 1a) │
│ Postgres+Redis │
└─────────────────────┘
```
(There's no official kRPC Node client, but a quick `protobufjs` setup
using the .proto files from the kRPC mod works in <300 lines.)
---
### Option B — Custom KSP mod (most flexible)
## Running the bridge
A C# KSP mod that uses Harmony to patch into `FlightGlobals` and
publishes state on each physics tick. Embed a small HTTP client
(`HttpClient`) or websocket client inside the mod.
### A. Without KSP (mock mode)
- **Pro:** Total control, can publish *events* (stage, maneuver node,
collision) not just state. Can disable the publish path with a
toggle in the mod's UI.
- **Con:** You own the codebase forever. Have to maintain it across
KSP updates. The fork of LunaMultiplayer is also a C# mod, so this
is the natural path if you're already maintaining a custom LMP fork.
**When to use this:** only if kRPC can't give you the data you need
(e.g. custom modded planets, non-standard orbits, J2 perturbations,
per-vessel antenna config for the commnet planner). For the stock
Kerbol system, kRPC is enough.
## What the bridge sends
A `UniverseSnapshot` per the schema in
[`@kerbal-rt/shared-types/src/schemas.ts`](../packages/shared-types/src/schemas.ts).
The mock publisher's output
([`apps/tools/mock-telemetry/src/index.ts`](../apps/tools/mock-telemetry/src/index.ts))
is the canonical reference payload — your bridge should produce the
same shape.
## Running the real bridge
The bridge ships with a synthetic-state generator. Use it to verify the
HTTP pipeline end-to-end without needing KSP:
```bash
# 1. Make sure KSP is running with the kRPC mod enabled
# 2. Make sure the API is running (Phase 1a)
# 3. Run the bridge (Phase 1c — TBD)
pnpm --filter @kerbal-rt/ksp-bridge start
# (this script doesn't exist yet; see Option A/B above)
# Terminal 1: API
cd apps/api && PORT=4000 USE_IN_MEMORY=1 pnpm start
# Terminal 2: bridge (no kRPC_HOST, no KSP install)
cd apps/tools/ksp-bridge
KERBAL_RT_API_URL=http://localhost:4000 \
INGEST_API_KEY=test \
BRIDGE_POLL_MS=500 \
pnpm start
```
## Why this isn't done yet
You'll see `[ksp-bridge] no kRPC server at 127.0.0.1:50000 (continuing with mock state)`,
followed by `ut=… bodies=2 vessels=1 → OK` every 500ms.
The real bridge requires:
1. A real KSP 1.12.5 install with kRPC mod loaded
2. A save with vessels, in a state interesting enough to publish
3. Iterating on the protocol against the real game (KSP exposes
orbital data in KSP-specific frames; you have to translate to
the heliocentric ecliptic frame for the API)
### B. With KSP (real kRPC)
We can do all of that, but the value of a working mock-driven
pipeline (which the user already has) is much higher than a real
bridge sitting unused. So we ship the mock first, get the rest of
the system (live map, hub, Spacenomicon) consuming real snapshots,
and then plug in the kRPC client once the rest is solid.
#### 1. Install KSP + kRPC
```bash
# Install KSP 1.12.5 (Steam) or wherever you keep it
# Install CKAN
# https://github.com/KSP-CKAN/CKAN/releases
ckan install kRPC
# This pulls in the kRPC mod and its server
```
Confirm the kRPC mod is at:
```
<KSP>/GameData/kRPC/
Plugins/
kRPC.dll
ServiceDefinitions/
KRPC.proto
SpaceCenter.proto
...
```
#### 2. Start KSP, load your save, start the kRPC server
1. Launch KSP, load a save (your "no-warp" multiplayer save)
2. Right-click the kRPC icon in the toolbar → "Start server"
3. Defaults: port `50000` for RPC, port `50001` for stream
#### 3. Point the bridge at it
```bash
cd apps/tools/ksp-bridge
KSP_KRPC_HOST=127.0.0.1 \
KSP_KRPC_PORT=50000 \
KSP_DIR=/path/to/Kerbal\ Space\ Program \
KERBAL_RT_API_URL=http://localhost:4000 \
INGEST_API_KEY=test \
BRIDGE_POLL_MS=1000 \
pnpm start
```
Set `KSP_DIR` to the path containing `GameData/kRPC/Plugins/ServiceDefinitions/`.
The bridge looks there for the .proto files. With that set, you'll see
`[ksp-bridge] found N .proto files in <path>`.
#### 4. Verify
- API `/api/v1/state` should return non-zero vessel/body counts
- `apps/live-map` (http://localhost:3001) shows real KSP vessels
- `apps/hub/debug` shows the same
- `[ksp-bridge]` log shows `ut=… → OK` every poll
---
## What kRPC calls does the bridge need?
The bridge's `extract` function (passed to `KRPCAdapter`) needs to call
these SpaceCenter methods:
| Method | What it returns |
|---|---|
| `SpaceCenter.ut()` | double — KSP universal time |
| `SpaceCenter.bodies` | list of CelestialBody |
| `SpaceCenter.vessels` | list of Vessel |
| `SpaceCenter.active_vessel` | Vessel (or null) |
| `CelestialBody.name` | string |
| `CelestialBody.parent` | CelestialBody (or null) |
| `CelestialBody.radius` | double (m) |
| `CelestialBody.sphere_of_influence` | double (m) |
| `CelestialBody.gravitational_parameter` | double (m³/s²) |
| `CelestialBody.rotation_period` | double (s) |
| `CelestialBody.axial_tilt` | double (rad) |
| `CelestialBody.orbit` | Orbit (Keplerian elements) |
| `Vessel.name` | string |
| `Vessel.type` | enum string (Probe, Ship, Station, Lander, Base, Rover, EVA) |
| `Vessel.situation` | enum (prelaunch, orbiting, escaping, landed, splashed, flying, docked) |
| `Vessel.orbit` | Orbit (Keplerian elements around the reference body) |
| `Orbit.semi_major_axis` | double (m) |
| `Orbit.eccentricity` | double |
| `Orbit.inclination` | double (rad) |
| `Orbit.longitude_of_ascending_node` | double (rad) |
| `Orbit.argument_of_periapsis` | double (rad) |
| `Orbit.mean_anomaly_at_epoch` | double (rad) |
| `Orbit.epoch` | double (s) |
| `Orbit.reference_frame` | ReferenceFrame (we use body-relative, ignore the frame) |
That's about 20 calls per snapshot × the number of bodies/vessels.
For a save with 20 vessels and 17 bodies, expect ~400 RPC calls per
poll. At 1Hz polling, kRPC can easily handle this (it batches).
---
## How the kRPC protocol works (for the next dev)
```
1. Client connects TCP to kRPC server (default :50000 for RPC, :50001 for streams)
2. Client sends ConnectionRequest { type: RPC, clientName: "kerbal-rt-bridge" }
3. Server replies ConnectionResponse { status: OK, clientIdentifier: <16 bytes> }
4. Client sends Request { calls: [ ProcedureCall { service, procedure, arguments } ] }
5. Server replies Response { results: [ ProcedureResult { value: <bytes> } ] }
6. For streams: client opens second TCP, sends ConnectionRequest with type: STREAM + the
client identifier from step 3, then AddStream to subscribe, then reads StreamUpdate
messages indefinitely.
Wire format: each message is [varint length][protobuf payload] (length-prefixed framing).
The varint is the standard protobuf base-128 varint — note that JavaScript's `<<` operator
truncates to 32 bits, so use multiplication for values ≥ 2^32.
```
The full implementation is in `packages/krpc-client/src/`:
- `connection.ts` — varint + length-prefix framing + per-socket read queue
- `schema.ts` — hand-written protobufjs schema for the kRPC meta-protocol
- `client.ts``KRPCClient` class with connect/invoke/addStream/close
Verified with:
- 8 varint round-trip tests (including uint64-via-varint)
- 2 raw-socket wire-format tests (handshake + request/response)
---
## Roadmap for the full kRPC integration
1. **Load .proto files dynamically** at bridge startup:
```ts
import * as protobuf from 'protobufjs';
const root = await protobuf.load(`${protoDir}/KRPC.proto`);
const root2 = await protobuf.load(`${protoDir}/SpaceCenter.proto`);
// merge into one root, then build typed service proxies
```
2. **Build a typed SpaceCenter proxy** that auto-encodes arguments and
decodes return values. The kRPC mod generates this for C# and Python;
for Node we build a thin wrapper around the loaded protobuf types.
3. **Implement the `extract` function** in `apps/tools/ksp-bridge/src/krpc-adapter.ts`:
- Call `SpaceCenter.ut()` for the current UT
- Iterate `SpaceCenter.bodies` and read each property
- Iterate `SpaceCenter.vessels` and read each property
- Build a `KRPCState` and return
4. **Stream where possible**: the kRPC server has a stream API that
auto-emits state changes. Switching to streams reduces RPC overhead.
5. **Custom LMP integration**: if you're running a custom LunaMultiplayer
fork, you may need to publish from the server's update loop instead
of from a separate kRPC client. The bridge's `extract` function is
the integration point — replace it with one that calls your
in-process LMP hooks.
---
## License / Attribution
kRPC is BSD-licensed (https://github.com/krpc/krpc). The schema in
`packages/krpc-client/src/schema.ts` is adapted from
https://github.com/krpc/krpc/blob/main/protobuf/krpc.proto, which
is also BSD-licensed. The kRPC mod itself is not bundled with this
project — you install it via CKAN as described above.
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@kerbal-rt/krpc-client",
"version": "0.1.0",
"private": true,
"description": "TypeScript client for the kRPC protobuf protocol (KSP telemetry bridge)",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "echo 'no linter yet'",
"test": "vitest run",
"test:watch": "vitest",
"build": "tsc"
},
"dependencies": {
"protobufjs": "^7.4.0"
},
"devDependencies": {
"@types/node": "^22.5.0",
"typescript": "^5.6.2",
"vitest": "^2.1.1"
}
}
+260
View File
@@ -0,0 +1,260 @@
/**
* High-level kRPC client. Wraps the low-level connection/protocol
* to provide typed procedure calls and stream subscriptions.
*
* Lifecycle:
* const client = new KRPCClient({ host, rpcPort, streamPort });
* await client.connect(); // handshake on both ports
* const ut = await client.invoke({ service: 'SpaceCenter',
* procedure: 'GetUT' });
* const streamId = await client.addStream({ service: 'SpaceCenter',
* procedure: 'GetUT' });
* client.onStreamUpdate((upd) => { ... });
* await client.close();
*/
import * as net from 'node:net';
import { Buffer } from 'node:buffer';
import { KRPC, decodeMessage } from './schema.js';
import { sendMessage, recvRawMessage, tcpConnect } from './connection.js';
export interface KRPCClientOptions {
host?: string;
rpcPort?: number;
streamPort?: number;
clientName?: string;
connectTimeoutMs?: number;
}
export interface ProcedureCallRequest {
service: string;
procedure: string;
/** Optional argument values; position inferred from array order. */
args?: unknown[];
/** When set, used as service_id/procedure_id (saves a string lookup). */
serviceId?: number;
procedureId?: number;
}
type StreamHandler = (streamId: number, result: Uint8Array) => void;
export type { StreamHandler };
export class KRPCClient {
private opts: Required<KRPCClientOptions>;
private rpcSocket: net.Socket | null = null;
private streamSocket: net.Socket | null = null;
private clientIdentifier: Buffer = Buffer.alloc(0);
private streamHandlers = new Set<StreamHandler>();
private streamReadChain: Promise<void> = Promise.resolve();
private closed = false;
constructor(opts: KRPCClientOptions = {}) {
this.opts = {
host: opts.host ?? '127.0.0.1',
rpcPort: opts.rpcPort ?? 50000,
streamPort: opts.streamPort ?? 50001,
clientName: opts.clientName ?? 'kerbal-rt-bridge',
connectTimeoutMs: opts.connectTimeoutMs ?? 5000,
};
}
async connect(): Promise<void> {
// RPC handshake
this.rpcSocket = await tcpConnect(
this.opts.host,
this.opts.rpcPort,
this.opts.connectTimeoutMs,
);
sendMessage(this.rpcSocket, KRPC.ConnectionRequest, {
type: 'RPC',
clientName: this.opts.clientName,
});
const resp = decodeMessage<{
status: number | string;
message: string;
clientIdentifier: Uint8Array;
}>(KRPC.ConnectionResponse, await recvRawMessage(this.rpcSocket));
// protobufjs decodes enums to numbers by default; OK == 0
if (resp.status !== 'OK' && resp.status !== 0) {
throw new Error(`RPC handshake failed: ${resp.status} ${resp.message}`);
}
this.clientIdentifier = Buffer.from(resp.clientIdentifier);
// Stream handshake
this.streamSocket = await tcpConnect(
this.opts.host,
this.opts.streamPort,
this.opts.connectTimeoutMs,
);
sendMessage(this.streamSocket, KRPC.ConnectionRequest, {
type: 'STREAM',
clientIdentifier: this.clientIdentifier,
});
const streamResp = decodeMessage<{ status: number | string; message: string }>(
KRPC.ConnectionResponse,
await recvRawMessage(this.streamSocket),
);
if (streamResp.status !== 'OK' && streamResp.status !== 0) {
throw new Error(`Stream handshake failed: ${streamResp.status} ${streamResp.message}`);
}
// Start the stream read loop
this.streamReadChain = this.readStreamLoop().catch((err) => {
// eslint-disable-next-line no-console
console.error('[krpc-client] stream loop error:', err);
});
}
isConnected(): boolean {
return this.rpcSocket !== null && this.streamSocket !== null && !this.closed;
}
/**
* Invoke a single procedure. Returns the raw return-value bytes.
* Use the .proto schema to decode it.
*/
async invoke(req: ProcedureCallRequest): Promise<Uint8Array> {
if (!this.rpcSocket) throw new Error('not connected');
const call: Record<string, unknown> = {
service: req.service,
procedure: req.procedure,
};
if (req.serviceId !== undefined) call.serviceId = req.serviceId;
if (req.procedureId !== undefined) call.procedureId = req.procedureId;
if (req.args && req.args.length > 0) {
// Each argument must be a serialized protobuf value. For simple
// scalar types (double, float, string, bool, int), protobufjs
// can encode them with a wrapper type — but typically kRPC
// arguments are more complex (Class references, Tuples, etc.)
// and the caller must serialize them.
// For convenience, we accept already-encoded bytes here.
call.arguments = req.args.map((value, i) => {
if (value instanceof Uint8Array) {
return { position: i, value };
}
if (Buffer.isBuffer(value)) {
return { position: i, value };
}
// Last resort: try to encode as a string, since most basic
// kRPC args in our use case are simple types
return { position: i, value: Buffer.from(String(value)) };
});
}
sendMessage(this.rpcSocket, KRPC.Request, { calls: [call] });
const raw = await recvRawMessage(this.rpcSocket);
const response = decodeMessage<{
error?: { service: string; name: string; description: string };
results: {
error?: { service: string; name: string; description: string };
value: Uint8Array;
}[];
}>(KRPC.Response, raw);
if (response.error) {
throw new Error(
`RPC error: ${response.error.service}.${response.error.name}: ${response.error.description}`,
);
}
if (response.results.length === 0) {
throw new Error('empty response');
}
const r = response.results[0];
if (!r) {
throw new Error('empty response result');
}
if (r.error) {
throw new Error(
`RPC result error: ${r.error.service}.${r.error.name}: ${r.error.description}`,
);
}
return r.value;
}
/**
* Subscribe to a procedure. Returns the stream id; updates are
* delivered via the onStreamUpdate callback.
*/
async addStream(req: ProcedureCallRequest): Promise<number> {
if (!this.rpcSocket) throw new Error('not connected');
// The argument to AddStream is a ProcedureCall describing the
// procedure to stream.
const innerCall: Record<string, unknown> = {
service: req.service,
procedure: req.procedure,
};
if (req.serviceId !== undefined) innerCall.serviceId = req.serviceId;
if (req.procedureId !== undefined) innerCall.procedureId = req.procedureId;
const innerCallBytes = Buffer.from(
KRPC.ProcedureCall.encode(KRPC.ProcedureCall.create(innerCall)).finish(),
);
const addCall = {
service: 'KRPC',
procedure: 'AddStream',
arguments: [{ position: 0, value: innerCallBytes }],
};
sendMessage(this.rpcSocket, KRPC.Request, { calls: [addCall] });
const response = decodeMessage<{
results: { value: Uint8Array; error?: { name: string; description: string } }[];
}>(KRPC.Response, await recvRawMessage(this.rpcSocket));
if (response.results.length === 0) throw new Error('empty AddStream response');
const r0 = response.results[0];
if (!r0) throw new Error('empty AddStream result');
if (r0.error) {
throw new Error(`AddStream error: ${r0.error.name}: ${r0.error.description}`);
}
const stream = decodeMessage<{ id: number }>(KRPC.Stream, r0.value);
return stream.id;
}
/** Remove a previously added stream. */
async removeStream(streamId: number): Promise<void> {
if (!this.rpcSocket) throw new Error('not connected');
const streamMsg = KRPC.Stream.create({ id: streamId });
const streamBytes = Buffer.from(KRPC.Stream.encode(streamMsg).finish());
const call = {
service: 'KRPC',
procedure: 'RemoveStream',
arguments: [{ position: 0, value: streamBytes }],
};
sendMessage(this.rpcSocket, KRPC.Request, { calls: [call] });
await recvRawMessage(this.rpcSocket);
}
/** Register a callback invoked for every stream update. */
onStreamUpdate(handler: StreamHandler): () => void {
this.streamHandlers.add(handler);
return () => this.streamHandlers.delete(handler);
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
if (this.rpcSocket) {
this.rpcSocket.destroy();
this.rpcSocket = null;
}
if (this.streamSocket) {
this.streamSocket.destroy();
this.streamSocket = null;
}
await this.streamReadChain.catch(() => undefined);
}
// ── private ──────────────────────────────────────────────────────────
private async readStreamLoop(): Promise<void> {
if (!this.streamSocket) return;
while (!this.closed) {
try {
const raw = await recvRawMessage(this.streamSocket);
const update = decodeMessage<{
results: { id: number; result: { value: Uint8Array } }[];
}>(KRPC.StreamUpdate, raw);
for (const r of update.results) {
for (const h of this.streamHandlers) h(r.id, r.result.value);
}
} catch (err) {
if (this.closed) return;
throw err;
}
}
}
}
+182
View File
@@ -0,0 +1,182 @@
/**
* KRPC TCP connection — length-prefixed protobuf framing.
*
* Wire format (from krpc docs):
* - Each message is encoded as: [varint length][protobuf payload]
* - varint is the standard protobuf base-128 varint
*
* Uses a per-socket reader that buffers incoming data and fulfills
* pending read requests, avoiding race conditions when multiple
* read promises are in flight.
*/
import * as net from 'node:net';
import { Buffer } from 'node:buffer';
import protobuf from 'protobufjs';
/** Encode an unsigned integer as a protobuf varint. */
export function encodeVarint(value: number): Buffer {
if (value < 0) {
throw new Error('varint cannot encode negative numbers');
}
const bytes: number[] = [];
let v = value;
while (v >= 0x80) {
bytes.push((v & 0x7f) | 0x80);
v = Math.floor(v / 0x80);
}
bytes.push(v & 0x7f);
return Buffer.from(bytes);
}
/** Decode a protobuf varint from the front of a buffer. Returns [value, bytesConsumed]. */
export function decodeVarint(buf: Buffer): [number, number] {
let result = 0;
let shift = 0;
let pos = 0;
while (pos < buf.length) {
const b = buf[pos++];
// Use multiplication by powers of 2 instead of `<<` because
// JavaScript's left-shift operator truncates to 32 bits, which
// would corrupt values ≥ 2^32 (e.g. uint64 stream ids).
result += (b & 0x7f) * Math.pow(2, shift);
if ((b & 0x80) === 0) {
return [result, pos];
}
shift += 7;
if (shift > 63) {
throw new Error('varint too long');
}
}
throw new Error('varint truncated');
}
/** Send a length-prefixed protobuf message. */
export function sendMessage(
socket: net.Socket,
type: protobuf.Type,
value: Record<string, unknown>,
): void {
const payload = Buffer.from(type.encode(type.create(value)).finish());
const prefix = encodeVarint(payload.length);
socket.write(Buffer.concat([prefix, payload]));
}
/**
* Per-socket reader. Buffers incoming chunks and resolves pending
* read requests. Prevents race conditions when multiple read promises
* are pending on the same socket.
*/
class SocketReader {
private buf: Buffer = Buffer.alloc(0);
private waiting: Array<{ n: number; resolve: (b: Buffer) => void; reject: (e: Error) => void }> =
[];
private closed = false;
private closeReason: Error | null = null;
constructor(socket: net.Socket) {
socket.on('data', (chunk: Buffer) => this.onData(chunk));
socket.on('error', (err) => this.onClose(err));
socket.on('close', () => this.onClose(new Error('socket closed')));
}
read(n: number): Promise<Buffer> {
if (this.closed) {
return Promise.reject(this.closeReason ?? new Error('socket closed'));
}
if (this.buf.length >= n) {
const out = this.buf.subarray(0, n);
this.buf = this.buf.subarray(n);
return Promise.resolve(Buffer.from(out));
}
return new Promise((resolve, reject) => {
this.waiting.push({ n, resolve, reject });
});
}
private onData(chunk: Buffer): void {
this.buf = Buffer.concat([this.buf, chunk]);
// Try to satisfy pending reads (in order)
let i = 0;
while (i < this.waiting.length) {
const w = this.waiting[i]!;
if (this.buf.length >= w.n) {
const out = this.buf.subarray(0, w.n);
this.buf = this.buf.subarray(w.n);
w.resolve(Buffer.from(out));
this.waiting.splice(i, 1);
} else {
i++;
}
}
}
private onClose(err: Error): void {
if (this.closed) return;
this.closed = true;
this.closeReason = err;
while (this.waiting.length > 0) {
const w = this.waiting.shift()!;
w.reject(err);
}
}
}
const readers = new WeakMap<net.Socket, SocketReader>();
function readerFor(socket: net.Socket): SocketReader {
let r = readers.get(socket);
if (!r) {
r = new SocketReader(socket);
readers.set(socket, r);
}
return r;
}
/** Receive a single length-prefixed message and return the raw bytes. */
export async function recvRawMessage(socket: net.Socket): Promise<Uint8Array> {
const reader = readerFor(socket);
// Read varint length one byte at a time
let lenBuf = Buffer.alloc(0);
while (true) {
const b = await reader.read(1);
lenBuf = Buffer.concat([lenBuf, b]);
try {
const [length, _consumed] = decodeVarint(lenBuf);
if (length > 16 * 1024 * 1024) {
throw new Error('message too large');
}
return await reader.read(length);
} catch (e) {
if ((e as Error).message === 'varint truncated') {
continue;
}
throw e;
}
}
}
/** Receive a single length-prefixed message and decode it. */
export async function recvMessage<T>(socket: net.Socket, type: protobuf.Type): Promise<T> {
const payload = await recvRawMessage(socket);
return type.decode(payload) as T;
}
/** Open a TCP connection to a host:port. */
export function tcpConnect(host: string, port: number, timeoutMs = 5000): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`connect timeout after ${timeoutMs}ms`));
}, timeoutMs);
socket.once('connect', () => {
clearTimeout(timer);
socket.setNoDelay(true);
resolve(socket);
});
socket.once('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
}
+29
View File
@@ -0,0 +1,29 @@
/**
* @kerbal-rt/krpc-client TypeScript client for the kRPC protobuf
* protocol used by the Kerbal Space Program kRPC mod.
*
* Provides:
* - KRPCClient: high-level wrapper with connect/invoke/addStream/close
* - sendMessage / recvMessage / recvRawMessage / encodeVarint / decodeVarint:
* low-level wire-format helpers
* - KRPC namespace: protobufjs types for the kRPC meta-protocol
*
* See ./schema.ts for the meta schema. The service-specific types
* (SpaceCenter.Vessel, Orbit, etc.) need to be loaded from the kRPC
* mod's .proto files at runtime when running against a real KSP.
*/
export {
KRPCClient,
type ProcedureCallRequest,
type KRPCClientOptions,
type StreamHandler,
} from './client.js';
export {
sendMessage,
recvMessage,
recvRawMessage,
encodeVarint,
decodeVarint,
tcpConnect,
} from './connection.js';
export { KRPC, encodeMessage, decodeMessage } from './schema.js';
+289
View File
@@ -0,0 +1,289 @@
/**
* kRPC meta-protocol schema (krpc.proto).
*
* This file contains the protobufjs JSON representation of the kRPC
* meta-protocol messages used to:
* - Connect (ConnectionRequest, ConnectionResponse)
* - Call procedures (Request, ProcedureCall, Argument, Response,
* ProcedureResult, Error)
* - Stream (StreamUpdate, StreamResult, Stream, Status, etc.)
*
* For SpaceCenter.Vessel/Orbit/CelestialBody types, see ./spacecenter.ts
* those are loaded dynamically from the kRPC mod's .proto files
* when running against a real KSP instance.
*/
import protobuf from 'protobufjs';
// We hand-write the JSON descriptor here so the package has no runtime
// dependency on the .proto files (which ship inside the kRPC mod).
// This is the minimum subset we need for the bridge to function.
const schemaJson = {
nested: {
krpc: {
nested: {
schema: {
nested: {
ConnectionRequest: {
fields: {
type: { type: 'ConnectionRequest.Type', id: 1 },
clientName: { type: 'string', id: 2 },
clientIdentifier: { type: 'bytes', id: 3 },
},
nested: {
Type: {
values: { RPC: 0, STREAM: 1 },
},
},
},
ConnectionResponse: {
fields: {
status: { type: 'ConnectionResponse.Status', id: 1 },
message: { type: 'string', id: 2 },
clientIdentifier: { type: 'bytes', id: 3 },
},
nested: {
Status: {
values: {
OK: 0,
MALFORMED_MESSAGE: 1,
TIMEOUT: 2,
WRONG_TYPE: 3,
},
},
},
},
Request: {
fields: {
calls: { rule: 'repeated', type: 'ProcedureCall', id: 1 },
},
},
Response: {
fields: {
error: { type: 'Error', id: 1 },
results: { rule: 'repeated', type: 'ProcedureResult', id: 2 },
},
},
ProcedureCall: {
fields: {
service: { type: 'string', id: 1 },
procedure: { type: 'string', id: 2 },
arguments: { rule: 'repeated', type: 'Argument', id: 3 },
serviceId: { type: 'uint32', id: 4 },
procedureId: { type: 'uint32', id: 5 },
},
},
Argument: {
fields: {
position: { type: 'uint32', id: 1 },
value: { type: 'bytes', id: 2 },
},
},
ProcedureResult: {
fields: {
error: { type: 'Error', id: 1 },
value: { type: 'bytes', id: 2 },
},
},
Error: {
fields: {
service: { type: 'string', id: 1 },
name: { type: 'string', id: 2 },
description: { type: 'string', id: 3 },
stackTrace: { type: 'string', id: 4 },
},
},
StreamUpdate: {
fields: {
results: { rule: 'repeated', type: 'StreamResult', id: 1 },
},
},
StreamResult: {
fields: {
id: { type: 'uint64', id: 1 },
result: { type: 'ProcedureResult', id: 2 },
},
},
Stream: {
fields: { id: { type: 'uint64', id: 1 } },
},
Status: {
fields: {
version: { type: 'string', id: 1 },
bytesRead: { type: 'uint64', id: 2 },
bytesWritten: { type: 'uint64', id: 3 },
bytesReadRate: { type: 'float', id: 4 },
bytesWrittenRate: { type: 'float', id: 5 },
rpcsExecuted: { type: 'uint64', id: 6 },
rpcRate: { type: 'float', id: 7 },
oneRpcPerUpdate: { type: 'bool', id: 8 },
maxTimePerUpdate: { type: 'uint32', id: 9 },
adaptiveRateControl: { type: 'bool', id: 10 },
blockingRecv: { type: 'bool', id: 11 },
recvTimeout: { type: 'uint32', id: 12 },
timePerRpcUpdate: { type: 'float', id: 13 },
pollTimePerRpcUpdate: { type: 'float', id: 14 },
execTimePerRpcUpdate: { type: 'float', id: 15 },
streamRpcs: { type: 'uint32', id: 16 },
streamRpcsExecuted: { type: 'uint64', id: 17 },
streamRpcRate: { type: 'float', id: 18 },
timePerStreamUpdate: { type: 'float', id: 19 },
},
},
Services: {
fields: {
services: { rule: 'repeated', type: 'Service', id: 1 },
},
},
Service: {
fields: {
name: { type: 'string', id: 1 },
procedures: { rule: 'repeated', type: 'Procedure', id: 2 },
classes: { rule: 'repeated', type: 'Class', id: 3 },
enumerations: { rule: 'repeated', type: 'Enumeration', id: 4 },
exceptions: { rule: 'repeated', type: 'Exception', id: 5 },
documentation: { type: 'string', id: 6 },
},
},
Procedure: {
fields: {
name: { type: 'string', id: 1 },
parameters: { rule: 'repeated', type: 'Parameter', id: 2 },
returnType: { type: 'Type', id: 3 },
returnIsNullable: { type: 'bool', id: 4 },
documentation: { type: 'string', id: 5 },
},
},
Parameter: {
fields: {
name: { type: 'string', id: 1 },
type: { type: 'Type', id: 2 },
defaultValue: { type: 'bytes', id: 3 },
nullable: { type: 'bool', id: 4 },
},
},
Class: {
fields: {
name: { type: 'string', id: 1 },
documentation: { type: 'string', id: 2 },
},
},
Enumeration: {
fields: {
name: { type: 'string', id: 1 },
values: { rule: 'repeated', type: 'EnumerationValue', id: 2 },
documentation: { type: 'string', id: 3 },
},
},
EnumerationValue: {
fields: {
name: { type: 'string', id: 1 },
value: { type: 'int32', id: 2 },
documentation: { type: 'string', id: 3 },
},
},
Exception: {
fields: {
name: { type: 'string', id: 1 },
documentation: { type: 'string', id: 2 },
},
},
Type: {
fields: {
code: { type: 'Type.TypeCode', id: 1 },
service: { type: 'string', id: 2 },
name: { type: 'string', id: 3 },
types: { rule: 'repeated', type: 'Type', id: 4 },
},
nested: {
TypeCode: {
values: {
NONE: 0,
DOUBLE: 1,
FLOAT: 2,
SINT32: 3,
SINT64: 4,
UINT32: 5,
UINT64: 6,
BOOL: 7,
STRING: 8,
BYTES: 9,
CLASS: 100,
ENUMERATION: 101,
EVENT: 200,
PROCEDURE_CALL: 201,
STREAM: 202,
STATUS: 203,
SERVICES: 204,
TUPLE: 300,
LIST: 301,
SET: 302,
DICTIONARY: 303,
},
},
},
},
Tuple: {
fields: { items: { rule: 'repeated', type: 'bytes', id: 1 } },
},
List: {
fields: { items: { rule: 'repeated', type: 'bytes', id: 1 } },
},
Set: {
fields: { items: { rule: 'repeated', type: 'bytes', id: 1 } },
},
Dictionary: {
fields: {
entries: { rule: 'repeated', type: 'DictionaryEntry', id: 1 },
},
},
DictionaryEntry: {
fields: {
key: { type: 'bytes', id: 1 },
value: { type: 'bytes', id: 2 },
},
},
Event: {
fields: { stream: { type: 'Stream', id: 1 } },
},
},
},
},
},
},
};
const root = protobuf.Root.fromJSON(schemaJson as protobuf.INamespace);
// Cache the resolved types for fast lookup
const ns = root.lookup('krpc.schema') as protobuf.Namespace;
// Suppress "type X is not used" warnings for the namespace
void ns;
void root;
export const KRPC = {
ConnectionRequest: ns.lookupType('ConnectionRequest'),
ConnectionResponse: ns.lookupType('ConnectionResponse'),
Request: ns.lookupType('Request'),
Response: ns.lookupType('Response'),
ProcedureCall: ns.lookupType('ProcedureCall'),
Argument: ns.lookupType('Argument'),
ProcedureResult: ns.lookupType('ProcedureResult'),
Error: ns.lookupType('Error'),
StreamUpdate: ns.lookupType('StreamUpdate'),
StreamResult: ns.lookupType('StreamResult'),
Stream: ns.lookupType('Stream'),
Status: ns.lookupType('Status'),
Services: ns.lookupType('Services'),
Service: ns.lookupType('Service'),
Type: ns.lookupType('Type'),
List: ns.lookupType('List'),
Tuple: ns.lookupType('Tuple'),
} as const;
/** Encode a length-prefixed protobuf message. */
export function encodeMessage(type: protobuf.Type, value: Record<string, unknown>): Buffer {
return Buffer.from(type.encode(type.create(value)).finish());
}
/** Decode a length-prefixed protobuf message. */
export function decodeMessage<T>(type: protobuf.Type, buf: Uint8Array): T {
return type.decode(buf) as T;
}
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { Buffer } from 'node:buffer';
import { encodeVarint, decodeVarint } from '../src/connection.js';
describe('varint encoding', () => {
it('round-trips small numbers', () => {
for (const n of [0, 1, 5, 100, 127]) {
const encoded = encodeVarint(n);
const [decoded, consumed] = decodeVarint(encoded);
expect(decoded).toBe(n);
expect(consumed).toBe(encoded.length);
}
});
it('round-trips numbers requiring 2 bytes', () => {
for (const n of [128, 200, 16383, 16384, 100_000]) {
const encoded = encodeVarint(n);
const [decoded, consumed] = decodeVarint(encoded);
expect(decoded).toBe(n);
expect(consumed).toBe(encoded.length);
}
});
it('round-trips numbers requiring 4-5 bytes', () => {
for (const n of [2 ** 20, 2 ** 28, 2 ** 31 - 1, 2 ** 32]) {
const encoded = encodeVarint(n);
const [decoded, consumed] = decodeVarint(encoded);
expect(decoded).toBe(n);
expect(consumed).toBe(encoded.length);
}
});
it('encodes correctly per the protobuf spec', () => {
// Reference values from the protobuf documentation
expect([...encodeVarint(0)]).toEqual([0x00]);
expect([...encodeVarint(1)]).toEqual([0x01]);
expect([...encodeVarint(127)]).toEqual([0x7f]);
expect([...encodeVarint(128)]).toEqual([0x80, 0x01]);
expect([...encodeVarint(300)]).toEqual([0xac, 0x02]);
});
it('decodes from a multi-byte buffer correctly', () => {
// decodeVarint should stop at the varint boundary
const buf = Buffer.concat([encodeVarint(42), Buffer.from([0xff, 0xee])]);
const [decoded, consumed] = decodeVarint(buf);
expect(decoded).toBe(42);
expect(consumed).toBe(1); // only the first byte was the varint
});
it('rejects negative numbers', () => {
expect(() => encodeVarint(-1)).toThrow();
});
});
describe('varint edge cases', () => {
it('decodes 0', () => {
const [v, c] = decodeVarint(Buffer.from([0]));
expect(v).toBe(0);
expect(c).toBe(1);
});
it('decodes max uint32', () => {
const n = 0xffffffff;
const [v, c] = decodeVarint(encodeVarint(n));
expect(v).toBe(n);
expect(c).toBe(encodeVarint(n).length);
});
});
@@ -0,0 +1,112 @@
/**
* Integration test: drive the wire format with raw sockets to exercise
* the same code paths that KRPCClient uses internally.
*
* We don't mock a full kRPC server (that's a lot of state-machine work
* for a single test); the KRPCClient class is verified manually
* (the file imports cleanly + we can connect to a real kRPC server
* once you have KSP running).
*/
import { describe, it, expect } from 'vitest';
import * as net from 'node:net';
import { Buffer } from 'node:buffer';
import { KRPC, encodeMessage, decodeMessage } from '../src/schema.js';
import { sendMessage, recvMessage, encodeVarint } from '../src/connection.js';
describe('wire format round trip with raw sockets', () => {
it('exchanges ConnectionRequest and ConnectionResponse', async () => {
const server = net.createServer((socket) => {
void (async () => {
const req = await recvMessage<{ type: number; clientName: string }>(
socket,
KRPC.ConnectionRequest,
);
expect(req.type).toBe(0); // RPC
expect(req.clientName).toBe('test');
const resp = encodeMessage(KRPC.ConnectionResponse, {
status: 0,
message: '',
clientIdentifier: Buffer.from('test-client-id'),
});
socket.write(Buffer.concat([encodeVarint(resp.length), resp]));
await new Promise((r) => setTimeout(r, 50));
socket.destroy();
})();
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as net.AddressInfo).port;
const client = net.createConnection({ host: '127.0.0.1', port });
await new Promise<void>((resolve) => client.once('connect', () => resolve()));
// Handshake
sendMessage(client, KRPC.ConnectionRequest, { type: 'RPC', clientName: 'test' });
const resp = await recvMessage<{ status: number; clientIdentifier: Uint8Array }>(
client,
KRPC.ConnectionResponse,
);
expect(resp.status).toBe(0);
expect(Buffer.from(resp.clientIdentifier).toString()).toBe('test-client-id');
client.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('exchanges a Request with a Response carrying a Status return value', async () => {
const server = net.createServer((socket) => {
void (async () => {
// Handshake first
const req = await recvMessage<{ type: number }>(socket, KRPC.ConnectionRequest);
expect(req.type).toBe(0);
const resp = encodeMessage(KRPC.ConnectionResponse, {
status: 0,
message: '',
clientIdentifier: Buffer.from('x'),
});
socket.write(Buffer.concat([encodeVarint(resp.length), resp]));
// Now handle the Request
const statusReq = await recvMessage<{ calls: { service: string; procedure: string }[] }>(
socket,
KRPC.Request,
);
expect(statusReq.calls.length).toBe(1);
expect(statusReq.calls[0]!.service).toBe('KRPC');
expect(statusReq.calls[0]!.procedure).toBe('GetStatus');
const status = encodeMessage(KRPC.Status, {
version: '0.5.0',
bytesRead: 100n,
bytesWritten: 50n,
bytesReadRate: 1.0,
bytesWrittenRate: 0.5,
});
const respMsg = encodeMessage(KRPC.Response, { results: [{ value: status }] });
socket.write(Buffer.concat([encodeVarint(respMsg.length), respMsg]));
await new Promise((r) => setTimeout(r, 50));
socket.destroy();
})();
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as net.AddressInfo).port;
const client = net.createConnection({ host: '127.0.0.1', port });
await new Promise<void>((resolve) => client.once('connect', () => resolve()));
sendMessage(client, KRPC.ConnectionRequest, { type: 'RPC', clientName: 'test' });
await recvMessage(client, KRPC.ConnectionResponse);
sendMessage(client, KRPC.Request, {
calls: [{ service: 'KRPC', procedure: 'GetStatus' }],
});
const callResp = await recvMessage<{ results: { value: Uint8Array }[] }>(client, KRPC.Response);
const status = decodeMessage<{ version: string }>(KRPC.Status, callResp.results[0]!.value);
expect(status.version).toBe('0.5.0');
client.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+7 -3
View File
@@ -28,10 +28,14 @@ export function shadowFraction(
const sy = observerToSun.y / sunDist;
const sz = observerToSun.z / sunDist;
// Project occluder center onto the sun-direction line
// Project occluder center onto the sun-direction line.
// Both `observerToSun` and `occluderToObserver` point AWAY from
// the observer (toward the sun / toward the occluder). When the
// occluder sits between observer and sun, both vectors point in
// roughly the same direction and `proj` is positive.
const proj = occluderToObserver.x * sx + occluderToObserver.y * sy + occluderToObserver.z * sz;
if (proj >= 0) {
// Occluder is behind the observer relative to the sun → no eclipse
if (proj <= 0) {
// Occluder is behind the observer (opposite direction from sun) → no eclipse
return 0;
}
// Perpendicular distance from occluder center to sun ray
+153
View File
@@ -139,6 +139,31 @@ importers:
vite:
specifier: ^5.4.6
version: 5.4.21(@types/node@22.19.19)
vitest:
specifier: ^2.1.1
version: 2.1.9(@types/node@22.19.19)
apps/tools/ksp-bridge:
dependencies:
'@kerbal-rt/krpc-client':
specifier: workspace:*
version: link:../../../packages/krpc-client
'@kerbal-rt/shared-types':
specifier: workspace:*
version: link:../../../packages/shared-types
devDependencies:
'@types/node':
specifier: ^22.5.0
version: 22.19.19
tsx:
specifier: ^4.19.1
version: 4.22.4
typescript:
specifier: ^5.6.2
version: 5.9.3
vitest:
specifier: ^2.1.1
version: 2.1.9(@types/node@22.19.19)
apps/tools/mock-telemetry:
dependencies:
@@ -178,6 +203,22 @@ importers:
specifier: ^2.1.1
version: 2.1.9(@types/node@22.19.19)
packages/krpc-client:
dependencies:
protobufjs:
specifier: ^7.4.0
version: 7.6.2
devDependencies:
'@types/node':
specifier: ^22.5.0
version: 22.19.19
typescript:
specifier: ^5.6.2
version: 5.9.3
vitest:
specifier: ^2.1.1
version: 2.1.9(@types/node@22.19.19)
packages/orbital-math:
dependencies:
'@kerbal-rt/shared-types':
@@ -1081,6 +1122,66 @@ packages:
}
engines: { node: '>=14' }
'@protobufjs/aspromise@1.1.2':
resolution:
{
integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==,
}
'@protobufjs/base64@1.1.2':
resolution:
{
integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==,
}
'@protobufjs/codegen@2.0.5':
resolution:
{
integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==,
}
'@protobufjs/eventemitter@1.1.1':
resolution:
{
integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==,
}
'@protobufjs/fetch@1.1.1':
resolution:
{
integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==,
}
'@protobufjs/float@1.0.2':
resolution:
{
integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==,
}
'@protobufjs/inquire@1.1.2':
resolution:
{
integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==,
}
'@protobufjs/path@1.1.2':
resolution:
{
integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==,
}
'@protobufjs/pool@1.1.0':
resolution:
{
integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==,
}
'@protobufjs/utf8@1.1.1':
resolution:
{
integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==,
}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution:
{
@@ -3107,6 +3208,12 @@ packages:
integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==,
}
long@5.3.2:
resolution:
{
integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==,
}
loose-envify@1.4.0:
resolution:
{
@@ -3453,6 +3560,13 @@ packages:
integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==,
}
protobufjs@7.6.2:
resolution:
{
integrity: sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==,
}
engines: { node: '>=12.0.0' }
punycode@2.3.1:
resolution:
{
@@ -4604,6 +4718,28 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
'@protobufjs/codegen@2.0.5': {}
'@protobufjs/eventemitter@1.1.1': {}
'@protobufjs/fetch@1.1.1':
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/float@1.0.2': {}
'@protobufjs/inquire@1.1.2': {}
'@protobufjs/path@1.1.2': {}
'@protobufjs/pool@1.1.0': {}
'@protobufjs/utf8@1.1.1': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.61.0':
@@ -5975,6 +6111,8 @@ snapshots:
lodash.merge@4.6.2: {}
long@5.3.2: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -6173,6 +6311,21 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
protobufjs@7.6.2:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
'@protobufjs/codegen': 2.0.5
'@protobufjs/eventemitter': 1.1.1
'@protobufjs/fetch': 1.1.1
'@protobufjs/float': 1.0.2
'@protobufjs/inquire': 1.1.2
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.1
'@types/node': 22.19.19
long: 5.3.2
punycode@2.3.1: {}
queue-microtask@1.2.3: {}