Files
Flight-Ops/lib/sim/movement.ts
T
kamilkrzywda a801311fc5
CI / build + unit + e2e (pull_request) Failing after 5m28s
feat: v0.16.0 — ISS-005 weight-proportional fuel burn + refuel just enough for the loaded package
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.
2026-09-19 12:24:34 +00:00

121 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}