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:
@@ -6,6 +6,8 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { openGameDb, type GameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { requiredFuelFor } from '@/lib/sim/movement';
|
||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
||||
import { HUB_ICAO } from './seed';
|
||||
import {
|
||||
@@ -412,4 +414,86 @@ describe('refuelPlane', () => {
|
||||
const inst = await freshDb('refuel-unknown.db');
|
||||
await expectActionError(refuelPlane(inst, 'ghost'), 404);
|
||||
});
|
||||
|
||||
// --- forFlight mode (v0.16.0) ---
|
||||
|
||||
it('forFlight adds exactly the liters needed for the package leg and deducts L × price', async () => {
|
||||
const inst = await freshDb('refuel-forflight-ok.db');
|
||||
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
|
||||
await loadPackage(inst, 'p-ff', 'plane-starter');
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const type = PLANE_TYPES.atr72;
|
||||
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
|
||||
|
||||
// Partial tank below the requirement so L > 0.
|
||||
const startFuel = Math.round(required / 2);
|
||||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' });
|
||||
|
||||
expect(cost).toBe(Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.fuel).toBeCloseTo(required, 6); // topped up to exactly the leg requirement
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
|
||||
});
|
||||
|
||||
it('forFlight is a free no-op when the tank already covers the leg', async () => {
|
||||
const inst = await freshDb('refuel-forflight-enough.db'); // seeded starter is full at EDDF
|
||||
await insertOpenPackage(inst, 'p-ff2', { destIcao: 'LEMD' });
|
||||
await loadPackage(inst, 'p-ff2', 'plane-starter');
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff2' });
|
||||
|
||||
expect(cost).toBe(0);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||||
});
|
||||
|
||||
it('forFlight rejects a package that is not loaded on this plane with 409', async () => {
|
||||
const inst = await freshDb('refuel-ff-open.db'); // open — never loaded
|
||||
await insertOpenPackage(inst, 'p-x');
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-x' }), 409);
|
||||
|
||||
const inst2 = await freshDb('refuel-ff-other.db'); // loaded on ANOTHER plane
|
||||
await insertOpenPackage(inst2, 'p-y', { status: 'loaded', planeId: 'plane-someone-else' });
|
||||
await expectActionError(refuelPlane(inst2, 'plane-starter', { mode: 'forFlight', packageId: 'p-y' }), 409);
|
||||
});
|
||||
|
||||
it('forFlight rejects an expired package with 409', async () => {
|
||||
const inst = await freshDb('refuel-ff-expired.db');
|
||||
await insertOpenPackage(inst, 'p-z', { status: 'expired', planeId: 'plane-starter' });
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-z' }), 409);
|
||||
});
|
||||
|
||||
it('forFlight rejects an unknown package id with 404', async () => {
|
||||
const inst = await freshDb('refuel-ff-unknown.db');
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'nope' }), 404);
|
||||
});
|
||||
|
||||
it('forFlight rejects a missing packageId with 400', async () => {
|
||||
const inst = await freshDb('refuel-ff-nopkg.db');
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight' }), 400);
|
||||
});
|
||||
|
||||
it('forFlight rejects a leg that does not fit even a full tank with 409', async () => {
|
||||
const inst = await freshDb('refuel-ff-nofit.db');
|
||||
// UNNT (Narsarsuaq, Greenland) ↔ LPPD are ~7670 km apart — far beyond the ATR's range.
|
||||
await insertOpenPackage(inst, 'p-far', { destIcao: 'LPPD' });
|
||||
await loadPackage(inst, 'p-far', 'plane-starter');
|
||||
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(
|
||||
refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-far' }),
|
||||
409,
|
||||
/does not fit/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+50
-7
@@ -4,6 +4,7 @@ import * as schema from '@/lib/db/schema';
|
||||
import { persist, type GameDb } from '@/lib/db/client';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { requiredFuelFor } from '@/lib/sim/movement';
|
||||
import { runAction } from '@/lib/sim/engine';
|
||||
import { getStateSnapshot } from './state';
|
||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
||||
@@ -105,9 +106,10 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
||||
const type = PLANE_TYPES[plane.model];
|
||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
||||
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
|
||||
const burnL = type.burnPerKm * distanceKm;
|
||||
if (burnL > plane.fuel) {
|
||||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(burnL)} L but the plane has ${Math.round(plane.fuel)} L`);
|
||||
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty.
|
||||
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
||||
if (requiredL > plane.fuel) {
|
||||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
|
||||
}
|
||||
|
||||
// runAction() ticked first, so this is current game time.
|
||||
@@ -225,8 +227,19 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
|
||||
|
||||
// --- Refuel (POST /api/planes/:planeId/refuel) ---
|
||||
|
||||
// Fill a grounded plane to maxFuel at FUEL_PRICE_PER_L per missing liter.
|
||||
export async function refuelPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
|
||||
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' adds just
|
||||
// enough fuel for one loaded package's destination leg — required liters per the
|
||||
// weighted burn model, independent of how much is already in the tank.
|
||||
export interface RefuelOptions {
|
||||
mode?: 'full' | 'forFlight';
|
||||
packageId?: string;
|
||||
}
|
||||
|
||||
// Fill a grounded plane to maxFuel at FUEL_PRICE_PER_L per missing liter — or, with
|
||||
// {mode:'forFlight', packageId}, top it up just enough to fly the loaded package's
|
||||
// destination leg (409 if the package isn't loaded on this plane, is expired, or the
|
||||
// leg doesn't fit a full tank).
|
||||
export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOptions = {}): Promise<{ cost: number }> {
|
||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const plane = rows[0];
|
||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||
@@ -234,14 +247,44 @@ export async function refuelPlane(inst: GameDb, planeId: string): Promise<{ cost
|
||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be refueled`);
|
||||
}
|
||||
|
||||
const cost = Math.round((plane.maxFuel - plane.fuel) * FUEL_PRICE_PER_L * 100) / 100;
|
||||
const mode = opts.mode ?? 'full';
|
||||
let liters: number; // how much fuel to add
|
||||
if (mode === 'forFlight') {
|
||||
if (!opts.packageId) throw new GameActionError(400, 'packageId is required when mode is "forFlight"');
|
||||
|
||||
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, opts.packageId));
|
||||
const pkg = pkgRows[0];
|
||||
if (!pkg) throw new GameActionError(404, `package ${opts.packageId} not found`);
|
||||
if (pkg.status !== 'loaded' || pkg.planeId !== plane.id) {
|
||||
const where = pkg.planeId === plane.id ? '' : ', and it is bound to another plane';
|
||||
throw new GameActionError(409, `only a package loaded on this plane can be refueled for (it is ${pkg.status}${where})`);
|
||||
}
|
||||
|
||||
const type = PLANE_TYPES[plane.model];
|
||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
||||
const origin = await airportByIcao(plane.locationIcao);
|
||||
const dest = await airportByIcao(pkg.destIcao);
|
||||
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
||||
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
||||
if (requiredL > plane.maxFuel) {
|
||||
throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`);
|
||||
}
|
||||
liters = Math.max(0, requiredL - plane.fuel);
|
||||
} else {
|
||||
liters = plane.maxFuel - plane.fuel;
|
||||
}
|
||||
|
||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * 100) / 100;
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
if (cost > 0 && account.cash < cost) {
|
||||
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
|
||||
}
|
||||
|
||||
await inst.db.update(schema.planes).set({ fuel: plane.maxFuel }).where(eq(schema.planes.id, planeId));
|
||||
// Full mode lands EXACTLY on maxFuel (as before v0.16.0), not fuel + liters — the sum can
|
||||
// drift a ulp for fractional tanks and clients/tests compare against maxFuel exactly.
|
||||
const targetFuel = mode === 'forFlight' ? plane.fuel + liters : plane.maxFuel;
|
||||
await inst.db.update(schema.planes).set({ fuel: targetFuel }).where(eq(schema.planes.id, planeId));
|
||||
if (cost > 0) {
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||
}
|
||||
|
||||
+10
-2
@@ -6,8 +6,11 @@ export interface PlaneType {
|
||||
name: string;
|
||||
// Fuel capacity, liters.
|
||||
maxFuel: number;
|
||||
// Fuel burn per km of flight, liters/km (cruise).
|
||||
// Fuel burn per km of flight at a FULL tank, liters/km (cruise) — the weighted burn
|
||||
// model (v0.16.0) calibrates its rate constant to this value at F = maxFuel.
|
||||
burnPerKm: number;
|
||||
// Operating empty weight in kg (v0.16.0) — feeds the weight-proportional burn model.
|
||||
emptyWeightKg: number;
|
||||
// Cruise speed, km/h — used by the sim tick to advance planes along routes.
|
||||
speedKmh: number;
|
||||
// Market price in € (v0.8.0): buying costs `price`, selling pays out
|
||||
@@ -22,7 +25,8 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||
id: 'atr72',
|
||||
name: 'ATR 72-600',
|
||||
maxFuel: 6300,
|
||||
burnPerKm: 1.75, // ~900 kg/h at 510 km/h cruise (Jet-A1 ≈ 0.8 kg/L)
|
||||
burnPerKm: 1.75, // full-tank rate: ~893 L/h ≈ 714 kg/h at 510 km/h cruise (Jet-A1 ≈ 0.8 kg/L); the weighted model burns less as fuel drops off
|
||||
emptyWeightKg: 9_300, // ATR 72-600 operating empty weight
|
||||
speedKmh: 510,
|
||||
// €200k — with the starting cash of €20k and typical hop rewards (~€1–3k),
|
||||
// a second ATR takes sustained play to afford, as intended for the MVP sink.
|
||||
@@ -49,6 +53,9 @@ export interface CatalogEntry {
|
||||
price: number;
|
||||
maxFuel: number;
|
||||
burnPerKm: number;
|
||||
// Weighted burn model inputs (v0.16.0) — clients can mirror the closed forms in
|
||||
// lib/sim/movement.ts with these + FUEL_DENSITY_KG_PER_L.
|
||||
emptyWeightKg: number;
|
||||
speedKmh: number;
|
||||
capacity: number;
|
||||
}
|
||||
@@ -62,6 +69,7 @@ export function catalogEntries(): CatalogEntry[] {
|
||||
price: t.price,
|
||||
maxFuel: t.maxFuel,
|
||||
burnPerKm: t.burnPerKm,
|
||||
emptyWeightKg: t.emptyWeightKg,
|
||||
speedKmh: t.speedKmh,
|
||||
capacity: t.capacity,
|
||||
}));
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { haversineKm } from '@/lib/geo';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
||||
import { fuelAt, progressAt, positionAt } from '@/lib/sim/movement';
|
||||
import { fuelAtWeighted, progressAt, positionAt } from '@/lib/sim/movement';
|
||||
|
||||
// Read-only game-state snapshot for client polling (GET /api/state).
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
|
||||
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
|
||||
return { ...pos, fuel: fuelAt(p.fuel, type.burnPerKm, distanceKm, progress) };
|
||||
return { ...pos, fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, progress) };
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
||||
planes: [],
|
||||
packages: [],
|
||||
catalog: [
|
||||
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510, capacity: 6 },
|
||||
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, capacity: 6 },
|
||||
],
|
||||
fuelPricePerL: 0.8,
|
||||
sellFraction: 0.5,
|
||||
|
||||
+24
-6
@@ -90,8 +90,17 @@ describe('OpenAPI spec', () => {
|
||||
expect(snap.sellFraction.type).toBe('number');
|
||||
|
||||
const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
|
||||
expect(Object.keys(catalog).sort()).toEqual(['burnPerKm', 'capacity', 'label', 'maxFuel', 'model', 'price', 'speedKmh']);
|
||||
for (const key of ['burnPerKm', 'maxFuel', 'price', 'speedKmh'] as const) {
|
||||
expect(Object.keys(catalog).sort()).toEqual([
|
||||
'burnPerKm',
|
||||
'capacity',
|
||||
'emptyWeightKg', // v0.16.0 weighted burn model input
|
||||
'label',
|
||||
'maxFuel',
|
||||
'model',
|
||||
'price',
|
||||
'speedKmh',
|
||||
]);
|
||||
for (const key of ['burnPerKm', 'emptyWeightKg', 'maxFuel', 'price', 'speedKmh'] as const) {
|
||||
expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number');
|
||||
}
|
||||
expect(catalog.capacity.type).toBe('integer'); // v0.12.0 slot capacity
|
||||
@@ -165,7 +174,7 @@ describe('OpenAPI spec', () => {
|
||||
const post = (p: string) =>
|
||||
((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as {
|
||||
responses: Record<string, unknown>;
|
||||
requestBody?: { content: Record<string, { schema: { $ref: string } }> };
|
||||
requestBody?: { required?: boolean; content: Record<string, { schema: { $ref: string } }> };
|
||||
parameters?: Array<{ name: string }>;
|
||||
};
|
||||
|
||||
@@ -189,9 +198,10 @@ describe('OpenAPI spec', () => {
|
||||
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
|
||||
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
|
||||
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
|
||||
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['404', '409']);
|
||||
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['400', '404', '409']); // v0.16.0 optional body
|
||||
|
||||
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon/refuel take none.
|
||||
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon takes none;
|
||||
// refuel's is OPTIONAL (v0.16.0) — no body still means "fill to full".
|
||||
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||
'#/components/schemas/LoadPackageRequest',
|
||||
);
|
||||
@@ -205,7 +215,9 @@ describe('OpenAPI spec', () => {
|
||||
'#/components/schemas/SellPlaneRequest',
|
||||
);
|
||||
expect(post('/api/packages/{id}/abandon').requestBody).toBeUndefined();
|
||||
expect(post('/api/planes/{planeId}/refuel').requestBody).toBeUndefined();
|
||||
const refuelBody = post('/api/planes/{planeId}/refuel').requestBody;
|
||||
expect(refuelBody?.content['application/json'].schema.$ref).toBe('#/components/schemas/RefuelRequest');
|
||||
expect(refuelBody?.required).toBe(false); // optional — no body still means "fill to full"
|
||||
|
||||
// Path parameters are declared on the parameterized routes.
|
||||
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
|
||||
@@ -227,5 +239,11 @@ describe('OpenAPI spec', () => {
|
||||
const sell = openApiSpec.components.schemas.SellPlaneRequest;
|
||||
expect(sell.required).toEqual(['planeId']);
|
||||
expect(sell.properties.planeId.type).toBe('string');
|
||||
|
||||
// RefuelRequest (v0.16.0): both fields optional — no body / {} means "full".
|
||||
const refuel = openApiSpec.components.schemas.RefuelRequest;
|
||||
expect((refuel as { required?: string[] }).required ?? []).toEqual([]);
|
||||
expect(((refuel.properties.mode) as { enum: string[] }).enum.sort()).toEqual(['forFlight', 'full']);
|
||||
expect(refuel.properties.packageId.type).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
+38
-7
@@ -197,7 +197,8 @@ export const openApiSpec = {
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description: 'Plane not grounded, or insufficient fuel for the leg (burnPerKm × great-circle km)',
|
||||
description:
|
||||
'Plane not grounded, or insufficient fuel for the leg (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
@@ -271,22 +272,35 @@ export const openApiSpec = {
|
||||
post: {
|
||||
summary: 'Refuel a grounded plane',
|
||||
description:
|
||||
'Fill a grounded plane to maxFuel (v0.8.0). Cost = (maxFuel − fuel) × fuel price per liter, deducted from cash; no body.',
|
||||
'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId adds just enough for that loaded package\'s destination leg — liters = required fuel for the great-circle distance (weight-proportional burn model) minus what is already in the tank.',
|
||||
operationId: 'refuelPlane',
|
||||
parameters: [
|
||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/RefuelRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Refueled — full state snapshot (fuel at max, cash reduced by the cost)',
|
||||
description: 'Refueled — full state snapshot (fuel raised by exactly the added liters, cash reduced by the cost)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid body (not an object, unknown field, unknown mode, or forFlight without packageId)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown plane id',
|
||||
description: 'Unknown plane id (or unknown package id in forFlight mode)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description: 'Plane is flying, or cash is insufficient for the fill-up',
|
||||
description:
|
||||
'Plane is flying, the package is not loaded on this plane (or is expired), the leg does not fit a full tank, or cash is insufficient for the refuel',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
@@ -376,11 +390,12 @@ export const openApiSpec = {
|
||||
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
|
||||
price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' },
|
||||
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
|
||||
burnPerKm: { type: 'number', description: 'Cruise fuel burn, liters/km.' },
|
||||
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this).' },
|
||||
emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' },
|
||||
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
|
||||
capacity: { type: 'integer', description: 'Cargo slot capacity — packages that can be loaded on one plane (v0.12.0).' },
|
||||
},
|
||||
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'speedKmh', 'capacity'],
|
||||
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'emptyWeightKg', 'speedKmh', 'capacity'],
|
||||
},
|
||||
AccountState: {
|
||||
type: 'object',
|
||||
@@ -440,6 +455,22 @@ export const openApiSpec = {
|
||||
},
|
||||
required: ['planeId'],
|
||||
},
|
||||
RefuelRequest: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Optional refuel body (v0.16.0) — omit it entirely or send {} to fill the tank; both fields are optional.',
|
||||
properties: {
|
||||
mode: {
|
||||
enum: ['full', 'forFlight'],
|
||||
description:
|
||||
'"full" (default) fills the tank to maxFuel; "forFlight" adds just enough fuel for one loaded package\'s destination leg.',
|
||||
},
|
||||
packageId: {
|
||||
type: 'string',
|
||||
description: 'Required with mode "forFlight": a package LOADED on this plane — its destIcao is the refuel target.',
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user