CI / build + unit + e2e (pull_request) Failing after 7m29s
Every leg gains a 10-min takeoff phase at the origin and a 10-min landing phase at the destination, each weather-scaled up to 2x via a monotone condition rule (Clear < Cloudy < Rain < Fog). Single composition in lib/sim/movement.ts: BASE_PHASE_MS, phaseFactor, MAX_PHASE_FACTOR, legPhases; arrivalTimeFor and progressAt route through it (progress is now piecewise — hold at origin / linear cruise / hold at destination), so engine arrival gate, client position/fuel recompute and the job expiry floor all agree. Phases burn no fuel. Marker shows the current phase next to the status in the popup (mapData derives it from departureSimTime + clockMs; new data-testid=plane-phase sibling). plane-status keeps the raw status. Tests: movement.test.ts (phaseFactor bounds/monotonicity, origin-vs-dest composition, piecewise progressAt), engine.test.ts (deleted local travelMs mirror — all ~18 fixture clocks use the real composition), seed.test.ts (phase-inclusive expiry floor mirrors). New e2e/phases.spec.ts asserts 'taking off' at the origin gate and 'landing' at the destination gate on EDDF→LEMD using the same legPhases boundaries as the engine.
228 lines
10 KiB
TypeScript
228 lines
10 KiB
TypeScript
// 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.
|
||
|
||
import { weatherFor, type AirportWeather } from '@/lib/weather';
|
||
|
||
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;
|
||
}
|
||
|
||
// --- Takeoff & landing phases (v1.12.0) ---
|
||
//
|
||
// Every leg has a 10-minute takeoff phase at the origin and a 10-minute landing phase at the
|
||
// destination, on top of the pure cruise time. Weather at the relevant airport stretches each
|
||
// phase up to 2× (bad conditions → longer rollout/approach); clear skies stay at 1×. The phases
|
||
// burn no fuel — they are pure time, so the weighted-burn model is untouched.
|
||
|
||
// One phase's duration in clear weather: 10 minutes of sim time.
|
||
export const BASE_PHASE_MS = 600_000;
|
||
|
||
// Weather → phase stretch factor, exactly [1, MAX_PHASE_FACTOR]. Simple monotone rule over the
|
||
// condition severity order Clear < Cloudy < Rain < Fog — visibility in this generator is a
|
||
// function of the condition (Fog ≤2 km, Rain 4–10 km, else 8–40 km), so it adds no independent
|
||
// signal. Each step up from Clear adds one third of the headroom; Fog lands exactly on MAX_PHASE_FACTOR.
|
||
export function phaseFactor(w: AirportWeather): number {
|
||
const step = w.condition === 'Clear' ? 0 : w.condition === 'Cloudy' ? 1 : w.condition === 'Rain' ? 2 : 3;
|
||
return 1 + step / 3;
|
||
}
|
||
|
||
// Upper end of phaseFactor over the weather domain — a Fog airport (the worst condition).
|
||
export const MAX_PHASE_FACTOR = 2;
|
||
|
||
export interface LegPhases {
|
||
takeoffMs: number; // origin weather
|
||
cruiseMs: number; // pure cruise — the old travelDurationMs value
|
||
landingMs: number; // destination weather
|
||
totalMs: number; // takeoff + cruise + landing
|
||
}
|
||
|
||
// The three timed segments of a leg. Takeoff scales on ORIGIN weather, landing on DESTINATION
|
||
// weather; both are deterministic functions of the ICAOs (weatherFor), so any site holding the
|
||
// two ICAs recomputes exactly what the engine used — no stored state needed.
|
||
export function legPhases(distanceKm: number, speedKmh: number, originIcao: string, destIcao: string): LegPhases {
|
||
const cruiseMs = travelDurationMs(distanceKm, speedKmh);
|
||
const takeoffMs = BASE_PHASE_MS * phaseFactor(weatherFor(originIcao));
|
||
const landingMs = BASE_PHASE_MS * phaseFactor(weatherFor(destIcao));
|
||
return { takeoffMs, cruiseMs, landingMs, totalMs: takeoffMs + cruiseMs + landingMs };
|
||
}
|
||
|
||
// Sim-clock time at which the leg completes — departure plus all three segments (v1.12.0).
|
||
export function arrivalTimeFor(
|
||
departureSimTime: number,
|
||
distanceKm: number,
|
||
speedKmh: number,
|
||
originIcao: string,
|
||
destIcao: string,
|
||
): number {
|
||
return departureSimTime + legPhases(distanceKm, speedKmh, originIcao, destIcao).totalMs;
|
||
}
|
||
|
||
// CRUISE-segment progress in [0, 1] at a given sim-clock time (v1.12.0) — clamped to 0 before
|
||
// departure and 1 after arrival: 0 while the plane holds at the origin during takeoff, linear
|
||
// over the cruise window, 1 while it holds at the destination during landing. That keeps
|
||
// positionAt/fuel pinned to each airport during its phase instead of crawling along the whole
|
||
// route. A zero-distance leg has no cruise: complete once departed.
|
||
export function progressAt(
|
||
departureSimTime: number,
|
||
distanceKm: number,
|
||
speedKmh: number,
|
||
originIcao: string,
|
||
destIcao: string,
|
||
clockMs: number,
|
||
): number {
|
||
const ph = legPhases(distanceKm, speedKmh, originIcao, destIcao);
|
||
const t = clockMs - departureSimTime;
|
||
if (t <= 0) return 0;
|
||
if (ph.cruiseMs <= 0) return 1; // zero-distance leg — nothing to cruise
|
||
if (t >= ph.totalMs) return 1;
|
||
if (t <= ph.takeoffMs) return 0; // holding at origin during takeoff
|
||
if (t >= ph.takeoffMs + ph.cruiseMs) return 1; // holding at destination during landing
|
||
return (t - ph.takeoffMs) / ph.cruiseMs;
|
||
}
|
||
|
||
export type LegPhase = 'takeoff' | 'cruise' | 'landing';
|
||
|
||
// Which timed segment a leg is in at a given sim-clock time — for marker display (v1.12.0).
|
||
export function legPhaseAt(
|
||
departureSimTime: number,
|
||
distanceKm: number,
|
||
speedKmh: number,
|
||
originIcao: string,
|
||
destIcao: string,
|
||
clockMs: number,
|
||
): LegPhase {
|
||
const ph = legPhases(distanceKm, speedKmh, originIcao, destIcao);
|
||
const t = clockMs - departureSimTime;
|
||
if (t < ph.takeoffMs) return 'takeoff';
|
||
if (t < ph.takeoffMs + ph.cruiseMs) return 'cruise';
|
||
return 'landing';
|
||
}
|
||
|
||
// 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])),
|
||
};
|
||
}
|
||
|
||
// --- Weight-proportional fuel burn (v0.16.0) ---
|
||
//
|
||
// The instantaneous burn rate is proportional to total weight: r(F) = k·(We + ρF) L/km,
|
||
// where We is the empty weight in kg, ρ the fuel density and F the current load in
|
||
// liters — a heavier plane burns more per km. k is calibrated so that a FULL tank
|
||
// burns exactly the catalog's constant burnPerKm: k = burnPerKm / (We + ρ·maxFuel),
|
||
// so full-tank legs match the old model at departure and only diverge as fuel (and
|
||
// weight) drops off in flight. Closed forms of dF/dx = −k(We + ρF):
|
||
// fuel after distance D from F₀: (F₀ + We/ρ)·e^{−kρD} − We/ρ
|
||
// consumption over D: (F₀ + We/ρ)·(1 − e^{−kρD})
|
||
// fuel required to cover D arriving with 0, independent of F₀: (We/ρ)(e^{kρD} − 1)
|
||
|
||
// Jet-A1 density in kg/L — converts a liter load into its weight contribution.
|
||
export const FUEL_DENSITY_KG_PER_L = 0.8;
|
||
|
||
// Calibration constant k for one plane type (see the model note above).
|
||
export function burnRateConstant(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
|
||
return burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel);
|
||
}
|
||
|
||
// Fuel remaining after flying `progress` of a leg of distanceKm from startFuel under the
|
||
// weighted model. Clamped at 0 — range exhaustion is not modeled (a plane that runs dry
|
||
// limps to the gate on fumes, same as before).
|
||
export function fuelAtWeighted(
|
||
startFuel: number,
|
||
burnPerKm: number,
|
||
emptyWeightKg: number,
|
||
maxFuel: number,
|
||
distanceKm: number,
|
||
progress: number,
|
||
): number {
|
||
const d = distanceKm * progress;
|
||
if (d <= 0) return startFuel;
|
||
const k = burnRateConstant(burnPerKm, emptyWeightKg, maxFuel);
|
||
const rho = FUEL_DENSITY_KG_PER_L;
|
||
const f = (startFuel + emptyWeightKg / rho) * Math.exp(-k * rho * d) - emptyWeightKg / rho;
|
||
return Math.max(0, f);
|
||
}
|
||
|
||
// Total fuel required to cover distanceKm arriving with an empty tank — independent of the
|
||
// starting load. A leg is flyable from a plane iff its current fuel covers this amount; it
|
||
// fits in the aircraft at all iff this does not exceed the tank capacity.
|
||
export function requiredFuelFor(
|
||
burnPerKm: number,
|
||
emptyWeightKg: number,
|
||
maxFuel: number,
|
||
distanceKm: number,
|
||
): number {
|
||
if (distanceKm <= 0) return 0;
|
||
const k = burnRateConstant(burnPerKm, emptyWeightKg, maxFuel);
|
||
const rho = FUEL_DENSITY_KG_PER_L;
|
||
return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1);
|
||
}
|
||
|
||
// Wind adjustment to per-km burn (v0.36.0, ISS-070): the headwind component of a wind blowing
|
||
// FROM `windDirDeg` against a leg heading `bearingDeg`, as a multiplier on burnPerKm —
|
||
// factor = 1 + headwindKt/150, exactly [0.8, 1.2] over the weather range 2–30 kt (±20%).
|
||
// Pure; callers apply it as burnPerKm * factor so every call site agrees on one value per leg.
|
||
export function windBurnFactor(windKt: number, windDirDeg: number, bearingDeg: number): number {
|
||
const headwindKt = windKt * Math.cos(((bearingDeg - windDirDeg) * Math.PI) / 180);
|
||
return 1 + headwindKt / 150;
|
||
}
|
||
|
||
// Max range from a FULL tank under the weighted model — solve requiredFuelFor(d) = maxFuel for d:
|
||
// (We/ρ)(e^{kρd} − 1) = F → d = ln(1 + ρF/We) · (We + ρF)/(ρ·burnPerKm).
|
||
// Returns raw km (call sites round); zero fuel gives exactly 0. Pure — display-only, the sim
|
||
// itself does not model range exhaustion (see fuelAtWeighted).
|
||
export function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
|
||
if (!(emptyWeightKg > 0)) throw new Error('emptyWeightKg must be > 0');
|
||
const rho = FUEL_DENSITY_KG_PER_L;
|
||
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
|
||
}
|
||
|
||
// Max headwind burn multiplier over the weather domain (v0.51.0, ISS-099): the upper end of
|
||
// windBurnFactor's [0.8, 1.2] range — a full 30 kt headwind (the max in lib/weather.ts) ÷ 150.
|
||
export const MAX_WIND_BURN_FACTOR = 1 + 30 / 150;
|
||
|
||
// Guaranteed full-tank range (v0.51.0, ISS-099): the maxRangeKm closed form under worst-case
|
||
// headwind burn — any leg within this distance is flyable in ALL weather, since windBurnFactor
|
||
// never exceeds MAX_WIND_BURN_FACTOR and requiredFuelFor grows with the burn rate. This is what
|
||
// the UI displays as "Max range", so a spec-possible leg always passes flyPlane's fuel guard
|
||
// (requiredFuelFor ≤ maxFuel under the same wind-adjusted burn).
|
||
export function guaranteedRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
|
||
return maxRangeKm(burnPerKm * MAX_WIND_BURN_FACTOR, emptyWeightKg, maxFuel);
|
||
}
|