Phase 1c: real kRPC bridge (full protocol + mock mode for development)
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
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.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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, ''),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user