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,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, ''),
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user