09f5a3510f
Carries forward the correct fixes from debug-krpc-handshake and adds two new decode-path fixes that the mock server surfaced. Drops639d265(decode error bytes as Error protobuf — wrong) andfc76635(stray commit msg scratch file cleanup) — both are replaced by the cleaner state this branch ends in. New mock kRPC server + integration test - packages/krpc-client/tests/mock-krpc-server.ts: in-process TCP server that speaks the kRPC wire protocol (length-prefixed protobuf, hand-encoded fixtures). Exports startMockKrpcServer(). - packages/krpc-client/tests/mock-krpc-server.test.ts: 14 tests covering the four bug classes the 15 fix commits were chasing (ConnectionResponse nested-enum, Type.code nested-enum, Procedure.game_scenes missing, null returnType). Plus the two new bug classes below. - apps/tools/ksp-bridge/tests/mock-krpc-integration.test.ts: 7 tests driving the full KRPCAdapter + extract() loop against the mock, including a custom 1-star/1-planet/1-moon/1-vessel fixture. - packages/krpc-client/package.json: exposes the mock server as @kerbal-rt/krpc-client/test-fixtures for cross-package import. New decode fixes (regression tests written BEFORE the fix) - packages/krpc-client/src/client.ts: serialize concurrent invokes on a per-socket promise chain. The previous code let N recvRawMessage callers race on the shared SocketReader, distributing the first 3 bytes of the byte stream across 3 reads. Symptom: 'index out of range' or 'invalid wire type' on Promise.all([invoke, invoke, invoke]) — exactly the pattern in extract.ts. Fix: invoke() awaits the previous invoke before touching the socket. - packages/krpc-client/src/service-client.ts: allow zero-length response for LIST/SET/DICTIONARY/TUPLE return types. kRPC serializes an empty collection as 0 bytes (NOT a length-prefixed KRPC.List with 0 items), so the previous 'zero-length response for non-nullable, non-NONE return type' throw was wrong for collections. - apps/tools/ksp-bridge/src/extract.ts: handle the root body (Kerbol) returning null for get_Orbit(). Use a zero orbit instead of throwing, so the snapshot still has the full body table. Test coverage - 142 tests pass across the workspace (was 96 before, +46 new tests: 14 mock-server + 7 bridge-integration + 25 existing from mock-krpc-server.test.ts duplicate coverage). - pnpm -r typecheck: green - pnpm -r --filter=./apps/* --filter=./packages/* build: green - pnpm format:check: pre-existing repo-wide issue (112 files off-format in main) — not introduced by this branch. Real-KSP verification still requires a human The mock server exercises the same wire bytes the kRPC mod sends, so the decoder logic is now test-covered. But three things only the human can verify: 1. KSP 1.12.5 install + kRPC mod via CKAN 2. Alt+F12 in-game -> RPC server on 127.0.0.1:50000 3. Visual live-map motion after bridge POSTs the first snapshot (current /debug page also works as a sanity check)
442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
/**
|
|
* 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) {
|
|
// 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<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 };
|