Wind-adjusted fuel burn ±20% + refuel-for-flight buys 150% margin (ISS-070, ISS-071, v0.36.0)
CI / build + unit + e2e (pull_request) Successful in 10m34s

This commit is contained in:
2026-09-21 08:47:42 +00:00
parent 6d8e1ad35e
commit 5030fcd8b4
16 changed files with 309 additions and 81 deletions
+28 -18
View File
@@ -6,8 +6,9 @@ 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 { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
import { HUB_ICAO } from './seed';
import {
@@ -591,17 +592,29 @@ describe('refuelPlane', () => {
// --- 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');
// Wind-adjusted requirement for the hub → LEMD leg (v0.36.0) — computed with the SAME helpers
// refuelPlane uses, so the expectations stay exact without hardcoded literals.
async function adjustedRequiredForHubToLemd() {
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);
const w = weatherFor(HUB_ICAO);
return requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
km,
);
}
it('forFlight buys 150% of the weather-adjusted leg requirement 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 required = await adjustedRequiredForHubToLemd();
// Partial tank below the requirement so L > 0.
const startFuel = Math.round(required / 2);
@@ -610,31 +623,28 @@ describe('refuelPlane', () => {
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);
// ISS-071: target is 150% of the requirement (below tank capacity for this leg).
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
expect(cost).toBe(Math.round((targetFuel - 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
expect(plane.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
});
it('forFlight leaves an exactly-feasible tank — flyPlane to the package dest succeeds right after', async () => {
it('forFlight leaves a tank with a 50% margin — flyPlane to the package dest succeeds right after', async () => {
const inst = await freshDb('refuel-ff-flyafter.db');
await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-fa', '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);
const required = await adjustedRequiredForHubToLemd();
// Empty-ish tank: well below the leg requirement.
await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter'));
await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' });
// The leg must now be flyable — no epsilon-short 409 (regression guard for the float-exact target).
// The leg must now be flyable — the 150% margin covers flyPlane's strict `>` check.
await flyPlane(inst, 'plane-starter', 'LEMD');
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
+24 -13
View File
@@ -3,8 +3,9 @@ import { and, eq, inArray } from 'drizzle-orm';
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 { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { runAction } from '@/lib/sim/engine';
import { getStateSnapshot, parseIcaoQueue } from './state';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
@@ -106,8 +107,11 @@ 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);
// 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);
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
// adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
const w = weatherFor(plane.locationIcao);
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon));
const requiredL = requiredFuelFor(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`);
}
@@ -312,9 +316,10 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
// --- Refuel (POST /api/planes/:planeId/refuel) ---
// 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.
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' buys 150% of
// the fuel required for one loaded package's destination leg (ISS-071) — wind-adjusted
// requirement per the weighted burn model, capped at tank capacity, independent of how
// much is already in the tank.
export interface RefuelOptions {
mode?: 'full' | 'forFlight';
packageId?: string;
@@ -350,15 +355,21 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
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);
// Same wind-adjusted requirement flyPlane checks (v0.36.0) — departure weather vs route heading.
const w = weatherFor(plane.locationIcao);
const requiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, dest.lat, dest.lon)),
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`);
}
// Land EXACTLY on the leg requirement when topping up: flyPlane re-computes this same value and
// compares with strict `>`, so fuel := requiredL guarantees the leg is feasible. The alternative
// (fuel + liters) can round a hair BELOW R in IEEE754 — f + fl(R − f) is not guaranteed ≥ R — and
// would 409 on an otherwise-feasible leg. Already enough → free no-op, tank untouched.
targetFuel = Math.max(plane.fuel, requiredL);
// ISS-071: buy 150% of the requirement (capped at tank capacity) so burn variance doesn't strand
// the plane. The margin makes flyPlane's strict `>` check pass with room to spare; already enough
// → free no-op, tank untouched.
targetFuel = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
} else {
// Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality.
targetFuel = plane.maxFuel;
+14 -3
View File
@@ -1,9 +1,10 @@
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
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 { fuelAtWeighted, progressAt, positionAt } from '@/lib/sim/movement';
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
// Read-only game-state snapshot for client polling (GET /api/state).
@@ -102,7 +103,17 @@ 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: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, progress) };
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
// recomputes exactly the factor the engine used for the leg; no stored state needed.
const w = weatherFor(p.locationIcao);
return { ...pos, fuel: fuelAtWeighted(
p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
progress,
) };
};
return {
+38 -1
View File
@@ -1,5 +1,42 @@
import { describe, expect, it } from 'vitest';
import { haversineKm } from './geo';
import { haversineKm, initialBearingDeg } from './geo';
describe('initialBearingDeg', () => {
it('is due north (0) for a point directly above', () => {
expect(initialBearingDeg(0, 0, 10, 0)).toBe(0);
});
it('is due east (90) along the equator', () => {
expect(initialBearingDeg(0, -10, 0, 10)).toBeCloseTo(90, 9);
});
it('is due south (180) for a point directly below', () => {
expect(initialBearingDeg(10, 0, 0, 0)).toBeCloseTo(180, 9);
});
it('is due west (270) along the equator — normalized into [0, 360)', () => {
expect(initialBearingDeg(0, 10, 0, -10)).toBeCloseTo(270, 9);
});
it('always normalizes to [0, 360) for arbitrary pairs', () => {
const pairs: Array<[number, number, number, number]> = [
[50.03, 8.57, 40.47, -3.56], // FRA → MAD (southwest-ish)
[40.47, -3.56, 50.03, 8.57], // reverse: northeast-ish
[65.66, -18.07, 50.03, 8.57], // far north → south-east wrap case
];
for (const [a1, a2, b1, b2] of pairs) {
const brg = initialBearingDeg(a1, a2, b1, b2);
expect(brg).toBeGreaterThanOrEqual(0);
expect(brg).toBeLessThan(360);
}
});
it('FRA → MAD points roughly south-southwest (between 180 and 270)', () => {
const brg = initialBearingDeg(50.03, 8.57, 40.47, -3.56);
expect(brg).toBeGreaterThan(180);
expect(brg).toBeLessThan(270);
});
});
describe('haversineKm', () => {
it('returns 0 for identical points', () => {
+13
View File
@@ -1,5 +1,18 @@
const EARTH_RADIUS_KM = 6371;
// Initial great-circle bearing from point 1 to point 2 (v0.36.0), degrees true [0, 360).
export function initialBearingDeg(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dLon = toRad(lon2 - lon1);
const theta = Math.atan2(
Math.sin(dLon) * Math.cos(phi2),
Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLon),
);
return (theta * 180 / Math.PI + 360) % 360;
}
// Great-circle distance between two WGS84 points (haversine), in km.
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
+3 -3
View File
@@ -370,7 +370,7 @@ export const openApiSpec = {
post: {
summary: 'Refuel a grounded plane',
description:
'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.',
'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 buys 150% of the weather-adjusted fuel required for that loaded package\'s destination leg, capped at tank capacity — liters = min(maxFuel, max(current fuel, 1.5 × required)) − current fuel, where required is the weight-proportional burn model over the great-circle distance with a ±20% wind adjustment (v0.36.0).',
operationId: 'refuelPlane',
parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
@@ -488,7 +488,7 @@ 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 at a full tank, liters/km (v0.16.0 weighted model calibrates to this).' },
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this). Actual per-leg burn varies ±20% with wind — departure weather vs route heading (v0.36.0).' },
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.' },
maxPayloadKg: { type: 'number', description: 'Max payload in kg — total package weight that can be loaded on one plane (v0.27.0, replaces the v0.12.0 slot count).' },
@@ -575,7 +575,7 @@ export const openApiSpec = {
mode: {
enum: ['full', 'forFlight'],
description:
'"full" (default) fills the tank to maxFuel; "forFlight" adds just enough fuel for one loaded package\'s destination leg.',
'"full" (default) fills the tank to maxFuel; "forFlight" buys 150% of the weather-adjusted fuel required for one loaded package\'s destination leg, capped at tank capacity (v0.36.0).',
},
packageId: {
type: 'string',
+23 -14
View File
@@ -6,10 +6,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import {
clampTimeScale,
deliverPackages,
@@ -165,7 +166,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
});
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
const { inst, to, km, type } = await setupFlight('deliver-mixed.db');
const { inst, from, to, km, type } = await setupFlight('deliver-mixed.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
// Three packages on the plane: one in-deadline for TO, one expired for TO,
@@ -184,8 +185,11 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(plane.locationIcao).toBe(TO);
expect(plane.lat).toBe(to.lat);
expect(plane.lon).toBe(to.lon);
// 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);
// Remaining fuel per the v0.16.0 weight-proportional burn model (closed form), wind-adjusted
// (v0.36.0) by the departure airport's weather vs the route heading.
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1), 6);
expect(plane.departureSimTime).toBeNull();
expect(plane.destIcao).toBeNull(); // flight context cleared on landing
@@ -241,14 +245,19 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
// --- Auto-chain on landing (v0.25.0, ISS-043) ---
it('auto-chains to the next queued leg when fuel covers it', async () => {
const { inst, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD
const { inst, from, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD
const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon);
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop.
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1);
expect(requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop — both legs
// wind-adjusted (v0.36.0) exactly as deliverPackages computes them.
const wFrom = weatherFor(FROM);
const fOut = windBurnFactor(wFrom.windKt, wFrom.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm * fOut, type.emptyWeightKg, type.maxFuel, km, 1);
const wTo = weatherFor(TO); // the chained leg departs from the arrival airport
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
expect(requiredFuelFor(type.burnPerKm * fNext, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
@@ -268,14 +277,14 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
});
it('grounds when fuel cannot cover the next queued leg, keeping the queue intact', async () => {
const { inst, km, type } = await setupFlight('chain-nofuel.db');
const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
const { inst, from, to, km, type } = await setupFlight('chain-nofuel.db');
// Depart with exactly the fuel for EDDF→LEMD → lands empty, cannot start LEMD→EHAM.
// Depart with exactly the wind-adjusted fuel for EDDF→LEMD (v0.36.0) → lands empty, cannot start LEMD→EHAM.
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
await inst.db.update(schema.planes)
.set({
fuel: requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km),
fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km),
queue: JSON.stringify(['EHAM']),
})
.where(eq(schema.planes.id, 'plane-starter'));
+27 -7
View File
@@ -3,7 +3,8 @@ import { eq, inArray } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { parseIcaoQueue } from '@/lib/game/state';
import {
@@ -13,7 +14,7 @@ import {
type Rng,
type SeedAirport,
} from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } 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
@@ -156,8 +157,17 @@ 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 weighted burn.
const landedFuel = fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1);
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
const w = weatherFor(p.locationIcao);
const landedFuel = fuelAtWeighted(
p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
1,
);
// Deliver every bound package whose destination is the airport just reached —
// runs whether or not a queued leg chains next (ISS-043).
@@ -188,9 +198,19 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
if (queue.length > 0) {
const nextDest = queue[0];
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
if (next && requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, haversineKm(to.lat, to.lon, next.lat, next.lon)) <= landedFuel) {
queue.shift();
chainedTo = nextDest;
if (next) {
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
const wNext = weatherFor(to.icao);
const nextRequiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, next.lat, next.lon),
);
if (nextRequiredL <= landedFuel) {
queue.shift();
chainedTo = nextDest;
}
}
}
await inst.db.update(schema.planes)
+48
View File
@@ -10,6 +10,7 @@ import {
progressAt,
requiredFuelFor,
travelDurationMs,
windBurnFactor,
} from './movement';
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of
@@ -166,6 +167,53 @@ describe('weighted burn model (v0.16.0)', () => {
});
});
describe('windBurnFactor (v0.36.0, ISS-070)', () => {
it('full headwind at max wind is exactly +20%', () => {
// bearing == windDirDeg: the wind blows straight from ahead.
expect(windBurnFactor(30, 90, 90)).toBe(1.2);
});
it('full tailwind at max wind is exactly −20%', () => {
// windDirDeg = bearing + 180 (mod 360): the wind blows from behind.
expect(windBurnFactor(30, 270, 90)).toBe(0.8);
});
it('a pure crosswind (90° off) leaves burn unchanged', () => {
expect(windBurnFactor(30, 0, 90)).toBeCloseTo(1, 9);
expect(windBurnFactor(30, 180, 90)).toBeCloseTo(1, 9);
});
it('small wind stays near 1', () => {
const f = windBurnFactor(2, 45, 45); // worst case: full headwind at min wind
expect(f).toBeGreaterThan(1.01);
expect(f).toBeLessThan(1.02);
});
it('stays within [0.8, 1.2] for the whole weather range', () => {
for (const kt of [2, 15, 30]) {
for (const off of [0, 45, 90, 135, 180, 270, 315]) {
const f = windBurnFactor(kt, 100, (100 + off) % 360);
expect(f).toBeGreaterThanOrEqual(0.8 - 1e-12);
expect(f).toBeLessThanOrEqual(1.2 + 1e-12);
}
}
});
it('applied as burnPerKm × f, requiredFuelFor and fuelAtWeighted scale accordingly (pinned)', () => {
// Fixed inputs: ATR numbers, 1000 km leg / half of the FRA→MAD leg.
expect(requiredFuelFor(BURN * 1.2, WE, TANK, 1_000)).toBeCloseTo(1444.911589, 6);
expect(requiredFuelFor(BURN * 0.8, WE, TANK, 1_000)).toBeCloseTo(944.348095, 6);
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 0.5)).toBeCloseTo(4867.313136, 6);
expect(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 0.5)).toBeCloseTo(5331.677856, 6);
// Headwind always costs more than tailwind over the same leg…
expect(requiredFuelFor(BURN * 1.2, WE, TANK, KM)).toBeGreaterThan(requiredFuelFor(BURN * 0.8, WE, TANK, KM));
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 1)).toBeLessThan(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 1));
// …and a neutral factor (f = 1) reproduces the unadjusted model exactly.
expect(requiredFuelFor(BURN * windBurnFactor(30, 0, 90), WE, TANK, KM)).toBeCloseTo(requiredFuelFor(BURN, WE, TANK, KM), 9);
});
});
describe('maxRangeKm (v0.28.0)', () => {
it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => {
const r = maxRangeKm(BURN, WE, TANK);
+9
View File
@@ -119,6 +119,15 @@ export function requiredFuelFor(
return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1);
}
// Wind adjustment to per-km burn (v0.36.0, ISS-070): the headwind component of a wind blowing
// FROM `windDirDeg` against a leg heading `bearingDeg`, as a multiplier on burnPerKm —
// factor = 1 + headwindKt/150, exactly [0.8, 1.2] over the weather range 2–30 kt (±20%).
// Pure; callers apply it as burnPerKm * factor so every call site agrees on one value per leg.
export function windBurnFactor(windKt: number, windDirDeg: number, bearingDeg: number): number {
const headwindKt = windKt * Math.cos(((bearingDeg - windDirDeg) * Math.PI) / 180);
return 1 + headwindKt / 150;
}
// Max range from a FULL tank under the weighted model — solve requiredFuelFor(d) = maxFuel for d:
// (We/ρ)(e^{kρd} − 1) = F → d = ln(1 + ρF/We) · (We + ρF)/(ρ·burnPerKm).
// Returns raw km (call sites round); zero fuel gives exactly 0. Pure — display-only, the sim