// Pure analytic flight math (v0.7.0). Position and fuel of an in-flight plane are // functions of the sim clock — no incremental stepping, so a tick can jump any // distance in game time without losing accuracy. All inputs are explicit; there // is no DB/IO here, which keeps this fully unit-testable with synthetic routes. const toRad = (d: number) => (d * Math.PI) / 180; const toDeg = (r: number) => (r * 180) / Math.PI; // Flight duration in ms for a leg of distanceKm at speedKmh. export function travelDurationMs(distanceKm: number, speedKmh: number): number { if (!(speedKmh > 0)) throw new Error('speedKmh must be > 0'); return (distanceKm / speedKmh) * 3_600_000; } // Sim-clock time at which the leg completes. export function arrivalTimeFor(departureSimTime: number, distanceKm: number, speedKmh: number): number { return departureSimTime + travelDurationMs(distanceKm, speedKmh); } // Flight progress in [0, 1] at a given sim-clock time — clamped to 0 before // departure and 1 after arrival. A zero-distance leg is complete immediately. export function progressAt( departureSimTime: number, distanceKm: number, speedKmh: number, clockMs: number, ): number { const duration = travelDurationMs(distanceKm, speedKmh); if (duration <= 0) return 1; return Math.min(1, Math.max(0, (clockMs - departureSimTime) / duration)); } // Great-circle interpolation between two WGS84 points at progress t ∈ [0,1] // (slerp on the unit sphere). Exact endpoints: t=0 → from, t=1 → to. export function positionAt( fromLat: number, fromLon: number, toLat: number, toLon: number, t: number, ): { lat: number; lon: number } { if (t <= 0) return { lat: fromLat, lon: fromLon }; if (t >= 1) return { lat: toLat, lon: toLon }; const vec = (lat: number, lon: number): [number, number, number] => { const phi = toRad(lat); const lambda = toRad(lon); return [Math.cos(phi) * Math.cos(lambda), Math.cos(phi) * Math.sin(lambda), Math.sin(phi)]; }; const a = vec(fromLat, fromLon); const b = vec(toLat, toLon); const dot = Math.max(-1, Math.min(1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2])); const theta = Math.acos(dot); if (theta < 1e-9) return { lat: fromLat, lon: fromLon }; // same point const sinTheta = Math.sin(theta); const wA = Math.sin((1 - t) * theta) / sinTheta; const wB = Math.sin(t * theta) / sinTheta; const v: [number, number, number] = [a[0] * wA + b[0] * wB, a[1] * wA + b[1] * wB, a[2] * wA + b[2] * wB]; return { lat: toDeg(Math.asin(Math.max(-1, Math.min(1, v[2])))), lon: toDeg(Math.atan2(v[1], v[0])), }; } // Fuel remaining after flying `progress` of a leg of distanceKm. Clamped at 0 — // range exhaustion is not modeled yet (fuel management/refueling comes later). export function fuelAt(startFuel: number, burnPerKm: number, distanceKm: number, progress: number): number { return Math.max(0, startFuel - burnPerKm * distanceKm * progress); }