diff --git a/CHANGELOG.md b/CHANGELOG.md index b29f0b8..ae58485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.36.0 — 2026-09-21 08:45 +- **Refuel-for-flight now buys 150% of the required fuel for the leg (ISS-071).** `refuelPlane` mode `'forFlight'` (`lib/game/actions.ts`) tops the tank up to `min(maxFuel, max(current fuel, 1.5 × required))` instead of exactly the requirement — a margin so burn variance doesn't strand planes at the far end. The "does not fit a full tank" 409 guard now compares against the weather-adjusted requirement (see below). No UI change: the per-leg Refuel button has no amount preview, and the left-pane "Refuel to full" row is unaffected +- **Wind affects per-leg fuel burn ±20% based on departure weather vs route heading (ISS-070).** The deterministic per-airport weather (`lib/weather.ts`, FNV-1a seeded by ICAO — no dayKey, stable across polls/sessions) now feeds the fuel math: a new `windBurnFactor(windKt, windDirDeg, bearingDeg)` in `lib/sim/movement.ts` returns `1 + headwindKt/150` (exactly [0.8, 1.2] over the 2–30 kt weather range), and a new `initialBearingDeg(lat1, lon1, lat2, lon2)` in `lib/geo.ts` gives the great-circle heading of each leg. Every fuel call site applies the departure airport's factor to `burnPerKm`: `flyPlane`'s feasibility check, `refuelPlane` forFlight, the engine's landed-fuel + auto-chain checks (`lib/sim/engine.ts`), and the live in-flight fuel display — computed server-side in `getStateSnapshot()`/`livePlane()` (`lib/game/state.ts`) from airport coordinates, so no schema change. Fully deterministic: one factor per leg, a pure function of (ICAO pair, catalog) +- **Tests & spec.** Unit tests added for both helpers and the ×1.5 expectation (computed in-test via the same helpers — no stale literals); e2e specs import the real helpers from `lib/` instead of mirroring the wind math (`e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`, `e2e/actions.spec.ts`), and their "farthest flyable" airport pickers now require BOTH directions to fit a full tank under wind. OpenAPI: description-only updates (refuel endpoint + `burnPerKm` note) + ## v0.35.0 — 2026-09-21 02:26 - **Job list shows cargo tonnage instead of count (ISS-066).** The open-package rows in the right pane's Jobs tab (`components/RightPane.tsx`) now show cargo as type + tonnage ("Mail 10.0t") via a new `formatCargo` in `lib/format.ts` — `(kg / 1000).toFixed(1) + 't'`, identical to the loadout display — instead of "type × count"; unit tests added to `lib/format.test.ts`; e2e assertion added to `e2e/right-pane.spec.ts` (the hook-created package is forced to 10 t and its row must contain "10.0t"). No server or OpenAPI changes — `weightKg` was already in the snapshot diff --git a/app/api/planes/[planeId]/refuel/route.test.ts b/app/api/planes/[planeId]/refuel/route.test.ts index 74b043c..fba80ff 100644 --- a/app/api/planes/[planeId]/refuel/route.test.ts +++ b/app/api/planes/[planeId]/refuel/route.test.ts @@ -6,8 +6,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as schema from '@/lib/db/schema'; import { getGameDb } 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 { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog'; import { HUB_ICAO } from '@/lib/game/seed'; import { POST } from './route'; @@ -124,7 +125,7 @@ describe('POST /api/planes/:planeId/refuel', () => { expect(res.status).toBe(400); }); - it('forFlight tops up exactly the liters needed for the loaded package leg', async () => { + it('forFlight buys 150% of the weather-adjusted leg requirement', async () => { const { db } = await getGameDb(); await db.update(schema.account).set({ cash: 50_000 }); // A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests). @@ -138,7 +139,14 @@ describe('POST /api/planes/:planeId/refuel', () => { 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); + // Same wind-adjusted requirement the server computes (v0.36.0). + const w = weatherFor(HUB_ICAO); + const required = requiredFuelFor( + type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)), + type.emptyWeightKg, + type.maxFuel, + km, + ); const startFuel = Math.round(required / 2); // partial tank so L > 0 await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter')); @@ -151,9 +159,11 @@ describe('POST /api/planes/:planeId/refuel', () => { account: { cash: number }; planes: Array<{ id: string; fuel: number }>; }; - const cost = Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100; + // ISS-071: target is 150% of the requirement, capped at tank capacity. + const targetFuel = Math.min(type.maxFuel, Math.max(startFuel, required * 1.5)); + const cost = Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100; expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6); - expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(required, 6); + expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(targetFuel, 6); // Clean up the test package. await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api')); diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts index e416d25..e39bbbe 100644 --- a/e2e/actions.spec.ts +++ b/e2e/actions.spec.ts @@ -1,5 +1,10 @@ import type { Page, Request } from '@playwright/test'; import { expect, test } from '@playwright/test'; +// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves +// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas. +import { initialBearingDeg } from '../lib/geo'; +import { weatherFor } from '../lib/weather'; +import { windBurnFactor } from '../lib/sim/movement'; // v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl): // the full core loop through the UI — hook creates a short package at EDDF → Load @@ -219,10 +224,18 @@ test('full core loop via UI: load → fly to destination → exact reward + rep const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100; expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9); - // Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift). + // Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift), + // wind-adjusted (v0.36.0) by departure weather vs route heading. const entry = before.catalog.find((e) => e.model === 'atr72')!; + const wHub = weatherFor(HUB); expect(landed.fuel).toBeCloseTo( - fuelAfter(before.planes.find((p) => p.id === planeId)!.fuel, entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, exactKm), + fuelAfter( + before.planes.find((p) => p.id === planeId)!.fuel, + entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)), + entry.emptyWeightKg, + entry.maxFuel, + exactKm, + ), 6, ); diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index 2301b96..b73eeb8 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -1,5 +1,10 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; +// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves +// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas. +import { initialBearingDeg } from '../lib/geo'; +import { weatherFor } from '../lib/weather'; +import { windBurnFactor } from '../lib/sim/movement'; // v0.12.0 — right Jobs & Market pane against the live origin: open packages render with // names/reward/deadline and Load (UI click) binds one to the starter; the on-board list @@ -119,22 +124,28 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo } // Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0 -// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range (UNNT is -// ~4803 km out and needs ~6955 L > the 6300 L tank), so a full-tank fly there would rightly 409; -// this test wants a long-but-flyable leg instead. +// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range, so a +// full-tank fly there would rightly 409; this test wants a long-but-flyable leg instead. Wind- +// adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the +// return leg is what the forFlight refuel below buys, which also 409s if it doesn't fit. async function farthestFlyableToHub( page: Page, entry: CatalogEntry, -): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> { +): Promise<{ icao: string; lat: number; lon: number; d: number; hubLat: number; hubLon: number }> { const fc = (await (await page.request.get('/api/airports')).json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }>; }; const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties; + const wHub = weatherFor(HUB); let farthest: { icao: string; lat: number; lon: number; d: number } | null = null; for (const f of fc.features) { if (f.properties.icao === HUB) continue; const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon); - if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range + const wDest = weatherFor(f.properties.icao); + const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon)); + if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range + const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon)); + if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either if (!farthest || d > farthest.d) { farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d }; } @@ -365,7 +376,7 @@ test('market buy/sell render with previews from the catalog in /api/state; left- } }); -test('on-board row Refuel tops up just enough for the package leg; left-pane button says "Refuel to full"', async ({ page }) => { +test('on-board row Refuel buys 150% of the weather-adjusted leg requirement; left-pane button says "Refuel to full"', async ({ page }) => { test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000 await flyHomeViaApi(page); @@ -375,7 +386,7 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but // Deterministic baseline: a full tank. expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200); - // Longest FLYABLE leg from the hub (fits a full tank) — long enough that "just enough" for it + // Longest FLYABLE leg from the hub (fits a full tank) — long enough that 150% of its requirement // exceeds what's left on arrival. const farthest = await farthestFlyableToHub(page, entry); const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000); @@ -396,12 +407,21 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000); expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); - // Expected "just enough" from the snapshot constants (mirrors requiredFuelFor in lib/sim/movement.ts). + // Expected "150% of the requirement" from the snapshot constants (mirrors requiredFuelFor in + // lib/sim/movement.ts, wind-adjusted per v0.36.0 — departure weather at the far end vs heading home). const pre = await getState(page); const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel; const cashBefore = pre.account.cash; const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end - const liters = Math.max(0, requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, km) - fuelBefore); + const wFar = weatherFor(farthest.icao); + const required = requiredFuelFor( + entry.burnPerKm * windBurnFactor(wFar.windKt, wFar.windDirDeg, initialBearingDeg(farthest.lat, farthest.lon, farthest.hubLat, farthest.hubLon)), + entry.emptyWeightKg, + entry.maxFuel, + km, + ); + const target = Math.min(entry.maxFuel, required * 1.5); // ISS-071: 150% margin, capped at tank capacity + const liters = Math.max(0, target - fuelBefore); expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100; @@ -424,8 +444,8 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full'); // Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds - // exactly the leg requirement, so this also proves "just enough" is enough. ISS-041: the far-end - // arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause). + // 150% of the leg requirement (or a full tank), so the return is comfortably feasible. ISS-041: + // the far-end arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause). await setScale(page, SCALE); expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200); await expect diff --git a/e2e/two-planes.spec.ts b/e2e/two-planes.spec.ts index 1a75582..ec645a2 100644 --- a/e2e/two-planes.spec.ts +++ b/e2e/two-planes.spec.ts @@ -1,5 +1,10 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; +// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves +// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas. +import { initialBearingDeg } from '../lib/geo'; +import { weatherFor } from '../lib/weather'; +import { windBurnFactor } from '../lib/sim/movement'; // v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo // if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a @@ -114,18 +119,25 @@ async function twoNearestToHub(page: Page): Promise { const entry = (await getState(page)).catalog.find((e) => e.model === BUY_MODEL)!; const fc = (await (await page.request.get('/api/airports')).json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }>; }; const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties; + const wHub = weatherFor(HUB); let farthest: { icao: string; lat: number; lon: number; d: number } | null = null; for (const f of fc.features) { if (f.properties.icao === HUB) continue; const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon); - if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range + const wDest = weatherFor(f.properties.icao); + const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon)); + if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range + const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon)); + if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either if (!farthest || d > farthest.d) { farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d }; } diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index a98f4bb..b9ba3d3 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -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'); diff --git a/lib/game/actions.ts b/lib/game/actions.ts index a7932e7..7192701 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -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; diff --git a/lib/game/state.ts b/lib/game/state.ts index f5fc67b..3a51568 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -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 { 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 { diff --git a/lib/geo.test.ts b/lib/geo.test.ts index 16d8287..a40abb1 100644 --- a/lib/geo.test.ts +++ b/lib/geo.test.ts @@ -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', () => { diff --git a/lib/geo.ts b/lib/geo.ts index cee0f9f..9648801 100644 --- a/lib/geo.ts +++ b/lib/geo.ts @@ -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; diff --git a/lib/openapi.ts b/lib/openapi.ts index fdc8361..f4e5e00 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -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', diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index 47f9297..37ef534 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -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')); diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index 002870f..3b52aa6 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -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 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) diff --git a/lib/sim/movement.test.ts b/lib/sim/movement.test.ts index abed4b8..0dc6606 100644 --- a/lib/sim/movement.test.ts +++ b/lib/sim/movement.test.ts @@ -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); diff --git a/lib/sim/movement.ts b/lib/sim/movement.ts index 49dd77c..85f55c0 100644 --- a/lib/sim/movement.ts +++ b/lib/sim/movement.ts @@ -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 diff --git a/package.json b/package.json index 0ff4ad1..fa53631 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.35.0", + "version": "0.36.0", "private": true, "scripts": { "dev": "next dev -p 3000",