a457b9d96f
CI / Lint, typecheck, test, build (pull_request) Failing after 10s
- packages/db: postgres.js + ioredis wrapper, StateStore interface with InMemory + Postgres implementations, schema migration runner - apps/api: refactored to use @kerbal-rt/db; live WebSocket hub subscribes to store changes and broadcasts to all clients - apps/tools/mock-telemetry: Node script that generates realistic KSP state and POSTs to /api/v1/ingest at 1Hz (uses same keplerian math as the live-map renderer) - apps/hub: new /debug page that connects to /api/v1/live and shows the live state - ksp/README.md: documents Phase 1c (real kRPC bridge) and the two implementation options - Tests: 17 total (5 kepler math, 5 db store, 5 API health/state, 2 API WebSocket fan-out) End-to-end verified: mock publisher → API → hub /debug page, 11 snapshots/5s over WebSocket, vessels advancing in mean anomaly.
167 lines
4.6 KiB
TypeScript
167 lines
4.6 KiB
TypeScript
/**
|
|
* Mock KSP telemetry publisher.
|
|
*
|
|
* Generates a universe snapshot at a configurable cadence (default 1Hz)
|
|
* using the KERBOL_SYSTEM catalog and a starter fleet of vessels. The
|
|
* vessel orbits are propagated forward in time using the
|
|
* @kerbal-rt/orbital-math library — exactly the same math the live-map
|
|
* uses to render the scene, so the publisher and renderer stay in sync.
|
|
*
|
|
* Each generated snapshot is POSTed to the API at /api/v1/ingest.
|
|
* Connect the hub's /debug page (or any WS client) and you'll see the
|
|
* state update in real time.
|
|
*/
|
|
import { meanMotion } from '@kerbal-rt/orbital-math';
|
|
import type {
|
|
CelestialBody,
|
|
KeplerianElements,
|
|
UniverseSnapshot,
|
|
Vessel,
|
|
} from '@kerbal-rt/shared-types';
|
|
import { KERBOL_SYSTEM, STARTER_FLEET } from './catalog.js';
|
|
|
|
const API_URL = process.env.MOCK_API_URL ?? 'http://localhost:4000';
|
|
const API_KEY = process.env.INGEST_API_KEY ?? '';
|
|
const INTERVAL_MS = Number(process.env.MOCK_INTERVAL_MS ?? 1000);
|
|
const START_UT = Number(process.env.MOCK_START_UT ?? 4_700_000);
|
|
|
|
interface MutableVessel {
|
|
id: string;
|
|
name: string;
|
|
type: string | null;
|
|
owner: string | null;
|
|
situation: Vessel['situation'];
|
|
status: Vessel['status'];
|
|
referenceBodyId: string;
|
|
/** Working keplerian elements at the last emitted time. */
|
|
elements: KeplerianElements;
|
|
createdAt: string;
|
|
retiredAt: string | null;
|
|
}
|
|
|
|
const bodies: CelestialBody[] = KERBOL_SYSTEM;
|
|
const bodiesById = new Map(bodies.map((b) => [b.id, b]));
|
|
|
|
const vessels: MutableVessel[] = STARTER_FLEET.map((v) => ({
|
|
id: v.id,
|
|
name: v.name,
|
|
type: v.type,
|
|
owner: v.owner,
|
|
situation: v.situation,
|
|
status: v.status,
|
|
referenceBodyId: v.referenceBodyId,
|
|
elements: { ...v.initialOrbit, epoch: START_UT },
|
|
createdAt: v.createdAt,
|
|
retiredAt: null,
|
|
}));
|
|
|
|
let currentUt = START_UT;
|
|
let snapshotCount = 0;
|
|
let lastError: string | null = null;
|
|
|
|
async function publish(snap: UniverseSnapshot): Promise<void> {
|
|
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
if (API_KEY) headers['x-api-key'] = API_KEY;
|
|
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/v1/ingest`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(snap),
|
|
});
|
|
if (!res.ok) {
|
|
lastError = `HTTP ${res.status}`;
|
|
} else {
|
|
lastError = null;
|
|
}
|
|
} catch (err) {
|
|
lastError = String((err as Error).message ?? err);
|
|
}
|
|
}
|
|
|
|
function buildSnapshot(ut: number): UniverseSnapshot {
|
|
// Advance each vessel's mean anomaly by n·dt from its last known epoch.
|
|
for (const v of vessels) {
|
|
const ref = bodiesById.get(v.referenceBodyId);
|
|
if (!ref) continue;
|
|
const dt = ut - v.elements.epoch;
|
|
if (dt !== 0) {
|
|
const n = meanMotion(v.elements.semiMajorAxis, ref.gravitationalParameter);
|
|
v.elements = {
|
|
...v.elements,
|
|
meanAnomalyAtEpoch: v.elements.meanAnomalyAtEpoch + n * dt,
|
|
epoch: ut,
|
|
};
|
|
}
|
|
}
|
|
|
|
const vesselsOut: Vessel[] = vessels.map((v) => ({
|
|
id: v.id,
|
|
name: v.name,
|
|
type: v.type,
|
|
owner: v.owner,
|
|
situation: v.situation,
|
|
status: v.status,
|
|
orbit: v.elements,
|
|
referenceBodyId: v.referenceBodyId,
|
|
createdAt: v.createdAt,
|
|
retiredAt: v.retiredAt,
|
|
}));
|
|
|
|
return {
|
|
ut,
|
|
capturedAt: new Date().toISOString(),
|
|
activeVesselId: vessels[0]?.id ?? null,
|
|
bodies,
|
|
vessels: vesselsOut,
|
|
groundStations: [
|
|
{
|
|
id: 'montana',
|
|
name: 'Montana DSN',
|
|
bodyId: 'kerbin',
|
|
lat: 47.0,
|
|
lon: -110.0,
|
|
alt: 1200,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
async function tick(): Promise<void> {
|
|
currentUt += INTERVAL_MS / 1000; // 1s wall = 1s KSP UT (1x speed)
|
|
const snap = buildSnapshot(currentUt);
|
|
snapshotCount += 1;
|
|
await publish(snap);
|
|
}
|
|
|
|
function printStatus(): void {
|
|
const status = lastError ? `ERROR (${lastError})` : 'OK';
|
|
// eslint-disable-next-line no-console
|
|
console.log(
|
|
`[mock-telemetry] ut=${currentUt.toFixed(0)} snapshots=${snapshotCount} → ${API_URL} ${status}`,
|
|
);
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
// eslint-disable-next-line no-console
|
|
console.log(
|
|
`[mock-telemetry] starting — API=${API_URL} interval=${INTERVAL_MS}ms key=${API_KEY ? 'set' : 'unset'} start_ut=${START_UT}`,
|
|
);
|
|
// eslint-disable-next-line no-console
|
|
console.log(`[mock-telemetry] publishing ${bodies.length} bodies, ${vessels.length} vessels`);
|
|
|
|
// Send the first snapshot immediately, then on the interval.
|
|
await tick();
|
|
printStatus();
|
|
setInterval(async () => {
|
|
await tick();
|
|
}, INTERVAL_MS);
|
|
setInterval(printStatus, 10_000);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
// eslint-disable-next-line no-console
|
|
console.error('[mock-telemetry] fatal:', err);
|
|
process.exit(1);
|
|
});
|