diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bcaae0..3055157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v1.12.0 — 2026-09-28 06:59 +- **Takeoff & landing phases on the marker.** Every leg now 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 (`lib/sim/movement.ts`): new `BASE_PHASE_MS`, `phaseFactor(weather)` ∈ [1, 2] (monotone over Clear < Cloudy < Rain < Fog — clear stays at 1×, Fog is exactly 2×), and `legPhases(distanceKm, speedKmh, originIcao, destIcao) → {takeoffMs, cruiseMs, landingMs, totalMs}` as the single composition. Takeoff scales on ORIGIN weather, landing on DESTINATION weather — both deterministic via `weatherFor(icao)`, so no stored state. The engine's arrival gate (`lib/sim/engine.ts`), the client's live position/fuel recompute (`lib/game/state.ts`) and the job expiry floor (`lib/game/seed.ts`) all route through it; `progressAt` is now piecewise — 0 while holding at the origin during takeoff, linear over cruise, 1 while holding at the destination during landing — so markers park at each gate instead of crawling along the whole route. The phases burn no fuel (pure time); the weighted-burn model is untouched. +- **Marker phase label.** `lib/mapData.ts` derives a phase label client-side from `departureSimTime + clockMs + legPhases` (all already in the snapshot) and the plane popup shows it next to the status — "flying · taking off" / "flying · cruising" / "flying · landing" (`components/Map.tsx`, new `data-testid="plane-phase"` sibling; `plane-status` keeps the raw status so existing assertions stay exact). +- **Tests.** Unit: `lib/sim/movement.test.ts` — pinned cruise duration unchanged, new suites for `phaseFactor` bounds/monotonicity (with `MAX_PHASE_FACTOR` pinned to the factor function like `MAX_WIND_BURN_FACTOR`), origin-vs-dest weather composition on a non-vacuous Rain/Cloudy pair, and piecewise `progressAt` holds; `lib/sim/engine.test.ts` — deleted the local `travelMs` mirror, all ~18 "exactly at arrival" fixture clocks now use the real `arrivalTimeFor(...)` composition (the wear-clamp second leg chains off leg 1's exact phase-inclusive arrival); `lib/game/seed.test.ts` expiry-floor mirrors updated to the phase-inclusive floor. E2E: new `e2e/phases.spec.ts` — dispatches the starter on EDDF→LEMD and, using the same `legPhases` boundaries as the engine, asserts the popup shows "taking off" while the plane holds at the origin gate, "landing" while it holds at the destination gate (scale dropped to 30× so each 600–1200 game-s window is observable), then "available" at Madrid; relocates home and restores the world rate. + ## v1.11.0 — 2026-09-27 22:55 - **Do not show jobs for planes that are in air (ISS-159).** The right pane's open-package list now hides a row while no GROUNDED plane sits at the package's origin (`components/RightPane.tsx`): new exported `visibleOpenPackages(state)` replaces the bare `status === 'open'` filter — a job whose only claimant is still en route disappears from the list and reappears on landing. Payload-blocked rows (a grounded plane at the origin with no room) stay visible with their disabled Load button, exactly as before. Display-only: `poolAirports` and the spawner are untouched, so world state — jobs waiting at a flying plane's destination — is unchanged; this only stops rendering rows the player cannot dispatch yet. Known pre-existing gap (NOT fixed here): `loadEligiblePlanes` ignores plane ownership per the v1.4.0 shared-market design, so another player's grounded plane at an origin can make Load enabled for you → 403 on click. - **Tests.** Unit: new `visibleOpenPackages` suite in `components/right-pane.test.ts` — grounded plane at origin → visible; only a flying plane there → hidden; grounded elsewhere + one flying to the origin → hidden; payload-blocked grounded plane at origin → still visible with Load disabled. E2E: new test in `e2e/right-pane.spec.ts` dispatches the only plane toward an airport, creates a job AT that destination (in the pool while airborne, so it reaches `/api/state`), and asserts no row renders until landing — pinning the client-side filter rather than the v0.65.0 server-side pool visibility. diff --git a/components/Map.tsx b/components/Map.tsx index af33036..1de96b4 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -326,12 +326,15 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout, isA if (!f) return; const p = f.properties as PlaneFeatureProps; const routeLine = p.routeText ? `
${escapeHtml(p.routeText)}` : ''; + // v1.12.0: phase label next to the status — "flying · taking off" etc. plane-status keeps + // the raw status so existing assertions on it stay exact. + const phaseLine = p.phaseLabel ? ` · ${p.phaseLabel}` : ''; new maplibregl.Popup({ offset: 12 }) .setLngLat(f.geometry.coordinates as [number, number]) .setHTML( `
` + `${escapeHtml(p.modelLabel)}
` + - `${p.status}
` + + `${p.status}${phaseLine}
` + `fuel ${p.fuelText}` + routeLine + `
`, diff --git a/e2e/phases.spec.ts b/e2e/phases.spec.ts new file mode 100644 index 0000000..25ddb37 --- /dev/null +++ b/e2e/phases.spec.ts @@ -0,0 +1,204 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { authenticate } from './auth'; +import { haversineKm } from '../lib/geo'; +import { legPhases } from '../lib/sim/movement'; +import { PLANE_TYPES, STARTER_MODEL } from '../lib/game/catalog'; + +// v1.12.0 — takeoff & landing phases on the marker: dispatch the starter plane on a known leg and +// assert the popup shows 'taking off' shortly after departure (the plane holds at the origin gate) +// and 'landing' near arrival (holding at the destination gate), then 'available' once landed. The +// phase boundaries come from the SAME legPhases composition the engine uses, so no timing guesswork: +// each observation happens inside a window computed from departureSimTime + clockMs. + +test.beforeEach(async ({ page }) => { await authenticate(page); }); + +const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts +const DEST = 'LEMD'; // ~1420 km — the same leg map-interactions.spec.ts uses +const STARTER = 'plane-starter'; +// Observation rate: a phase window (600–1200 game-s) lasts 20–40 real seconds at this scale — long +// enough for the fit + click + popup pipeline, short enough to keep the test fast. +const OBS = 30; +// Fast-forward through the cruise between the two observations (pure arrival wait). +const MID = 1_000; +// Self-heal and final arrival waits — no mid-leg observation at this rate. +const FAST = 50_000; +const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts + +interface StateBody { + simState: { clockMs: number }; + planes: Array<{ id: string; status: string; locationIcao: string; departureSimTime: number | null }>; +} + +async function getState(page: Page): Promise { + // ISS-127: /api/state intermittently 500s under dind load — retry briefly instead of failing. + for (let attempt = 1; ; attempt++) { + const res = await page.request.get('/api/state'); + if (res.status() === 200) return (await res.json()) as StateBody; + if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); + await page.waitForTimeout(500); + } +} + +async function setScale(page: Page, scale: number): Promise { + const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } }); + expect(res.status()).toBe(200); + const body = (await res.json()) as { timeScale: number }; + expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale); +} + +// Leave the live instance in a sane state for the other specs (the fixed world rate). +test.afterEach(async ({ page }) => { + await setScale(page, WORLD); +}); + +async function waitForAirportCount(page: Page): Promise { + const countEl = page.getByTestId('airport-count'); + await expect.poll(async () => { + const text = (await countEl.textContent()) ?? ''; + const m = text.match(/(\d+)/); + return m ? parseInt(m[1], 10) : -1; + }).toBeGreaterThan(0); +} + +// Self-heal: wait until the starter is grounded at HUB, flying it back first if another spec left +// it elsewhere. Runs at FAST — a cross-Europe hop lands in well under a second. +async function ensureGroundedAt(page: Page, icao: string): Promise { + await expect.poll(async () => { + const p = (await getState(page)).planes.find((x) => x.id === STARTER)!; + if (p.status === 'available' && p.locationIcao === icao) return 'ok'; + if (p.status === 'available') { + await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: icao } }); + return `flying home from ${p.locationIcao}`; + } + return p.status; // flying or repairing — keep waiting (FAST makes both finish quickly) + }, { timeout: 120_000, message: 'waiting for the starter plane to be grounded at ' + icao }).toBe('ok'); +} + +// Frame both endpoints so the marker is rendered wherever it holds on the leg. +async function fitToAirports(page: Page, icaos: string[]): Promise { + const fc = (await (await page.request.get('/api/airports')).json()) as { + features: Array<{ properties: { icao: string; lat: number; lon: number } }>; + }; + const points = icaos.map((icao) => { + const f = fc.features.find((x) => x.properties.icao === icao); + if (!f) throw new Error(`airport ${icao} not found in /api/airports`); + return [f.properties.lon, f.properties.lat] as [number, number]; + }); + await page.evaluate((pts: [number, number][]) => { + const m = (window as any).__flightMap; + if (!m) throw new Error('map not ready'); + const lons = pts.map((p) => p[0]); + const lats = pts.map((p) => p[1]); + // duration 0 → instant camera jump, no animation to wait out. + m.fitBounds( + [ + [Math.min(...lons), Math.min(...lats)], + [Math.max(...lons), Math.max(...lats)], + ], + { padding: 40, duration: 0 }, + ); + }, points); +} + +// Click the RENDERED marker position (the map draws from its own ~1 s poll, so a fresh /api/state +// fetch can be stale by tens of px) and wait for the popup. `flyingOnly` skips grounded frames — +// used right after dispatch, when the last rendered frame may predate it. +async function clickPlane(page: Page, flyingOnly: boolean): Promise { + const pt = await page.evaluate(async (onlyFlying: boolean) => { + const m = (window as any).__flightMap; + if (!m) return null; + const deadline = Date.now() + 10_000; + for (;;) { + let feats: Array<{ properties?: Record; geometry?: { coordinates: number[] } }> = []; + try { + feats = m.queryRenderedFeatures(undefined, { layers: ['planes-flying', 'planes-grounded'] }); + } catch { + // map not ready yet — keep polling + } + const f = feats.find((x) => x.properties?.id === STARTER && (!onlyFlying || x.properties?.flying === true)); + if (f && f.geometry) { + const projected = m.project(f.geometry.coordinates); + const rect = m.getContainer().getBoundingClientRect(); + return { x: rect.left + projected.x, y: rect.top + projected.y }; + } + if (Date.now() >= deadline) return null; + await new Promise((r) => setTimeout(r, 100)); + } + }, flyingOnly); + expect(pt, 'plane marker not rendered').not.toBeNull(); + + // Popups are created per click and never auto-close — remove any stale ones so the testids below + // stay unique. + await page.evaluate(() => document.querySelectorAll('.maplibregl-popup').forEach((el) => el.remove())); + await page.mouse.click(pt!.x, pt!.y); + const popup = page.getByTestId('plane-popup'); + await expect(popup).toBeVisible({ timeout: 5_000 }); +} + +test('marker shows the takeoff and landing phases on a dispatched leg', async ({ page }) => { + await page.goto('/'); + await waitForAirportCount(page); + + // Self-heal to the onboarding state (starter grounded at HUB) regardless of what prior specs left. + await setScale(page, FAST); + await ensureGroundedAt(page, HUB); + + // The exact phase boundaries for this leg — same composition the engine uses (v1.12.0). + const fc = (await (await page.request.get('/api/airports')).json()) as { + features: Array<{ properties: { icao: string; lat: number; lon: number } }>; + }; + const hub = fc.features.find((x) => x.properties.icao === HUB)!; + const dest = fc.features.find((x) => x.properties.icao === DEST)!; + const km = haversineKm(hub.properties.lat, hub.properties.lon, dest.properties.lat, dest.properties.lon); + const ph = legPhases(km, PLANE_TYPES[STARTER_MODEL].speedKmh, HUB, DEST); + + // --- Takeoff: dispatch at OBS and read the popup while the plane holds at the origin gate. --- + await setScale(page, OBS); + const fly = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: DEST } }); + expect(fly.status()).toBe(200); + const T_dep = ((await fly.json()) as StateBody).planes.find((x) => x.id === STARTER)!.departureSimTime!; + + await fitToAirports(page, [HUB, DEST]); + await clickPlane(page, true); // flyingOnly: the last rendered frame may predate the dispatch + await expect(page.getByTestId('plane-status')).toHaveText('flying'); + await expect(page.getByTestId('plane-phase')).toContainText('taking off'); + + // --- Landing: fast-forward through the cruise to just before the landing window, then drop back + // to OBS so the hold at the destination gate is observable. --- + const landingStart = T_dep + ph.takeoffMs + ph.cruiseMs; + await expect.poll(async () => { + const body = await getState(page); + const p = body.planes.find((x) => x.id === STARTER)!; + if (p.status !== 'flying') throw new Error('the clock jumped past the landing window — flake, retry'); + return body.simState.clockMs >= landingStart - 60_000; // one game-minute before the hold begins + }, { timeout: 120_000, message: 'fast-forwarding through the cruise' }).toBe(true); + + await setScale(page, OBS); + // Enter the window at a point guaranteed to be inside it (map frames lag ≤ ~1 s = 30 game-s) and + // before arrival (landingMs ≥ BASE_PHASE_MS > the offset below). + const landingTarget = landingStart + Math.min(ph.landingMs / 2, 1_000); + await expect.poll(async () => { + const body = await getState(page); + if (body.planes.find((x) => x.id === STARTER)!.status !== 'flying') throw new Error('the clock jumped past the landing window — flake, retry'); + return body.simState.clockMs >= landingTarget; + }, { timeout: 120_000, message: 'waiting for the landing hold to begin' }).toBe(true); + + await clickPlane(page, true); + await expect(page.getByTestId('plane-status')).toHaveText('flying'); + await expect(page.getByTestId('plane-phase')).toContainText('landing'); + + // --- Arrival: fast-forward until the engine flips it to available at DEST. --- + await setScale(page, FAST); + await expect.poll(async () => { + const p = (await getState(page)).planes.find((x) => x.id === STARTER)!; + return p.status === 'available' && p.locationIcao === DEST; + }, { timeout: 60_000, message: 'waiting for the landing to complete' }).toBe(true); + + // Leave the onboarding state for the other specs: starter grounded back at HUB. + await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } }); + await expect.poll(async () => { + const p = (await getState(page)).planes.find((x) => x.id === STARTER)!; + return p.status === 'available' && p.locationIcao === HUB; + }, { timeout: 60_000, message: 'waiting for the relocation home to finish' }).toBe(true); +}); diff --git a/lib/game/seed.test.ts b/lib/game/seed.test.ts index 342c90d..4dd7d65 100644 --- a/lib/game/seed.test.ts +++ b/lib/game/seed.test.ts @@ -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++) { diff --git a/lib/game/seed.ts b/lib/game/seed.ts index c85711c..9c692c9 100644 --- a/lib/game/seed.ts +++ b/lib/game/seed.ts @@ -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. diff --git a/lib/game/state.ts b/lib/game/state.ts index f85acda..2f8bdc1 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -192,7 +192,9 @@ export async function getStateSnapshot(playerId: string): Promise 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. diff --git a/lib/mapData.ts b/lib/mapData.ts index 5423cef..18848a0 100644 --- a/lib/mapData.ts +++ b/lib/mapData.ts @@ -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 = { 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, }, }; diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index 6b197d8..f21a4b2 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -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; -} + diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index cb53346..c881536 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -228,7 +228,8 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise { 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); }); }); diff --git a/lib/sim/movement.ts b/lib/sim/movement.ts index 838e0df..ea6f4c4 100644 --- a/lib/sim/movement.ts +++ b/lib/sim/movement.ts @@ -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] diff --git a/package.json b/package.json index 175e199..49a8fdb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "1.11.0", + "version": "1.12.0", "private": true, "scripts": { "dev": "next dev -p 3000",