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
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:
+58
-14
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user