diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c81449..5a261dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v0.51.0 — 2026-09-22 08:21 +- **Max range display is now guaranteed flyable in all weather (ISS-099).** The spec card's "Max range" (`components/RightPane.tsx`) showed `maxRangeKm` — the weighted-model closed form with NO wind — while dispatch's fuel guard (`flyPlane`, and the engine's auto-chain) uses wind-adjusted burn (up to +20% headwind, v0.36.0), so a leg inside the displayed range could 409 "insufficient fuel" when the departure airport had a headwind. New `MAX_WIND_BURN_FACTOR` (= 1.2 — pinned in tests to be exactly `windBurnFactor`'s maximum) and `guaranteedRangeKm(burnPerKm, emptyWeightKg, maxFuel)` = `maxRangeKm` under worst-case headwind burn (`lib/sim/movement.ts`). The UI now shows the guaranteed range: any leg within it is flyable in all weather. ATR 72-600: 4436 → 3696 km; Boeing 777: 13045 → 10871 km — matching the `maxRangeKm ÷ 1.2` convention seed.ts already uses for its job-generation cap +- **Tests.** Unit +15: new `guaranteedRangeKm` block in `lib/sim/movement.test.ts` (3 tests — MAX_WIND_BURN_FACTOR pinned to `windBurnFactor(30, 90, 90)`, ATR ≈ 3697 km ±5%, zero-fuel/invalid-weight guards) and a per-type consistency loop in `lib/game/catalog.test.ts` (12 tests — for ALL six catalog types: `requiredFuelFor(guaranteed range)` ≤ maxFuel under worst-case headwind, tightness at 1.05× > maxFuel, and the no-wind closed form still an exact inverse). E2E: `e2e/right-pane.spec.ts` market-tab test now imports and asserts `guaranteedRangeKm` directly (local mirror deleted) + ## v0.49.0 — 2026-09-22 07:25 - **Airport popup Relocate now works for flying/repairing planes (ISS-096).** The Relocate row in the map's airport popup (`components/Map.tsx`) previously listed only grounded planes and showed "No grounded planes to relocate." when none was available. It now lists ALL planes from the latest snapshot, and the click handler picks the endpoint by the SELECTED plane's current status: `available` → `POST /api/planes/{id}/fly` (immediate relocation, unchanged), `flying`/`repairing` → `POST /api/planes/{id}/queue` (the destination is appended to the plane's flight queue and chains on landing). Success text is "Relocation started." for fly and "Added to queue." for queue; the no-planes fallback guard is reworded to "No planes to relocate." No API changes — both endpoints existed since v0.25.0/v0.46.0 - **Tests.** E2E +1 (`e2e/relocation.spec.ts`): dispatches the starter plane on a leg via the API so it is flying, slows the sim to 1x so the leg cannot land mid-test, opens the airport popup for a different airport, selects the flying plane from the (now full) list and clicks "Relocate here", then asserts via state that the plane's `queue` contains the clicked airport's ICAO while it is still flying its original destination diff --git a/components/RightPane.tsx b/components/RightPane.tsx index 86f0cdf..6e39829 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog'; -import { maxRangeKm } from '@/lib/sim/movement'; +import { guaranteedRangeKm } from '@/lib/sim/movement'; import { formatCargo, formatCash, formatDeadlineRelative, formatKm, formatSpecialLabel } from '@/lib/format'; import { useGameAction } from '@/lib/useGameAction'; import type { HoverTarget } from './hoverTypes'; @@ -322,7 +322,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr {/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */} {/* v0.28.0 (ISS-047): tech-spec panel per catalog type — progression order (price ascending). - All values come from the snapshot's catalog; max range is the weighted-model closed form. */} + All values come from the snapshot's catalog; max range is the GUARANTEED full-tank range — + worst-case headwind, so any leg within it is flyable in all weather (v0.51.0, ISS-099). */} {[...catalog].sort((a, b) => a.price - b.price).map((e) => (

{e.label}

