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
+84
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: {
+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);
}