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
+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;
}