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:
+34
-16
@@ -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).
|
||||
|
||||
@@ -38,6 +38,7 @@ interface CatalogEntry {
|
||||
price: number;
|
||||
maxFuel: number;
|
||||
burnPerKm: number;
|
||||
emptyWeightKg: number; // v0.16.0 weighted burn input
|
||||
speedKmh: number;
|
||||
capacity: number;
|
||||
}
|
||||
@@ -114,6 +115,30 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
|
||||
return nearest;
|
||||
}
|
||||
|
||||
// Farthest airport from EDDF — the longest possible leg (v0.16.0 refuel-for-flight test).
|
||||
async function farthestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||
};
|
||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||
let farthest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
|
||||
for (const f of fc.features) {
|
||||
if (f.properties.icao === HUB) continue;
|
||||
if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) > haversineKm(hub.lat, hub.lon, farthest.lat, farthest.lon)) {
|
||||
farthest = f.properties;
|
||||
}
|
||||
}
|
||||
return { icao: farthest.icao, lat: farthest.lat, lon: farthest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
||||
}
|
||||
|
||||
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
|
||||
// closed form of the weight-proportional burn model, k calibrated so a full tank burns burnPerKm.
|
||||
const FUEL_DENSITY_KG_PER_L = 0.8; // must match FUEL_DENSITY_KG_PER_L in lib/sim/movement.ts
|
||||
function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number {
|
||||
const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel);
|
||||
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> {
|
||||
const created = await page.request.post('/api/test/create-package', {
|
||||
@@ -307,3 +332,73 @@ test('market buy/sell/refuel render with previews from the catalog in /api/state
|
||||
expect(after.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(starter.maxFuel);
|
||||
}
|
||||
});
|
||||
|
||||
test('on-board row Refuel tops up just enough for the package leg; market button says "Refuel to full"', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
const body = await getState(page);
|
||||
const entry = body.catalog.find((e) => e.model === 'atr72')!;
|
||||
|
||||
// Deterministic baseline: a full tank.
|
||||
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
|
||||
|
||||
// Longest leg from the hub — long enough that "just enough" for it exceeds what's left on arrival.
|
||||
const farthest = await farthestToHub(page);
|
||||
const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000);
|
||||
expect((await page.request.post(`/api/packages/${pkg1.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
|
||||
|
||||
// Fly there (delivers pkg1 on arrival) — the tank is now partial.
|
||||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: farthest.icao } })).status()).toBe(200);
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const b = await getState(page);
|
||||
const p = b.planes.find((x) => x.id === 'plane-starter');
|
||||
return !!p && p.status === 'available' && p.locationIcao === farthest.icao;
|
||||
}, { timeout: 240_000, message: `waiting for arrival at ${farthest.icao} (scale 1000)` })
|
||||
.toBe(true);
|
||||
|
||||
// New work at the far end: a package back to the hub — its leg is exactly what we refuel for.
|
||||
const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000);
|
||||
expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
|
||||
|
||||
// Expected "just enough" from the snapshot constants (mirrors requiredFuelFor in lib/sim/movement.ts).
|
||||
const pre = await getState(page);
|
||||
const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel;
|
||||
const cashBefore = pre.account.cash;
|
||||
const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end
|
||||
const liters = Math.max(0, requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, km) - fuelBefore);
|
||||
expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left
|
||||
const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100;
|
||||
|
||||
await page.goto('/');
|
||||
const refuelBtn = page.getByTestId(`refuel-for-${pkg2.id}`);
|
||||
await expect(refuelBtn, 'row Refuel button appears for the loaded package').toBeVisible({ timeout: 15_000 });
|
||||
// Capture the endpoint's OWN response — the full post-action snapshot at the action's clock.
|
||||
const [res] = await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes(`/api/planes/plane-starter/refuel`), { timeout: 15_000 }),
|
||||
refuelBtn.click(),
|
||||
]);
|
||||
expect(res.status()).toBe(200);
|
||||
const post = (await res.json()) as StateBody;
|
||||
|
||||
// Cash dropped by exactly L × price and fuel rose by exactly L.
|
||||
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6);
|
||||
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(fuelBefore + liters, 6);
|
||||
|
||||
// Market row: the fill-up button is labeled "Refuel to full" (testid unchanged).
|
||||
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
|
||||
|
||||
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
|
||||
// exactly the leg requirement, so this also proves "just enough" is enough.
|
||||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for plane-starter to land back at the hub',
|
||||
})
|
||||
.toBe(true);
|
||||
// Top the tank back up so later specs start with a full tank.
|
||||
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user