v1.12.0 — takeoff & landing phases on the marker
CI / build + unit + e2e (pull_request) Failing after 7m29s

Every leg gains a 10-min takeoff phase at the origin and a 10-min
landing phase at the destination, each weather-scaled up to 2x via a
monotone condition rule (Clear < Cloudy < Rain < Fog). Single
composition in lib/sim/movement.ts: BASE_PHASE_MS, phaseFactor,
MAX_PHASE_FACTOR, legPhases; arrivalTimeFor and progressAt route
through it (progress is now piecewise — hold at origin / linear cruise
/ hold at destination), so engine arrival gate, client position/fuel
recompute and the job expiry floor all agree. Phases burn no fuel.

Marker shows the current phase next to the status in the popup
(mapData derives it from departureSimTime + clockMs; new
data-testid=plane-phase sibling). plane-status keeps the raw status.

Tests: movement.test.ts (phaseFactor bounds/monotonicity, origin-vs-dest
composition, piecewise progressAt), engine.test.ts (deleted local travelMs
mirror — all ~18 fixture clocks use the real composition), seed.test.ts
(phase-inclusive expiry floor mirrors). New e2e/phases.spec.ts asserts
'taking off' at the origin gate and 'landing' at the destination gate on
EDDF→LEMD using the same legPhases boundaries as the engine.
This commit is contained in:
2026-09-28 07:09:19 +00:00
parent db5ad639fb
commit 82e4c64206
12 changed files with 451 additions and 67 deletions
+5 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { haversineKm } from '@/lib/geo';
import { travelDurationMs } from '@/lib/sim/movement';
import { legPhases } from '@/lib/sim/movement';
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { SPECIALS } from './specials';
import {
@@ -478,7 +478,8 @@ describe('expiry floor (v0.54.0, ISS-119)', () => {
const c = generatePackage({ id: `c-floor-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW }); // special rolls naturally (~15% priority)
const o = airports.find((a) => a.icao === c.originIcao)!;
const d = airports.find((a) => a.icao === c.destIcao)!;
const floorMs = travelDurationMs(haversineKm(o.lat, o.lon, d.lat, d.lon), FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
// v1.12.0: the floor includes the weather-scaled takeoff/landing phases (worst case for this pair).
const floorMs = legPhases(haversineKm(o.lat, o.lon, d.lat, d.lon), FASTEST_CATALOG_SPEED_KMH, o.icao, d.icao).totalMs + EXPIRY_BUFFER_MS;
expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs));
}
});
@@ -503,7 +504,8 @@ describe('expiry floor (v0.54.0, ISS-119)', () => {
}
expect(bestKm).toBeGreaterThan(8_000); // the dataset really spans near-cap legs
const floorMs = travelDurationMs(bestKm, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
// v1.12.0: phase-inclusive floor — same composition seed.ts uses for this pair.
const floorMs = legPhases(bestKm, FASTEST_CATALOG_SPEED_KMH, airports[bestI].icao, airports[bestJ].icao).totalMs + EXPIRY_BUFFER_MS;
for (const special of ['priority', null] as const) {
let clamped = 0;
for (let seed = 1; seed <= 50; seed++) {
+6 -4
View File
@@ -1,5 +1,5 @@
import { haversineKm } from '@/lib/geo';
import { travelDurationMs } from '@/lib/sim/movement';
import { legPhases } from '@/lib/sim/movement';
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { pickSpecial, SPECIALS } from './specials';
@@ -284,9 +284,11 @@ export function generatePackage(input: {
// single uniform value, so clamping it up AFTER special scaling covers both requirements: short legs
// (drawn × scale already above the floor) are byte-identical to before, and when the raw draw falls
// below the floor the deadline lands exactly on it — which stays ≤ the scaled ceiling for any pair of
// airports on Earth (max leg ~20000 km → floor ≈ 22.8 h < the 36 h priority ceiling), so no separate
// ceiling raise is needed. Forced offsets skip the floor, same as they skip the special scaling.
const expiryFloorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
// airports on Earth (max leg ~20000 km → floor ≈ 23.5 h incl. worst-case phases < the 36 h priority
// ceiling), so no separate ceiling raise is needed. Forced offsets skip the floor, same as they skip
// the special scaling. v1.12.0: the travel time includes the weather-scaled takeoff/landing phases —
// a package must be finishable even when both airports are in their worst (Fog) conditions.
const expiryFloorMs = legPhases(km, FASTEST_CATALOG_SPEED_KMH, origin.icao, dest.icao).totalMs + EXPIRY_BUFFER_MS;
const deadlineMs = input.deadlineOffsetMs ?? Math.max(drawnWindowMs * (mods?.deadlineScale ?? 1), expiryFloorMs);
// v0.38.0 (ISS-072): reputation bonus composed with the special multiplier, single round at the end.
+3 -1
View File
@@ -192,7 +192,9 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
const to = coordsByIcao.get(p.destIcao);
if (!type || !from || !to) return null;
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
// v1.12.0: piecewise cruise progress — holds at the origin during takeoff and at the
// destination during landing (weather-scaled phase durations from legPhases).
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, p.locationIcao, p.destIcao, simRow.clockMs);
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
// recomputes exactly the factor the engine used for the leg; no stored state needed.
+25 -2
View File
@@ -1,6 +1,7 @@
import type { PlaneState, StateSnapshot } from './game/state';
import { formatFuel } from './format';
import { positionAt } from './sim/movement';
import { haversineKm } from './geo';
import { legPhaseAt, positionAt, type LegPhase } from './sim/movement';
// Pure GeoJSON builders for the map's dynamic layers (v0.11.0, package model
// v0.12.0): one Point feature per plane, one LineString per FLYING plane's
@@ -14,9 +15,14 @@ export interface PlaneFeatureProps {
status: 'available' | 'flying' | 'repairing';
fuelText: string; // "1234 / 6300 L"
routeText: string; // "Origin → Destination" while flying, '' when grounded
// v1.12.0: 'taking off' | 'cruising' | 'landing' while flying (derived from the sim clock), '' when grounded.
phaseLabel: string;
flying: boolean;
}
// v1.12.0: display text for each timed leg segment (see legPhaseAt in lib/sim/movement.ts).
const PHASE_LABELS: Record<LegPhase, string> = { takeoff: 'taking off', cruise: 'cruising', landing: 'landing' };
export interface RouteFeatureProps {
planeId: string;
}
@@ -54,13 +60,29 @@ export function planesGeoJson(
if (!state) return EMPTY_POINTS;
// Catalog display name ("ATR 72-600") for the popup — raw model id as fallback.
const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label]));
const speedByModel = new Map(state.catalog.map((e) => [e.model, e.speedKmh]));
const clockMs = state.simState.clockMs;
const features: MapPointFeature[] = state.planes.map((p) => {
const flying = p.status === 'flying';
let lon = p.lon;
if (flying && p.destIcao) {
// v1.12.0: the marker's phase label — same legPhases composition the engine uses, derived from
// departureSimTime + clockMs (both in the snapshot) so no extra state is needed.
let phaseLabel = '';
if (flying && p.destIcao && p.departureSimTime != null) {
const from = airportsByIcao.get(p.locationIcao);
const to = airportsByIcao.get(p.destIcao);
const speedKmh = speedByModel.get(p.model);
if (from && to) lon = unwrapLon(p.lon, from.lon);
if (from && to && speedKmh !== undefined) {
phaseLabel = PHASE_LABELS[legPhaseAt(
p.departureSimTime,
haversineKm(from.lat, from.lon, to.lat, to.lon),
speedKmh,
p.locationIcao,
p.destIcao,
clockMs,
)];
}
}
return {
type: 'Feature',
@@ -71,6 +93,7 @@ export function planesGeoJson(
status: p.status,
fuelText: formatFuel(p.fuel, p.maxFuel),
routeText: flying && p.destName ? `${p.locationName} → ${p.destName}` : '',
phaseLabel,
flying,
},
};
+19 -22
View File
@@ -231,7 +231,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
const { inst, km, type } = await setupFlight('deliver-early.db');
const playerBefore = (await getLocalPlayer(inst));
const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO) - 1; // one ms before arrival
expect(await deliverPackages(inst, clock)).toBe(0);
const plane = (await inst.db.select().from(schema.planes))[0];
@@ -252,7 +252,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
{ id: 'p-elsewhere', originIcao: FROM, destIcao: 'EHAM', reward: 9_000, cargoType: 'Mail', cargoCount: 10, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
]);
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
@@ -298,7 +298,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
{ id: 'p-b', originIcao: FROM, destIcao: TO, reward: 2_500, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
]);
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh);
const clock = arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO);
expect(await deliverPackages(inst, clock)).toBe(1);
const player = (await getLocalPlayer(inst));
@@ -317,7 +317,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
{ id: 'p-prio', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 10, special: 'priority', deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
]);
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival
const clock = arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const player = (await getLocalPlayer(inst));
@@ -358,7 +358,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
.where(eq(schema.planes.id, 'plane-starter'));
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
@@ -383,7 +383,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.where(eq(schema.planes.id, 'plane-starter'));
const playerBefore = (await getLocalPlayer(inst)); // seeded €20k — the top-up is affordable
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
// Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), wind-adjusted
@@ -429,7 +429,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.where(eq(schema.planes.id, 'plane-starter'));
await updateLocalPlayerEconomy(inst, { cash: 0 });
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
@@ -445,7 +445,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
it('grounds as before when the queue is empty', async () => {
const { inst, km } = await setupFlight('chain-empty.db'); // seeded default queue '[]'
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // unchanged landing behavior…
@@ -473,7 +473,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.set({ queue: JSON.stringify(['FGSL']) })
.where(eq(schema.planes.id, 'plane-starter'));
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
@@ -490,7 +490,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
.set({ queue: JSON.stringify(['LEMD', 'EHAM']) })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, type.speedKmh, FROM, TO))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // grounded — the self-leg was dropped, not flown…
@@ -514,7 +514,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
{ id: 'p-owner', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
]);
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival
const clock = arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const bAfter = (await getPlayer(inst, 'player-b'));
@@ -540,7 +540,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
const localBefore = (await getPlayer(inst, LOCAL_PLAYER.id));
const bBefore = (await getPlayer(inst, 'player-b'));
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
const clock = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
// Expected cost from the SAME helpers the engine uses — computed with B's rep discount.
@@ -594,7 +594,7 @@ describe('wear on landed legs (v0.39.0, ISS-069)', () => {
it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => {
const { inst, km } = await setupFlight('wear-leg.db');
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, PLANE_TYPES.atr72.speedKmh, FROM, TO))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −0.98 health (v0.43.0, ISS-085)
@@ -607,18 +607,19 @@ describe('wear on landed legs (v0.39.0, ISS-069)', () => {
.set({ health: WEAR_PER_KM * km / 2 })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, type.speedKmh, FROM, TO))).toBe(1);
let plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBe(0); // clamped — never negative…
// …and a second leg keeps it pinned at 0.
const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties;
const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon);
const arr1 = arrivalTimeFor(T0, km, type.speedKmh, FROM, TO); // leg 1's exact arrival (v1.12.0 phases)
await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: T0 + travelMs(km, type.speedKmh), fuel: type.maxFuel })
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: arr1, fuel: type.maxFuel })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh) + travelMs(nextKm, type.speedKmh))).toBe(1);
expect(await deliverPackages(inst, arrivalTimeFor(arr1, nextKm, type.speedKmh, TO, 'EHAM'))).toBe(1);
plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBe(0); // still clamped after the second leg
});
@@ -632,7 +633,7 @@ describe('wear on landed legs (v0.39.0, ISS-069)', () => {
.set({ health: WEAR_PER_KM * km / 2, queue: JSON.stringify(['EHAM']) })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
expect(await deliverPackages(inst, arrivalTimeFor(T0, km, type.speedKmh, FROM, TO))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBe(0); // broken by this landing's wear…
@@ -1006,8 +1007,4 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
});
});
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
// engine composes arrivalTimeFor; tests just need the duration for fixtures).
function travelMs(km: number, speedKmh: number): number {
return (km / speedKmh) * 3_600_000;
}
+2 -1
View File
@@ -228,7 +228,8 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
if (!type || !from || !to) continue; // no route info — leave alone
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
// v1.12.0: the gate includes the takeoff/landing phases (weather-scaled) — see legPhases.
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh, p.locationIcao, p.destIcao)) continue;
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
+94 -24
View File
@@ -1,13 +1,19 @@
import { describe, expect, it } from 'vitest';
import { haversineKm } from '@/lib/geo';
import { weatherFor, type AirportWeather, type WeatherCondition } from '@/lib/weather';
import {
arrivalTimeFor,
BASE_PHASE_MS,
burnRateConstant,
FUEL_DENSITY_KG_PER_L,
fuelAtWeighted,
guaranteedRangeKm,
legPhases,
legPhaseAt,
MAX_PHASE_FACTOR,
MAX_WIND_BURN_FACTOR,
maxRangeKm,
phaseFactor,
positionAt,
progressAt,
requiredFuelFor,
@@ -38,36 +44,98 @@ describe('travelDurationMs / arrivalTimeFor', () => {
expect(() => travelDurationMs(100, 0)).toThrow();
});
it('arrival is departure + duration', () => {
const arrival = arrivalTimeFor(DEP, KM, SPEED);
expect(arrival).toBeCloseTo(DEP + (KM / SPEED) * 3_600_000, 9);
expect(arrival).toBeGreaterThan(DEP);
it('arrival is departure plus all three segments (v1.12.0)', () => {
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
expect(arrivalTimeFor(DEP, KM, SPEED, 'EDDF', 'LEMD')).toBe(DEP + ph.totalMs);
// The cruise part is the old pure duration — phases only add on top.
expect(ph.cruiseMs).toBeCloseTo((KM / SPEED) * 3_600_000, 9);
expect(arrivalTimeFor(DEP, KM, SPEED, 'EDDF', 'LEMD')).toBeGreaterThan(DEP + ph.cruiseMs);
});
});
describe('progressAt', () => {
const duration = travelDurationMs(KM, SPEED);
describe('phaseFactor / legPhases (v1.12.0)', () => {
const mk = (condition: WeatherCondition): AirportWeather => ({ condition, windDirDeg: 90, windKt: 15, visibilityM: 8_000 });
it('is 0 before departure and at departure', () => {
expect(progressAt(DEP, KM, SPEED, DEP - 1)).toBe(0);
expect(progressAt(DEP, KM, SPEED, DEP)).toBe(0);
it('MAX_PHASE_FACTOR is phaseFactor\'s actual maximum over the weather domain', () => {
// Fog is the worst condition — the constant must track the factor function, not a copy.
expect(MAX_PHASE_FACTOR).toBe(phaseFactor(mk('Fog')));
});
it('is linear in the middle of the flight', () => {
it('stays within [1, MAX_PHASE_FACTOR] and is monotone in condition severity', () => {
for (const c of ['Clear', 'Cloudy', 'Rain', 'Fog'] as const) {
const f = phaseFactor(mk(c));
expect(f).toBeGreaterThanOrEqual(1);
expect(f).toBeLessThanOrEqual(MAX_PHASE_FACTOR);
}
expect(phaseFactor(mk('Clear'))).toBe(1); // clear skies stay at 1×
expect(phaseFactor(mk('Cloudy'))).toBeGreaterThan(phaseFactor(mk('Clear')));
expect(phaseFactor(mk('Rain'))).toBeGreaterThan(phaseFactor(mk('Cloudy')));
expect(phaseFactor(mk('Fog'))).toBe(MAX_PHASE_FACTOR); // worst condition is exactly 2×
});
it('is a deterministic function of the ICAO via weatherFor', () => {
for (const icao of ['EDDF', 'LEMD', 'EHAM', 'KSMF']) {
expect(phaseFactor(weatherFor(icao))).toBe(phaseFactor(weatherFor(icao)));
}
// KSMF is Clear in the deterministic generator — a real ICAO pinned at exactly 1×.
expect(phaseFactor(weatherFor('KSMF'))).toBe(1);
});
it('takeoff scales on ORIGIN weather, landing on DESTINATION weather', () => {
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
expect(ph.takeoffMs).toBe(BASE_PHASE_MS * phaseFactor(weatherFor('EDDF')));
expect(ph.landingMs).toBe(BASE_PHASE_MS * phaseFactor(weatherFor('LEMD')));
// Non-vacuous: the two airports really do differ (Rain vs Cloudy), so a swapped
// origin/dest would fail these pins.
expect(phaseFactor(weatherFor('EDDF'))).not.toBe(phaseFactor(weatherFor('LEMD')));
});
it('total is the sum of the three segments and exceeds the old pure duration', () => {
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
expect(ph.totalMs).toBe(ph.takeoffMs + ph.cruiseMs + ph.landingMs);
expect(ph.totalMs).toBeGreaterThan(travelDurationMs(KM, SPEED));
});
it('legPhaseAt walks takeoff → cruise → landing over the leg', () => {
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP)).toBe('takeoff');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs - 1)).toBe('takeoff');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs)).toBe('cruise');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs + ph.cruiseMs - 1)).toBe('cruise');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.totalMs - 1)).toBe('landing');
});
});
describe('progressAt (piecewise, v1.12.0)', () => {
const ORIGIN = 'EDDF';
const DEST = 'LEMD';
const ph = legPhases(KM, SPEED, ORIGIN, DEST);
it('is 0 before departure and throughout the takeoff hold at the origin', () => {
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP - 1)).toBe(0);
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs / 2)).toBe(0);
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs - 1));
expect(p).toEqual({ lat: FROM.lat, lon: FROM.lon }); // still at the gate — no crawl along the route
});
it('is linear over the cruise window', () => {
// Precision 9: clock values sit at ~1.75e12 ms, so (clock - DEP) loses a few
// ulps to cancellation — 1e-9 progress is still sub-millisecond on this leg.
expect(progressAt(DEP, KM, SPEED, DEP + duration / 2)).toBeCloseTo(0.5, 9);
expect(progressAt(DEP, KM, SPEED, DEP + duration * 0.25)).toBeCloseTo(0.25, 9);
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs / 2)).toBeCloseTo(0.5, 9);
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs * 0.25)).toBeCloseTo(0.25, 9);
});
it('is exactly 1 at arrival and stays 1 after', () => {
const arrival = DEP + duration;
expect(progressAt(DEP, KM, SPEED, arrival)).toBe(1);
expect(progressAt(DEP, KM, SPEED, arrival + 10_000)).toBe(1);
it('is exactly 1 at the start of the landing hold and stays 1 through arrival', () => {
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs));
expect(p).toEqual({ lat: TO.lat, lon: TO.lon }); // parked at the destination gate during landing
const arrival = DEP + ph.totalMs;
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, arrival)).toBe(1);
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, arrival + 10_000)).toBe(1);
});
it('a zero-distance leg is complete immediately', () => {
expect(progressAt(DEP, 0, SPEED, DEP - 5)).toBe(1);
it('a zero-distance leg has no cruise to progress through', () => {
expect(progressAt(DEP, 0, SPEED, 'EDDF', 'EDDF', DEP - 5)).toBe(0); // before departure
expect(progressAt(DEP, 0, SPEED, 'EDDF', 'EDDF', DEP + 1)).toBe(1); // complete once departed
});
});
@@ -160,12 +228,14 @@ describe('weighted burn model (v0.16.0)', () => {
expect(fuelAtWeighted(TANK, BURN, WE, TANK, 0, 1)).toBe(TANK);
});
it('composes with progressAt for a just-before-arrival snapshot', () => {
const duration = travelDurationMs(KM, SPEED);
const clock = DEP + duration - 1; // one ms before arrival
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, clock));
expect(haversineKm(p.lat, p.lon, TO.lat, TO.lon)).toBeGreaterThan(0); // not yet at the gate
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0);
it('composes with progressAt for a just-before-arrival snapshot (v1.12.0)', () => {
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
const clock = DEP + ph.totalMs - 1; // one ms before arrival — inside the landing hold
const progress = progressAt(DEP, KM, SPEED, 'EDDF', 'LEMD', clock);
expect(progress).toBe(1); // cruise is done: the plane holds at the destination gate…
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progress);
expect(p).toEqual({ lat: TO.lat, lon: TO.lon }); // …so it IS at the gate (v1.12.0 piecewise)
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progress)).toBeGreaterThan(0);
});
});
+83 -8
View File
@@ -3,6 +3,8 @@
// distance in game time without losing accuracy. All inputs are explicit; there
// is no DB/IO here, which keeps this fully unit-testable with synthetic routes.
import { weatherFor, type AirportWeather } from '@/lib/weather';
const toRad = (d: number) => (d * Math.PI) / 180;
const toDeg = (r: number) => (r * 180) / Math.PI;
@@ -12,22 +14,95 @@ export function travelDurationMs(distanceKm: number, speedKmh: number): number {
return (distanceKm / speedKmh) * 3_600_000;
}
// Sim-clock time at which the leg completes.
export function arrivalTimeFor(departureSimTime: number, distanceKm: number, speedKmh: number): number {
return departureSimTime + travelDurationMs(distanceKm, speedKmh);
// --- Takeoff & landing phases (v1.12.0) ---
//
// Every leg has a 10-minute takeoff phase at the origin and a 10-minute landing phase at the
// destination, on top of the pure cruise time. Weather at the relevant airport stretches each
// phase up to 2× (bad conditions → longer rollout/approach); clear skies stay at 1×. The phases
// burn no fuel — they are pure time, so the weighted-burn model is untouched.
// One phase's duration in clear weather: 10 minutes of sim time.
export const BASE_PHASE_MS = 600_000;
// Weather → phase stretch factor, exactly [1, MAX_PHASE_FACTOR]. Simple monotone rule over the
// condition severity order Clear < Cloudy < Rain < Fog — visibility in this generator is a
// function of the condition (Fog ≤2 km, Rain 4–10 km, else 8–40 km), so it adds no independent
// signal. Each step up from Clear adds one third of the headroom; Fog lands exactly on MAX_PHASE_FACTOR.
export function phaseFactor(w: AirportWeather): number {
const step = w.condition === 'Clear' ? 0 : w.condition === 'Cloudy' ? 1 : w.condition === 'Rain' ? 2 : 3;
return 1 + step / 3;
}
// Flight progress in [0, 1] at a given sim-clock time — clamped to 0 before
// departure and 1 after arrival. A zero-distance leg is complete immediately.
// Upper end of phaseFactor over the weather domain — a Fog airport (the worst condition).
export const MAX_PHASE_FACTOR = 2;
export interface LegPhases {
takeoffMs: number; // origin weather
cruiseMs: number; // pure cruise — the old travelDurationMs value
landingMs: number; // destination weather
totalMs: number; // takeoff + cruise + landing
}
// The three timed segments of a leg. Takeoff scales on ORIGIN weather, landing on DESTINATION
// weather; both are deterministic functions of the ICAOs (weatherFor), so any site holding the
// two ICAs recomputes exactly what the engine used — no stored state needed.
export function legPhases(distanceKm: number, speedKmh: number, originIcao: string, destIcao: string): LegPhases {
const cruiseMs = travelDurationMs(distanceKm, speedKmh);
const takeoffMs = BASE_PHASE_MS * phaseFactor(weatherFor(originIcao));
const landingMs = BASE_PHASE_MS * phaseFactor(weatherFor(destIcao));
return { takeoffMs, cruiseMs, landingMs, totalMs: takeoffMs + cruiseMs + landingMs };
}
// Sim-clock time at which the leg completes — departure plus all three segments (v1.12.0).
export function arrivalTimeFor(
departureSimTime: number,
distanceKm: number,
speedKmh: number,
originIcao: string,
destIcao: string,
): number {
return departureSimTime + legPhases(distanceKm, speedKmh, originIcao, destIcao).totalMs;
}
// CRUISE-segment progress in [0, 1] at a given sim-clock time (v1.12.0) — clamped to 0 before
// departure and 1 after arrival: 0 while the plane holds at the origin during takeoff, linear
// over the cruise window, 1 while it holds at the destination during landing. That keeps
// positionAt/fuel pinned to each airport during its phase instead of crawling along the whole
// route. A zero-distance leg has no cruise: complete once departed.
export function progressAt(
departureSimTime: number,
distanceKm: number,
speedKmh: number,
originIcao: string,
destIcao: string,
clockMs: number,
): number {
const duration = travelDurationMs(distanceKm, speedKmh);
if (duration <= 0) return 1;
return Math.min(1, Math.max(0, (clockMs - departureSimTime) / duration));
const ph = legPhases(distanceKm, speedKmh, originIcao, destIcao);
const t = clockMs - departureSimTime;
if (t <= 0) return 0;
if (ph.cruiseMs <= 0) return 1; // zero-distance leg — nothing to cruise
if (t >= ph.totalMs) return 1;
if (t <= ph.takeoffMs) return 0; // holding at origin during takeoff
if (t >= ph.takeoffMs + ph.cruiseMs) return 1; // holding at destination during landing
return (t - ph.takeoffMs) / ph.cruiseMs;
}
export type LegPhase = 'takeoff' | 'cruise' | 'landing';
// Which timed segment a leg is in at a given sim-clock time — for marker display (v1.12.0).
export function legPhaseAt(
departureSimTime: number,
distanceKm: number,
speedKmh: number,
originIcao: string,
destIcao: string,
clockMs: number,
): LegPhase {
const ph = legPhases(distanceKm, speedKmh, originIcao, destIcao);
const t = clockMs - departureSimTime;
if (t < ph.takeoffMs) return 'takeoff';
if (t < ph.takeoffMs + ph.cruiseMs) return 'cruise';
return 'landing';
}
// Great-circle interpolation between two WGS84 points at progress t ∈ [0,1]