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
+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]