feat: v0.16.0 — ISS-005 weight-proportional fuel burn + refuel just enough for the loaded package
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.
This commit is contained in:
2026-09-19 12:24:34 +00:00
parent 1dc5beb0dd
commit a801311fc5
19 changed files with 606 additions and 74 deletions
+3 -2
View File
@@ -9,7 +9,7 @@ import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
import { arrivalTimeFor } from './movement';
import { arrivalTimeFor, fuelAtWeighted } from './movement';
import {
clampTimeScale,
deliverPackages,
@@ -182,7 +182,8 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(plane.locationIcao).toBe(TO);
expect(plane.lat).toBe(to.lat);
expect(plane.lon).toBe(to.lon);
expect(plane.fuel).toBeCloseTo(Math.max(0, type.maxFuel - type.burnPerKm * km), 6); // remaining fuel
// Remaining fuel per the v0.16.0 weight-proportional burn model (closed form).
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1), 6);
expect(plane.departureSimTime).toBeNull();
expect(plane.destIcao).toBeNull(); // flight context cleared on landing
+3 -3
View File
@@ -12,7 +12,7 @@ import {
type Rng,
type SeedAirport,
} from '@/lib/game/seed';
import { arrivalTimeFor, fuelAt } from './movement';
import { arrivalTimeFor, fuelAtWeighted } from './movement';
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
@@ -131,14 +131,14 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg burn.
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn.
await inst.db.update(schema.planes)
.set({
status: 'available',
locationIcao: to.icao,
lat: to.lat,
lon: to.lon,
fuel: fuelAt(p.fuel, type.burnPerKm, distanceKm, 1),
fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1),
departureSimTime: null,
destIcao: null,
})
+58 -14
View File
@@ -1,12 +1,23 @@
import { describe, expect, it } from 'vitest';
import { haversineKm } from '@/lib/geo';
import { arrivalTimeFor, fuelAt, positionAt, progressAt, travelDurationMs } from './movement';
import {
arrivalTimeFor,
burnRateConstant,
FUEL_DENSITY_KG_PER_L,
fuelAtWeighted,
positionAt,
progressAt,
requiredFuelFor,
travelDurationMs,
} from './movement';
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of
// the sim clock. ATR 72-600 numbers from lib/game/catalog.ts.
const DEP = 1_750_000_000_000; // departure sim time (ms)
const SPEED = 510; // km/h
const BURN = 1.75; // L/km
const BURN = 1.75; // L/km at a full tank
const WE = 9_300; // kg, operating empty weight
const TANK = 6_300; // liters, full tank
// Frankfurt → Madrid, real coordinates from the CSV dataset.
const FROM = { lat: 50.0333, lon: 8.5706 };
@@ -96,17 +107,53 @@ describe('positionAt (great-circle slerp)', () => {
});
});
describe('fuelAt', () => {
it('burns fuel proportionally to distance flown', () => {
const start = 6_300; // full ATR tank
expect(fuelAt(start, BURN, KM, 0)).toBe(start);
expect(fuelAt(start, BURN, KM, 0.5)).toBeCloseTo(start - BURN * KM / 2, 9);
expect(fuelAt(start, BURN, KM, 1)).toBeCloseTo(start - BURN * KM, 9);
describe('weighted burn model (v0.16.0)', () => {
it('calibrates so a full tank burns exactly the old constant rate', () => {
const k = burnRateConstant(BURN, WE, TANK);
expect(k * (WE + FUEL_DENSITY_KG_PER_L * TANK)).toBeCloseTo(BURN, 12);
// Over a short hop from full, weighted ≈ constant — divergence only grows as fuel drops off.
const d = 100;
const consumed = TANK - fuelAtWeighted(TANK, BURN, WE, TANK, d, 1);
expect(Math.abs(consumed - BURN * d)).toBeLessThan(1); // < 1 L over 100 km from full
});
it('consumption matches numeric integration of r(F) = k·(We + ρF)', () => {
const k = burnRateConstant(BURN, WE, TANK);
const rho = FUEL_DENSITY_KG_PER_L;
// Euler-integrate dF/dx = −k(We + ρF) from a full tank over the whole leg.
const steps = 200_000;
const h = KM / steps;
let f = TANK;
for (let i = 0; i < steps; i++) f -= k * (WE + rho * f) * h;
expect(Math.abs(fuelAtWeighted(TANK, BURN, WE, TANK, KM, 1) - f)).toBeLessThan(0.001); // Euler drift is sub-milliliter here
});
it('requiredFuelFor is zero for a zero leg and strictly increasing in distance', () => {
expect(requiredFuelFor(BURN, WE, TANK, 0)).toBe(0);
let prev = requiredFuelFor(BURN, WE, TANK, 100);
for (const d of [500, 1_000, 2_000, 3_000, 4_000]) {
const cur = requiredFuelFor(BURN, WE, TANK, d);
expect(cur).toBeGreaterThan(prev);
prev = cur;
}
});
it('a lighter start burns less over the same distance', () => {
const burnFromFull = TANK - fuelAtWeighted(TANK, BURN, WE, TANK, KM, 1);
const burnFromHalf = TANK / 2 - fuelAtWeighted(TANK / 2, BURN, WE, TANK, KM, 1);
expect(burnFromFull).toBeGreaterThan(burnFromHalf);
});
it('starting with exactly the required fuel arrives on empty', () => {
const req = requiredFuelFor(BURN, WE, TANK, KM);
expect(fuelAtWeighted(req, BURN, WE, TANK, KM, 1)).toBeCloseTo(0, 6);
});
it('clamps at zero instead of going negative', () => {
expect(fuelAt(100, BURN, 1_000, 1)).toBe(0); // needs 1750 L, has 100
expect(fuelAt(100, BURN, 1_000, 0.01)).toBeCloseTo(100 - 17.5, 9);
expect(fuelAtWeighted(100, BURN, WE, TANK, KM, 1)).toBe(0); // needs ~2800 L from full, has 100
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, 0)).toBe(TANK);
expect(fuelAtWeighted(TANK, BURN, WE, TANK, 0, 1)).toBe(TANK);
});
it('composes with progressAt for a just-before-arrival snapshot', () => {
@@ -114,9 +161,6 @@ describe('fuelAt', () => {
const clock = DEP + duration - 1; // one ms before arrival
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, clock));
expect(haversineKm(p.lat, p.lon, TO.lat, TO.lon)).toBeGreaterThan(0); // not yet at the gate
expect(fuelAt(6_300, BURN, KM, progressAt(DEP, KM, SPEED, clock))).toBeCloseTo(
6_300 - BURN * KM * (1 - 1 / duration),
6,
);
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0);
});
});
+52 -4
View File
@@ -65,8 +65,56 @@ export function positionAt(
};
}
// 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);
// --- 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);
}