v1.12.0 — takeoff & landing phases on the marker
CI / build + unit + e2e (pull_request) Failing after 7m29s
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:
+83
-8
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user