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:
@@ -107,19 +107,11 @@ const SERVICE = 'SpaceCenter';
|
||||
|
||||
// ── Low-level typed accessors ───────────────────────────────────────────
|
||||
|
||||
async function getBodyDouble(
|
||||
sc: KrpcServices,
|
||||
bodyId: bigint,
|
||||
method: string,
|
||||
): Promise<number> {
|
||||
async function getBodyDouble(sc: KrpcServices, bodyId: bigint, method: string): Promise<number> {
|
||||
return sc.invoke<number>(SERVICE, `CelestialBody.${method}`, bodyId);
|
||||
}
|
||||
|
||||
async function getBodyString(
|
||||
sc: KrpcServices,
|
||||
bodyId: bigint,
|
||||
method: string,
|
||||
): Promise<string> {
|
||||
async function getBodyString(sc: KrpcServices, bodyId: bigint, method: string): Promise<string> {
|
||||
return sc.invoke<string>(SERVICE, `CelestialBody.${method}`, bodyId);
|
||||
}
|
||||
|
||||
@@ -147,11 +139,7 @@ async function getVesselString(
|
||||
return sc.invoke<string>(SERVICE, `Vessel.${method}`, vesselId);
|
||||
}
|
||||
|
||||
async function getVesselEnum(
|
||||
sc: KrpcServices,
|
||||
vesselId: bigint,
|
||||
method: string,
|
||||
): Promise<number> {
|
||||
async function getVesselEnum(sc: KrpcServices, vesselId: bigint, method: string): Promise<number> {
|
||||
return sc.invoke<number>(SERVICE, `Vessel.${method}`, vesselId);
|
||||
}
|
||||
|
||||
@@ -214,7 +202,23 @@ async function readBody(
|
||||
}
|
||||
|
||||
if (orbitId === null) {
|
||||
throw new Error(`body ${name} (id=${bodyId}) has no orbit`);
|
||||
// The root body (the star — Kerbol in stock KSP) has no orbit in
|
||||
// the kRPC model: there's nothing it orbits around. Real KSP
|
||||
// returns null for the Sun's CelestialBody.get_Orbit(). Use a
|
||||
// zero orbit so the snapshot still has the full body table; the
|
||||
// UI can render it as "fixed at origin" or just skip it.
|
||||
return {
|
||||
name,
|
||||
kind: classifyBody(name),
|
||||
parentId: parentName,
|
||||
parentName,
|
||||
radius,
|
||||
sphereOfInfluence: soi,
|
||||
gravitationalParameter: gm,
|
||||
rotationPeriod: rot,
|
||||
axialTilt: tilt,
|
||||
orbit: zeroOrbit(),
|
||||
};
|
||||
}
|
||||
const orbit = await readOrbit(sc, orbitId);
|
||||
return {
|
||||
@@ -319,9 +323,7 @@ export async function extract(sc: KrpcServices): Promise<ExtractedState> {
|
||||
// Second pass: read vessels. Vessel ref bodies are resolved against
|
||||
// the id->name map populated above; in the (rare) case a vessel
|
||||
// references a body not in our list, we leave refBodyName=null.
|
||||
const vessels = await Promise.all(
|
||||
vesselIds.map((id) => readVessel(sc, id, idToBodyName)),
|
||||
);
|
||||
const vessels = await Promise.all(vesselIds.map((id) => readVessel(sc, id, idToBodyName)));
|
||||
|
||||
return { ut, bodies, vessels };
|
||||
}
|
||||
@@ -330,10 +332,7 @@ export async function extract(sc: KrpcServices): Promise<ExtractedState> {
|
||||
* Build a UniverseSnapshot from extracted KSP state. Pure function,
|
||||
* no I/O — easy to test.
|
||||
*/
|
||||
export function buildSnapshot(
|
||||
state: ExtractedState,
|
||||
capturedAt: string,
|
||||
): UniverseSnapshot {
|
||||
export function buildSnapshot(state: ExtractedState, capturedAt: string): UniverseSnapshot {
|
||||
const ourBodies: OurCelestialBody[] = state.bodies.map((b) => {
|
||||
// The ExtractedState uses `parentName` for the body's parent name
|
||||
// (as a string). For tests / legacy code paths, we also accept
|
||||
@@ -355,7 +354,8 @@ export function buildSnapshot(
|
||||
const ourVessels: OurVessel[] = state.vessels.map((v) => {
|
||||
// The ExtractedState uses `referenceBodyName`. For tests / legacy
|
||||
// code paths, also accept `referenceBodyId` (as a string).
|
||||
const refBody = v.referenceBodyName ?? (v as { referenceBodyId?: string }).referenceBodyId ?? '';
|
||||
const refBody =
|
||||
v.referenceBodyName ?? (v as { referenceBodyId?: string }).referenceBodyId ?? '';
|
||||
return vesselToOurs({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
|
||||
Reference in New Issue
Block a user