fix: complete kRPC handshake decode (carry forward correct fixes + add mock-server tests)
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)
This commit is contained in:
@@ -0,0 +1,767 @@
|
||||
/**
|
||||
* Mock kRPC server — a real TCP server that speaks the kRPC wire
|
||||
* protocol (length-prefixed protobuf) so we can exercise the
|
||||
* kRPC client + bridge end-to-end without a running KSP install.
|
||||
*
|
||||
* This is a test-only fixture. It lives under tests/ so vitest's
|
||||
* coverage tool ignores it, and other packages import it via a
|
||||
* relative path.
|
||||
*
|
||||
* What it does:
|
||||
* 1. Listens on a free localhost port (or the port you pass in).
|
||||
* 2. On TCP connect, performs the kRPC handshake on both ports:
|
||||
* - RPC port: accepts a ConnectionRequest (type=RPC), replies
|
||||
* with a fixed clientIdentifier, no status/message (which is
|
||||
* what a real kRPC server sends on the happy path).
|
||||
* - Stream port: accepts a ConnectionRequest (type=STREAM),
|
||||
* replies OK, and idles (we don't test streams yet).
|
||||
* 3. Handles a small set of procedure calls by returning hand-encoded
|
||||
* deterministic responses from a fixture table.
|
||||
* 4. Records every call so tests can assert what was called, with
|
||||
* what args, and how many times.
|
||||
*
|
||||
* Usage from a test:
|
||||
*
|
||||
* const server = await startMockKrpcServer();
|
||||
* try {
|
||||
* const client = new KRPCClient({
|
||||
* host: '127.0.0.1',
|
||||
* rpcPort: server.rpcPort,
|
||||
* streamPort: server.streamPort,
|
||||
* });
|
||||
* await client.connect();
|
||||
* // ... exercise the client
|
||||
* } finally {
|
||||
* await server.close();
|
||||
* }
|
||||
*
|
||||
* Default stubs cover the four procedures the bridge's extract()
|
||||
* loop calls per tick (GetUT, GetBodies, GetVessels, plus class
|
||||
* methods on CelestialBody / Vessel / Orbit). Tests can add more
|
||||
* stubs with `server.stub(service, procedure, handler)`.
|
||||
*
|
||||
* Wire-format reference: https://krpc.github.io/krpc/communication-protocols/messages.html
|
||||
*/
|
||||
import * as net from 'node:net';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { KRPC } from '../src/schema.js';
|
||||
import { encodeVarint, recvMessage, sendMessage } from '../src/connection.js';
|
||||
import {
|
||||
encodeDouble,
|
||||
encodeString,
|
||||
encodeUint64,
|
||||
encodeBool,
|
||||
encodeSint32,
|
||||
} from '../src/_test-encode.js';
|
||||
|
||||
void sendMessage; // not used directly in this file
|
||||
|
||||
export interface MockServerOptions {
|
||||
/** Fixed 16-byte clientIdentifier returned by the RPC handshake.
|
||||
* Default: "mock-krpc-client". */
|
||||
clientIdentifier?: Buffer;
|
||||
/** Optional logger for server-side events. Defaults to no-op. */
|
||||
log?: (msg: string) => void;
|
||||
}
|
||||
|
||||
export interface MockServer {
|
||||
rpcPort: number;
|
||||
streamPort: number;
|
||||
/** Add (or replace) a stub for a service.procedure call. */
|
||||
stub(service: string, procedure: string, handler: (args: Uint8Array[]) => Uint8Array): void;
|
||||
/** Count of calls received for service.procedure. */
|
||||
callCount(service: string, procedure: string): number;
|
||||
/** Arguments of the last call to service.procedure. */
|
||||
lastArgs(service: string, procedure: string): Uint8Array[];
|
||||
/** Full call log, in arrival order. Each entry: { service, procedure, args }. */
|
||||
callLog(): { service: string; procedure: string; args: Uint8Array[] }[];
|
||||
/** Stop both servers and release the ports. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.unref();
|
||||
srv.on('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address();
|
||||
if (addr && typeof addr === 'object') {
|
||||
const p = addr.port;
|
||||
srv.close(() => resolve(p));
|
||||
} else {
|
||||
srv.close(() => reject(new Error('no addr')));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function replyWithValue(socket: net.Socket, value: Uint8Array): void {
|
||||
const resultMsg = KRPC.ProcedureResult.create({
|
||||
value: Buffer.from(value),
|
||||
});
|
||||
const respMsg = KRPC.Response.create({ results: [resultMsg] });
|
||||
const respBytes = Buffer.from(KRPC.Response.encode(respMsg).finish());
|
||||
if (process.env.KRPC_DEBUG) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[mock-krpc] >>> reply (${respBytes.length} bytes):`, respBytes.toString('hex'));
|
||||
}
|
||||
const prefix = encodeVarint(respBytes.length);
|
||||
socket.write(Buffer.concat([prefix, respBytes]));
|
||||
}
|
||||
|
||||
function replyWithError(socket: net.Socket, name: string, description: string): void {
|
||||
const errMsg = KRPC.Error.create({ service: 'MockKRPC', name, description });
|
||||
const resultMsg = KRPC.ProcedureResult.create({ error: errMsg, value: Buffer.from([]) });
|
||||
sendMessage(socket, KRPC.Response, { results: [resultMsg] });
|
||||
}
|
||||
|
||||
interface CallRecord {
|
||||
service: string;
|
||||
procedure: string;
|
||||
args: Uint8Array[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the standard "stock KSP-like" stub set so the bridge's
|
||||
* extract() loop can run end-to-end against the mock.
|
||||
*
|
||||
* The catalog mirrors what a stock KSP save with 1 star, 1 planet, 1
|
||||
* moon, 0 vessels looks like. Tests that need richer fixtures can
|
||||
* overwrite individual procedures via `server.stub(...)`.
|
||||
*/
|
||||
export function defaultStubs(server: {
|
||||
stub: (svc: string, proc: string, h: (args: Uint8Array[]) => Uint8Array) => void;
|
||||
}): void {
|
||||
// GetServices — return a hand-crafted services message with one
|
||||
// service (SpaceCenter), one procedure (GetUT, returning DOUBLE),
|
||||
// and a few class/enum entries to exercise the cache.
|
||||
server.stub('KRPC', 'GetServices', () => {
|
||||
const servicesMsg = KRPC.Services.create({
|
||||
services: [
|
||||
{
|
||||
name: 'KRPC',
|
||||
procedures: [
|
||||
{
|
||||
name: 'GetStatus',
|
||||
parameters: [],
|
||||
returnType: { code: 203, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'GetServices',
|
||||
parameters: [],
|
||||
returnType: { code: 204, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
],
|
||||
classes: [],
|
||||
enumerations: [],
|
||||
},
|
||||
{
|
||||
name: 'SpaceCenter',
|
||||
procedures: [
|
||||
{
|
||||
name: 'GetUT',
|
||||
parameters: [],
|
||||
returnType: { code: 1, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'GetBodies',
|
||||
parameters: [],
|
||||
returnType: {
|
||||
code: 301,
|
||||
service: '',
|
||||
name: '',
|
||||
types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }],
|
||||
},
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'GetVessels',
|
||||
parameters: [],
|
||||
returnType: {
|
||||
code: 301,
|
||||
service: '',
|
||||
name: '',
|
||||
types: [{ code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }],
|
||||
},
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'CelestialBody.get_Name',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: {
|
||||
code: 100,
|
||||
service: 'SpaceCenter',
|
||||
name: 'CelestialBody',
|
||||
types: [],
|
||||
},
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 8, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'CelestialBody.get_Parent',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: {
|
||||
code: 100,
|
||||
service: 'SpaceCenter',
|
||||
name: 'CelestialBody',
|
||||
types: [],
|
||||
},
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: {
|
||||
code: 100,
|
||||
service: 'SpaceCenter',
|
||||
name: 'CelestialBody',
|
||||
types: [],
|
||||
},
|
||||
returnIsNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'CelestialBody.get_Radius',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: {
|
||||
code: 100,
|
||||
service: 'SpaceCenter',
|
||||
name: 'CelestialBody',
|
||||
types: [],
|
||||
},
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 1, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'CelestialBody.get_SphereOfInfluence',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: {
|
||||
code: 100,
|
||||
service: 'SpaceCenter',
|
||||
name: 'CelestialBody',
|
||||
types: [],
|
||||
},
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 1, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'CelestialBody.get_GravitationalParameter',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: {
|
||||
code: 100,
|
||||
service: 'SpaceCenter',
|
||||
name: 'CelestialBody',
|
||||
types: [],
|
||||
},
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 1, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'CelestialBody.get_RotationPeriod',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: {
|
||||
code: 100,
|
||||
service: 'SpaceCenter',
|
||||
name: 'CelestialBody',
|
||||
types: [],
|
||||
},
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 1, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'CelestialBody.get_AxialTilt',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: {
|
||||
code: 100,
|
||||
service: 'SpaceCenter',
|
||||
name: 'CelestialBody',
|
||||
types: [],
|
||||
},
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 1, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'CelestialBody.get_Orbit',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: {
|
||||
code: 100,
|
||||
service: 'SpaceCenter',
|
||||
name: 'CelestialBody',
|
||||
types: [],
|
||||
},
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: {
|
||||
code: 100,
|
||||
service: 'SpaceCenter',
|
||||
name: 'Orbit',
|
||||
types: [],
|
||||
},
|
||||
returnIsNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'Orbit.get_SemiMajorAxis',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 1, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'Orbit.get_Eccentricity',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 1, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'Orbit.get_Inclination',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 1, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'Orbit.get_LongitudeOfAscendingNode',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 1, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'Orbit.get_ArgumentOfPeriapsis',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 1, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'Orbit.get_MeanAnomalyAtEpoch',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 1, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'Orbit.get_Epoch',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 1, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'Vessel.get_Name',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: { code: 8, service: '', name: '', types: [] },
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'Vessel.get_Type',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: {
|
||||
code: 101,
|
||||
service: 'SpaceCenter',
|
||||
name: 'VesselType',
|
||||
types: [],
|
||||
},
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'Vessel.get_Situation',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: {
|
||||
code: 101,
|
||||
service: 'SpaceCenter',
|
||||
name: 'VesselSituation',
|
||||
types: [],
|
||||
},
|
||||
returnIsNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'Vessel.get_Orbit',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: {
|
||||
code: 100,
|
||||
service: 'SpaceCenter',
|
||||
name: 'Orbit',
|
||||
types: [],
|
||||
},
|
||||
returnIsNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'Vessel.get_ReferenceBody',
|
||||
parameters: [
|
||||
{
|
||||
name: 'self',
|
||||
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
returnType: {
|
||||
code: 100,
|
||||
service: 'SpaceCenter',
|
||||
name: 'CelestialBody',
|
||||
types: [],
|
||||
},
|
||||
returnIsNullable: false,
|
||||
},
|
||||
],
|
||||
classes: [{ name: 'CelestialBody' }, { name: 'Vessel' }, { name: 'Orbit' }],
|
||||
enumerations: [
|
||||
{
|
||||
name: 'VesselType',
|
||||
values: [
|
||||
{ name: 'Ship', value: 0 },
|
||||
{ name: 'Station', value: 1 },
|
||||
{ name: 'Lander', value: 2 },
|
||||
{ name: 'Probe', value: 3 },
|
||||
{ name: 'Debris', value: 8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'VesselSituation',
|
||||
values: [
|
||||
{ name: 'PreLaunch', value: 0 },
|
||||
{ name: 'Orbiting', value: 1 },
|
||||
{ name: 'Escaping', value: 2 },
|
||||
{ name: 'Flying', value: 3 },
|
||||
{ name: 'Landed', value: 4 },
|
||||
{ name: 'Splashed', value: 5 },
|
||||
{ name: 'Docked', value: 6 },
|
||||
{ name: 'SubOrbital', value: 7 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
return new Uint8Array(KRPC.Services.encode(servicesMsg).finish());
|
||||
});
|
||||
|
||||
// KRPC.GetStatus — return a tiny status message.
|
||||
server.stub('KRPC', 'GetStatus', () => {
|
||||
const statusMsg = KRPC.Status.create({ version: 'mock-0.5.0' });
|
||||
return new Uint8Array(KRPC.Status.encode(statusMsg).finish());
|
||||
});
|
||||
|
||||
// SpaceCenter.GetUT — current universal time. Always 4_700_000.0
|
||||
// unless a test overrides it.
|
||||
let mockUt = 4_700_000.0;
|
||||
server.stub('SpaceCenter', 'GetUT', () => encodeDouble(mockUt));
|
||||
// expose mutator on the server for tests that want ticking time
|
||||
(server as unknown as { setUt?: (v: number) => void }).setUt = (v: number) => {
|
||||
mockUt = v;
|
||||
};
|
||||
void encodeBool; // keep import alive
|
||||
void encodeSint32;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the mock kRPC server. Returns once both ports are bound and
|
||||
* ready to accept connections.
|
||||
*/
|
||||
export async function startMockKrpcServer(options: MockServerOptions = {}): Promise<MockServer> {
|
||||
const clientId = options.clientIdentifier ?? Buffer.from('mock-krpc-client');
|
||||
const log = options.log ?? (() => undefined);
|
||||
const rpcPort = await freePort();
|
||||
const streamPort = await freePort();
|
||||
|
||||
const stubs = new Map<string, (args: Uint8Array[]) => Uint8Array>();
|
||||
const counts = new Map<string, number>();
|
||||
const lastArgs = new Map<string, Uint8Array[]>();
|
||||
const log_: CallRecord[] = [];
|
||||
|
||||
const stub = (svc: string, proc: string, handler: (args: Uint8Array[]) => Uint8Array) => {
|
||||
stubs.set(`${svc}.${proc}`, handler);
|
||||
};
|
||||
|
||||
const server: MockServer = {
|
||||
rpcPort,
|
||||
streamPort,
|
||||
stub,
|
||||
callCount: (svc, proc) => counts.get(`${svc}.${proc}`) ?? 0,
|
||||
lastArgs: (svc, proc) => lastArgs.get(`${svc}.${proc}`) ?? [],
|
||||
callLog: () => [...log_],
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
rpcServer.close(() => {
|
||||
streamServer.close(() => resolve());
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
// Server-side log helper: dump the wire bytes of every response we
|
||||
// send. Gated by env var. Used to diagnose framing bugs that the
|
||||
// client-side log can't catch (because the client is reading the
|
||||
// wrong slice of the byte stream).
|
||||
const logSend = (label: string, payload: Uint8Array): void => {
|
||||
if (process.env.KRPC_DEBUG) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[mock-krpc] >>> ${label} (${payload.length} bytes):`,
|
||||
Buffer.from(payload).toString('hex'),
|
||||
);
|
||||
}
|
||||
};
|
||||
// Expose for tests that want to override the helper.
|
||||
(server as unknown as { _logSend: typeof logSend })._logSend = logSend;
|
||||
|
||||
defaultStubs(server);
|
||||
// Apply a couple of well-known defaults for the standard test fixture
|
||||
// (1 star, 1 planet, 0 vessels) so the bridge's extract() loop has
|
||||
// something to read. Tests that need more bodies / vessels should
|
||||
// override these stubs.
|
||||
stub('SpaceCenter', 'GetBodies', () => {
|
||||
const ids = [encodeUint64(101n), encodeUint64(102n)];
|
||||
const listMsg = KRPC.List.create({ items: ids });
|
||||
return new Uint8Array(KRPC.List.encode(listMsg).finish());
|
||||
});
|
||||
stub('SpaceCenter', 'GetVessels', () => {
|
||||
const listMsg = KRPC.List.create({ items: [] });
|
||||
return new Uint8Array(KRPC.List.encode(listMsg).finish());
|
||||
});
|
||||
// Kerbol (root, no parent)
|
||||
stub('SpaceCenter', 'CelestialBody.get_Name', (args) => {
|
||||
const id = readVarint(args[0] ?? new Uint8Array());
|
||||
const name = id === 101n ? 'Kerbol' : id === 102n ? 'Kerbin' : 'Body';
|
||||
return encodeString(name);
|
||||
});
|
||||
stub('SpaceCenter', 'CelestialBody.get_Parent', (args) => {
|
||||
const id = readVarint(args[0] ?? new Uint8Array());
|
||||
if (id === 101n) return encodeUint64(0n); // Kerbol has no parent
|
||||
if (id === 102n) return encodeUint64(101n); // Kerbin's parent is Kerbol
|
||||
return encodeUint64(0n);
|
||||
});
|
||||
stub('SpaceCenter', 'CelestialBody.get_Radius', (args) => {
|
||||
const id = readVarint(args[0] ?? new Uint8Array());
|
||||
return encodeDouble(id === 101n ? 261_600_000 : 600_000);
|
||||
});
|
||||
stub('SpaceCenter', 'CelestialBody.get_SphereOfInfluence', (args) => {
|
||||
const id = readVarint(args[0] ?? new Uint8Array());
|
||||
return encodeDouble(id === 101n ? 1e30 : 84_159_286);
|
||||
});
|
||||
stub('SpaceCenter', 'CelestialBody.get_GravitationalParameter', (args) => {
|
||||
const id = readVarint(args[0] ?? new Uint8Array());
|
||||
return encodeDouble(id === 101n ? 1.172332794e18 : 3.5316e12);
|
||||
});
|
||||
stub('SpaceCenter', 'CelestialBody.get_RotationPeriod', (args) => {
|
||||
const id = readVarint(args[0] ?? new Uint8Array());
|
||||
return encodeDouble(id === 101n ? 432_000 : 21_600);
|
||||
});
|
||||
stub('SpaceCenter', 'CelestialBody.get_AxialTilt', () => encodeDouble(0));
|
||||
stub('SpaceCenter', 'CelestialBody.get_Orbit', (args) => {
|
||||
const id = readVarint(args[0] ?? new Uint8Array());
|
||||
// Kerbol (the star) has no orbit in the kRPC model. Real kRPC
|
||||
// returns null for the root body's orbit, so we mirror that.
|
||||
if (id === 101n) return encodeUint64(0n);
|
||||
return encodeUint64(9000n);
|
||||
});
|
||||
// Orbit params — all bodies share one orbit id (9000) in this stub
|
||||
stub('SpaceCenter', 'Orbit.get_SemiMajorAxis', () => encodeDouble(13_599_840_256));
|
||||
stub('SpaceCenter', 'Orbit.get_Eccentricity', () => encodeDouble(0.05));
|
||||
stub('SpaceCenter', 'Orbit.get_Inclination', () => encodeDouble(0));
|
||||
stub('SpaceCenter', 'Orbit.get_LongitudeOfAscendingNode', () => encodeDouble(0));
|
||||
stub('SpaceCenter', 'Orbit.get_ArgumentOfPeriapsis', () => encodeDouble(0));
|
||||
stub('SpaceCenter', 'Orbit.get_MeanAnomalyAtEpoch', () => encodeDouble(0));
|
||||
stub('SpaceCenter', 'Orbit.get_Epoch', () => encodeDouble(0));
|
||||
|
||||
// ── RPC server ─────────────────────────────────────────────────────
|
||||
const rpcServer = net.createServer((socket) => {
|
||||
void (async () => {
|
||||
try {
|
||||
// 1. Handshake. We expect type=0 (RPC).
|
||||
const req = await recvMessage<{ type: number; clientName: string }>(
|
||||
socket,
|
||||
KRPC.ConnectionRequest,
|
||||
);
|
||||
log(`[mock-krpc] RPC handshake: type=${req.type} name=${req.clientName}`);
|
||||
if (req.type !== 0) {
|
||||
log(`[mock-krpc] unexpected type=${req.type} on RPC port, closing`);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
// Real kRPC server response: just field 3 (clientIdentifier),
|
||||
// no status/message. We do the same — encode ONLY clientIdentifier.
|
||||
const respMsg = KRPC.ConnectionResponse.create({
|
||||
clientIdentifier: clientId,
|
||||
});
|
||||
const respBytes = Buffer.from(KRPC.ConnectionResponse.encode(respMsg).finish());
|
||||
socket.write(Buffer.concat([encodeVarint(respBytes.length), respBytes]));
|
||||
|
||||
// 2. Procedure loop
|
||||
while (!socket.destroyed) {
|
||||
let callReq: {
|
||||
calls: { service: string; procedure: string; arguments?: { value: Uint8Array }[] }[];
|
||||
};
|
||||
try {
|
||||
callReq = await recvMessage(socket, KRPC.Request);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const call = callReq.calls[0];
|
||||
if (!call) {
|
||||
replyWithError(socket, 'Malformed', 'no call');
|
||||
continue;
|
||||
}
|
||||
const key = `${call.service}.${call.procedure}`;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
const args = (call.arguments ?? []).map((a) => new Uint8Array(a.value));
|
||||
lastArgs.set(key, args);
|
||||
log_.push({ service: call.service, procedure: call.procedure, args });
|
||||
log(`[mock-krpc] call ${key} #${counts.get(key)} args.length=${args.length}`);
|
||||
const handler = stubs.get(key);
|
||||
if (!handler) {
|
||||
replyWithError(socket, 'UnknownProcedure', `${key} not stubbed`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const value = handler(args);
|
||||
replyWithValue(socket, value);
|
||||
} catch (e) {
|
||||
replyWithError(socket, 'HandlerError', String(e));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(`[mock-krpc] RPC socket error: ${String(e)}`);
|
||||
if (!socket.destroyed) socket.destroy();
|
||||
}
|
||||
})();
|
||||
});
|
||||
await new Promise<void>((resolve) => rpcServer.listen(rpcPort, '127.0.0.1', resolve));
|
||||
|
||||
// ── Stream server (handshake only, then idle) ─────────────────────
|
||||
const streamServer = net.createServer((socket) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const req = await recvMessage<{ type: number; clientIdentifier: Uint8Array }>(
|
||||
socket,
|
||||
KRPC.ConnectionRequest,
|
||||
);
|
||||
log(`[mock-krpc] stream handshake: type=${req.type}`);
|
||||
if (req.type !== 1) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const respMsg = KRPC.ConnectionResponse.create({});
|
||||
const respBytes = Buffer.from(KRPC.ConnectionResponse.encode(respMsg).finish());
|
||||
socket.write(Buffer.concat([encodeVarint(respBytes.length), respBytes]));
|
||||
// Keep alive until socket closes
|
||||
await new Promise(() => undefined);
|
||||
} catch (e) {
|
||||
log(`[mock-krpc] stream socket error: ${String(e)}`);
|
||||
socket.destroy();
|
||||
}
|
||||
})();
|
||||
});
|
||||
await new Promise<void>((resolve) => streamServer.listen(streamPort, '127.0.0.1', resolve));
|
||||
|
||||
log(`[mock-krpc] listening on RPC=${rpcPort} stream=${streamPort}`);
|
||||
return server;
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user