/** * 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, 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 { return sc.invoke(SERVICE, `CelestialBody.${method}`, bodyId); } async function getBodyString(sc: KrpcServices, bodyId: bigint, method: string): Promise { return sc.invoke(SERVICE, `CelestialBody.${method}`, bodyId); } async function getBodyClass( sc: KrpcServices, bodyId: bigint, method: string, ): Promise { return sc.invoke(SERVICE, `CelestialBody.${method}`, bodyId); } async function getVesselClass( sc: KrpcServices, vesselId: bigint, method: string, ): Promise { return sc.invoke(SERVICE, `Vessel.${method}`, vesselId); } async function getVesselString( sc: KrpcServices, vesselId: bigint, method: string, ): Promise { return sc.invoke(SERVICE, `Vessel.${method}`, vesselId); } async function getVesselEnum(sc: KrpcServices, vesselId: bigint, method: string): Promise { return sc.invoke(SERVICE, `Vessel.${method}`, vesselId); } // ── Keplerian elements ────────────────────────────────────────────────── async function readOrbit(sc: KrpcServices, orbitId: bigint): Promise { const [a, e, i, lan, argPe, m0, epoch] = await Promise.all([ sc.invoke(SERVICE, 'Orbit.GetSemiMajorAxis', orbitId), sc.invoke(SERVICE, 'Orbit.GetEccentricity', orbitId), sc.invoke(SERVICE, 'Orbit.GetInclination', orbitId), sc.invoke(SERVICE, 'Orbit.GetLongitudeOfAscendingNode', orbitId), sc.invoke(SERVICE, 'Orbit.GetArgumentOfPeriapsis', orbitId), sc.invoke(SERVICE, 'Orbit.GetMeanAnomalyAtEpoch', orbitId), sc.invoke(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, ): Promise { 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) { // The root body (the star — Kerbol in stock KSP) has no orbit in // the kRPC model: there's nothing it orbits around. Real KSP // returns null for the Sun's CelestialBody.get_Orbit(). Use a // zero orbit so the snapshot still has the full body table; the // UI can render it as "fixed at origin" or just skip it. return { name, kind: classifyBody(name), parentId: parentName, parentName, radius, sphereOfInfluence: soi, gravitationalParameter: gm, rotationPeriod: rot, axialTilt: tilt, orbit: zeroOrbit(), }; } 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, ): 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; vessels: Awaited>[]; /** 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 { // Top-level: time + body/vessel lists const [ut, bodyIds, vesselIds] = await Promise.all([ sc.invoke(SERVICE, 'GetUT'), sc.invoke(SERVICE, 'GetBodies'), sc.invoke(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(); 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 };