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:
@@ -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 };
|
||||
Reference in New Issue
Block a user