CI / build + unit + e2e (pull_request) Failing after 5m28s
Part A: r(F) = k(We + rho*F) L/km, k calibrated so a full tank burns exactly
the old constant; closed forms in lib/sim/movement.ts (fuelAtWeighted,
requiredFuelFor, burnRateConstant, FUEL_DENSITY_KG_PER_L). Catalog gains
emptyWeightKg (ATR72 OEW 9300 kg), exposed in the snapshot. All three burn
call sites (engine landing, state live fuel, fly validation) moved to the
weighted model; old constant-rate fuelAt removed.
Part B: POST /api/planes/{planeId}/refuel takes an optional body
{mode:'full'|'forFlight', packageId} — no body keeps today's fill-to-full
behavior exactly (fuel lands bit-exact on maxFuel); forFlight adds just
enough for the loaded package's destination leg (409 not-loaded/expired/
doesn't-fit, 400 bad body). UI: per-cargo-row 'Refuel' button
(refuel-for-{pkg.id}), market button renamed 'Refuel to full'.
OpenAPI: RefuelRequest schema + optional requestBody + 400 on refuel, fly's
409 description updated, CatalogEntry.emptyWeightKg. Tests: new weighted-burn
suite, forFlight action/route cases (incl. ~7670 km UNNT<->LPPD doesn't-fit),
openapi guards; landing-fuel assertions in engine.test.ts + e2e/actions.spec.ts
use the closed form; the e2e 'return leg unfuelable' scenario reworked — a full
tank now covers the EDDF<->LEZL round trip, so the 409 moves to the second
outbound. New e2e: row Refuel deducts exactly L*price / raises fuel by exactly
L via the endpoint's own response snapshot.
121 lines
5.0 KiB
TypeScript
121 lines
5.0 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.
|
||
|
||
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])),
|
||
};
|
||
}
|
||
|
||
// --- 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);
|
||
}
|