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 });