aebee77843
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.)
200 lines
6.9 KiB
TypeScript
200 lines
6.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { ServiceCache, type RawServicesMessage } from '../src/services.js';
|
|
|
|
/**
|
|
* A small hand-crafted services message that mirrors the shape we'd
|
|
* get from KRPC.GetServices() on a real KSP install. Just enough
|
|
* services/procedures/enums to exercise the cache.
|
|
*/
|
|
const SAMPLE_RAW: RawServicesMessage = {
|
|
services: [
|
|
{
|
|
name: 'SpaceCenter',
|
|
procedures: [
|
|
{
|
|
name: 'GetUT',
|
|
parameters: [],
|
|
returnType: { code: 1, service: '', name: '', types: [] }, // DOUBLE
|
|
returnIsNullable: false,
|
|
},
|
|
{
|
|
name: 'GetBodies',
|
|
parameters: [],
|
|
returnType: {
|
|
code: 301, // LIST
|
|
service: '',
|
|
name: '',
|
|
types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }],
|
|
},
|
|
returnIsNullable: false,
|
|
},
|
|
{
|
|
name: 'CelestialBody.GetName',
|
|
parameters: [
|
|
{
|
|
name: 'self',
|
|
type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] },
|
|
nullable: false,
|
|
},
|
|
],
|
|
returnType: { code: 8, service: '', name: '', types: [] }, // STRING
|
|
returnIsNullable: false,
|
|
},
|
|
{
|
|
name: 'CelestialBody.GetParent',
|
|
parameters: [
|
|
{
|
|
name: 'self',
|
|
type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] },
|
|
nullable: false,
|
|
},
|
|
],
|
|
// Nullable CelestialBody (i.e. Sun has no parent).
|
|
returnType: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] },
|
|
returnIsNullable: true,
|
|
},
|
|
],
|
|
classes: [
|
|
{ name: 'CelestialBody' },
|
|
{ name: 'Vessel' },
|
|
{ name: 'Orbit' },
|
|
],
|
|
enumerations: [
|
|
{
|
|
name: 'VesselType',
|
|
values: [
|
|
{ name: 'Ship', value: 0 },
|
|
{ name: 'Station', value: 1 },
|
|
{ name: 'Probe', value: 3 },
|
|
{ name: 'Debris', value: 8 },
|
|
],
|
|
},
|
|
{
|
|
name: 'VesselSituation',
|
|
values: [
|
|
{ name: 'PreLaunch', value: 0 },
|
|
{ name: 'Orbiting', value: 1 },
|
|
{ name: 'Escaping', value: 2 },
|
|
{ name: 'Landed', value: 4 },
|
|
{ name: 'Splashed', value: 5 },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: 'KRPC',
|
|
procedures: [
|
|
{
|
|
name: 'GetStatus',
|
|
parameters: [],
|
|
returnType: { code: 203, service: '', name: '', types: [] }, // STATUS
|
|
returnIsNullable: false,
|
|
},
|
|
],
|
|
classes: [],
|
|
enumerations: [],
|
|
},
|
|
],
|
|
};
|
|
|
|
describe('ServiceCache', () => {
|
|
it('builds from a raw services message', () => {
|
|
const cache = new ServiceCache(SAMPLE_RAW);
|
|
expect(cache.procedureCount()).toBe(5);
|
|
expect(cache.serviceNames()).toEqual(['KRPC', 'SpaceCenter']);
|
|
});
|
|
|
|
it('looks up a top-level procedure', () => {
|
|
const cache = new ServiceCache(SAMPLE_RAW);
|
|
const r = cache.lookup('SpaceCenter', 'GetUT');
|
|
expect(r.found).toBe(true);
|
|
if (!r.found) throw new Error('unreachable');
|
|
expect(r.info.service).toBe('SpaceCenter');
|
|
expect(r.info.name).toBe('GetUT');
|
|
expect(r.info.returnType.code).toBe(1); // DOUBLE
|
|
expect(r.info.returnIsNullable).toBe(false);
|
|
expect(r.info.parameters).toHaveLength(0);
|
|
});
|
|
|
|
it('looks up a class-prefixed procedure', () => {
|
|
const cache = new ServiceCache(SAMPLE_RAW);
|
|
const r = cache.lookup('SpaceCenter', 'CelestialBody.GetName');
|
|
expect(r.found).toBe(true);
|
|
if (!r.found) throw new Error('unreachable');
|
|
expect(r.info.parameters).toHaveLength(1);
|
|
expect(r.info.parameters[0]?.name).toBe('self');
|
|
expect(r.info.parameters[0]?.type.code).toBe(100); // CLASS
|
|
expect(r.info.parameters[0]?.type.name).toBe('CelestialBody');
|
|
expect(r.info.returnType.code).toBe(8); // STRING
|
|
});
|
|
|
|
it('returns not-found for an unknown procedure', () => {
|
|
const cache = new ServiceCache(SAMPLE_RAW);
|
|
expect(cache.lookup('SpaceCenter', 'GetNothing').found).toBe(false);
|
|
expect(cache.lookup('Nope', 'X').found).toBe(false);
|
|
});
|
|
|
|
it('returns not-found for an unknown service', () => {
|
|
const cache = new ServiceCache(SAMPLE_RAW);
|
|
expect(cache.lookup('KerbalAlarmClock', 'GetAlarms').found).toBe(false);
|
|
});
|
|
|
|
it('records returnIsNullable for nullable CLASS returns', () => {
|
|
const cache = new ServiceCache(SAMPLE_RAW);
|
|
const r = cache.lookup('SpaceCenter', 'CelestialBody.GetParent');
|
|
expect(r.found).toBe(true);
|
|
if (!r.found) throw new Error('unreachable');
|
|
expect(r.info.returnIsNullable).toBe(true);
|
|
expect(r.info.returnType.code).toBe(100);
|
|
expect(r.info.returnType.name).toBe('CelestialBody');
|
|
});
|
|
|
|
it('resolves enum values by name', () => {
|
|
const cache = new ServiceCache(SAMPLE_RAW);
|
|
expect(cache.getEnumValue('SpaceCenter', 'VesselType', 'Ship')).toBe(0);
|
|
expect(cache.getEnumValue('SpaceCenter', 'VesselType', 'Station')).toBe(1);
|
|
expect(cache.getEnumValue('SpaceCenter', 'VesselType', 'Probe')).toBe(3);
|
|
expect(cache.getEnumValue('SpaceCenter', 'VesselSituation', 'Orbiting')).toBe(1);
|
|
});
|
|
|
|
it('throws for unknown enum or enum value', () => {
|
|
const cache = new ServiceCache(SAMPLE_RAW);
|
|
expect(() => cache.getEnumValue('SpaceCenter', 'NoSuch', 'X')).toThrow(/unknown enum/);
|
|
expect(() => cache.getEnumValue('SpaceCenter', 'VesselType', 'Hovercraft')).toThrow(
|
|
/unknown value/,
|
|
);
|
|
});
|
|
|
|
it('resolves enum value names by code', () => {
|
|
const cache = new ServiceCache(SAMPLE_RAW);
|
|
expect(cache.getEnumName('SpaceCenter', 'VesselType', 0)).toBe('Ship');
|
|
expect(cache.getEnumName('SpaceCenter', 'VesselType', 3)).toBe('Probe');
|
|
expect(cache.getEnumName('SpaceCenter', 'VesselType', 999)).toBeNull();
|
|
});
|
|
|
|
it('lists enum value names', () => {
|
|
const cache = new ServiceCache(SAMPLE_RAW);
|
|
const names = cache.getEnumNames('SpaceCenter', 'VesselType');
|
|
expect(names).toEqual(['Debris', 'Probe', 'Ship', 'Station']); // sorted
|
|
});
|
|
|
|
it('lists procedures in a service', () => {
|
|
const cache = new ServiceCache(SAMPLE_RAW);
|
|
const procs = cache.proceduresInService('SpaceCenter');
|
|
expect(procs).toContain('SpaceCenter.GetUT');
|
|
expect(procs).toContain('SpaceCenter.CelestialBody.GetName');
|
|
expect(procs).toContain('SpaceCenter.CelestialBody.GetParent');
|
|
});
|
|
|
|
it('preserves nested list type info', () => {
|
|
const cache = new ServiceCache(SAMPLE_RAW);
|
|
const r = cache.lookup('SpaceCenter', 'GetBodies');
|
|
expect(r.found).toBe(true);
|
|
if (!r.found) throw new Error('unreachable');
|
|
expect(r.info.returnType.code).toBe(301); // LIST
|
|
expect(r.info.returnType.types).toHaveLength(1);
|
|
expect(r.info.returnType.types[0]?.code).toBe(100);
|
|
expect(r.info.returnType.types[0]?.name).toBe('CelestialBody');
|
|
});
|
|
});
|