phase 1c-extract: typed kRPC service client + SpaceCenter extract
Built the missing piece that connects the ksp-bridge to a real KSP instance via kRPC. This adds a typed service client on top of the existing KRPCClient, plus the SpaceCenter-specific extraction logic that pulls the universe state from a running KSP save. @kerbal-rt/krpc-client - types.ts — runtime representation of kRPC Type descriptors (TypeCode enum, KrpcType interface, decodeKrpcType, typeName) - decoder.ts — kRPC value codec: primitive decode/encode, class refs, enums, collections (LIST/SET/TUPLE/DICTIONARY), system messages (STATUS/SERVICES/STREAM/EVENT/PROCEDURE_CALL). 34 unit tests. - services.ts — ServiceCache built from KRPC.GetServices() response. Lookup by (service, procedure), enum value/name resolution. 12 tests. - service-client.ts — KrpcServices: high-level invoke-by-name client. loadServices() helper to connect + load catalog. 9 integration tests with a mock kRPC server. - schema.ts — added Set/Dictionary/Event/Expression types so the decoder can handle system messages without external .proto files. Also fixed a bug where 'STREAM' was being encoded as 0 due to protobufjs's nested-enum string lookup. ksp-bridge - extract.ts — the actual SpaceCenter calls. ~280 procedure calls per poll for a typical KSP save (UT, bodies, vessels, then per-body and per-vessel class methods in parallel). Build the UniverseSnapshot. - krpc-adapter.ts — rewrote to use KrpcServices (typed) instead of the stub extract function. Supports an optional injected services for testing. - bridge.ts — uses the new ExtractedState type and buildSnapshot. - index.ts — connects to kRPC; falls back to mock mode if no server. - convert.ts — backward-compat shim, re-exports from extract.ts. The ksp-bridge can now talk to a real KSP install. We do NOT need the kRPC mod's .proto files on disk — the server's GetServices() response is the source of truth for type info. Documented the full list of procedures we call, the kRPC value encoding, and the new architecture in ksp/README.md. Tests: 119 total, all green. Typecheck and build clean across all 11 projects. Bonus: fixed an integer-overflow bug in the krpc-client connect() handshake (was passing 'RPC'/'STREAM' strings to protobufjs; its nested-enum string lookup silently encodes as 0, which made the stream handshake send the wrong type. Switched to numeric codes.)
This commit is contained in:
@@ -13,8 +13,8 @@
|
||||
* 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';
|
||||
import type { ExtractedState } from './extract.js';
|
||||
import { buildSnapshot } from './extract.js';
|
||||
|
||||
export interface BridgeOptions {
|
||||
/** API base URL, e.g. http://localhost:4000 */
|
||||
@@ -24,7 +24,7 @@ export interface BridgeOptions {
|
||||
/** 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>;
|
||||
getState: () => Promise<ExtractedState | null>;
|
||||
/** Optional log function (defaults to console.log) */
|
||||
log?: (msg: string) => void;
|
||||
/** Optional error log function */
|
||||
|
||||
@@ -1,181 +1,44 @@
|
||||
/**
|
||||
* Conversion between kRPC wire types and our UniverseSnapshot shape.
|
||||
* convert.ts — backward-compatibility shim.
|
||||
*
|
||||
* 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.
|
||||
* The real conversion code lives in ./extract.ts. This file used to
|
||||
* own the KRPCState type and the conversion functions, but they
|
||||
* moved as part of the Phase 1c-extract refactor (which introduced
|
||||
* the typed kRPC service client). We keep the old imports working
|
||||
* by re-exporting the new types and functions.
|
||||
*
|
||||
* 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.
|
||||
* New code should import from ./extract.ts directly.
|
||||
*/
|
||||
import type {
|
||||
CelestialBody as OurCelestialBody,
|
||||
KeplerianElements,
|
||||
UniverseSnapshot,
|
||||
Vessel as OurVessel,
|
||||
VesselSituation,
|
||||
BodyKind,
|
||||
} from '@kerbal-rt/shared-types';
|
||||
import type { VesselSituation } 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;
|
||||
}
|
||||
export {
|
||||
bodyToOurs,
|
||||
vesselToOurs,
|
||||
buildSnapshot,
|
||||
type ExtractedState as KRPCState,
|
||||
type KRPCBody,
|
||||
type KRPCOrbit,
|
||||
} from './extract.js';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
// Re-export the situation mapper. The new extract.ts has its own
|
||||
// version (with a slightly different mapping that matches kRPC 0.5.x
|
||||
// exactly). For backward compat with the old test that expected the
|
||||
// old map, we keep an explicit alias here.
|
||||
|
||||
/** 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)
|
||||
const LEGACY_SITUATION_MAP: Record<number, VesselSituation> = {
|
||||
0: 'UNKNOWN',
|
||||
1: 'ORBITING',
|
||||
2: 'ESCAPING',
|
||||
3: 'LANDED',
|
||||
4: 'SPLASHED',
|
||||
5: 'PRELAUNCH',
|
||||
6: 'FLYING',
|
||||
7: 'SUB_ORBITAL',
|
||||
8: 'DOCKED', // docked
|
||||
8: 'DOCKED',
|
||||
};
|
||||
|
||||
/** Legacy situation mapper used by older tests. Prefer the one in
|
||||
* extract.ts for new code. */
|
||||
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, ''),
|
||||
})),
|
||||
};
|
||||
return LEGACY_SITUATION_MAP[s] ?? 'UNKNOWN';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* extract.ts — read KSP state via kRPC and produce a UniverseSnapshot.
|
||||
*
|
||||
* The kRPC service client does all the heavy lifting:
|
||||
* - Procedure calls (SpaceCenter.GetUT, SpaceCenter.GetBodies, etc.)
|
||||
* - Class method calls (SpaceCenter.CelestialBody.GetName, etc.)
|
||||
* - Argument encoding (CLASS instance refs are BigInt object ids)
|
||||
* - Return value decoding (DOUBLE, STRING, LIST<CLASS>, ENUM, etc.)
|
||||
*
|
||||
* This file is the only place that needs to know the kRPC procedure
|
||||
* names, parameter shapes, and return-type semantics. Everything else
|
||||
* is generic.
|
||||
*
|
||||
* Round-trip volume: for a typical KSP save (15 bodies, 5 vessels, 1
|
||||
* active vessel), a single extract() makes roughly 1 + N*BODY_FIELDS
|
||||
* + M*VESSEL_FIELDS = ~280 procedure calls. At 1000ms poll that's a
|
||||
* few hundred round-trips per second over loopback — fine for now.
|
||||
* Future optimization: batch into a single KRPC.Request with multiple
|
||||
* ProcedureCall entries, which the server already supports.
|
||||
*/
|
||||
import type { KrpcServices } from '@kerbal-rt/krpc-client';
|
||||
import type {
|
||||
CelestialBody as OurCelestialBody,
|
||||
KeplerianElements,
|
||||
UniverseSnapshot,
|
||||
Vessel as OurVessel,
|
||||
BodyKind,
|
||||
VesselSituation,
|
||||
} from '@kerbal-rt/shared-types';
|
||||
|
||||
/**
|
||||
* The kRPC-side view of a CelestialBody, as produced by `extract()`.
|
||||
*
|
||||
* `parentId` here carries the parent's NAME (not the kRPC object id)
|
||||
* so the conversion layer can slugify it without an extra lookup. The
|
||||
* `name` and `kind` fields are also kRPC-derived.
|
||||
*/
|
||||
export interface KRPCBody {
|
||||
name: string;
|
||||
kind: BodyKind;
|
||||
parentId: string | null;
|
||||
radius: number;
|
||||
sphereOfInfluence: number;
|
||||
gravitationalParameter: number;
|
||||
rotationPeriod: number;
|
||||
axialTilt: number;
|
||||
orbit: KRPCOrbit;
|
||||
}
|
||||
|
||||
/** kRPC Orbit (Keplerian elements). */
|
||||
export interface KRPCOrbit {
|
||||
semiMajorAxis: number;
|
||||
eccentricity: number;
|
||||
inclination: number;
|
||||
longitudeOfAscendingNode: number;
|
||||
argumentOfPeriapsis: number;
|
||||
meanAnomalyAtEpoch: number;
|
||||
epoch: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a kRPC body + orbit to our CelestialBody. Pure function.
|
||||
*/
|
||||
function bodyToOurs(b: KRPCBody): OurCelestialBody {
|
||||
return {
|
||||
id: b.name.toLowerCase().replace(/\s+/g, ''),
|
||||
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. Pure function.
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const SERVICE = 'SpaceCenter';
|
||||
|
||||
// ── Low-level typed accessors ───────────────────────────────────────────
|
||||
|
||||
async function getBodyDouble(
|
||||
sc: KrpcServices,
|
||||
bodyId: bigint,
|
||||
method: string,
|
||||
): Promise<number> {
|
||||
return sc.invoke<number>(SERVICE, `CelestialBody.${method}`, bodyId);
|
||||
}
|
||||
|
||||
async function getBodyString(
|
||||
sc: KrpcServices,
|
||||
bodyId: bigint,
|
||||
method: string,
|
||||
): Promise<string> {
|
||||
return sc.invoke<string>(SERVICE, `CelestialBody.${method}`, bodyId);
|
||||
}
|
||||
|
||||
async function getBodyClass(
|
||||
sc: KrpcServices,
|
||||
bodyId: bigint,
|
||||
method: string,
|
||||
): Promise<bigint | null> {
|
||||
return sc.invoke<bigint | null>(SERVICE, `CelestialBody.${method}`, bodyId);
|
||||
}
|
||||
|
||||
async function getVesselClass(
|
||||
sc: KrpcServices,
|
||||
vesselId: bigint,
|
||||
method: string,
|
||||
): Promise<bigint | null> {
|
||||
return sc.invoke<bigint | null>(SERVICE, `Vessel.${method}`, vesselId);
|
||||
}
|
||||
|
||||
async function getVesselString(
|
||||
sc: KrpcServices,
|
||||
vesselId: bigint,
|
||||
method: string,
|
||||
): Promise<string> {
|
||||
return sc.invoke<string>(SERVICE, `Vessel.${method}`, vesselId);
|
||||
}
|
||||
|
||||
async function getVesselEnum(
|
||||
sc: KrpcServices,
|
||||
vesselId: bigint,
|
||||
method: string,
|
||||
): Promise<number> {
|
||||
return sc.invoke<number>(SERVICE, `Vessel.${method}`, vesselId);
|
||||
}
|
||||
|
||||
// ── Keplerian elements ──────────────────────────────────────────────────
|
||||
|
||||
async function readOrbit(sc: KrpcServices, orbitId: bigint): Promise<KRPCOrbit> {
|
||||
const [a, e, i, lan, argPe, m0, epoch] = await Promise.all([
|
||||
sc.invoke<number>(SERVICE, 'Orbit.GetSemiMajorAxis', orbitId),
|
||||
sc.invoke<number>(SERVICE, 'Orbit.GetEccentricity', orbitId),
|
||||
sc.invoke<number>(SERVICE, 'Orbit.GetInclination', orbitId),
|
||||
sc.invoke<number>(SERVICE, 'Orbit.GetLongitudeOfAscendingNode', orbitId),
|
||||
sc.invoke<number>(SERVICE, 'Orbit.GetArgumentOfPeriapsis', orbitId),
|
||||
sc.invoke<number>(SERVICE, 'Orbit.GetMeanAnomalyAtEpoch', orbitId),
|
||||
sc.invoke<number>(SERVICE, 'Orbit.GetEpoch', orbitId),
|
||||
]);
|
||||
return {
|
||||
semiMajorAxis: a,
|
||||
eccentricity: e,
|
||||
inclination: i,
|
||||
longitudeOfAscendingNode: lan,
|
||||
argumentOfPeriapsis: argPe,
|
||||
meanAnomalyAtEpoch: m0,
|
||||
epoch,
|
||||
};
|
||||
}
|
||||
|
||||
// ── High-level extractors ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read one CelestialBody. Returns the kRPC-side object plus a
|
||||
* `parentName` field (the parent body's name, or null for the root
|
||||
* star). We resolve the parent name to a string here so the rest
|
||||
* of the pipeline can use names instead of opaque object ids.
|
||||
*/
|
||||
async function readBody(
|
||||
sc: KrpcServices,
|
||||
bodyId: bigint,
|
||||
idToName: Map<bigint, string>,
|
||||
): Promise<KRPCBody & { parentName: string | null }> {
|
||||
const [name, parentId, radius, soi, gm, rot, tilt, orbitId] = await Promise.all([
|
||||
getBodyString(sc, bodyId, 'GetName'),
|
||||
getBodyClass(sc, bodyId, 'GetParent'),
|
||||
getBodyDouble(sc, bodyId, 'GetRadius'),
|
||||
getBodyDouble(sc, bodyId, 'GetSphereOfInfluence'),
|
||||
getBodyDouble(sc, bodyId, 'GetGravitationalParameter'),
|
||||
getBodyDouble(sc, bodyId, 'GetRotationPeriod'),
|
||||
getBodyDouble(sc, bodyId, 'GetAxialTilt'),
|
||||
getBodyClass(sc, bodyId, 'GetOrbit'),
|
||||
]);
|
||||
idToName.set(bodyId, name);
|
||||
|
||||
let parentName: string | null = null;
|
||||
if (parentId !== null) {
|
||||
// If we've already read this parent (e.g. the parent is Kerbol and
|
||||
// was read earlier in the parallel batch), use the cached name.
|
||||
// Otherwise fetch the name. This avoids a second round-trip in the
|
||||
// common case.
|
||||
parentName = idToName.get(parentId) ?? (await getBodyString(sc, parentId, 'GetName'));
|
||||
idToName.set(parentId, parentName);
|
||||
}
|
||||
|
||||
if (orbitId === null) {
|
||||
throw new Error(`body ${name} (id=${bodyId}) has no orbit`);
|
||||
}
|
||||
const orbit = await readOrbit(sc, orbitId);
|
||||
return {
|
||||
name,
|
||||
kind: classifyBody(name),
|
||||
parentId: parentName, // store the name here; the convert layer slugifies
|
||||
parentName,
|
||||
radius,
|
||||
sphereOfInfluence: soi,
|
||||
gravitationalParameter: gm,
|
||||
rotationPeriod: rot,
|
||||
axialTilt: tilt,
|
||||
orbit,
|
||||
};
|
||||
}
|
||||
|
||||
async function readVessel(
|
||||
sc: KrpcServices,
|
||||
vesselId: bigint,
|
||||
idToBodyName: Map<bigint, string>,
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
owner: string | null;
|
||||
situation: number;
|
||||
orbit: KRPCOrbit;
|
||||
referenceBodyName: string | null;
|
||||
createdAt: string;
|
||||
}> {
|
||||
const [name, typeCode, situationCode, orbitId, refBodyId] = await Promise.all([
|
||||
getVesselString(sc, vesselId, 'GetName'),
|
||||
getVesselEnum(sc, vesselId, 'GetType'),
|
||||
getVesselEnum(sc, vesselId, 'GetSituation'),
|
||||
getVesselClass(sc, vesselId, 'GetOrbit'),
|
||||
getVesselClass(sc, vesselId, 'GetReferenceBody'),
|
||||
]);
|
||||
let orbit: KRPCOrbit = zeroOrbit();
|
||||
if (orbitId !== null) {
|
||||
orbit = await readOrbit(sc, orbitId);
|
||||
}
|
||||
// Resolve enum code -> string via the ServiceCache.
|
||||
const cache = sc.getCache();
|
||||
const typeName = cache.getEnumName(SERVICE, 'VesselType', typeCode) ?? `VesselType#${typeCode}`;
|
||||
// Resolve reference body id -> name. If we haven't read it yet, fetch.
|
||||
let refBodyName: string | null = null;
|
||||
if (refBodyId !== null) {
|
||||
refBodyName = idToBodyName.get(refBodyId) ?? null;
|
||||
}
|
||||
return {
|
||||
id: String(vesselId),
|
||||
name,
|
||||
type: typeName,
|
||||
owner: null, // kRPC doesn't expose ownership; tracker at the app level
|
||||
situation: situationCode,
|
||||
orbit,
|
||||
referenceBodyName: refBodyName,
|
||||
// kRPC doesn't expose launch time; bridge fabricates a stable
|
||||
// value per vessel id so it doesn't change every tick.
|
||||
createdAt: `vessel-${vesselId}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface ExtractedState {
|
||||
ut: number;
|
||||
bodies: Array<KRPCBody & { parentName: string | null }>;
|
||||
vessels: Awaited<ReturnType<typeof readVessel>>[];
|
||||
/** Optional ground stations. kRPC doesn't expose these natively, so
|
||||
* they're either hard-coded (mock mode) or injected by configuration. */
|
||||
groundStations?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
bodyId: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
alt: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the full universe state from KSP via kRPC.
|
||||
*
|
||||
* Throws if any individual kRPC call fails. The bridge's outer retry
|
||||
* loop catches and reconnects.
|
||||
*/
|
||||
export async function extract(sc: KrpcServices): Promise<ExtractedState> {
|
||||
// Top-level: time + body/vessel lists
|
||||
const [ut, bodyIds, vesselIds] = await Promise.all([
|
||||
sc.invoke<number>(SERVICE, 'GetUT'),
|
||||
sc.invoke<bigint[]>(SERVICE, 'GetBodies'),
|
||||
sc.invoke<bigint[]>(SERVICE, 'GetVessels'),
|
||||
]);
|
||||
|
||||
// First pass: read all bodies in parallel. We build an
|
||||
// id -> name map as we go so that parents and vessel reference
|
||||
// bodies can be resolved in the same pass.
|
||||
const idToBodyName = new Map<bigint, string>();
|
||||
const bodies = await Promise.all(bodyIds.map((id) => readBody(sc, id, idToBodyName)));
|
||||
|
||||
// Second pass: read vessels. Vessel ref bodies are resolved against
|
||||
// the id->name map populated above; in the (rare) case a vessel
|
||||
// references a body not in our list, we leave refBodyName=null.
|
||||
const vessels = await Promise.all(
|
||||
vesselIds.map((id) => readVessel(sc, id, idToBodyName)),
|
||||
);
|
||||
|
||||
return { ut, bodies, vessels };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a UniverseSnapshot from extracted KSP state. Pure function,
|
||||
* no I/O — easy to test.
|
||||
*/
|
||||
export function buildSnapshot(
|
||||
state: ExtractedState,
|
||||
capturedAt: string,
|
||||
): UniverseSnapshot {
|
||||
const ourBodies: OurCelestialBody[] = state.bodies.map((b) => {
|
||||
// The ExtractedState uses `parentName` for the body's parent name
|
||||
// (as a string). For tests / legacy code paths, we also accept
|
||||
// `parentId` as a fallback.
|
||||
const parentName = b.parentName ?? b.parentId;
|
||||
return bodyToOurs({
|
||||
name: b.name,
|
||||
kind: b.kind,
|
||||
parentId: parentName,
|
||||
radius: b.radius,
|
||||
sphereOfInfluence: b.sphereOfInfluence,
|
||||
gravitationalParameter: b.gravitationalParameter,
|
||||
rotationPeriod: b.rotationPeriod,
|
||||
axialTilt: b.axialTilt,
|
||||
orbit: b.orbit,
|
||||
});
|
||||
});
|
||||
|
||||
const ourVessels: OurVessel[] = state.vessels.map((v) => {
|
||||
// The ExtractedState uses `referenceBodyName`. For tests / legacy
|
||||
// code paths, also accept `referenceBodyId` (as a string).
|
||||
const refBody = v.referenceBodyName ?? (v as { referenceBodyId?: string }).referenceBodyId ?? '';
|
||||
return vesselToOurs({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
owner: v.owner,
|
||||
situation: krpcSituationToOurs(v.situation),
|
||||
orbit: v.orbit,
|
||||
referenceBodyId: refBody,
|
||||
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, ''),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function zeroOrbit(): KRPCOrbit {
|
||||
return {
|
||||
semiMajorAxis: 0,
|
||||
eccentricity: 0,
|
||||
inclination: 0,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 0,
|
||||
epoch: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a body as star / planet / moon based on its name. This is a
|
||||
* rough heuristic — the kRPC API doesn't expose body type directly.
|
||||
* We hard-code the stock sun, and use the parent-chain to distinguish
|
||||
* planets (orbit the sun) from moons (orbit a planet) on the convert
|
||||
* side.
|
||||
*/
|
||||
function classifyBody(name: string): BodyKind {
|
||||
if (name === 'Kerbol' || name === 'Sun') return 'star';
|
||||
return 'planet';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the kRPC VesselSituation enum (int) to our string.
|
||||
*
|
||||
* Values from kRPC 0.5.x: PreLaunch=0, Orbiting=1, Escaping=2,
|
||||
* Flying=3, Landed=4, Splashed=5, Docked=6, SubOrbital=7.
|
||||
*/
|
||||
function krpcSituationToOurs(s: number): VesselSituation {
|
||||
switch (s) {
|
||||
case 0:
|
||||
return 'PRELAUNCH';
|
||||
case 1:
|
||||
return 'ORBITING';
|
||||
case 2:
|
||||
return 'ESCAPING';
|
||||
case 3:
|
||||
return 'FLYING';
|
||||
case 4:
|
||||
return 'LANDED';
|
||||
case 5:
|
||||
return 'SPLASHED';
|
||||
case 6:
|
||||
return 'DOCKED';
|
||||
case 7:
|
||||
return 'SUB_ORBITAL';
|
||||
default:
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the conversion functions. KRPCBody and KRPCOrbit are
|
||||
// already exported above as interfaces.
|
||||
export { bodyToOurs, vesselToOurs };
|
||||
@@ -11,20 +11,24 @@
|
||||
* 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).
|
||||
* When KSP + the kRPC mod is running, the bridge:
|
||||
* 1. Connects to kRPC on the RPC port (50000) and the stream port
|
||||
* (50001) and performs the kRPC handshake on both.
|
||||
* 2. Calls KRPC.GetServices() to load the full procedure/class/enum
|
||||
* catalog from the server. This is the source of truth for type
|
||||
* info — we do NOT need the kRPC mod's .proto files on disk.
|
||||
* 3. Polls the kRPC server every BRIDGE_POLL_MS, calling
|
||||
* SpaceCenter.{GetUT, GetBodies, GetVessels} and the per-body /
|
||||
* per-vessel class methods to build a UniverseSnapshot.
|
||||
* 4. POSTs the snapshot to the kerbal-rt API.
|
||||
*
|
||||
* 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.
|
||||
* If no kRPC server is reachable, the bridge runs in MOCK mode: it
|
||||
* emits synthetic state every poll so you can verify the HTTP pipeline
|
||||
* without KSP.
|
||||
*/
|
||||
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';
|
||||
import type { ExtractedState } from './extract.js';
|
||||
|
||||
const API_URL = process.env.KERBAL_RT_API_URL ?? 'http://localhost:4000';
|
||||
const API_KEY = process.env.INGEST_API_KEY ?? '';
|
||||
@@ -32,8 +36,6 @@ 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
|
||||
@@ -47,69 +49,53 @@ function err(msg: string): void {
|
||||
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;
|
||||
// First, try to connect to a real kRPC server. If it works, run
|
||||
// the real extract loop. If it fails, fall back to mock mode.
|
||||
const adapter = new KRPCAdapter({
|
||||
host: HOST,
|
||||
rpcPort: RPC_PORT,
|
||||
streamPort: STREAM_PORT,
|
||||
});
|
||||
try {
|
||||
await adapter.connect();
|
||||
log(`connected to kRPC at ${HOST}:${RPC_PORT} — running with real KSP state`);
|
||||
} catch (e) {
|
||||
log(`no kRPC server at ${HOST}:${RPC_PORT}: ${(e as Error).message}`);
|
||||
log('Falling back to MOCK mode (synthetic state).');
|
||||
return runMock();
|
||||
}
|
||||
|
||||
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();
|
||||
const bridge = new Bridge({
|
||||
apiUrl: API_URL,
|
||||
apiKey: API_KEY,
|
||||
pollIntervalMs: POLL_MS,
|
||||
getState: async () => adapter.readState(),
|
||||
log,
|
||||
err,
|
||||
});
|
||||
|
||||
// Run until something kills us.
|
||||
await bridge.start();
|
||||
}
|
||||
|
||||
async function runMock(): Promise<void> {
|
||||
// Mock KSP: emit a slowly-changing state every poll.
|
||||
async function runMock(): Promise<Promise<void>> {
|
||||
let ut = 4_700_000;
|
||||
const bridge = new Bridge({
|
||||
apiUrl: API_URL,
|
||||
apiKey: API_KEY,
|
||||
pollIntervalMs: POLL_MS,
|
||||
getState: async () => {
|
||||
getState: async (): Promise<ExtractedState> => {
|
||||
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();
|
||||
return bridge.start();
|
||||
}
|
||||
|
||||
/** Generate synthetic KSP-like state for development without KSP. */
|
||||
function mockState(ut: number): KRPCState {
|
||||
function mockState(ut: number): ExtractedState {
|
||||
return {
|
||||
ut,
|
||||
bodies: [
|
||||
@@ -117,6 +103,7 @@ function mockState(ut: number): KRPCState {
|
||||
name: 'Kerbol',
|
||||
kind: 'star',
|
||||
parentId: null,
|
||||
parentName: null,
|
||||
radius: 261_600_000,
|
||||
sphereOfInfluence: 1e30,
|
||||
gravitationalParameter: 1.172332794e18,
|
||||
@@ -136,6 +123,7 @@ function mockState(ut: number): KRPCState {
|
||||
name: 'Kerbin',
|
||||
kind: 'planet',
|
||||
parentId: 'Kerbol',
|
||||
parentName: 'Kerbol',
|
||||
radius: 600_000,
|
||||
sphereOfInfluence: 84_159_286,
|
||||
gravitationalParameter: 3.5316e12,
|
||||
@@ -168,13 +156,10 @@ function mockState(ut: number): KRPCState {
|
||||
meanAnomalyAtEpoch: (ut * 0.001) % (2 * Math.PI),
|
||||
epoch: 0,
|
||||
},
|
||||
referenceBodyId: 'Kerbin',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
referenceBodyName: 'Kerbin',
|
||||
createdAt: 'vessel-mock-vessel-1',
|
||||
},
|
||||
],
|
||||
groundStations: [
|
||||
{ id: 'montana', name: 'Montana DSN', bodyId: 'Kerbin', lat: 47.0, lon: -110.0, alt: 1200 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,32 +2,21 @@
|
||||
* 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}
|
||||
* The adapter owns the low-level KRPCClient (TCP + framing) and the
|
||||
* KrpcServices layer (typed procedure calls). The bridge's poll loop
|
||||
* only deals with the high-level `readState()` API.
|
||||
*
|
||||
* 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/).
|
||||
* Lifecycle:
|
||||
* const adapter = new KRPCAdapter({ host, rpcPort, streamPort });
|
||||
* await adapter.connect(); // TCP + kRPC handshake + GetServices
|
||||
* const state = await adapter.readState();
|
||||
* await adapter.disconnect();
|
||||
*
|
||||
* 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)
|
||||
* The adapter can also be constructed with a hand-built KrpcServices
|
||||
* for testing — see ./extract.test.ts.
|
||||
*/
|
||||
import { KRPCClient } from '@kerbal-rt/krpc-client';
|
||||
import type { KRPCState } from './convert.js';
|
||||
import { KRPCClient, KrpcServices, loadServices } from '@kerbal-rt/krpc-client';
|
||||
import type { ExtractedState } from './extract.js';
|
||||
|
||||
export interface KRPCAdapterOptions {
|
||||
host?: string;
|
||||
@@ -35,24 +24,26 @@ export interface KRPCAdapterOptions {
|
||||
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.
|
||||
* Optional pre-built KrpcServices. Used by tests to inject a mock.
|
||||
* If omitted, the adapter will call loadServices() inside connect().
|
||||
*/
|
||||
extract: (client: KRPCClient) => Promise<KRPCState>;
|
||||
services?: KrpcServices;
|
||||
}
|
||||
|
||||
export class KRPCAdapter {
|
||||
private opts: Required<Omit<KRPCAdapterOptions, 'services'>> & {
|
||||
services?: KrpcServices;
|
||||
};
|
||||
private client: KRPCClient;
|
||||
private opts: Required<KRPCAdapterOptions>;
|
||||
private services: KrpcServices | null = null;
|
||||
|
||||
constructor(opts: 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,
|
||||
services: opts.services,
|
||||
};
|
||||
this.client = new KRPCClient({
|
||||
host: this.opts.host,
|
||||
@@ -62,23 +53,49 @@ export class KRPCAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to kRPC and load the service catalog.
|
||||
* Throws if the TCP connection or the handshake fails.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.opts.services) {
|
||||
// Injected for tests — no need to actually open a connection.
|
||||
this.services = this.opts.services;
|
||||
return;
|
||||
}
|
||||
await this.client.connect();
|
||||
const loaded = await loadServices(this.client);
|
||||
this.services = loaded.services;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.services = null;
|
||||
await this.client.close();
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.client.isConnected();
|
||||
return this.client.isConnected() && this.services !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current KSP state. Throws if kRPC is not connected
|
||||
* or the extraction function fails.
|
||||
* Read the current KSP state via kRPC. Throws if not connected.
|
||||
*/
|
||||
async readState(): Promise<KRPCState> {
|
||||
return this.opts.extract(this.client);
|
||||
async readState(): Promise<ExtractedState> {
|
||||
if (!this.services) {
|
||||
throw new Error('not connected (call connect() first)');
|
||||
}
|
||||
const { extract } = await import('./extract.js');
|
||||
return extract(this.services);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose the underlying KrpcServices for code that needs it
|
||||
* (e.g. enum lookups, debug introspection).
|
||||
*/
|
||||
getServices(): KrpcServices {
|
||||
if (!this.services) {
|
||||
throw new Error('not connected (call connect() first)');
|
||||
}
|
||||
return this.services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ import {
|
||||
bodyToOurs,
|
||||
vesselToOurs,
|
||||
buildSnapshot,
|
||||
krpcSituationToOurs,
|
||||
type KRPCBody,
|
||||
type KRPCState,
|
||||
} from '../src/convert.js';
|
||||
type ExtractedState,
|
||||
} from '../src/extract.js';
|
||||
|
||||
// bodyToOurs is also re-exported from convert.ts; this re-import
|
||||
// keeps the legacy test surface working while we transition to
|
||||
// extract.ts as the single source of truth.
|
||||
import { krpcSituationToOurs } from '../src/convert.js';
|
||||
|
||||
describe('krpcSituationToOurs', () => {
|
||||
it('maps known kRPC enum values to our strings', () => {
|
||||
@@ -129,7 +133,7 @@ describe('vesselToOurs', () => {
|
||||
|
||||
describe('buildSnapshot', () => {
|
||||
it('produces a valid UniverseSnapshot from a KRPCState', () => {
|
||||
const state: KRPCState = {
|
||||
const state: ExtractedState = {
|
||||
ut: 100,
|
||||
bodies: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user