Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d31bc5d60f | ||
|
|
8319b2fa78 | ||
|
|
f01c3f417c | ||
|
|
c3339f0e40 | ||
|
|
d215203988 | ||
|
|
11fc6f9d5c | ||
|
|
8f32d5bf81 |
@@ -1,5 +1,17 @@
|
|||||||
# 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
|
||||||
|
- **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
|
||||||
|
|
||||||
## v0.48.0 — 2026-09-22 06:55
|
## v0.48.0 — 2026-09-22 06:55
|
||||||
- **Leg distance shown on contracts (ISS-095).** `PackageState` gains `distanceKm` (`lib/game/state.ts`) — the great-circle origin→dest distance, computed server-side in `getStateSnapshot()` from the airport GeoJSON already loaded for live-plane math (same `haversineKm` as flight movement; 0 if a coord is missing). Shown in two places via new `formatKm()` (`lib/format.ts`, integer km, no space before "km"): the on-board contract line (`EGCC → GMME 554km €23,826.00 …`) and the job-list row (`€37,408.00 215km Perishables 0.4t …`). OpenAPI spec updated in lockstep; nothing stored
|
- **Leg distance shown on contracts (ISS-095).** `PackageState` gains `distanceKm` (`lib/game/state.ts`) — the great-circle origin→dest distance, computed server-side in `getStateSnapshot()` from the airport GeoJSON already loaded for live-plane math (same `haversineKm` as flight movement; 0 if a coord is missing). Shown in two places via new `formatKm()` (`lib/format.ts`, integer km, no space before "km"): the on-board contract line (`EGCC → GMME 554km €23,826.00 …`) and the job-list row (`€37,408.00 215km Perishables 0.4t …`). OpenAPI spec updated in lockstep; nothing stored
|
||||||
- **Tests.** Unit: `app/api/state/route.test.ts` asserts every package's `distanceKm` equals the haversine of its airports' coordinates (computed with the same lib/geo helper); `lib/openapi.test.ts` pinned key list gains `distanceKm`; new `formatKm` test in `lib/format.test.ts`. E2E: `right-pane.spec.ts` +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), `actions.spec.ts` +1 (on-board line)
|
- **Tests.** Unit: `app/api/state/route.test.ts` asserts every package's `distanceKm` equals the haversine of its airports' coordinates (computed with the same lib/geo helper); `lib/openapi.test.ts` pinned key list gains `distanceKm`; new `formatKm` test in `lib/format.test.ts`. E2E: `right-pane.spec.ts` +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), `actions.spec.ts` +1 (on-board line)
|
||||||
|
|||||||
+19
-12
@@ -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';
|
||||||
@@ -231,21 +233,22 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
// deterministic generated weather — no gameplay effects per PLAN.md.
|
// deterministic generated weather — no gameplay effects per PLAN.md.
|
||||||
const w = weatherFor(p.icao);
|
const w = weatherFor(p.icao);
|
||||||
const localTime = approxLocalTime(p.lon, Date.now());
|
const localTime = approxLocalTime(p.lon, Date.now());
|
||||||
// ISS-010 (v0.18.0): Relocate row — grounded planes from the latest snapshot can be
|
// ISS-010 (v0.18.0): Relocate row — every plane from the latest snapshot can be sent to
|
||||||
// repositioned to this airport via POST /api/planes/{id}/fly (pure repositioning).
|
// this airport: grounded ones fly immediately (POST /api/planes/{id}/fly), flying/repairing
|
||||||
|
// ones get the leg appended to their queue (POST /api/planes/{id}/queue, ISS-096 v0.49.0).
|
||||||
const st = stateRef.current;
|
const st = stateRef.current;
|
||||||
const groundedPlanes = st ? st.planes.filter((pl) => pl.status === 'available') : [];
|
const planes = st ? st.planes : [];
|
||||||
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
|
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
|
||||||
const relocateHtml =
|
const relocateHtml =
|
||||||
groundedPlanes.length > 0
|
planes.length > 0
|
||||||
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
|
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
|
||||||
`<option value="">Select a plane\u2026</option>` +
|
`<option value="">Select a plane\u2026</option>` +
|
||||||
groundedPlanes.map(
|
planes.map(
|
||||||
(pl) =>
|
(pl) =>
|
||||||
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
|
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
|
||||||
).join('') +
|
).join('') +
|
||||||
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
|
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
|
||||||
: `<span data-testid="airport-popup-no-planes">No grounded planes to relocate.</span>`;
|
: `<span data-testid="airport-popup-no-planes">No planes to relocate.</span>`;
|
||||||
const popup = new maplibregl.Popup({ offset: 10 })
|
const popup = new maplibregl.Popup({ offset: 10 })
|
||||||
.setLngLat(f.geometry.coordinates as [number, number])
|
.setLngLat(f.geometry.coordinates as [number, number])
|
||||||
.setHTML(
|
.setHTML(
|
||||||
@@ -263,7 +266,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
const el = popup.getElement();
|
const el = popup.getElement();
|
||||||
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
|
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
|
||||||
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
|
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
|
||||||
// The status span is always in the popup HTML (even with no grounded planes), so it
|
// The status span is always in the popup HTML (even with no planes), so it
|
||||||
// exists whenever the select/button do.
|
// exists whenever the select/button do.
|
||||||
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
|
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
|
||||||
if (select && button && statusEl) {
|
if (select && button && statusEl) {
|
||||||
@@ -276,7 +279,11 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
statusEl.textContent = 'Relocating\u2026';
|
statusEl.textContent = 'Relocating\u2026';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/planes/${planeId}/fly`, {
|
// ISS-096 (v0.49.0): endpoint follows the SELECTED plane's current status — grounded
|
||||||
|
// planes fly now, flying/repairing ones get the leg queued for after landing.
|
||||||
|
const planeStatus = stateRef.current?.planes.find((pl) => pl.id === planeId)?.status ?? 'available';
|
||||||
|
const action = planeStatus === 'available' ? 'fly' : 'queue';
|
||||||
|
const res = await fetch(`/api/planes/${planeId}/${action}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ destIcao: p.icao }),
|
body: JSON.stringify({ destIcao: p.icao }),
|
||||||
@@ -285,7 +292,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
const data = (await res.json()) as StateSnapshot & { error?: string };
|
const data = (await res.json()) as StateSnapshot & { error?: string };
|
||||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||||
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
|
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
|
||||||
statusEl.textContent = 'Relocation started.';
|
statusEl.textContent = action === 'fly' ? 'Relocation started.' : 'Added to queue.';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusEl.textContent = err instanceof Error ? err.message : String(err);
|
statusEl.textContent = err instanceof Error ? err.message : String(err);
|
||||||
button.disabled = false; // allow retry / picking another plane
|
button.disabled = false; // allow retry / picking another plane
|
||||||
@@ -377,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',
|
||||||
@@ -391,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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+83
-3
@@ -5,8 +5,9 @@ import { expect, test } from '@playwright/test';
|
|||||||
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
|
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
|
||||||
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
|
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
|
||||||
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
|
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
|
||||||
// the map (airport-popup-plane / airport-popup-relocate). Marker-size regression is covered by
|
// the map (airport-popup-plane / airport-popup-relocate). Since v0.49.0 (ISS-096) the popup row also
|
||||||
// the existing e2e/map.spec.ts click tests, not here.
|
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
||||||
|
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
|
||||||
|
|
||||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time
|
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time
|
||||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||||
@@ -15,7 +16,7 @@ const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (
|
|||||||
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
|
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
|
||||||
|
|
||||||
interface StateBody {
|
interface StateBody {
|
||||||
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null }>;
|
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getState(page: Page): Promise<StateBody> {
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
@@ -195,3 +196,82 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
|
|||||||
return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao;
|
return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao;
|
||||||
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true);
|
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg, fast-forwarded in cleanup
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||||
|
await setFuelFull(page, 'plane-starter');
|
||||||
|
|
||||||
|
// ISS-096: the popup must offer Relocate for a FLYING plane too. Dispatch the starter on a leg via
|
||||||
|
// the API, then slow the sim to 1x so the leg cannot land during the UI steps (at 1x even the
|
||||||
|
// shortest leg takes minutes of real time) — "still flying its original destination" below is
|
||||||
|
// deterministic regardless of how fast the UI moves.
|
||||||
|
const nearest = await nearestToHub(page);
|
||||||
|
expect(nearest.icao).not.toBe(POPUP_AIRPORT.icao); // popup click target must differ from the active leg
|
||||||
|
await setScale(page, 1);
|
||||||
|
|
||||||
|
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
||||||
|
expect(flyRes.ok()).toBeTruthy();
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const b = await getState(page);
|
||||||
|
const p = b.planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
return p.status === 'flying' && p.destIcao === nearest.icao;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the API dispatch to take effect (status "flying" + dest set)' }).toBe(true);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Same map-ready waits as the grounded popup test above: airport count rendered + a snapshot with
|
||||||
|
// the plane in the planes source — the popup's Relocate row is built from that same latest snapshot.
|
||||||
|
const countEl = page.getByTestId('airport-count');
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const text = (await countEl.textContent()) ?? '';
|
||||||
|
const m = text.match(/(\d+)/);
|
||||||
|
return m ? parseInt(m[1], 10) : -1;
|
||||||
|
}, { timeout: 15_000 }).toBeGreaterThan(0);
|
||||||
|
await expect.poll(async () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const m = (window as any).__flightMap;
|
||||||
|
if (!m || !m.getSource('planes')) return false;
|
||||||
|
try {
|
||||||
|
return m.querySourceFeatures('planes').some((f: { properties?: Record<string, unknown> }) => f.properties?.id === 'plane-starter');
|
||||||
|
} catch {
|
||||||
|
return false; // source not ready yet — keep polling
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const pt = await page.evaluate(({ lon, lat }) => {
|
||||||
|
const map = (window as any).__flightMap;
|
||||||
|
const p = map.project([lon, lat]);
|
||||||
|
const rect = map.getContainer().getBoundingClientRect();
|
||||||
|
return { x: rect.left + p.x, y: rect.top + p.y };
|
||||||
|
}, POPUP_AIRPORT);
|
||||||
|
await page.mouse.click(pt.x, pt.y);
|
||||||
|
|
||||||
|
const popup = page.getByTestId('airport-popup');
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
await expect(popup).toContainText(POPUP_AIRPORT.icao);
|
||||||
|
|
||||||
|
// The Relocate row now lists ALL planes (ISS-096) — the flying starter is selectable even though it
|
||||||
|
// is not grounded; while flying its option shows the DEPARTURE airport.
|
||||||
|
const planeSel = page.getByTestId('airport-popup-plane');
|
||||||
|
await expect(planeSel).toBeVisible();
|
||||||
|
await expect(planeSel.locator('option[value="plane-starter"]')).toHaveText(/ATR 72-600 — EDDF/);
|
||||||
|
const goBtn = page.getByTestId('airport-popup-relocate');
|
||||||
|
await expect(goBtn).toBeDisabled(); // placeholder selected
|
||||||
|
|
||||||
|
await planeSel.selectOption('plane-starter');
|
||||||
|
await expect(goBtn).toBeEnabled();
|
||||||
|
await goBtn.click();
|
||||||
|
|
||||||
|
// The click fires the queue POST asynchronously — poll until it took effect: the leg is APPENDED to
|
||||||
|
// the flight queue while the plane keeps flying its original destination.
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const b = await getState(page);
|
||||||
|
const p = b.planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
return p.status === 'flying' && p.destIcao === nearest.icao && p.queue.includes(POPUP_AIRPORT.icao);
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (queue contains LFRS, still flying original dest)' }).toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.48.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