v0.51.0 — spec card shows guaranteed (worst-case headwind) max range (#99) #104
@@ -1,5 +1,9 @@
|
|||||||
# 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
|
## 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
|
- **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
|
- **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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.50.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