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)
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
/**
|
|
* Bridge integration test — drives the full `KRPCAdapter` + `extract()`
|
|
* pipeline against the in-process mock kRPC server.
|
|
*
|
|
* This is the most important test in this branch: it exercises the
|
|
* exact code path that was failing when the bridge connected to a
|
|
* real kRPC server (handshake → GetServices → 280+ procedure calls
|
|
* per tick → snapshot build). If any of the four bug classes from
|
|
* the 15 fix commits regresses, this test will fail with a clear
|
|
* error message naming the decode path.
|
|
*
|
|
* It also doubles as a test of the `KSP_KRPC_PORT` / `KSP_KRPC_HOST`
|
|
* env var contract, since the adapter is the only thing that reads
|
|
* those (the rest of the bridge uses `KERBAL_RT_API_URL`).
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { KRPCAdapter } from '../src/krpc-adapter.js';
|
|
import { buildSnapshot } from '../src/extract.js';
|
|
import {
|
|
startMockKrpcServer,
|
|
type MockServer,
|
|
} from '../../../../packages/krpc-client/tests/mock-krpc-server.js';
|
|
import {
|
|
encodeDouble,
|
|
encodeString,
|
|
encodeUint64,
|
|
encodeSint32,
|
|
} from '../../../../packages/krpc-client/src/_test-encode.js';
|
|
import { Buffer } from 'node:buffer';
|
|
import { KRPC } from '../../../../packages/krpc-client/src/schema.js';
|
|
|
|
describe('Bridge integration — full extract() against mock kRPC', () => {
|
|
let server: MockServer;
|
|
let adapter: KRPCAdapter;
|
|
|
|
beforeAll(async () => {
|
|
server = await startMockKrpcServer();
|
|
adapter = new KRPCAdapter({
|
|
host: '127.0.0.1',
|
|
rpcPort: server.rpcPort,
|
|
streamPort: server.streamPort,
|
|
clientName: 'bridge-integration-test',
|
|
});
|
|
await adapter.connect();
|
|
}, 10_000);
|
|
|
|
afterAll(async () => {
|
|
await adapter.disconnect();
|
|
await server.close();
|
|
});
|
|
|
|
it('connects and loads the service catalog', () => {
|
|
expect(adapter.isConnected()).toBe(true);
|
|
const services = adapter.getServices();
|
|
expect(services.getCache().serviceNames()).toEqual(
|
|
expect.arrayContaining(['KRPC', 'SpaceCenter']),
|
|
);
|
|
});
|
|
|
|
it('runs extract() and produces a UniverseSnapshot', async () => {
|
|
// First do SEQUENTIAL calls to verify the mock + decoder are correct
|
|
// before we try Promise.all (which is what extract() uses).
|
|
const services = adapter.getServices();
|
|
const ut = await services.invoke<number>('SpaceCenter', 'GetUT');
|
|
expect(ut).toBeCloseTo(4_700_000, 1);
|
|
const bodyIds = await services.invoke<bigint[]>('SpaceCenter', 'GetBodies');
|
|
expect(bodyIds).toEqual([101n, 102n]);
|
|
const vesselIds = await services.invoke<bigint[]>('SpaceCenter', 'GetVessels');
|
|
expect(vesselIds).toEqual([]);
|
|
|
|
// Now exercise the full extract() which uses Promise.all
|
|
const state = await adapter.readState();
|
|
expect(state.ut).toBeCloseTo(4_700_000, 1);
|
|
expect(state.bodies.length).toBeGreaterThan(0);
|
|
const bodyNames = state.bodies.map((b) => b.name).sort();
|
|
expect(bodyNames).toEqual(['Kerbin', 'Kerbol']);
|
|
expect(state.vessels.length).toBe(0);
|
|
});
|
|
|
|
it('buildSnapshot produces a valid UniverseSnapshot', async () => {
|
|
const state = await adapter.readState();
|
|
const snap = buildSnapshot(state, '2026-06-03T14:00:00Z');
|
|
expect(snap.ut).toBeCloseTo(4_700_000, 1);
|
|
expect(snap.capturedAt).toBe('2026-06-03T14:00:00Z');
|
|
expect(snap.bodies.length).toBe(2);
|
|
// Kerbin's id is "kerbin" (lowercased, slugified)
|
|
const kerbin = snap.bodies.find((b) => b.id === 'kerbin');
|
|
expect(kerbin).toBeDefined();
|
|
if (!kerbin) throw new Error('unreachable');
|
|
expect(kerbin.name).toBe('Kerbin');
|
|
expect(kerbin.kind).toBe('planet');
|
|
expect(kerbin.parentId).toBe('kerbol');
|
|
expect(kerbin.radius).toBe(600_000);
|
|
expect(kerbin.sphereOfInfluence).toBe(84_159_286);
|
|
// Kerbin's orbit
|
|
expect(kerbin.orbit.semiMajorAxis).toBe(13_599_840_256);
|
|
expect(kerbin.orbit.eccentricity).toBeCloseTo(0.05, 6);
|
|
});
|
|
|
|
it('records the procedure call counts (proves the wire path is exercised)', () => {
|
|
// After the extract() calls above, the mock should have seen:
|
|
// - 3 top-level SpaceCenter calls (GetUT, GetBodies, GetVessels)
|
|
// - 2 + 2 = 4 body-name/parent lookups (Kerbol has no parent lookup
|
|
// since the parent's name is cached on subsequent calls; Kerbin
|
|
// fetches Kerbol's name on first call)
|
|
// - 2*8 = 16 Orbit field lookups (one for Kerbin, one for the
|
|
// shared orbit id)
|
|
// - 2 * 6 = 12 CelestialBody field lookups (name, parent, radius,
|
|
// soi, gm, rotation, tilt, orbit) = 8 per body * 2 bodies = 16
|
|
//
|
|
// Exact counts depend on caching, but we can at least verify
|
|
// SpaceCenter.GetUT was called.
|
|
expect(server.callCount('SpaceCenter', 'GetUT')).toBeGreaterThanOrEqual(2);
|
|
expect(server.callCount('SpaceCenter', 'GetBodies')).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Custom fixture: 1 star + 1 planet + 1 moon + 1 vessel. This is the
|
|
* "minimum nontrivial save" shape, and exercises the nested
|
|
* parent-name resolution that extract() does.
|
|
*/
|
|
describe('Bridge integration — custom fixture (1 star, 1 planet, 1 moon, 1 vessel)', () => {
|
|
let server: MockServer;
|
|
let adapter: KRPCAdapter;
|
|
|
|
// Object id map for the test fixture
|
|
const IDS = {
|
|
kerbol: 1n,
|
|
kerbin: 2n,
|
|
mun: 3n,
|
|
vessel: 4n,
|
|
};
|
|
const ORBIT_KERBOL = 100n; // Kerbol doesn't have a real orbit; we use 0 in the default
|
|
const ORBIT_KERBIN = 101n;
|
|
const ORBIT_MUN = 102n;
|
|
const ORBIT_VESSEL = 103n;
|
|
|
|
beforeAll(async () => {
|
|
server = await startMockKrpcServer();
|
|
|
|
// Override the default stubs to use our object ids
|
|
server.stub('SpaceCenter', 'GetBodies', () => {
|
|
const listMsg = KRPC.List.create({
|
|
items: [encodeUint64(IDS.kerbol), encodeUint64(IDS.kerbin), encodeUint64(IDS.mun)],
|
|
});
|
|
return new Uint8Array(KRPC.List.encode(listMsg).finish());
|
|
});
|
|
server.stub('SpaceCenter', 'GetVessels', () => {
|
|
const listMsg = KRPC.List.create({
|
|
items: [encodeUint64(IDS.vessel)],
|
|
});
|
|
return new Uint8Array(KRPC.List.encode(listMsg).finish());
|
|
});
|
|
|
|
// Name lookups
|
|
const NAMES: Record<string, string> = {
|
|
[IDS.kerbol.toString()]: 'Kerbol',
|
|
[IDS.kerbin.toString()]: 'Kerbin',
|
|
[IDS.mun.toString()]: 'Mun',
|
|
};
|
|
server.stub('SpaceCenter', 'CelestialBody.get_Name', (args) => {
|
|
const id = readVarint(args[0] ?? new Uint8Array());
|
|
return encodeString(NAMES[id.toString()] ?? 'Body');
|
|
});
|
|
|
|
// Parent lookups
|
|
const PARENTS: Record<string, bigint> = {
|
|
[IDS.kerbol.toString()]: 0n, // no parent
|
|
[IDS.kerbin.toString()]: IDS.kerbol,
|
|
[IDS.mun.toString()]: IDS.kerbin,
|
|
};
|
|
server.stub('SpaceCenter', 'CelestialBody.get_Parent', (args) => {
|
|
const id = readVarint(args[0] ?? new Uint8Array());
|
|
return encodeUint64(PARENTS[id.toString()] ?? 0n);
|
|
});
|
|
|
|
// Body scalar fields — distinct per body
|
|
const RADII: Record<string, number> = {
|
|
[IDS.kerbol.toString()]: 261_600_000,
|
|
[IDS.kerbin.toString()]: 600_000,
|
|
[IDS.mun.toString()]: 200_000,
|
|
};
|
|
server.stub('SpaceCenter', 'CelestialBody.get_Radius', (args) => {
|
|
const id = readVarint(args[0] ?? new Uint8Array());
|
|
return encodeDouble(RADII[id.toString()] ?? 0);
|
|
});
|
|
const SOI: Record<string, number> = {
|
|
[IDS.kerbol.toString()]: 1e30,
|
|
[IDS.kerbin.toString()]: 84_159_286,
|
|
[IDS.mun.toString()]: 2_429_581,
|
|
};
|
|
server.stub('SpaceCenter', 'CelestialBody.get_SphereOfInfluence', (args) => {
|
|
const id = readVarint(args[0] ?? new Uint8Array());
|
|
return encodeDouble(SOI[id.toString()] ?? 0);
|
|
});
|
|
const GM: Record<string, number> = {
|
|
[IDS.kerbol.toString()]: 1.172332794e18,
|
|
[IDS.kerbin.toString()]: 3.5316e12,
|
|
[IDS.mun.toString()]: 6.5138398e10,
|
|
};
|
|
server.stub('SpaceCenter', 'CelestialBody.get_GravitationalParameter', (args) => {
|
|
const id = readVarint(args[0] ?? new Uint8Array());
|
|
return encodeDouble(GM[id.toString()] ?? 0);
|
|
});
|
|
const ROT: Record<string, number> = {
|
|
[IDS.kerbol.toString()]: 432_000,
|
|
[IDS.kerbin.toString()]: 21_600,
|
|
[IDS.mun.toString()]: 138_984.38,
|
|
};
|
|
server.stub('SpaceCenter', 'CelestialBody.get_RotationPeriod', (args) => {
|
|
const id = readVarint(args[0] ?? new Uint8Array());
|
|
return encodeDouble(ROT[id.toString()] ?? 0);
|
|
});
|
|
server.stub('SpaceCenter', 'CelestialBody.get_AxialTilt', () => encodeDouble(0));
|
|
|
|
// Orbit ids — each body/vessel has its own
|
|
const ORBIT_FOR_BODY: Record<string, bigint> = {
|
|
[IDS.kerbol.toString()]: 0n, // Kerbol has no orbit
|
|
[IDS.kerbin.toString()]: ORBIT_KERBIN,
|
|
[IDS.mun.toString()]: ORBIT_MUN,
|
|
};
|
|
server.stub('SpaceCenter', 'CelestialBody.get_Orbit', (args) => {
|
|
const id = readVarint(args[0] ?? new Uint8Array());
|
|
return encodeUint64(ORBIT_FOR_BODY[id.toString()] ?? 0n);
|
|
});
|
|
server.stub('SpaceCenter', 'Vessel.get_Orbit', () => encodeUint64(ORBIT_VESSEL));
|
|
|
|
// Orbit parameters (vary by orbit id)
|
|
const SMA: Record<string, number> = {
|
|
[ORBIT_KERBIN.toString()]: 13_599_840_256,
|
|
[ORBIT_MUN.toString()]: 12_000_000,
|
|
[ORBIT_VESSEL.toString()]: 7_500_000,
|
|
};
|
|
const ECC: Record<string, number> = {
|
|
[ORBIT_KERBIN.toString()]: 0.05,
|
|
[ORBIT_MUN.toString()]: 0,
|
|
[ORBIT_VESSEL.toString()]: 0.01,
|
|
};
|
|
const INC: Record<string, number> = {
|
|
[ORBIT_KERBIN.toString()]: 0,
|
|
[ORBIT_MUN.toString()]: 0,
|
|
[ORBIT_VESSEL.toString()]: 0.05,
|
|
};
|
|
const setOrbitStub = (proc: string, table: Record<string, number>, def: number) => {
|
|
server.stub('SpaceCenter', `Orbit.${proc}`, (args) => {
|
|
const id = readVarint(args[0] ?? new Uint8Array());
|
|
return encodeDouble(table[id.toString()] ?? def);
|
|
});
|
|
};
|
|
setOrbitStub('get_SemiMajorAxis', SMA, 0);
|
|
setOrbitStub('get_Eccentricity', ECC, 0);
|
|
setOrbitStub('get_Inclination', INC, 0);
|
|
setOrbitStub('get_LongitudeOfAscendingNode', {}, 0);
|
|
setOrbitStub('get_ArgumentOfPeriapsis', {}, 0);
|
|
setOrbitStub('get_MeanAnomalyAtEpoch', {}, 0);
|
|
setOrbitStub('get_Epoch', {}, 0);
|
|
|
|
// Vessel fields
|
|
server.stub('SpaceCenter', 'Vessel.get_Name', () => encodeString('Test Probe'));
|
|
server.stub('SpaceCenter', 'Vessel.get_Type', () => encodeSint32(3)); // Probe
|
|
server.stub('SpaceCenter', 'Vessel.get_Situation', () => encodeSint32(1)); // Orbiting
|
|
server.stub('SpaceCenter', 'Vessel.get_ReferenceBody', () => encodeUint64(IDS.kerbin));
|
|
|
|
adapter = new KRPCAdapter({
|
|
host: '127.0.0.1',
|
|
rpcPort: server.rpcPort,
|
|
streamPort: server.streamPort,
|
|
clientName: 'custom-fixture-test',
|
|
});
|
|
await adapter.connect();
|
|
}, 10_000);
|
|
|
|
afterAll(async () => {
|
|
await adapter.disconnect();
|
|
await server.close();
|
|
});
|
|
|
|
it('extracts 3 bodies and 1 vessel', async () => {
|
|
const state = await adapter.readState();
|
|
expect(state.bodies.length).toBe(3);
|
|
expect(state.vessels.length).toBe(1);
|
|
const bodyNames = state.bodies.map((b) => b.name).sort();
|
|
expect(bodyNames).toEqual(['Kerbin', 'Kerbol', 'Mun']);
|
|
const v = state.vessels[0];
|
|
expect(v?.name).toBe('Test Probe');
|
|
expect(v?.type).toBe('Probe');
|
|
expect(v?.referenceBodyName).toBe('Kerbin');
|
|
});
|
|
|
|
it('produces a UniverseSnapshot with the right id slugs', async () => {
|
|
const state = await adapter.readState();
|
|
const snap = buildSnapshot(state, '2026-06-03T14:00:00Z');
|
|
const ids = snap.bodies.map((b) => b.id).sort();
|
|
expect(ids).toEqual(['kerbin', 'kerbol', 'mun']);
|
|
const kerbin = snap.bodies.find((b) => b.id === 'kerbin');
|
|
expect(kerbin?.parentId).toBe('kerbol');
|
|
const mun = snap.bodies.find((b) => b.id === 'mun');
|
|
expect(mun?.parentId).toBe('kerbin');
|
|
const kerbol = snap.bodies.find((b) => b.id === 'kerbol');
|
|
expect(kerbol?.parentId).toBeNull();
|
|
});
|
|
|
|
it('decodes the vessel situation and type from the enum values', async () => {
|
|
const state = await adapter.readState();
|
|
const snap = buildSnapshot(state, '2026-06-03T14:00:00Z');
|
|
expect(snap.vessels[0]?.situation).toBe('ORBITING');
|
|
expect(snap.vessels[0]?.type).toBe('Probe');
|
|
expect(snap.vessels[0]?.referenceBodyId).toBe('kerbin');
|
|
});
|
|
});
|
|
|
|
function readVarint(bytes: Uint8Array): bigint {
|
|
let v = 0n;
|
|
let shift = 0n;
|
|
for (const b of bytes) {
|
|
v |= BigInt(b & 0x7f) << shift;
|
|
if ((b & 0x80) === 0) return v;
|
|
shift += 7n;
|
|
}
|
|
return v;
|
|
}
|
|
|
|
void Buffer; // keep import alive
|