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:
+19
-22
@@ -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
@@ -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
@@ -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
@@ -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