From a801311fc5522cdda5f01b3bfe5991af8382714c Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 19 Sep 2026 12:24:34 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20v0.16.0=20=E2=80=94=20ISS-005=20wei?= =?UTF-8?q?ght-proportional=20fuel=20burn=20+=20refuel=20just=20enough=20f?= =?UTF-8?q?or=20the=20loaded=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part A: r(F) = k(We + rho*F) L/km, k calibrated so a full tank burns exactly the old constant; closed forms in lib/sim/movement.ts (fuelAtWeighted, requiredFuelFor, burnRateConstant, FUEL_DENSITY_KG_PER_L). Catalog gains emptyWeightKg (ATR72 OEW 9300 kg), exposed in the snapshot. All three burn call sites (engine landing, state live fuel, fly validation) moved to the weighted model; old constant-rate fuelAt removed. Part B: POST /api/planes/{planeId}/refuel takes an optional body {mode:'full'|'forFlight', packageId} — no body keeps today's fill-to-full behavior exactly (fuel lands bit-exact on maxFuel); forFlight adds just enough for the loaded package's destination leg (409 not-loaded/expired/ doesn't-fit, 400 bad body). UI: per-cargo-row 'Refuel' button (refuel-for-{pkg.id}), market button renamed 'Refuel to full'. OpenAPI: RefuelRequest schema + optional requestBody + 400 on refuel, fly's 409 description updated, CatalogEntry.emptyWeightKg. Tests: new weighted-burn suite, forFlight action/route cases (incl. ~7670 km UNNT<->LPPD doesn't-fit), openapi guards; landing-fuel assertions in engine.test.ts + e2e/actions.spec.ts use the closed form; the e2e 'return leg unfuelable' scenario reworked — a full tank now covers the EDDF<->LEZL round trip, so the 409 moves to the second outbound. New e2e: row Refuel deducts exactly L*price / raises fuel by exactly L via the endpoint's own response snapshot. --- CHANGELOG.md | 13 +++ app/api/planes/[planeId]/refuel/route.test.ts | 86 ++++++++++++++++- app/api/planes/[planeId]/refuel/route.ts | 48 ++++++++-- components/RightPane.tsx | 11 ++- components/right-pane.test.ts | 2 +- e2e/actions.spec.ts | 50 ++++++---- e2e/right-pane.spec.ts | 95 +++++++++++++++++++ lib/game/actions.test.ts | 84 ++++++++++++++++ lib/game/actions.ts | 57 +++++++++-- lib/game/catalog.ts | 12 ++- lib/game/state.ts | 4 +- lib/mapData.test.ts | 2 +- lib/openapi.test.ts | 30 ++++-- lib/openapi.ts | 45 +++++++-- lib/sim/engine.test.ts | 5 +- lib/sim/engine.ts | 6 +- lib/sim/movement.test.ts | 72 +++++++++++--- lib/sim/movement.ts | 56 ++++++++++- package.json | 2 +- 19 files changed, 606 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab20488..9fbae2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v0.16.0 — 2026-09-19 12:18 +- **Weight-proportional fuel burn (ISS-005, part A).** Fuel no longer burns at a constant L/km — the instantaneous rate is proportional to total weight, `r(F) = k·(We + ρF)` L/km with We the empty weight and ρ = 0.8 kg/L (Jet-A1), so a heavier plane burns more per km and a tank gets lighter as it empties. The constant `k` is calibrated so a FULL tank burns exactly the catalog's old `burnPerKm`, keeping full-tank departure behavior identical to before; divergence only grows in flight as fuel drops off. Closed forms of `dF/dx = −k(We + ρF)` live in `lib/sim/movement.ts` (single source of truth): + - `fuelAtWeighted(startFuel, burnPerKm, emptyWeightKg, maxFuel, distanceKm, progress)` — fuel remaining after flying `progress` of the leg (replaces the old constant-rate `fuelAt`, which is removed) + - `requiredFuelFor(burnPerKm, emptyWeightKg, maxFuel, distanceKm)` — liters needed to cover the distance arriving empty, independent of starting load; a leg is flyable iff current fuel ≥ this and fits at all iff it ≤ tank capacity + - `burnRateConstant(...)` + exported `FUEL_DENSITY_KG_PER_L` (0.8) +- **Catalog gains `emptyWeightKg`** (`lib/game/catalog.ts`, ATR 72-600 OEW ≈ 9300 kg), exposed in the snapshot's `catalog[]` so clients can mirror the model; OpenAPI `CatalogEntry` follows +- **All three burn call sites moved to the weighted model**: landing fuel on arrival (`lib/sim/engine.ts`), live in-flight fuel in the state snapshot (`lib/game/state.ts`), and fly validation (`lib/game/actions.ts` — a leg now needs `requiredFuelFor`, not `burnPerKm × km`) +- **Refuel "just enough for the loaded package" (ISS-005, part B).** `POST /api/planes/{planeId}/refuel` takes an OPTIONAL JSON body `{ mode?: 'full' | 'forFlight', packageId?: string }`: no body or `mode:'full'` is exactly today's fill-to-max behavior; `mode:'forFlight'` adds just enough fuel to fly that loaded package's destination leg — liters = `requiredFuelFor(haversine(plane → pkg.destIcao)) − current fuel`, 409 if the package isn't loaded on this plane / is expired, or the leg doesn't fit even a full tank; bad bodies (unknown field/mode, forFlight without packageId) are 400. OpenAPI gains `RefuelRequest` + the 400 +- **UI** (`components/RightPane.tsx`): each on-board cargo row gets a "Refuel" button (`refuel-for-{pkg.id}` → `{mode:'forFlight', packageId}`, disabled while flying or busy, mirroring fly-to/abandon); the market fill-up button is renamed **"Refuel to full"** (testid `market-refuel` and behavior unchanged) +- Tests: unit — new weighted-burn suite in `lib/sim/movement.test.ts` (full-tank calibration ≈ old constant, closed form vs numeric integration of r(F), F_req monotonic + empty-arrival identity, lighter-start burns less, clamping); `refuelPlane` forFlight cases in `lib/game/actions.test.ts` (exact liters/cost, no-op when enough, 409 not-loaded/expired/doesn't-fit via the ~7670 km UNNT↔LPPD pair, 404 unknown package, 400 missing packageId); refuel route body-validation tests; `lib/openapi.test.ts` updated (optional RefuelRequest, errorCodes `['400','404','409']`, CatalogEntry.emptyWeightKg). Adjusted where the model legitimately shifts numbers: landing-fuel assertions in `lib/sim/engine.test.ts` and `e2e/actions.spec.ts` now use the weighted closed form; the e2e "return leg unfuelable" scenario is reworked — under weight-proportional burn a full tank covers the EDDF↔LEZL round trip (the plane lightens as it flies), so the 409 insufficient-fuel assertion moves to the SECOND outbound after landing back with a partial tank +- e2e (`right-pane.spec.ts`): new test — fly the longest hub leg on a full tank, load a package back to the hub at the far end, click the row "Refuel" and assert via the endpoint's own response snapshot that cash drops by exactly L×price and fuel rises by exactly L (L from the mirrored closed form); also asserts the market button label is "Refuel to full". `e2e/api.spec.ts` path list unchanged +- Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack + ## v0.15.1 — 2026-09-19 11:04 - **Right pane widened, "On board" wrapping fixed (ISS-004).** The 3-pane shell grid (`app/globals.css` `.app-shell`) moves from `300px / 1fr / 320px` to `280px / 1fr / 360px` — the map column absorbs the delta (MapLibre sizes itself from its container, no JS change). Three wrap offenders in the right pane's "On board" blocks are fixed: - In-flight leg header (`components/RightPane.tsx`, `.contract-route` span): the class already had `white-space: nowrap; overflow: hidden; text-overflow: ellipsis`, but as a flex item with default `min-width: auto` it could not shrink below its full text width, so long routes (e.g. "Frankfurt → Madrid–Barajas") overflowed instead of ellipsizing — `.contract-route` now has `min-width: 0` diff --git a/app/api/planes/[planeId]/refuel/route.test.ts b/app/api/planes/[planeId]/refuel/route.test.ts index 3d69aa0..74b043c 100644 --- a/app/api/planes/[planeId]/refuel/route.test.ts +++ b/app/api/planes/[planeId]/refuel/route.test.ts @@ -5,7 +5,11 @@ import { eq } from 'drizzle-orm'; 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 { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog'; +import { HUB_ICAO } from '@/lib/game/seed'; import { POST } from './route'; let dir: string; @@ -22,7 +26,15 @@ afterAll(async () => { await fs.rm(dir, { recursive: true, force: true }); }); -const post = (planeId: string) => POST(new Request(`http://localhost/api/planes/${planeId}/refuel`), { params: Promise.resolve({ planeId }) }); +const post = (planeId: string, body?: unknown) => + POST( + new Request(`http://localhost/api/planes/${planeId}/refuel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + { params: Promise.resolve({ planeId }) }, + ); describe('POST /api/planes/:planeId/refuel', () => { it('rejects an unknown id with 404', async () => { @@ -74,4 +86,76 @@ describe('POST /api/planes/:planeId/refuel', () => { expect(res.status).toBe(409); expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i); }); + + it('accepts an explicit {mode:"full"} body exactly like no body', async () => { + const { db } = await getGameDb(); + await db.update(schema.account).set({ cash: 50_000 }); // previous test left the account poor + await db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter')); + + const res = await post('plane-starter', { mode: 'full' }); + expect(res.status).toBe(200); + const body = (await res.json()) as { planes: Array<{ id: string; fuel: number }> }; + expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(PLANE_TYPES.atr72.maxFuel); + }); + + it('rejects an unknown mode with 400', async () => { + const res = await post('plane-starter', { mode: 'half' }); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toMatch(/mode/); + }); + + it('rejects forFlight without a packageId with 400', async () => { + const res = await post('plane-starter', { mode: 'forFlight' }); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toMatch(/packageId/); + }); + + it('rejects an unknown body field with 400', async () => { + const res = await post('plane-starter', { liters: 100 }); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toMatch(/unknown field/); + }); + + it('rejects invalid JSON with 400', async () => { + const res = await POST( + new Request(`http://localhost/api/planes/plane-starter/refuel`, { method: 'POST', body: '{nope' }), + { params: Promise.resolve({ planeId: 'plane-starter' }) }, + ); + expect(res.status).toBe(400); + }); + + it('forFlight tops up exactly the liters needed for the loaded package leg', 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). + await db.insert(schema.packages).values({ + id: 'p-ff-api', originIcao: HUB_ICAO, destIcao: 'LEMD', reward: 1_000, cargoType: 'Mail', cargoCount: 4, + deadlineSimTime: Date.now() + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: Date.now(), + }); + + 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 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')); + const cashBefore = (await db.select().from(schema.account))[0].cash; + + const res = await post('plane-starter', { mode: 'forFlight', packageId: 'p-ff-api' }); + expect(res.status).toBe(200); + + const body = (await res.json()) as { + account: { cash: number }; + planes: Array<{ id: string; fuel: number }>; + }; + const cost = Math.round((required - 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); + + // Clean up the test package. + await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api')); + }); }); diff --git a/app/api/planes/[planeId]/refuel/route.ts b/app/api/planes/[planeId]/refuel/route.ts index c2bce45..3ed9c3e 100644 --- a/app/api/planes/[planeId]/refuel/route.ts +++ b/app/api/planes/[planeId]/refuel/route.ts @@ -1,11 +1,47 @@ import { performAction, refuelPlane } from '@/lib/game/actions'; -// POST /api/planes/:planeId/refuel (v0.8.0) — no body. Fills a grounded plane to -// maxFuel at the fuel price per missing liter; deducts the cost from cash. 404 -// unknown id; 409 if the plane is flying or cash is insufficient. On success -// returns the full state snapshot (fuel back to max, cash reduced by the cost). +// POST /api/planes/:planeId/refuel (v0.8.0; optional body v0.16.0) — fills a grounded +// plane at the fuel price per added liter and deducts the cost from cash. The body is +// OPTIONAL: no body, {} or {mode:'full'} fill to maxFuel (the original behavior); +// {mode:'forFlight', packageId} adds just enough for that loaded package's destination +// leg. 400 bad body (unknown field, unknown mode, forFlight without packageId); 404 +// unknown plane/package id; 409 if the plane is flying, the package isn't loaded on it +// (or is expired), the leg doesn't fit a full tank, or cash is insufficient. On success +// returns the full state snapshot (fuel raised by exactly the added liters). -export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) { +export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) { const { planeId } = await ctx.params; - return performAction((inst) => refuelPlane(inst, planeId)); + + let body: unknown; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch { + return Response.json({ error: 'invalid JSON body' }, { status: 400 }); + } + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); + } + const input = body as Record; + for (const key of Object.keys(input)) { + if (key !== 'mode' && key !== 'packageId') { + return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); + } + } + const mode = input.mode ?? 'full'; + if (mode !== 'full' && mode !== 'forFlight') { + return Response.json({ error: 'mode must be "full" or "forFlight"' }, { status: 400 }); + } + let packageId: string | undefined; + if (input.packageId !== undefined) { + if (typeof input.packageId !== 'string' || input.packageId.length === 0) { + return Response.json({ error: 'packageId must be a non-empty string' }, { status: 400 }); + } + packageId = input.packageId; + } + if (mode === 'forFlight' && !packageId) { + return Response.json({ error: 'packageId is required when mode is "forFlight"' }, { status: 400 }); + } + + return performAction((inst) => refuelPlane(inst, planeId, { mode, packageId })); } diff --git a/components/RightPane.tsx b/components/RightPane.tsx index 077f3ed..d2009e2 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -273,6 +273,15 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri return (
+
diff --git a/components/right-pane.test.ts b/components/right-pane.test.ts index b58e493..0073783 100644 --- a/components/right-pane.test.ts +++ b/components/right-pane.test.ts @@ -8,7 +8,7 @@ import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; // directly on the single eligible plane, or shows a picker when several qualify. const CATALOG = [ - { model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510, capacity: 6 }, + { model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, capacity: 6 }, ]; function plane(over: Partial & Pick): PlaneState { diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts index 3e77826..0f46896 100644 --- a/e2e/actions.spec.ts +++ b/e2e/actions.spec.ts @@ -10,14 +10,23 @@ import { expect, test } from '@playwright/test'; const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts) -const FAR = 'LEZL'; // Seville — one-way burn 3174 L > half tank, so the return leg is unfuelable +const FAR = 'LEZL'; // Seville — a long leg: under the v0.16.0 weighted model a full tank covers the + // round trip, but the return leaves too little for another outbound -// Mirrors lib/sim/movement.ts + lib/game/catalog.ts (atr72). -const BURN_PER_KM = 1.75; -const FUEL_PRICE_PER_L = 0.8; +// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn) + lib/game/catalog.ts (atr72). +const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts +const FUEL_PRICE_PER_L = 0.8; // must match catalog.ts const REP_BASE = 1; // lib/sim/engine.ts const REP_PER_KM = 0.0005; +// Fuel remaining after distanceKm from startFuel — closed form of dF/dx = −k(We + ρF), +// k calibrated so a full tank burns exactly burnPerKm (mirrors fuelAtWeighted). +function fuelAfter(startFuel: number, burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number { + const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel); + const rho = FUEL_DENSITY_KG_PER_L; + return Math.max(0, (startFuel + emptyWeightKg / rho) * Math.exp(-k * rho * distanceKm) - emptyWeightKg / rho); +} + // Mirrors lib/game/penalty.ts — the exact abandon penalty at a given game clock. function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: number, clockMs: number): number { const span = deadlineSimTime - createdSimTime; @@ -33,6 +42,7 @@ interface StateBody { id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number; deadlineSimTime: number; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; createdSimTime: number; }>; + catalog: Array<{ model: string; burnPerKm: number; emptyWeightKg: number; maxFuel: number }>; // v0.16.0 weighted burn inputs } async function getState(page: Page): Promise { @@ -174,8 +184,12 @@ 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 exactly burnPerKm × km (analytic movement — no rounding drift). - expect(landed.fuel).toBeCloseTo(Math.max(0, before.planes.find((p) => p.id === planeId)!.fuel - BURN_PER_KM * exactKm), 6); + // Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift). + const entry = before.catalog.find((e) => e.model === 'atr72')!; + expect(landed.fuel).toBeCloseTo( + fuelAfter(before.planes.find((p) => p.id === planeId)!.fuel, entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, exactKm), + 6, + ); // Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, deducted exactly. const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`); @@ -317,12 +331,14 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => { expect(res.ok()).toBeTruthy(); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(HUB); - // Insufficient fuel: a full-tank one-way burn of > half the tank makes the return leg unfuelable. + // Insufficient fuel (v0.16.0 weighted burn): a full tank covers the FAR round trip — + // the plane gets lighter as fuel burns off, so both legs fit — but landing back at the + // hub leaves too little for ANOTHER long leg: the second outbound must 409. const s0 = await getState(page); const starter = s0.planes.find((p) => p.id === 'plane-starter')!; expect(starter.locationIcao).toBe(HUB); // landed back at the hub from the hop above if (starter.fuel < starter.maxFuel) { - await page.request.post('/api/planes/plane-starter/refuel'); // ensure a full tank for the long leg + await page.request.post('/api/planes/plane-starter/refuel'); // ensure a full tank for the long legs } const farRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } }); expect(farRes.ok()).toBeTruthy(); @@ -332,16 +348,18 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => { const sFar = await getState(page); const atFar = sFar.planes.find((p) => p.id === 'plane-starter')!; expect(atFar.locationIcao).toBe(FAR); - // The return leg needs the same burn as the outbound — more than what's left. + // The return leg fits: the weighted burn over it is less than what's left in the tank. const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); - expect(backRes.status()).toBe(409); - expect(((await backRes.json()) as { error: string }).error).toMatch(/insufficient fuel/i); - - // Restore: refuel + fly home. - await page.request.post('/api/planes/plane-starter/refuel'); - const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); - expect(homeRes.ok()).toBeTruthy(); + expect(backRes.ok()).toBeTruthy(); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB); + + // …but the tank is now too light for another FAR leg. + const againFar = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } }); + expect(againFar.status()).toBe(409); + expect(((await againFar.json()) as { error: string }).error).toMatch(/insufficient fuel/i); + + // Restore: refuel (the plane is already home). + await page.request.post('/api/planes/plane-starter/refuel'); }); // Great-circle distance in km — mirrors lib/geo.ts (same formula, same R). diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index ae5437b..0da60a9 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -38,6 +38,7 @@ interface CatalogEntry { price: number; maxFuel: number; burnPerKm: number; + emptyWeightKg: number; // v0.16.0 weighted burn input speedKmh: number; capacity: number; } @@ -114,6 +115,30 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo return nearest; } +// Farthest airport from EDDF — the longest possible leg (v0.16.0 refuel-for-flight test). +async function farthestToHub(page: Page): Promise<{ icao: string; lat: number; lon: 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; + let farthest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties; + for (const f of fc.features) { + if (f.properties.icao === HUB) continue; + if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) > haversineKm(hub.lat, hub.lon, farthest.lat, farthest.lon)) { + farthest = f.properties; + } + } + return { icao: farthest.icao, lat: farthest.lat, lon: farthest.lon, hubLat: hub.lat, hubLon: hub.lon }; +} + +// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty — +// closed form of the weight-proportional burn model, k calibrated so a full tank burns burnPerKm. +const FUEL_DENSITY_KG_PER_L = 0.8; // must match FUEL_DENSITY_KG_PER_L in lib/sim/movement.ts +function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number { + const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel); + return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1); +} + // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise { const created = await page.request.post('/api/test/create-package', { @@ -307,3 +332,73 @@ test('market buy/sell/refuel render with previews from the catalog in /api/state expect(after.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(starter.maxFuel); } }); + +test('on-board row Refuel tops up just enough for the package leg; market button says "Refuel to full"', async ({ page }) => { + test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000 + await flyHomeViaApi(page); + + const body = await getState(page); + const entry = body.catalog.find((e) => e.model === 'atr72')!; + + // Deterministic baseline: a full tank. + expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200); + + // Longest leg from the hub — long enough that "just enough" for it exceeds what's left on arrival. + const farthest = await farthestToHub(page); + const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000); + expect((await page.request.post(`/api/packages/${pkg1.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); + + // Fly there (delivers pkg1 on arrival) — the tank is now partial. + expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: farthest.icao } })).status()).toBe(200); + await setScale(page, SCALE); + await expect + .poll(async () => { + const b = await getState(page); + const p = b.planes.find((x) => x.id === 'plane-starter'); + return !!p && p.status === 'available' && p.locationIcao === farthest.icao; + }, { timeout: 240_000, message: `waiting for arrival at ${farthest.icao} (scale 1000)` }) + .toBe(true); + + // New work at the far end: a package back to the hub — its leg is exactly what we refuel for. + 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). + 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); + expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left + const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100; + + await page.goto('/'); + const refuelBtn = page.getByTestId(`refuel-for-${pkg2.id}`); + await expect(refuelBtn, 'row Refuel button appears for the loaded package').toBeVisible({ timeout: 15_000 }); + // Capture the endpoint's OWN response — the full post-action snapshot at the action's clock. + const [res] = await Promise.all([ + page.waitForResponse((r) => r.url().includes(`/api/planes/plane-starter/refuel`), { timeout: 15_000 }), + refuelBtn.click(), + ]); + expect(res.status()).toBe(200); + const post = (await res.json()) as StateBody; + + // Cash dropped by exactly L × price and fuel rose by exactly L. + expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6); + expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(fuelBefore + liters, 6); + + // Market row: the fill-up button is labeled "Refuel to full" (testid unchanged). + 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. + expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200); + await expect + .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, { + timeout: 240_000, + message: 'waiting for plane-starter to land back at the hub', + }) + .toBe(true); + // Top the tank back up so later specs start with a full tank. + expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200); +}); diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index 023cb97..0414c8d 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -6,6 +6,8 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { openGameDb, type GameDb } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; import { loadAirportsGeoJson } from '@/lib/airports'; +import { haversineKm } from '@/lib/geo'; +import { requiredFuelFor } from '@/lib/sim/movement'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog'; import { HUB_ICAO } from './seed'; import { @@ -412,4 +414,86 @@ describe('refuelPlane', () => { const inst = await freshDb('refuel-unknown.db'); await expectActionError(refuelPlane(inst, 'ghost'), 404); }); + + // --- forFlight mode (v0.16.0) --- + + it('forFlight adds exactly the liters needed for the package leg and deducts L × price', async () => { + const inst = await freshDb('refuel-forflight-ok.db'); + await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD + await loadPackage(inst, 'p-ff', 'plane-starter'); + + const fc = await loadAirportsGeoJson(); + const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties; + const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties; + const km = haversineKm(from.lat, from.lon, to.lat, to.lon); + const type = PLANE_TYPES.atr72; + const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km); + + // Partial tank below the requirement so L > 0. + const startFuel = Math.round(required / 2); + await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter')); + const cashBefore = (await inst.db.select().from(schema.account))[0].cash; + + const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' }); + + expect(cost).toBe(Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100); + const plane = (await inst.db.select().from(schema.planes))[0]; + expect(plane.fuel).toBeCloseTo(required, 6); // topped up to exactly the leg requirement + const account = (await inst.db.select().from(schema.account))[0]; + expect(account.cash).toBeCloseTo(cashBefore - cost, 6); + }); + + it('forFlight is a free no-op when the tank already covers the leg', async () => { + const inst = await freshDb('refuel-forflight-enough.db'); // seeded starter is full at EDDF + await insertOpenPackage(inst, 'p-ff2', { destIcao: 'LEMD' }); + await loadPackage(inst, 'p-ff2', 'plane-starter'); + const cashBefore = (await inst.db.select().from(schema.account))[0].cash; + + const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff2' }); + + expect(cost).toBe(0); + const plane = (await inst.db.select().from(schema.planes))[0]; + expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched + expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); + }); + + it('forFlight rejects a package that is not loaded on this plane with 409', async () => { + const inst = await freshDb('refuel-ff-open.db'); // open — never loaded + await insertOpenPackage(inst, 'p-x'); + await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-x' }), 409); + + const inst2 = await freshDb('refuel-ff-other.db'); // loaded on ANOTHER plane + await insertOpenPackage(inst2, 'p-y', { status: 'loaded', planeId: 'plane-someone-else' }); + await expectActionError(refuelPlane(inst2, 'plane-starter', { mode: 'forFlight', packageId: 'p-y' }), 409); + }); + + it('forFlight rejects an expired package with 409', async () => { + const inst = await freshDb('refuel-ff-expired.db'); + await insertOpenPackage(inst, 'p-z', { status: 'expired', planeId: 'plane-starter' }); + await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-z' }), 409); + }); + + it('forFlight rejects an unknown package id with 404', async () => { + const inst = await freshDb('refuel-ff-unknown.db'); + await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'nope' }), 404); + }); + + it('forFlight rejects a missing packageId with 400', async () => { + const inst = await freshDb('refuel-ff-nopkg.db'); + await expectActionError(refuelPlane(inst, 'plane-starter', { mode: 'forFlight' }), 400); + }); + + it('forFlight rejects a leg that does not fit even a full tank with 409', async () => { + const inst = await freshDb('refuel-ff-nofit.db'); + // UNNT (Narsarsuaq, Greenland) ↔ LPPD are ~7670 km apart — far beyond the ATR's range. + await insertOpenPackage(inst, 'p-far', { destIcao: 'LPPD' }); + await loadPackage(inst, 'p-far', 'plane-starter'); + await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter')); + + await expectActionError( + refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-far' }), + 409, + /does not fit/i, + ); + }); }); diff --git a/lib/game/actions.ts b/lib/game/actions.ts index 904823a..af2274d 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -4,6 +4,7 @@ import * as schema from '@/lib/db/schema'; import { persist, type GameDb } from '@/lib/db/client'; import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm } from '@/lib/geo'; +import { requiredFuelFor } from '@/lib/sim/movement'; import { runAction } from '@/lib/sim/engine'; import { getStateSnapshot } from './state'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog'; @@ -105,9 +106,10 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): const type = PLANE_TYPES[plane.model]; if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon); - const burnL = type.burnPerKm * distanceKm; - if (burnL > plane.fuel) { - throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(burnL)} L but the plane has ${Math.round(plane.fuel)} L`); + // Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty. + const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm); + if (requiredL > plane.fuel) { + throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`); } // runAction() ticked first, so this is current game time. @@ -225,8 +227,19 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout // --- Refuel (POST /api/planes/:planeId/refuel) --- -// Fill a grounded plane to maxFuel at FUEL_PRICE_PER_L per missing liter. -export async function refuelPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> { +// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' adds just +// enough fuel for one loaded package's destination leg — required liters per the +// weighted burn model, independent of how much is already in the tank. +export interface RefuelOptions { + mode?: 'full' | 'forFlight'; + packageId?: string; +} + +// Fill a grounded plane to maxFuel at FUEL_PRICE_PER_L per missing liter — or, with +// {mode:'forFlight', packageId}, top it up just enough to fly the loaded package's +// destination leg (409 if the package isn't loaded on this plane, is expired, or the +// leg doesn't fit a full tank). +export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOptions = {}): Promise<{ cost: number }> { const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); const plane = rows[0]; if (!plane) throw new GameActionError(404, `plane ${planeId} not found`); @@ -234,14 +247,44 @@ export async function refuelPlane(inst: GameDb, planeId: string): Promise<{ cost throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be refueled`); } - const cost = Math.round((plane.maxFuel - plane.fuel) * FUEL_PRICE_PER_L * 100) / 100; + const mode = opts.mode ?? 'full'; + let liters: number; // how much fuel to add + if (mode === 'forFlight') { + if (!opts.packageId) throw new GameActionError(400, 'packageId is required when mode is "forFlight"'); + + const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, opts.packageId)); + const pkg = pkgRows[0]; + if (!pkg) throw new GameActionError(404, `package ${opts.packageId} not found`); + if (pkg.status !== 'loaded' || pkg.planeId !== plane.id) { + const where = pkg.planeId === plane.id ? '' : ', and it is bound to another plane'; + throw new GameActionError(409, `only a package loaded on this plane can be refueled for (it is ${pkg.status}${where})`); + } + + const type = PLANE_TYPES[plane.model]; + if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data + const origin = await airportByIcao(plane.locationIcao); + const dest = await airportByIcao(pkg.destIcao); + const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon); + const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm); + if (requiredL > plane.maxFuel) { + throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`); + } + liters = Math.max(0, requiredL - plane.fuel); + } else { + liters = plane.maxFuel - plane.fuel; + } + + const cost = Math.round(liters * FUEL_PRICE_PER_L * 100) / 100; const account = (await inst.db.select().from(schema.account))[0]; if (!account) throw new Error('account row missing'); if (cost > 0 && account.cash < cost) { throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`); } - await inst.db.update(schema.planes).set({ fuel: plane.maxFuel }).where(eq(schema.planes.id, planeId)); + // Full mode lands EXACTLY on maxFuel (as before v0.16.0), not fuel + liters — the sum can + // drift a ulp for fractional tanks and clients/tests compare against maxFuel exactly. + const targetFuel = mode === 'forFlight' ? plane.fuel + liters : plane.maxFuel; + await inst.db.update(schema.planes).set({ fuel: targetFuel }).where(eq(schema.planes.id, planeId)); if (cost > 0) { await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 }); } diff --git a/lib/game/catalog.ts b/lib/game/catalog.ts index f7d320b..c644963 100644 --- a/lib/game/catalog.ts +++ b/lib/game/catalog.ts @@ -6,8 +6,11 @@ export interface PlaneType { name: string; // Fuel capacity, liters. maxFuel: number; - // Fuel burn per km of flight, liters/km (cruise). + // Fuel burn per km of flight at a FULL tank, liters/km (cruise) — the weighted burn + // model (v0.16.0) calibrates its rate constant to this value at F = maxFuel. burnPerKm: number; + // Operating empty weight in kg (v0.16.0) — feeds the weight-proportional burn model. + emptyWeightKg: number; // Cruise speed, km/h — used by the sim tick to advance planes along routes. speedKmh: number; // Market price in € (v0.8.0): buying costs `price`, selling pays out @@ -22,7 +25,8 @@ export const PLANE_TYPES: Record = { id: 'atr72', name: 'ATR 72-600', maxFuel: 6300, - burnPerKm: 1.75, // ~900 kg/h at 510 km/h cruise (Jet-A1 ≈ 0.8 kg/L) + burnPerKm: 1.75, // full-tank rate: ~893 L/h ≈ 714 kg/h at 510 km/h cruise (Jet-A1 ≈ 0.8 kg/L); the weighted model burns less as fuel drops off + emptyWeightKg: 9_300, // ATR 72-600 operating empty weight speedKmh: 510, // €200k — with the starting cash of €20k and typical hop rewards (~€1–3k), // a second ATR takes sustained play to afford, as intended for the MVP sink. @@ -49,6 +53,9 @@ export interface CatalogEntry { price: number; maxFuel: number; burnPerKm: number; + // Weighted burn model inputs (v0.16.0) — clients can mirror the closed forms in + // lib/sim/movement.ts with these + FUEL_DENSITY_KG_PER_L. + emptyWeightKg: number; speedKmh: number; capacity: number; } @@ -62,6 +69,7 @@ export function catalogEntries(): CatalogEntry[] { price: t.price, maxFuel: t.maxFuel, burnPerKm: t.burnPerKm, + emptyWeightKg: t.emptyWeightKg, speedKmh: t.speedKmh, capacity: t.capacity, })); diff --git a/lib/game/state.ts b/lib/game/state.ts index d7e5cb1..0f491c6 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -3,7 +3,7 @@ import { haversineKm } from '@/lib/geo'; import { getGameDb } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog'; -import { fuelAt, progressAt, positionAt } from '@/lib/sim/movement'; +import { fuelAtWeighted, progressAt, positionAt } from '@/lib/sim/movement'; // Read-only game-state snapshot for client polling (GET /api/state). @@ -84,7 +84,7 @@ export async function getStateSnapshot(): Promise { const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon); const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs); const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress); - return { ...pos, fuel: fuelAt(p.fuel, type.burnPerKm, distanceKm, progress) }; + return { ...pos, fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, progress) }; }; return { diff --git a/lib/mapData.test.ts b/lib/mapData.test.ts index 380bae2..53aa48e 100644 --- a/lib/mapData.test.ts +++ b/lib/mapData.test.ts @@ -17,7 +17,7 @@ function snapshot(overrides: Partial = {}): StateSnapshot { planes: [], packages: [], catalog: [ - { model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510, capacity: 6 }, + { model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, capacity: 6 }, ], fuelPricePerL: 0.8, sellFraction: 0.5, diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 1e1fa59..3e662bc 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -90,8 +90,17 @@ describe('OpenAPI spec', () => { expect(snap.sellFraction.type).toBe('number'); const catalog = openApiSpec.components.schemas.CatalogEntry.properties; - expect(Object.keys(catalog).sort()).toEqual(['burnPerKm', 'capacity', 'label', 'maxFuel', 'model', 'price', 'speedKmh']); - for (const key of ['burnPerKm', 'maxFuel', 'price', 'speedKmh'] as const) { + expect(Object.keys(catalog).sort()).toEqual([ + 'burnPerKm', + 'capacity', + 'emptyWeightKg', // v0.16.0 weighted burn model input + 'label', + 'maxFuel', + 'model', + 'price', + 'speedKmh', + ]); + for (const key of ['burnPerKm', 'emptyWeightKg', 'maxFuel', 'price', 'speedKmh'] as const) { expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number'); } expect(catalog.capacity.type).toBe('integer'); // v0.12.0 slot capacity @@ -165,7 +174,7 @@ describe('OpenAPI spec', () => { const post = (p: string) => ((openApiSpec.paths as Record)[p]?.post ?? {}) as { responses: Record; - requestBody?: { content: Record }; + requestBody?: { required?: boolean; content: Record }; parameters?: Array<{ name: string }>; }; @@ -189,9 +198,10 @@ describe('OpenAPI spec', () => { expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']); expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']); expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']); - expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['404', '409']); + expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['400', '404', '409']); // v0.16.0 optional body - // Request bodies: load/fly/buy/sell take their dedicated schemas; abandon/refuel take none. + // Request bodies: load/fly/buy/sell take their dedicated schemas; abandon takes none; + // refuel's is OPTIONAL (v0.16.0) — no body still means "fill to full". expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe( '#/components/schemas/LoadPackageRequest', ); @@ -205,7 +215,9 @@ describe('OpenAPI spec', () => { '#/components/schemas/SellPlaneRequest', ); expect(post('/api/packages/{id}/abandon').requestBody).toBeUndefined(); - expect(post('/api/planes/{planeId}/refuel').requestBody).toBeUndefined(); + const refuelBody = post('/api/planes/{planeId}/refuel').requestBody; + expect(refuelBody?.content['application/json'].schema.$ref).toBe('#/components/schemas/RefuelRequest'); + expect(refuelBody?.required).toBe(false); // optional — no body still means "fill to full" // Path parameters are declared on the parameterized routes. const paramNames = (p: string) => post(p).parameters?.map((x) => x.name); @@ -227,5 +239,11 @@ describe('OpenAPI spec', () => { const sell = openApiSpec.components.schemas.SellPlaneRequest; expect(sell.required).toEqual(['planeId']); expect(sell.properties.planeId.type).toBe('string'); + + // RefuelRequest (v0.16.0): both fields optional — no body / {} means "full". + const refuel = openApiSpec.components.schemas.RefuelRequest; + expect((refuel as { required?: string[] }).required ?? []).toEqual([]); + expect(((refuel.properties.mode) as { enum: string[] }).enum.sort()).toEqual(['forFlight', 'full']); + expect(refuel.properties.packageId.type).toBe('string'); }); }); diff --git a/lib/openapi.ts b/lib/openapi.ts index 2b3ef0b..dc66167 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -197,7 +197,8 @@ export const openApiSpec = { content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 409: { - description: 'Plane not grounded, or insufficient fuel for the leg (burnPerKm × great-circle km)', + description: + 'Plane not grounded, or insufficient fuel for the leg (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, }, @@ -271,22 +272,35 @@ export const openApiSpec = { post: { summary: 'Refuel a grounded plane', description: - 'Fill a grounded plane to maxFuel (v0.8.0). Cost = (maxFuel − fuel) × fuel price per liter, deducted from cash; no body.', + 'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId adds just enough for that loaded package\'s destination leg — liters = required fuel for the great-circle distance (weight-proportional burn model) minus what is already in the tank.', operationId: 'refuelPlane', parameters: [ { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, ], + requestBody: { + required: false, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/RefuelRequest' }, + }, + }, + }, responses: { 200: { - description: 'Refueled — full state snapshot (fuel at max, cash reduced by the cost)', + description: 'Refueled — full state snapshot (fuel raised by exactly the added liters, cash reduced by the cost)', content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, }, + 400: { + description: 'Invalid body (not an object, unknown field, unknown mode, or forFlight without packageId)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, 404: { - description: 'Unknown plane id', + description: 'Unknown plane id (or unknown package id in forFlight mode)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 409: { - description: 'Plane is flying, or cash is insufficient for the fill-up', + description: + 'Plane is flying, the package is not loaded on this plane (or is expired), the leg does not fit a full tank, or cash is insufficient for the refuel', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, }, @@ -376,11 +390,12 @@ export const openApiSpec = { label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' }, price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' }, maxFuel: { type: 'number', description: 'Fuel capacity, liters.' }, - burnPerKm: { type: 'number', description: 'Cruise fuel burn, liters/km.' }, + burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this).' }, + emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' }, speedKmh: { type: 'number', description: 'Cruise speed, km/h.' }, capacity: { type: 'integer', description: 'Cargo slot capacity — packages that can be loaded on one plane (v0.12.0).' }, }, - required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'speedKmh', 'capacity'], + required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'emptyWeightKg', 'speedKmh', 'capacity'], }, AccountState: { type: 'object', @@ -440,6 +455,22 @@ export const openApiSpec = { }, required: ['planeId'], }, + RefuelRequest: { + type: 'object', + description: + 'Optional refuel body (v0.16.0) — omit it entirely or send {} to fill the tank; both fields are optional.', + properties: { + mode: { + enum: ['full', 'forFlight'], + description: + '"full" (default) fills the tank to maxFuel; "forFlight" adds just enough fuel for one loaded package\'s destination leg.', + }, + packageId: { + type: 'string', + description: 'Required with mode "forFlight": a package LOADED on this plane — its destIcao is the refuel target.', + }, + }, + }, Error: { type: 'object', properties: { diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index 5312e86..6c77879 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -9,7 +9,7 @@ import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm } from '@/lib/geo'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed'; -import { arrivalTimeFor } from './movement'; +import { arrivalTimeFor, fuelAtWeighted } from './movement'; import { clampTimeScale, deliverPackages, @@ -182,7 +182,8 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => { expect(plane.locationIcao).toBe(TO); expect(plane.lat).toBe(to.lat); expect(plane.lon).toBe(to.lon); - expect(plane.fuel).toBeCloseTo(Math.max(0, type.maxFuel - type.burnPerKm * km), 6); // remaining fuel + // Remaining fuel per the v0.16.0 weight-proportional burn model (closed form). + expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1), 6); expect(plane.departureSimTime).toBeNull(); expect(plane.destIcao).toBeNull(); // flight context cleared on landing diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index 066e2dc..123d97c 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -12,7 +12,7 @@ import { type Rng, type SeedAirport, } from '@/lib/game/seed'; -import { arrivalTimeFor, fuelAt } from './movement'; +import { arrivalTimeFor, fuelAtWeighted } from './movement'; // Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background // timer. Every read/action endpoint calls tickNow() first; the clock advances by @@ -131,14 +131,14 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise { }); }); -describe('fuelAt', () => { - it('burns fuel proportionally to distance flown', () => { - const start = 6_300; // full ATR tank - expect(fuelAt(start, BURN, KM, 0)).toBe(start); - expect(fuelAt(start, BURN, KM, 0.5)).toBeCloseTo(start - BURN * KM / 2, 9); - expect(fuelAt(start, BURN, KM, 1)).toBeCloseTo(start - BURN * KM, 9); +describe('weighted burn model (v0.16.0)', () => { + it('calibrates so a full tank burns exactly the old constant rate', () => { + const k = burnRateConstant(BURN, WE, TANK); + expect(k * (WE + FUEL_DENSITY_KG_PER_L * TANK)).toBeCloseTo(BURN, 12); + + // Over a short hop from full, weighted ≈ constant — divergence only grows as fuel drops off. + const d = 100; + const consumed = TANK - fuelAtWeighted(TANK, BURN, WE, TANK, d, 1); + expect(Math.abs(consumed - BURN * d)).toBeLessThan(1); // < 1 L over 100 km from full + }); + + it('consumption matches numeric integration of r(F) = k·(We + ρF)', () => { + const k = burnRateConstant(BURN, WE, TANK); + const rho = FUEL_DENSITY_KG_PER_L; + // Euler-integrate dF/dx = −k(We + ρF) from a full tank over the whole leg. + const steps = 200_000; + const h = KM / steps; + let f = TANK; + for (let i = 0; i < steps; i++) f -= k * (WE + rho * f) * h; + expect(Math.abs(fuelAtWeighted(TANK, BURN, WE, TANK, KM, 1) - f)).toBeLessThan(0.001); // Euler drift is sub-milliliter here + }); + + it('requiredFuelFor is zero for a zero leg and strictly increasing in distance', () => { + expect(requiredFuelFor(BURN, WE, TANK, 0)).toBe(0); + let prev = requiredFuelFor(BURN, WE, TANK, 100); + for (const d of [500, 1_000, 2_000, 3_000, 4_000]) { + const cur = requiredFuelFor(BURN, WE, TANK, d); + expect(cur).toBeGreaterThan(prev); + prev = cur; + } + }); + + it('a lighter start burns less over the same distance', () => { + const burnFromFull = TANK - fuelAtWeighted(TANK, BURN, WE, TANK, KM, 1); + const burnFromHalf = TANK / 2 - fuelAtWeighted(TANK / 2, BURN, WE, TANK, KM, 1); + expect(burnFromFull).toBeGreaterThan(burnFromHalf); + }); + + it('starting with exactly the required fuel arrives on empty', () => { + const req = requiredFuelFor(BURN, WE, TANK, KM); + expect(fuelAtWeighted(req, BURN, WE, TANK, KM, 1)).toBeCloseTo(0, 6); }); it('clamps at zero instead of going negative', () => { - expect(fuelAt(100, BURN, 1_000, 1)).toBe(0); // needs 1750 L, has 100 - expect(fuelAt(100, BURN, 1_000, 0.01)).toBeCloseTo(100 - 17.5, 9); + expect(fuelAtWeighted(100, BURN, WE, TANK, KM, 1)).toBe(0); // needs ~2800 L from full, has 100 + expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, 0)).toBe(TANK); + expect(fuelAtWeighted(TANK, BURN, WE, TANK, 0, 1)).toBe(TANK); }); it('composes with progressAt for a just-before-arrival snapshot', () => { @@ -114,9 +161,6 @@ describe('fuelAt', () => { const clock = DEP + duration - 1; // one ms before arrival const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, clock)); expect(haversineKm(p.lat, p.lon, TO.lat, TO.lon)).toBeGreaterThan(0); // not yet at the gate - expect(fuelAt(6_300, BURN, KM, progressAt(DEP, KM, SPEED, clock))).toBeCloseTo( - 6_300 - BURN * KM * (1 - 1 / duration), - 6, - ); + expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0); }); }); diff --git a/lib/sim/movement.ts b/lib/sim/movement.ts index 029b7a6..2f0cd13 100644 --- a/lib/sim/movement.ts +++ b/lib/sim/movement.ts @@ -65,8 +65,56 @@ export function positionAt( }; } -// Fuel remaining after flying `progress` of a leg of distanceKm. Clamped at 0 — -// range exhaustion is not modeled yet (fuel management/refueling comes later). -export function fuelAt(startFuel: number, burnPerKm: number, distanceKm: number, progress: number): number { - return Math.max(0, startFuel - burnPerKm * distanceKm * progress); +// --- Weight-proportional fuel burn (v0.16.0) --- +// +// The instantaneous burn rate is proportional to total weight: r(F) = k·(We + ρF) L/km, +// where We is the empty weight in kg, ρ the fuel density and F the current load in +// liters — a heavier plane burns more per km. k is calibrated so that a FULL tank +// burns exactly the catalog's constant burnPerKm: k = burnPerKm / (We + ρ·maxFuel), +// so full-tank legs match the old model at departure and only diverge as fuel (and +// weight) drops off in flight. Closed forms of dF/dx = −k(We + ρF): +// fuel after distance D from F₀: (F₀ + We/ρ)·e^{−kρD} − We/ρ +// consumption over D: (F₀ + We/ρ)·(1 − e^{−kρD}) +// fuel required to cover D arriving with 0, independent of F₀: (We/ρ)(e^{kρD} − 1) + +// Jet-A1 density in kg/L — converts a liter load into its weight contribution. +export const FUEL_DENSITY_KG_PER_L = 0.8; + +// Calibration constant k for one plane type (see the model note above). +export function burnRateConstant(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number { + return burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel); +} + +// Fuel remaining after flying `progress` of a leg of distanceKm from startFuel under the +// weighted model. Clamped at 0 — range exhaustion is not modeled (a plane that runs dry +// limps to the gate on fumes, same as before). +export function fuelAtWeighted( + startFuel: number, + burnPerKm: number, + emptyWeightKg: number, + maxFuel: number, + distanceKm: number, + progress: number, +): number { + const d = distanceKm * progress; + if (d <= 0) return startFuel; + const k = burnRateConstant(burnPerKm, emptyWeightKg, maxFuel); + const rho = FUEL_DENSITY_KG_PER_L; + const f = (startFuel + emptyWeightKg / rho) * Math.exp(-k * rho * d) - emptyWeightKg / rho; + return Math.max(0, f); +} + +// Total fuel required to cover distanceKm arriving with an empty tank — independent of the +// starting load. A leg is flyable from a plane iff its current fuel covers this amount; it +// fits in the aircraft at all iff this does not exceed the tank capacity. +export function requiredFuelFor( + burnPerKm: number, + emptyWeightKg: number, + maxFuel: number, + distanceKm: number, +): number { + if (distanceKm <= 0) return 0; + const k = burnRateConstant(burnPerKm, emptyWeightKg, maxFuel); + const rho = FUEL_DENSITY_KG_PER_L; + return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1); } diff --git a/package.json b/package.json index 44f0f77..675935e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.15.1", + "version": "0.16.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0 From 687e4f21692134333f1678e6501e08c3acb94d05 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 19 Sep 2026 13:00:06 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20forFlight=20refuel=20leaves=20an=20e?= =?UTF-8?q?xactly-feasible=20tank=20(round=20up)=20=E2=80=94=20e2e=20409?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + e2e/right-pane.spec.ts | 26 ++++++++++++++++++-------- lib/game/actions.test.ts | 24 ++++++++++++++++++++++++ lib/game/actions.ts | 15 +++++++++------ 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fbae2d..97b03b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Tests: unit — new weighted-burn suite in `lib/sim/movement.test.ts` (full-tank calibration ≈ old constant, closed form vs numeric integration of r(F), F_req monotonic + empty-arrival identity, lighter-start burns less, clamping); `refuelPlane` forFlight cases in `lib/game/actions.test.ts` (exact liters/cost, no-op when enough, 409 not-loaded/expired/doesn't-fit via the ~7670 km UNNT↔LPPD pair, 404 unknown package, 400 missing packageId); refuel route body-validation tests; `lib/openapi.test.ts` updated (optional RefuelRequest, errorCodes `['400','404','409']`, CatalogEntry.emptyWeightKg). Adjusted where the model legitimately shifts numbers: landing-fuel assertions in `lib/sim/engine.test.ts` and `e2e/actions.spec.ts` now use the weighted closed form; the e2e "return leg unfuelable" scenario is reworked — under weight-proportional burn a full tank covers the EDDF↔LEZL round trip (the plane lightens as it flies), so the 409 insufficient-fuel assertion moves to the SECOND outbound after landing back with a partial tank - e2e (`right-pane.spec.ts`): new test — fly the longest hub leg on a full tank, load a package back to the hub at the far end, click the row "Refuel" and assert via the endpoint's own response snapshot that cash drops by exactly L×price and fuel rises by exactly L (L from the mirrored closed form); also asserts the market button label is "Refuel to full". `e2e/api.spec.ts` path list unchanged - Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack +- **Fix (CI run #40): forFlight refuel leaves an exactly-feasible tank, and the new e2e's premise corrected.** The right-pane e2e picked the dataset's absolute farthest airport from EDDF — UNNT at ~4803 km, whose leg needs ~6955 L > the 6300 L tank (max range from empty is ~4436 km) — so the full-tank fly rightly 409'd; `farthestToHub` becomes `farthestFlyableToHub`, picking the farthest airport whose leg still fits a full tank (UWUU, ~3195 km). Server hardening in the same area: forFlight now sets fuel to EXACTLY `requiredFuelFor(...)` instead of `fuel + liters` — that sum is not guaranteed ≥ R in IEEE754 (`f + fl(R − f)` can round a hair below), and flyPlane's strict comparison would then 409 on an otherwise-feasible leg; cost math unchanged (still the added liters × price). New unit regression test: forFlight on an empty-ish tank → immediate `flyPlane` to the package destination succeeds ## v0.15.1 — 2026-09-19 11:04 - **Right pane widened, "On board" wrapping fixed (ISS-004).** The 3-pane shell grid (`app/globals.css` `.app-shell`) moves from `300px / 1fr / 320px` to `280px / 1fr / 360px` — the map column absorbs the delta (MapLibre sizes itself from its container, no JS change). Three wrap offenders in the right pane's "On board" blocks are fixed: diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index 0da60a9..345d5bc 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -115,20 +115,29 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo return nearest; } -// Farthest airport from EDDF — the longest possible leg (v0.16.0 refuel-for-flight test). -async function farthestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> { +// 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. +async function farthestFlyableToHub( + page: Page, + entry: CatalogEntry, +): Promise<{ icao: string; lat: number; lon: 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; - let farthest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties; + let farthest: { icao: string; lat: number; lon: number; d: number } | null = null; for (const f of fc.features) { if (f.properties.icao === HUB) continue; - if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) > haversineKm(hub.lat, hub.lon, farthest.lat, farthest.lon)) { - farthest = f.properties; + 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 + if (!farthest || d > farthest.d) { + farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d }; } } - return { icao: farthest.icao, lat: farthest.lat, lon: farthest.lon, hubLat: hub.lat, hubLon: hub.lon }; + if (!farthest) throw new Error('no full-tank-flyable airport found in the dataset'); + return { ...farthest, hubLat: hub.lat, hubLon: hub.lon }; } // Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty — @@ -343,8 +352,9 @@ test('on-board row Refuel tops up just enough for the package leg; market button // Deterministic baseline: a full tank. expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200); - // Longest leg from the hub — long enough that "just enough" for it exceeds what's left on arrival. - const farthest = await farthestToHub(page); + // Longest FLYABLE leg from the hub (fits a full tank) — long enough that "just enough" for it + // exceeds what's left on arrival. + const farthest = await farthestFlyableToHub(page, entry); const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000); expect((await page.request.post(`/api/packages/${pkg1.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index 0414c8d..f212971 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -443,6 +443,30 @@ describe('refuelPlane', () => { expect(account.cash).toBeCloseTo(cashBefore - cost, 6); }); + it('forFlight leaves an exactly-feasible tank — 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); + + // 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). + await flyPlane(inst, 'plane-starter', 'LEMD'); + const plane = (await inst.db.select().from(schema.planes))[0]; + expect(plane.status).toBe('flying'); + expect(plane.destIcao).toBe('LEMD'); + }); + it('forFlight is a free no-op when the tank already covers the leg', async () => { const inst = await freshDb('refuel-forflight-enough.db'); // seeded starter is full at EDDF await insertOpenPackage(inst, 'p-ff2', { destIcao: 'LEMD' }); diff --git a/lib/game/actions.ts b/lib/game/actions.ts index af2274d..5a208fb 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -248,7 +248,7 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt } const mode = opts.mode ?? 'full'; - let liters: number; // how much fuel to add + let targetFuel: number; // tank level after the refuel if (mode === 'forFlight') { if (!opts.packageId) throw new GameActionError(400, 'packageId is required when mode is "forFlight"'); @@ -269,11 +269,17 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt if (requiredL > plane.maxFuel) { throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`); } - liters = Math.max(0, requiredL - plane.fuel); + // 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); } else { - liters = plane.maxFuel - plane.fuel; + // Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality. + targetFuel = plane.maxFuel; } + const liters = Math.max(0, targetFuel - plane.fuel); const cost = Math.round(liters * FUEL_PRICE_PER_L * 100) / 100; const account = (await inst.db.select().from(schema.account))[0]; if (!account) throw new Error('account row missing'); @@ -281,9 +287,6 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`); } - // Full mode lands EXACTLY on maxFuel (as before v0.16.0), not fuel + liters — the sum can - // drift a ulp for fractional tanks and clients/tests compare against maxFuel exactly. - const targetFuel = mode === 'forFlight' ? plane.fuel + liters : plane.maxFuel; await inst.db.update(schema.planes).set({ fuel: targetFuel }).where(eq(schema.planes.id, planeId)); if (cost > 0) { await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 }); -- 2.54.0 From ae1cb93a4f82c4f0ae840f8a278b99a44ab6dffe Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 19 Sep 2026 13:36:27 +0000 Subject: [PATCH 3/3] test: harden full-core-loop against Load/Fly precondition races --- CHANGELOG.md | 1 + e2e/actions.spec.ts | 33 +++++++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97b03b7..9577498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - e2e (`right-pane.spec.ts`): new test — fly the longest hub leg on a full tank, load a package back to the hub at the far end, click the row "Refuel" and assert via the endpoint's own response snapshot that cash drops by exactly L×price and fuel rises by exactly L (L from the mirrored closed form); also asserts the market button label is "Refuel to full". `e2e/api.spec.ts` path list unchanged - Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack - **Fix (CI run #40): forFlight refuel leaves an exactly-feasible tank, and the new e2e's premise corrected.** The right-pane e2e picked the dataset's absolute farthest airport from EDDF — UNNT at ~4803 km, whose leg needs ~6955 L > the 6300 L tank (max range from empty is ~4436 km) — so the full-tank fly rightly 409'd; `farthestToHub` becomes `farthestFlyableToHub`, picking the farthest airport whose leg still fits a full tank (UWUU, ~3195 km). Server hardening in the same area: forFlight now sets fuel to EXACTLY `requiredFuelFor(...)` instead of `fuel + liters` — that sum is not guaranteed ≥ R in IEEE754 (`f + fl(R − f)` can round a hair below), and flyPlane's strict comparison would then 409 on an otherwise-feasible leg; cost math unchanged (still the added liters × price). New unit regression test: forFlight on an empty-ish tank → immediate `flyPlane` to the package destination succeeds +- **E2e flake hardening (runs #36/#41): full-core-loop Load/Fly precondition races** — test mechanics only, no app code changes. Two independent races in `e2e/actions.spec.ts`: (1) the Fly click fires its POST asynchronously (`runAction` is fire-and-forget from the test's view), so the immediate `getState` could race ahead of it and still see status 'available' — now an `expect.poll` waits until the server shows 'flying' with the expected destIcao before proceeding; (2) `flyHomeViaApi` made its repositioning decision from a snapshot taken BEFORE waiting for a leftover in-flight leg to land — while flying, locationIcao stays at the departure airport, so a plane that landed away from home was still believed to be home and never repositioned (run #41 retry: starter stranded at EDFH) — state is now re-read after landing; plus an explicit server-side precondition poll (starter grounded at the package origin) before clicking Load ## v0.15.1 — 2026-09-19 11:04 - **Right pane widened, "On board" wrapping fixed (ISS-004).** The 3-pane shell grid (`app/globals.css` `.app-shell`) moves from `300px / 1fr / 320px` to `280px / 1fr / 360px` — the map column absorbs the delta (MapLibre sizes itself from its container, no JS change). Three wrap offenders in the right pane's "On board" blocks are fixed: diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts index 0f46896..02302d0 100644 --- a/e2e/actions.spec.ts +++ b/e2e/actions.spec.ts @@ -100,15 +100,21 @@ test.describe.configure({ mode: 'serial' }); // shared live DB — order matters // (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so // exact cash/rep assertions start from a clean baseline even after an interrupted previous run. async function flyHomeViaApi(page: Page): Promise { - const s0 = await getState(page); - const plane = s0.planes.find((p) => p.id === 'plane-starter')!; + let s = await getState(page); + let plane = s.planes.find((p) => p.id === 'plane-starter')!; if (plane.status === 'flying' || plane.locationIcao !== HUB) { // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). await setScale(page, SCALE); } if (plane.status === 'flying') { - // Let the in-flight leg finish before repositioning. + // Let the in-flight leg finish before repositioning. While flying, locationIcao stays at the + // DEPARTURE airport, so the pre-landing snapshot can't tell us where the plane ends up — re-read + // state after landing before deciding whether a repositioning flight is needed (run #41 retry: a + // leftover EDDF→EDFH leg landed away from home but the helper still believed it was home and + // skipped the fly-home). await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available'); + s = await getState(page); + plane = s.planes.find((p) => p.id === 'plane-starter')!; } if (plane.locationIcao !== HUB) { const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); @@ -143,6 +149,14 @@ test('full core loop via UI: load → fly to destination → exact reward + rep expect(pkg.originIcao).toBe(HUB); expect(pkg.destIcao).toBe(nearest.icao); + // Precondition (server truth) before clicking Load: the starter must be grounded at the package's + // origin — that is exactly what makes it load-eligible, and a stale pane snapshot can't prove it. + await expect.poll(async () => { + const b = await getState(page); + const p = b.planes.find((x) => x.id === 'plane-starter')!; + return p.status === 'available' && p.locationIcao === pkg.originIcao; + }, { timeout: 30_000, message: 'waiting for plane-starter to be grounded at the package origin (self-heal incomplete?)' }).toBe(true); + // LOAD via the UI: exactly one eligible plane (the starter at EDDF) → direct button. await page.goto('/'); const loadBtn = page.getByTestId(`load-${pkg.id}`); @@ -159,10 +173,17 @@ test('full core loop via UI: load → fly to destination → exact reward + rep await expect(flyBtn).toBeEnabled(); await flyBtn.click(); - let s = await getState(page); const planeId = 'plane-starter'; - expect(s.planes.find((p) => p.id === planeId)!.status).toBe('flying'); - expect(s.planes.find((p) => p.id === planeId)!.destIcao).toBe(nearest.icao); + // The click fires the fly POST asynchronously (runAction is fire-and-forget from the test's view), + // so a getState issued immediately can race ahead of it and still see 'available' — poll until the + // fly actually took effect on the server (the run #36/#41 flake). + await expect.poll(async () => { + const b = await getState(page); + const p = b.planes.find((x) => x.id === planeId)!; + return p.status === 'flying' && p.destIcao === nearest.icao; + }, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true); + + let s = await getState(page); // Fast-forward to arrival (a few seconds of real time at 1000×). await setScale(page, SCALE); -- 2.54.0