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
CI / build + unit + e2e (pull_request) Successful in 10m34s
This commit is contained in:
+28
-18
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user