Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8319b2fa78 | ||
|
|
f01c3f417c | ||
|
|
c3339f0e40 | ||
|
|
d215203988 |
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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.50.0 — 2026-09-22 08:00
|
||||||
|
- **Queued/planned route line is now amber/gold instead of gray (ISS-098).** The dotted queue line in `components/Map.tsx` (a flying plane's not-yet-started legs, ISS-043) was drawn in muted slate `#64748b`. It is now an amber/gold `#d4a017`, readable on both light and dark tiles. The color was extracted from the inline literal to a new exported module-scope constant `QUEUE_ROUTE_LINE_COLOR` so it is testable; the dashed/dotted style (`line-dasharray [0.1, 1.5]`) and width (3) are unchanged
|
||||||
|
- **Tests.** Unit +1: new `components/map.test.ts` pins `QUEUE_ROUTE_LINE_COLOR === '#d4a017'`. No new e2e spec — the existing queued-route rendering paths (`e2e/queue.spec.ts`, `e2e/relocation.spec.ts`) already exercise this layer, and a pure color swap needs no behavioral coverage
|
||||||
|
|
||||||
## v0.49.0 — 2026-09-22 07:25
|
## 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
|
- **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
|
- **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
|
||||||
|
|||||||
+5
-3
@@ -32,6 +32,8 @@ const ROUTES_LEGS_LAYER_ID = 'routes-legs';
|
|||||||
// (dest → queue[0] → …) as their own source + layer, drawn under everything else dynamic.
|
// (dest → queue[0] → …) as their own source + layer, drawn under everything else dynamic.
|
||||||
const QUEUE_ROUTES_SOURCE_ID = 'queue-routes';
|
const QUEUE_ROUTES_SOURCE_ID = 'queue-routes';
|
||||||
const QUEUE_ROUTES_LAYER_ID = 'queue-routes';
|
const QUEUE_ROUTES_LAYER_ID = 'queue-routes';
|
||||||
|
// ISS-098 (v0.50.0): amber/gold — planned legs read as "queued", visible on light and dark tiles alike.
|
||||||
|
export const QUEUE_ROUTE_LINE_COLOR = '#d4a017';
|
||||||
// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they
|
// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they
|
||||||
// render above them. Each starts filtered to match nothing; applyHoverRef drives the filters.
|
// render above them. Each starts filtered to match nothing; applyHoverRef drives the filters.
|
||||||
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
|
const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight';
|
||||||
@@ -382,8 +384,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// ISS-043 (v0.26.0): dotted queue line — added after routes-legs but BEFORE the hover
|
// ISS-043 (v0.26.0): dotted queue line — added after routes-legs but BEFORE the hover
|
||||||
// layers so a hovered trace still draws on top of it. Muted slate + fine dots reads as
|
// layers so a hovered trace still draws on top of it. Amber/gold + fine dots reads as
|
||||||
// "planned/future", distinct from the solid amber active leg and the [2,2] yellow hover dash.
|
// "planned/future", distinct from the solid bright-amber active leg and the [2,2] yellow hover dash.
|
||||||
if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) {
|
if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) {
|
||||||
map.addSource(QUEUE_ROUTES_SOURCE_ID, {
|
map.addSource(QUEUE_ROUTES_SOURCE_ID, {
|
||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
@@ -396,7 +398,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
source: QUEUE_ROUTES_SOURCE_ID,
|
source: QUEUE_ROUTES_SOURCE_ID,
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': '#64748b', // muted slate — planned legs, not actively flying
|
'line-color': QUEUE_ROUTE_LINE_COLOR, // amber/gold — planned legs, not actively flying (ISS-098)
|
||||||
'line-width': 3,
|
'line-width': 3,
|
||||||
'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash
|
'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
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 { 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 { formatCargo, formatCash, formatDeadlineRelative, formatKm, formatSpecialLabel } from '@/lib/format';
|
||||||
import { useGameAction } from '@/lib/useGameAction';
|
import { useGameAction } from '@/lib/useGameAction';
|
||||||
import type { HoverTarget } from './hoverTypes';
|
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. */}
|
{/* 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).
|
{/* 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) => (
|
{[...catalog].sort((a, b) => a.price - b.price).map((e) => (
|
||||||
<div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}>
|
<div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}>
|
||||||
<h4 className="plane-type-name">{e.label}</h4>
|
<h4 className="plane-type-name">{e.label}</h4>
|
||||||
@@ -353,7 +354,7 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
<div className="stat-row">
|
<div className="stat-row">
|
||||||
<span>Max range</span>
|
<span>Max range</span>
|
||||||
<strong data-testid={`plane-type-range-${e.model}`}>
|
<strong data-testid={`plane-type-range-${e.model}`}>
|
||||||
{Math.round(maxRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
|
{Math.round(guaranteedRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { QUEUE_ROUTE_LINE_COLOR } from './Map';
|
||||||
|
|
||||||
|
// ISS-098 (v0.50.0): the dotted queued/planned route line is amber/gold, not gray — pinned so a
|
||||||
|
// future retune is deliberate. E2E already exercises the rendering path (queue.spec.ts, relocation.spec.ts).
|
||||||
|
|
||||||
|
describe('queued route line color', () => {
|
||||||
|
it('pins the amber/gold queue-line color readable on light and dark tiles', () => {
|
||||||
|
expect(QUEUE_ROUTE_LINE_COLOR).toBe('#d4a017');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
|
||||||
import { initialBearingDeg } from '../lib/geo';
|
import { initialBearingDeg } from '../lib/geo';
|
||||||
import { weatherFor } from '../lib/weather';
|
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.
|
// 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 { 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';
|
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);
|
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`
|
// 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.
|
// 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<PackageState> {
|
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number, special?: string | null): Promise<PackageState> {
|
||||||
@@ -483,11 +477,11 @@ test('Planes market tab lists all plane types with tech specs', async ({ page })
|
|||||||
|
|
||||||
await page.getByTestId('tab-planes-market').click();
|
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')!;
|
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'), 'the ATR card appears').toBeVisible({ timeout: 15_000 });
|
||||||
await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label);
|
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`);
|
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.
|
// Ladder sanity: the most expensive type (Boeing 777-300ER) is present with its €9M price.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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 { 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 { 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.
|
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
|
||||||
describe('PLANE_TYPES', () => {
|
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);
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
burnRateConstant,
|
burnRateConstant,
|
||||||
FUEL_DENSITY_KG_PER_L,
|
FUEL_DENSITY_KG_PER_L,
|
||||||
fuelAtWeighted,
|
fuelAtWeighted,
|
||||||
|
guaranteedRangeKm,
|
||||||
|
MAX_WIND_BURN_FACTOR,
|
||||||
maxRangeKm,
|
maxRangeKm,
|
||||||
positionAt,
|
positionAt,
|
||||||
progressAt,
|
progressAt,
|
||||||
@@ -234,3 +236,22 @@ describe('maxRangeKm (v0.28.0)', () => {
|
|||||||
expect(() => maxRangeKm(BURN, 0, TANK)).toThrow();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -137,3 +137,16 @@ export function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: nu
|
|||||||
const rho = FUEL_DENSITY_KG_PER_L;
|
const rho = FUEL_DENSITY_KG_PER_L;
|
||||||
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.49.0",
|
"version": "0.51.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user