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,198 @@
|
||||
/**
|
||||
* ServiceCache — a queryable index over a KRPC.GetServices() response.
|
||||
*
|
||||
* After connecting to kRPC, the canonical first call is `KRPC.GetServices()`,
|
||||
* which returns a `KRPC.Services` message describing every service, every
|
||||
* class, every enum, and every procedure. We decode that into a lookup
|
||||
* table keyed by (service, procedure) so the service client can ask:
|
||||
*
|
||||
* - "what is the return type of SpaceCenter.GetBodies?"
|
||||
* - "what are the param types of SpaceCenter.CelestialBody.GetName?"
|
||||
* - "is SpaceCenter.VesselType.Ship == 0?"
|
||||
*
|
||||
* The cache is built once after connect, then read-only. It is decoupled
|
||||
* from the network so it can be unit-tested by feeding in a hand-crafted
|
||||
* Services message.
|
||||
*/
|
||||
import {
|
||||
decodeKrpcType,
|
||||
type KrpcType,
|
||||
type RawKrpcTypeMessage,
|
||||
} from './types.js';
|
||||
|
||||
/** Shape of the KRPC.GetServices() response after protobufjs decoding. */
|
||||
export interface RawServicesMessage {
|
||||
services: RawServiceMessage[];
|
||||
}
|
||||
|
||||
export interface RawServiceMessage {
|
||||
name: string;
|
||||
procedures: RawProcedureMessage[];
|
||||
classes: { name: string }[];
|
||||
enumerations: RawEnumerationMessage[];
|
||||
}
|
||||
|
||||
export interface RawProcedureMessage {
|
||||
name: string;
|
||||
parameters: RawParameterMessage[];
|
||||
returnType: RawKrpcTypeMessage;
|
||||
returnIsNullable: boolean;
|
||||
}
|
||||
|
||||
export interface RawParameterMessage {
|
||||
name: string;
|
||||
type: RawKrpcTypeMessage;
|
||||
nullable: boolean;
|
||||
}
|
||||
|
||||
export interface RawEnumerationMessage {
|
||||
name: string;
|
||||
values: { name: string; value: number }[];
|
||||
}
|
||||
|
||||
export interface ProcedureInfo {
|
||||
service: string;
|
||||
name: string;
|
||||
/** Full procedure name with class prefix, e.g. `CelestialBody.GetName`. */
|
||||
fullName: string;
|
||||
returnType: KrpcType;
|
||||
returnIsNullable: boolean;
|
||||
parameters: { name: string; type: KrpcType; nullable: boolean }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a name lookup. Either we found the proc and we know its
|
||||
* signature, or we didn't and the caller can decide what to do.
|
||||
*/
|
||||
export type ProcedureLookup =
|
||||
| { found: true; info: ProcedureInfo }
|
||||
| { found: false };
|
||||
|
||||
export class ServiceCache {
|
||||
/** "SpaceCenter.CelestialBody.GetName" -> ProcedureInfo */
|
||||
private byFullName = new Map<string, ProcedureInfo>();
|
||||
/** "SpaceCenter" -> "SpaceCenter" (just the service name) */
|
||||
private services = new Set<string>();
|
||||
/** "SpaceCenter.VesselType" -> "Ship" (enum name) -> int value */
|
||||
private enumValues = new Map<string, Map<string, number>>();
|
||||
/** "SpaceCenter" -> "VesselType" (enum name) -> Map<name, value> */
|
||||
private enumsByService = new Map<string, Map<string, Map<string, number>>>();
|
||||
|
||||
constructor(raw: RawServicesMessage) {
|
||||
for (const svc of raw.services ?? []) {
|
||||
this.services.add(svc.name);
|
||||
for (const proc of svc.procedures ?? []) {
|
||||
// proc.name already includes the class prefix when applicable
|
||||
// (e.g. "CelestialBody.GetName"), per the kRPC wire format.
|
||||
const info: ProcedureInfo = {
|
||||
service: svc.name,
|
||||
name: proc.name,
|
||||
fullName: `${svc.name}.${proc.name}`,
|
||||
returnType: decodeKrpcType(proc.returnType),
|
||||
returnIsNullable: !!proc.returnIsNullable,
|
||||
parameters: (proc.parameters ?? []).map((p) => ({
|
||||
name: p.name,
|
||||
type: decodeKrpcType(p.type),
|
||||
nullable: !!p.nullable,
|
||||
})),
|
||||
};
|
||||
this.byFullName.set(info.fullName, info);
|
||||
}
|
||||
for (const e of svc.enumerations ?? []) {
|
||||
const m = new Map<string, number>();
|
||||
for (const v of e.values ?? []) {
|
||||
m.set(v.name, v.value);
|
||||
}
|
||||
const fq = `${svc.name}.${e.name}`;
|
||||
this.enumValues.set(fq, m);
|
||||
let inner = this.enumsByService.get(svc.name);
|
||||
if (!inner) {
|
||||
inner = new Map();
|
||||
this.enumsByService.set(svc.name, inner);
|
||||
}
|
||||
inner.set(e.name, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** All known service names, e.g. ["KRPC", "SpaceCenter", "KerbalAlarmClock", ...]. */
|
||||
serviceNames(): string[] {
|
||||
return [...this.services].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* All procedure full names in a service, e.g.
|
||||
* ["SpaceCenter.GetUT", "SpaceCenter.CelestialBody.GetName", ...].
|
||||
*/
|
||||
proceduresInService(service: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const info of this.byFullName.values()) {
|
||||
if (info.service === service) out.push(info.fullName);
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a procedure by `service.procedure` (e.g. "SpaceCenter.GetUT") or
|
||||
* by the class-prefixed form ("SpaceCenter.CelestialBody.GetName").
|
||||
*/
|
||||
lookup(service: string, procedure: string): ProcedureLookup {
|
||||
const info = this.byFullName.get(`${service}.${procedure}`);
|
||||
if (!info) return { found: false };
|
||||
return { found: true, info };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an enum value name to its int code. e.g.
|
||||
* getEnumValue("SpaceCenter", "VesselType", "Ship") -> 0
|
||||
* Throws if the enum or value is unknown.
|
||||
*/
|
||||
getEnumValue(service: string, enumName: string, valueName: string): number {
|
||||
const m = this.enumValues.get(`${service}.${enumName}`);
|
||||
if (!m) {
|
||||
throw new Error(`unknown enum ${service}.${enumName}`);
|
||||
}
|
||||
const v = m.get(valueName);
|
||||
if (v === undefined) {
|
||||
throw new Error(`unknown value ${valueName} for ${service}.${enumName}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse: resolve an int code to a value name. Returns null if the
|
||||
* code is not in the enum's range — kRPC may add new values in newer
|
||||
* versions, so callers should be defensive.
|
||||
*/
|
||||
getEnumName(
|
||||
service: string,
|
||||
enumName: string,
|
||||
valueCode: number,
|
||||
): string | null {
|
||||
const m = this.enumValues.get(`${service}.${enumName}`);
|
||||
if (!m) return null;
|
||||
for (const [n, v] of m.entries()) {
|
||||
if (v === valueCode) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* All enum value names for an enum, e.g.
|
||||
* getEnumNames("SpaceCenter", "VesselType")
|
||||
* -> ["Ship", "Station", "Lander", "Probe", ...]
|
||||
*/
|
||||
getEnumNames(service: string, enumName: string): string[] {
|
||||
const m = this.enumValues.get(`${service}.${enumName}`);
|
||||
if (!m) return [];
|
||||
return [...m.keys()].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of distinct procedures across all services.
|
||||
* Useful for tests and diagnostics.
|
||||
*/
|
||||
procedureCount(): number {
|
||||
return this.byFullName.size;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user