Phase 1: data pipeline (Postgres+Redis with in-memory fallback, mock telemetry publisher, WebSocket fan-out, hub /debug page)
CI / Lint, typecheck, test, build (pull_request) Failing after 10s
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.
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Hard-coded Kerbol system catalog. Matches the SQL seed in
|
||||
* infra/init-sql/02-bodies-seed.sql so the mock and real worlds agree.
|
||||
*
|
||||
* Only the fields the live map needs to render are included. The
|
||||
* real telemetry bridge (Phase 1c) will pull these from kRPC.
|
||||
*/
|
||||
import type { CelestialBody } from '@kerbal-rt/shared-types';
|
||||
|
||||
export const KERBOL_SYSTEM: CelestialBody[] = [
|
||||
{
|
||||
id: 'kerbol',
|
||||
name: 'Kerbol',
|
||||
kind: 'star',
|
||||
parentId: null,
|
||||
radius: 261_600_000,
|
||||
sphereOfInfluence: 1e30, // sentinel for "infinity" at JSON boundary
|
||||
gravitationalParameter: 1.172332794e18,
|
||||
rotationPeriod: 432_000,
|
||||
axialTilt: 0,
|
||||
orbit: {
|
||||
semiMajorAxis: 0,
|
||||
eccentricity: 0,
|
||||
inclination: 0,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 0,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'moho',
|
||||
name: 'Moho',
|
||||
kind: 'planet',
|
||||
parentId: 'kerbol',
|
||||
radius: 250_000,
|
||||
sphereOfInfluence: 9_646_663,
|
||||
gravitationalParameter: 1.686842e11,
|
||||
rotationPeriod: 1_210_000,
|
||||
axialTilt: 0.05,
|
||||
orbit: {
|
||||
semiMajorAxis: 5_263_138_304,
|
||||
eccentricity: 0.2,
|
||||
inclination: 0.075,
|
||||
longitudeOfAscendingNode: 1.5,
|
||||
argumentOfPeriapsis: 2.8,
|
||||
meanAnomalyAtEpoch: 1.0,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'eve',
|
||||
name: 'Eve',
|
||||
kind: 'planet',
|
||||
parentId: 'kerbol',
|
||||
radius: 700_000,
|
||||
sphereOfInfluence: 85_109_365,
|
||||
gravitationalParameter: 8.1717302e12,
|
||||
rotationPeriod: 80_500,
|
||||
axialTilt: 0.1,
|
||||
orbit: {
|
||||
semiMajorAxis: 9_832_684_544,
|
||||
eccentricity: 0.01,
|
||||
inclination: 0.1,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 3.14,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kerbin',
|
||||
name: 'Kerbin',
|
||||
kind: 'planet',
|
||||
parentId: 'kerbol',
|
||||
radius: 600_000,
|
||||
sphereOfInfluence: 84_159_286,
|
||||
gravitationalParameter: 3.5316e12,
|
||||
rotationPeriod: 21_600,
|
||||
axialTilt: 0,
|
||||
orbit: {
|
||||
semiMajorAxis: 13_599_840_256,
|
||||
eccentricity: 0.05,
|
||||
inclination: 0,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 0.7,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duna',
|
||||
name: 'Duna',
|
||||
kind: 'planet',
|
||||
parentId: 'kerbol',
|
||||
radius: 320_000,
|
||||
sphereOfInfluence: 47_921_949,
|
||||
gravitationalParameter: 3.0136321e11,
|
||||
rotationPeriod: 65_518,
|
||||
axialTilt: 0.06,
|
||||
orbit: {
|
||||
semiMajorAxis: 20_726_155_264,
|
||||
eccentricity: 0.05,
|
||||
inclination: 0.02,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 1.5,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dres',
|
||||
name: 'Dres',
|
||||
kind: 'planet',
|
||||
parentId: 'kerbol',
|
||||
radius: 138_000,
|
||||
sphereOfInfluence: 32_832_840,
|
||||
gravitationalParameter: 2.1484489e10,
|
||||
rotationPeriod: 34_800,
|
||||
axialTilt: 0.08,
|
||||
orbit: {
|
||||
semiMajorAxis: 40_839_348_203,
|
||||
eccentricity: 0.14,
|
||||
inclination: 0.08,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 2.1,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'jool',
|
||||
name: 'Jool',
|
||||
kind: 'planet',
|
||||
parentId: 'kerbol',
|
||||
radius: 6_000_000,
|
||||
sphereOfInfluence: 2_450_055_988,
|
||||
gravitationalParameter: 2.82528e14,
|
||||
rotationPeriod: 36_000,
|
||||
axialTilt: 0.05,
|
||||
orbit: {
|
||||
semiMajorAxis: 68_773_560_320,
|
||||
eccentricity: 0.05,
|
||||
inclination: 0.04,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 4.2,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'eeloo',
|
||||
name: 'Eeloo',
|
||||
kind: 'planet',
|
||||
parentId: 'kerbol',
|
||||
radius: 210_000,
|
||||
sphereOfInfluence: 119_082_940,
|
||||
gravitationalParameter: 7.4410815e10,
|
||||
rotationPeriod: 19_460,
|
||||
axialTilt: 0.1,
|
||||
orbit: {
|
||||
semiMajorAxis: 90_118_820_000,
|
||||
eccentricity: 0.26,
|
||||
inclination: 0.13,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 5.5,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gilly',
|
||||
name: 'Gilly',
|
||||
kind: 'moon',
|
||||
parentId: 'eve',
|
||||
radius: 13_000,
|
||||
sphereOfInfluence: 126_123,
|
||||
gravitationalParameter: 8_289_449.8,
|
||||
rotationPeriod: 28_260,
|
||||
axialTilt: 0.05,
|
||||
orbit: {
|
||||
semiMajorAxis: 31_500_000,
|
||||
eccentricity: 0.18,
|
||||
inclination: 0.2,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 0.5,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mun',
|
||||
name: 'Mun',
|
||||
kind: 'moon',
|
||||
parentId: 'kerbin',
|
||||
radius: 200_000,
|
||||
sphereOfInfluence: 2_429_559,
|
||||
gravitationalParameter: 6.514e10,
|
||||
rotationPeriod: 138_984,
|
||||
axialTilt: 0,
|
||||
orbit: {
|
||||
semiMajorAxis: 12_000_000,
|
||||
eccentricity: 0,
|
||||
inclination: 0,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 1.0,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'minmus',
|
||||
name: 'Minmus',
|
||||
kind: 'moon',
|
||||
parentId: 'kerbin',
|
||||
radius: 60_000,
|
||||
sphereOfInfluence: 2_247_428,
|
||||
gravitationalParameter: 1.765e9,
|
||||
rotationPeriod: 40_400,
|
||||
axialTilt: 0.04,
|
||||
orbit: {
|
||||
semiMajorAxis: 47_000_000,
|
||||
eccentricity: 0.0,
|
||||
inclination: 0.075,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 2.5,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ike',
|
||||
name: 'Ike',
|
||||
kind: 'moon',
|
||||
parentId: 'duna',
|
||||
radius: 130_000,
|
||||
sphereOfInfluence: 1_048_598,
|
||||
gravitationalParameter: 1.856e9,
|
||||
rotationPeriod: 65_518,
|
||||
axialTilt: 0.05,
|
||||
orbit: {
|
||||
semiMajorAxis: 3_200_000,
|
||||
eccentricity: 0.0,
|
||||
inclination: 0,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 1.5,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'laythe',
|
||||
name: 'Laythe',
|
||||
kind: 'moon',
|
||||
parentId: 'jool',
|
||||
radius: 500_000,
|
||||
sphereOfInfluence: 3_723_645,
|
||||
gravitationalParameter: 1.84e11,
|
||||
rotationPeriod: 52_980,
|
||||
axialTilt: 0,
|
||||
orbit: {
|
||||
semiMajorAxis: 27_184_000,
|
||||
eccentricity: 0.0,
|
||||
inclination: 0,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 2.0,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vall',
|
||||
name: 'Vall',
|
||||
kind: 'moon',
|
||||
parentId: 'jool',
|
||||
radius: 240_000,
|
||||
sphereOfInfluence: 2_406_401,
|
||||
gravitationalParameter: 2.061e10,
|
||||
rotationPeriod: 106_200,
|
||||
axialTilt: 0,
|
||||
orbit: {
|
||||
semiMajorAxis: 43_152_000,
|
||||
eccentricity: 0.0,
|
||||
inclination: 0,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 2.5,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tylo',
|
||||
name: 'Tylo',
|
||||
kind: 'moon',
|
||||
parentId: 'jool',
|
||||
radius: 375_000,
|
||||
sphereOfInfluence: 10_856_418,
|
||||
gravitationalParameter: 2.122e11,
|
||||
rotationPeriod: 84_600,
|
||||
axialTilt: 0,
|
||||
orbit: {
|
||||
semiMajorAxis: 68_500_000,
|
||||
eccentricity: 0.0,
|
||||
inclination: 0,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 3.0,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bop',
|
||||
name: 'Bop',
|
||||
kind: 'moon',
|
||||
parentId: 'jool',
|
||||
radius: 65_000,
|
||||
sphereOfInfluence: 1_220_600,
|
||||
gravitationalParameter: 2.486e8,
|
||||
rotationPeriod: 360,
|
||||
axialTilt: 0.05,
|
||||
orbit: {
|
||||
semiMajorAxis: 128_500_000,
|
||||
eccentricity: 0.23,
|
||||
inclination: 0.2,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 4.0,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pol',
|
||||
name: 'Pol',
|
||||
kind: 'moon',
|
||||
parentId: 'jool',
|
||||
radius: 44_000,
|
||||
sphereOfInfluence: 1_042_138,
|
||||
gravitationalParameter: 7.214e7,
|
||||
rotationPeriod: 340,
|
||||
axialTilt: 0.05,
|
||||
orbit: {
|
||||
semiMajorAxis: 179_890_000,
|
||||
eccentricity: 0.17,
|
||||
inclination: 0.15,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 4.5,
|
||||
epoch: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** A starter fleet — three vessels in different situations. */
|
||||
export const STARTER_FLEET = [
|
||||
{
|
||||
id: 'kasa-regolith-1',
|
||||
name: 'CAPS Regolith 1',
|
||||
type: 'Probe',
|
||||
owner: 'KASA',
|
||||
situation: 'ORBITING' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
referenceBodyId: 'kerbin',
|
||||
initialOrbit: {
|
||||
semiMajorAxis: 7_500_000, // ~700km LKO
|
||||
eccentricity: 0.01,
|
||||
inclination: 0.05,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 0,
|
||||
epoch: 0,
|
||||
},
|
||||
createdAt: '2026-01-15T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'spes-longshot',
|
||||
name: 'SPES Longshot',
|
||||
type: 'Probe',
|
||||
owner: 'SPES',
|
||||
situation: 'ESCAPING' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
referenceBodyId: 'kerbol',
|
||||
initialOrbit: {
|
||||
// Heliocentric transfer ellipse to Duna
|
||||
semiMajorAxis: 17_000_000_000,
|
||||
eccentricity: 0.18,
|
||||
inclination: 0.02,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 1.2,
|
||||
meanAnomalyAtEpoch: 0,
|
||||
epoch: 0,
|
||||
},
|
||||
createdAt: '2026-02-20T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'kasa-relay-iii',
|
||||
name: 'KASA Relay Net III',
|
||||
type: 'Relay',
|
||||
owner: 'KASA',
|
||||
situation: 'ORBITING' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
referenceBodyId: 'duna',
|
||||
initialOrbit: {
|
||||
// Duna stationary-ish relay
|
||||
semiMajorAxis: 4_700_000,
|
||||
eccentricity: 0.0,
|
||||
inclination: 0,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 0,
|
||||
epoch: 0,
|
||||
},
|
||||
createdAt: '2026-03-10T00:00:00Z',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user