@@ -353,7 +354,7 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
Max range - {Math.round(maxRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km + {Math.round(guaranteedRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index 40a4ec7..e06ea3b 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -4,7 +4,7 @@ import { expect, test } from '@playwright/test'; // 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 { fuelAtWeighted, windBurnFactor } from '../lib/sim/movement'; +import { fuelAtWeighted, guaranteedRangeKm, windBurnFactor } from '../lib/sim/movement'; // v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers. import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog'; import { STARTING_REPUTATION } from '../lib/game/seed'; @@ -137,12 +137,6 @@ function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: numb return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1); } -// Mirrors maxRangeKm in lib/sim/movement.ts (v0.28.0): full-tank range under the weighted model. -function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number { - const rho = FUEL_DENSITY_KG_PER_L; - return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm)); -} - // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. Omitting `special` // makes the hook force null (no roll), so existing specs' exact reward/rep assertions stay deterministic. async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number, special?: string | null): Promise { @@ -483,11 +477,11 @@ test('Planes market tab lists all plane types with tech specs', async ({ page }) await page.getByTestId('tab-planes-market').click(); - // ATR card: name + full-tank range from the same closed form as lib/sim/movement.ts (v0.28.0). + // ATR card: name + guaranteed full-tank range (worst-case headwind) from lib/sim/movement.ts (v0.51.0). const atr = body.catalog.find((e) => e.model === 'atr72')!; await expect(page.getByTestId('plane-type-atr72'), 'the ATR card appears').toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label); - const atrRange = Math.round(maxRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel)); + const atrRange = Math.round(guaranteedRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel)); await expect(page.getByTestId('plane-type-range-atr72')).toContainText(`${atrRange} km`); // Ladder sanity: the most expensive type (Boeing 777-300ER) is present with its €9M price. diff --git a/lib/game/catalog.test.ts b/lib/game/catalog.test.ts index 6d8aebc..bf0a225 100644 --- a/lib/game/catalog.test.ts +++ b/lib/game/catalog.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog'; import { STARTING_REPUTATION } from './seed'; +import { guaranteedRangeKm, MAX_WIND_BURN_FACTOR, maxRangeKm, requiredFuelFor } from '@/lib/sim/movement'; // v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added. describe('PLANE_TYPES', () => { @@ -43,3 +44,25 @@ describe('repRate', () => { expect(repRate(1_000_000, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(MAX_FUEL_DISCOUNT); }); }); + +// v0.51.0 (ISS-099) — max range consistency: for EVERY catalog type the displayed guaranteed range +// must be flyable on a full tank under worst-case headwind burn (the same requiredFuelFor ≤ maxFuel +// rule as flyPlane's fuel guard), and tight — not padded. The closed form inverts exactly, so the +// "≤" side allows only float noise (~1e-11 L → relative 1e-9 tolerance). +describe('max range consistency (v0.51.0, ISS-099)', () => { + for (const t of Object.values(PLANE_TYPES)) { + it(`${t.id}: guaranteed range is flyable in worst-case headwind and not padded`, () => { + const r = guaranteedRangeKm(t.burnPerKm, t.emptyWeightKg, t.maxFuel); + expect(requiredFuelFor(t.burnPerKm * MAX_WIND_BURN_FACTOR, t.emptyWeightKg, t.maxFuel, r)) + .toBeLessThanOrEqual(t.maxFuel * (1 + 1e-9)); + // Tight: 5% beyond the displayed range no longer fits a full tank. + expect(requiredFuelFor(t.burnPerKm * MAX_WIND_BURN_FACTOR, t.emptyWeightKg, t.maxFuel, 1.05 * r)) + .toBeGreaterThan(t.maxFuel); + }); + + it(`${t.id}: the no-wind closed form inverts requiredFuelFor exactly`, () => { + const r = maxRangeKm(t.burnPerKm, t.emptyWeightKg, t.maxFuel); + expect(requiredFuelFor(t.burnPerKm, t.emptyWeightKg, t.maxFuel, r)).toBeLessThanOrEqual(t.maxFuel * (1 + 1e-9)); + }); + } +}); diff --git a/lib/sim/movement.test.ts b/lib/sim/movement.test.ts index 0dc6606..64917aa 100644 --- a/lib/sim/movement.test.ts +++ b/lib/sim/movement.test.ts @@ -5,6 +5,8 @@ import { burnRateConstant, FUEL_DENSITY_KG_PER_L, fuelAtWeighted, + guaranteedRangeKm, + MAX_WIND_BURN_FACTOR, maxRangeKm, positionAt, progressAt, @@ -234,3 +236,22 @@ describe('maxRangeKm (v0.28.0)', () => { expect(() => maxRangeKm(BURN, 0, TANK)).toThrow(); }); }); + +describe('guaranteedRangeKm (v0.51.0, ISS-099)', () => { + it("MAX_WIND_BURN_FACTOR is windBurnFactor's actual maximum over the weather domain", () => { + // Full headwind at max wind: exactly +20% — the constant must track the factor function, not a copy. + expect(MAX_WIND_BURN_FACTOR).toBe(windBurnFactor(30, 90, 90)); + }); + + it('is maxRangeKm under worst-case headwind burn (ATR ≈ 3696 km ±5%)', () => { + const r = guaranteedRangeKm(BURN, WE, TANK); + expect(r).toBeCloseTo(maxRangeKm(BURN * MAX_WIND_BURN_FACTOR, WE, TANK), 12); + expect(r).toBeGreaterThan(3696 * 0.95); + expect(r).toBeLessThan(3696 * 1.05); + }); + + it('zero fuel → zero range; rejects a non-positive empty weight', () => { + expect(guaranteedRangeKm(BURN, WE, 0)).toBe(0); + expect(() => guaranteedRangeKm(BURN, 0, TANK)).toThrow(); + }); +}); diff --git a/lib/sim/movement.ts b/lib/sim/movement.ts index 85f55c0..838e0df 100644 --- a/lib/sim/movement.ts +++ b/lib/sim/movement.ts @@ -137,3 +137,16 @@ export function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: nu const rho = FUEL_DENSITY_KG_PER_L; return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm)); } + +// Max headwind burn multiplier over the weather domain (v0.51.0, ISS-099): the upper end of +// windBurnFactor's [0.8, 1.2] range — a full 30 kt headwind (the max in lib/weather.ts) ÷ 150. +export const MAX_WIND_BURN_FACTOR = 1 + 30 / 150; + +// Guaranteed full-tank range (v0.51.0, ISS-099): the maxRangeKm closed form under worst-case +// headwind burn — any leg within this distance is flyable in ALL weather, since windBurnFactor +// never exceeds MAX_WIND_BURN_FACTOR and requiredFuelFor grows with the burn rate. This is what +// the UI displays as "Max range", so a spec-possible leg always passes flyPlane's fuel guard +// (requiredFuelFor ≤ maxFuel under the same wind-adjusted burn). +export function guaranteedRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number { + return maxRangeKm(burnPerKm * MAX_WIND_BURN_FACTOR, emptyWeightKg, maxFuel); +} diff --git a/package.json b/package.json index 2b99152..8210610 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.49.0", + "version": "0.51.0", "private": true, "scripts": { "dev": "next dev -p 3000",