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
+34 -16
View File
@@ -10,14 +10,23 @@ import { expect, test } from '@playwright/test';
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
const FAR = 'LEZL'; // Seville — one-way burn 3174 L > half tank, so the return leg is unfuelable
const FAR = 'LEZL'; // Seville — a long leg: under the v0.16.0 weighted model a full tank covers the
// round trip, but the return leaves too little for another outbound
// Mirrors lib/sim/movement.ts + lib/game/catalog.ts (atr72).
const BURN_PER_KM = 1.75;
const FUEL_PRICE_PER_L = 0.8;
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn) + lib/game/catalog.ts (atr72).
const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts
const FUEL_PRICE_PER_L = 0.8; // must match catalog.ts
const REP_BASE = 1; // lib/sim/engine.ts
const REP_PER_KM = 0.0005;
// Fuel remaining after distanceKm from startFuel — closed form of dF/dx = −k(We + ρF),
// k calibrated so a full tank burns exactly burnPerKm (mirrors fuelAtWeighted).
function fuelAfter(startFuel: number, burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number {
const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel);
const rho = FUEL_DENSITY_KG_PER_L;
return Math.max(0, (startFuel + emptyWeightKg / rho) * Math.exp(-k * rho * distanceKm) - emptyWeightKg / rho);
}
// Mirrors lib/game/penalty.ts — the exact abandon penalty at a given game clock.
function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: number, clockMs: number): number {
const span = deadlineSimTime - createdSimTime;
@@ -33,6 +42,7 @@ interface StateBody {
id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number;
deadlineSimTime: number; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; createdSimTime: number;
}>;
catalog: Array<{ model: string; burnPerKm: number; emptyWeightKg: number; maxFuel: number }>; // v0.16.0 weighted burn inputs
}
async function getState(page: Page): Promise<StateBody> {
@@ -174,8 +184,12 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
// Fuel burned exactly burnPerKm × km (analytic movement — no rounding drift).
expect(landed.fuel).toBeCloseTo(Math.max(0, before.planes.find((p) => p.id === planeId)!.fuel - BURN_PER_KM * exactKm), 6);
// Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift).
const entry = before.catalog.find((e) => e.model === 'atr72')!;
expect(landed.fuel).toBeCloseTo(
fuelAfter(before.planes.find((p) => p.id === planeId)!.fuel, entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, exactKm),
6,
);
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, deducted exactly.
const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`);
@@ -317,12 +331,14 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
expect(res.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(HUB);
// Insufficient fuel: a full-tank one-way burn of > half the tank makes the return leg unfuelable.
// Insufficient fuel (v0.16.0 weighted burn): a full tank covers the FAR round trip —
// the plane gets lighter as fuel burns off, so both legs fit — but landing back at the
// hub leaves too little for ANOTHER long leg: the second outbound must 409.
const s0 = await getState(page);
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
expect(starter.locationIcao).toBe(HUB); // landed back at the hub from the hop above
if (starter.fuel < starter.maxFuel) {
await page.request.post('/api/planes/plane-starter/refuel'); // ensure a full tank for the long leg
await page.request.post('/api/planes/plane-starter/refuel'); // ensure a full tank for the long legs
}
const farRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } });
expect(farRes.ok()).toBeTruthy();
@@ -332,16 +348,18 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
const sFar = await getState(page);
const atFar = sFar.planes.find((p) => p.id === 'plane-starter')!;
expect(atFar.locationIcao).toBe(FAR);
// The return leg needs the same burn as the outbound — more than what's left.
// The return leg fits: the weighted burn over it is less than what's left in the tank.
const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(backRes.status()).toBe(409);
expect(((await backRes.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
// Restore: refuel + fly home.
await page.request.post('/api/planes/plane-starter/refuel');
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy();
expect(backRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
// …but the tank is now too light for another FAR leg.
const againFar = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } });
expect(againFar.status()).toBe(409);
expect(((await againFar.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
// Restore: refuel (the plane is already home).
await page.request.post('/api/planes/plane-starter/refuel');
});
// Great-circle distance in km — mirrors lib/geo.ts (same formula, same R).