diff --git a/CHANGELOG.md b/CHANGELOG.md index 26a1be4..0b97f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v0.54.0 — 2026-09-22 15:20 +- **Long priority jobs are now always finishable (ISS-119).** `generatePackage` in `lib/game/seed.ts` drew a flat 6–72 h expiry window (priority ×0.5 → 3–36 h) independent of leg distance, so some jobs' deadlines were shorter than the flight itself and could never be completed (legs go up to MAX_JOB_LEG_KM = 9000 km; the fastest catalog plane is ~920 km/h ≈ 10 h for a max leg). The drawn window is now clamped UP after special scaling: deadline ≥ now + `travelDurationMs(legKm, FASTEST_CATALOG_SPEED_KMH)` + `EXPIRY_BUFFER_MS` (flat 1 h), reusing the existing `travelDurationMs` from `lib/sim/movement.ts`. New constant `FASTEST_CATALOG_SPEED_KMH` in `lib/game/catalog.ts` — data-driven max of `PLANE_TYPES` speeds, so a job must be finishable with the best available aircraft (NOT the starter's 510 km/h, which would wreck long-job economics); adding a faster plane tightens the floor automatically. `EXPIRY_BUFFER_MS` lives next to the deadline tunables in seed.ts. The window is a single uniform draw, so clamping up 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 `deadlineOffsetMs` skips the floor, same as it skips special scaling (the e2e expiry hook keeps working). +- **Tests.** Unit +5 in `lib/game/seed.test.ts` (new "expiry floor" block): pins the constants (fastest = max of catalog speeds and > starter speed; buffer exactly 1 h); a seeded sweep over 200 unforced packages on the real dataset asserting deadline ≥ now + travel time at max speed + buffer for every one (specials roll naturally, ~15% priority); the farthest within-cap pair in the real dataset (~9000 km) forced with both `priority` and null across 50 seeds each — invariant holds everywhere AND at least one package per special lands exactly on the floor (deterministic for fixed seeds, proving the clamp engages rather than holding vacuously); short legs (EDDF→EHAM ≈ 370 km → floor ≈ 1.4 h < the priority minimum window) pinned byte-identical to a replay of the pre-v0.54.0 draw order (no economy regression); forced deadline offsets still skip the floor. E2E +1 in `e2e/expiry.spec.ts`: the EXISTING `POST /api/test/create-package` hook forces EDDB→KSMF (~9000 km, at MAX_JOB_LEG_KM) with `special: 'priority'`; 10 packages are created and every one asserted to expire ≥ travel time at max speed + buffer after creation (and ≤ the 36 h priority ceiling), then all ignored for cleanup — no new server hooks. + ## v0.53.0 — 2026-09-22 14:47 - **Antimeridian routes no longer chord across the map (ISS-120).** Routes whose great circle crosses ±180° (e.g. Auckland → Fua'amotu, Tonga) rendered as a line spanning the whole map: `greatCircleArc` sampled `positionAt`, which wraps every sample's longitude into [−180, 180], so consecutive samples jumped +179.x → −179.x and MapLibre — which draws LineStrings as-is with no ±180 splitting — connected them across the globe. New module-private `unwrapLons` in `lib/mapData.ts` re-expresses each arc's sample sequence in continuous longitudes (first sample keeps its raw lon; every later one is shifted by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180), applied inside `greatCircleArc` so all four line builders (`routesGeoJson`, `queuedRoutesGeoJson`, `hoverRouteGeoJson`, `hoverTraceGeoJson`) are fixed at once. One line stays one LineString — no splitting; out-of-range values render on MapLibre's adjacent world copies (`renderWorldCopies` is default-on). `queuedRoutesGeoJson` gets a second global pass after concatenation: each leg's arc unwraps relative to its OWN start airport, so at a joint the sequence can jump ±360 (leg 1 ends at dest+360k, leg 2 restarts at the raw dest) and the re-unwrap keeps multi-leg chains continuous. Non-crossing routes are byte-identical (no |Δlon| > 180 anywhere → the pass is a no-op). - **Tests.** Unit +4 in `lib/mapData.test.ts` (new "antimeridian crossing" block, real dataset pairs NZAA/NFTF/NZCH): eastward and westward crossings stay continuous (every consecutive |Δlon| < 180), endpoints exact mod 360 with the first sample still the raw origin, sampled chord length ≈ haversine within 1%, a non-crossing route's lons stay in [−180, 180], and a grounded plane's two-leg queue chain crossing ±180 stays continuous across the joint (the regression `queuedRoutesGeoJson`'s global pass guards). E2E +1 in `e2e/hover.spec.ts`: hovering an antimeridian-crossing package (hook-created NZAA → NFTF) draws one continuous trace — no rendered segment may contain a ±180 longitude jump; `expectHoverRoute`'s endpoint check now compares longitudes on the circle (mod 360, identical for any pair within ±180 so existing routes are unaffected) and `fitToAirports` unwraps crossing pairs so their bounds follow the short arc across the date line instead of spanning the wrong side of the globe. diff --git a/e2e/expiry.spec.ts b/e2e/expiry.spec.ts new file mode 100644 index 0000000..b6f4a33 --- /dev/null +++ b/e2e/expiry.spec.ts @@ -0,0 +1,72 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +// Real helpers (v0.54.0) — the expiry floor must match lib/ exactly; Playwright resolves the repo's +// tsconfig paths, so importing beats mirroring a third copy of the constants/formulas. +import { haversineKm } from '../lib/geo'; +import { travelDurationMs } from '../lib/sim/movement'; +import { FASTEST_CATALOG_SPEED_KMH } from '../lib/game/catalog'; +import { EXPIRY_BUFFER_MS } from '../lib/game/seed'; + +// v0.54.0 (ISS-119) — long priority jobs must be finishable: a package's deadline is clamped up to +// now + travel time at the fastest catalog speed + buffer, so no generated job can expire before even +// the best plane could land. The EXISTING POST /api/test/create-package hook (FLIGHT_TEST_HOOKS=1) +// forces a far-apart endpoint pair (~9000 km, at MAX_JOB_LEG_KM); this spec asserts the invariant on +// every returned package — no new server hooks needed. No scale changes, plane interaction, polling or +// map queries are involved (the assertion is self-contained in the hook response), so none of those +// conventions apply here. + +const FROM = 'EDDB'; // Berlin Brandenburg +const TO = 'KSMF'; // Sacramento International — ~9000 km from EDDB, at MAX_JOB_LEG_KM +const COUNT = 10; // priority packages created and checked + +interface Pkg { + id: string; + originIcao: string; + destIcao: string; + createdSimTime: number; + deadlineSimTime: number; +} + +async function createPriorityPackage(page: Page, from: string, to: string): Promise { + const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, special: 'priority' } }); + expect(res.status()).toBe(201); + return (await res.json()) as Pkg; +} + +test('long-leg priority packages always expire after travel time at max speed + buffer', async ({ page }) => { + // The pair's real coordinates from the live dataset — the floor is computed on the same km the server uses. + const res = await page.request.get('/api/airports'); + expect(res.ok()).toBeTruthy(); + const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> }; + const from = fc.features.find((f) => f.properties.icao === FROM)?.properties; + const to = fc.features.find((f) => f.properties.icao === TO)?.properties; + expect(from, `${FROM} missing from the airport dataset`).toBeTruthy(); + expect(to, `${TO} missing from the airport dataset`).toBeTruthy(); + if (!from || !to) return; // guarded above — keeps the math below total + + const km = haversineKm(from.lat, from.lon, to.lat, to.lon); + expect(km).toBeGreaterThan(7_000); // guard: the pair must still be a long leg or this test is vacuous + const floorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS; + + const created: Pkg[] = []; + try { + for (let i = 0; i < COUNT; i++) { + const pkg = await createPriorityPackage(page, FROM, TO); + expect(pkg.originIcao).toBe(FROM); + expect(pkg.destIcao).toBe(TO); + // The invariant (ISS-119): the deadline must leave at least travel time + buffer after creation. + // deadlineSimTime = round(now + offset) with offset ≥ floorMs, so this holds exactly. + const offsetMs = pkg.deadlineSimTime - pkg.createdSimTime; + expect(offsetMs).toBeGreaterThanOrEqual(Math.floor(floorMs)); + // And the clamp must not blow past the priority ceiling (36 h) — the deadline stays sane. + expect(offsetMs).toBeLessThanOrEqual(36 * 3_600_000); + created.push(pkg); + } + } finally { + // Cleanup: dismiss every package this test created so it leaves no trace on the shared board. + for (const pkg of created) { + const r = await page.request.post(`/api/packages/${pkg.id}/ignore`); + expect(r.ok(), `ignoring ${pkg.id}`).toBeTruthy(); + } + } +}); diff --git a/lib/game/catalog.ts b/lib/game/catalog.ts index 5ee5753..77101f0 100644 --- a/lib/game/catalog.ts +++ b/lib/game/catalog.ts @@ -97,6 +97,11 @@ export const PLANE_TYPES: Record = { }, }; +// v0.54.0 (ISS-119): fastest cruise speed in the catalog — a generated job must be finishable by +// the best available aircraft, so seed's expiry floor references this, not the starter's 510 km/h +// (that would wreck long-job economics). Data-driven: adding a faster plane tightens the floor. +export const FASTEST_CATALOG_SPEED_KMH = Math.max(...Object.values(PLANE_TYPES).map((t) => t.speedKmh)); + // The plane the player starts with. export const STARTER_MODEL = 'atr72'; diff --git a/lib/game/seed.test.ts b/lib/game/seed.test.ts index 8cfa93c..648cab1 100644 --- a/lib/game/seed.test.ts +++ b/lib/game/seed.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest'; import { WORLD_TIME_SCALE } from '@/lib/worldClock'; import { haversineKm } from '@/lib/geo'; -import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog'; +import { travelDurationMs } from '@/lib/sim/movement'; +import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog'; import { SPECIALS } from './specials'; import { BASE_REWARD, buildSeed, + EXPIRY_BUFFER_MS, JOB_BONUS_PER_REP, MAX_JOB_BONUS, PACKAGE_COUNT_MAX, @@ -437,3 +439,84 @@ describe('distance cap (v0.44.0, ISS-079)', () => { } }); }); + +describe('expiry floor (v0.54.0, ISS-119)', () => { + // Pre-v0.54.0 deadline for a forced-endpoint + forced-special package: replays the fixed draw order + // (cargoType, cargoCount, weight jitter, window — forced inputs consume no draws) so short-leg + // packages can be pinned byte-identical to the old behavior. + const oldDeadlineFor = (seed: number, scale: number): number => { + const rng = mulberry32(seed); + rng(); // cargoType + rng(); // cargoCount + rng(); // weight jitter + const w = rng(); // deadline window + return Math.round(NOW + (6 + w * (72 - 6)) * 3_600_000 * scale); + }; + + it('pins the constants: fastest catalog speed (not the starter) and a flat 1 h buffer', () => { + expect(FASTEST_CATALOG_SPEED_KMH).toBe(Math.max(...Object.values(PLANE_TYPES).map((t) => t.speedKmh))); + expect(FASTEST_CATALOG_SPEED_KMH).toBeGreaterThan(PLANE_TYPES[STARTER_MODEL].speedKmh); + expect(EXPIRY_BUFFER_MS).toBe(3_600_000); + }); + + it('every generated package on the real dataset satisfies deadline ≥ now + travel time at max speed + buffer', async () => { + const { loadAirportsGeoJson } = await import('@/lib/airports'); + const fc = await loadAirportsGeoJson(); + const airports: SeedAirport[] = fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })); + for (let seed = 1; seed <= 200; seed++) { + 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; + expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs)); + } + }); + + it('long legs near MAX_JOB_LEG_KM are clamped up to the floor for every special (real dataset)', async () => { + const { loadAirportsGeoJson } = await import('@/lib/airports'); + const fc = await loadAirportsGeoJson(); + const airports: SeedAirport[] = fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })); + // Farthest pair within the cap — a leg as long as any generated job can be. + let bestI = -1; + let bestJ = -1; + let bestKm = 0; + for (let i = 0; i < airports.length; i++) { + for (let j = i + 1; j < airports.length; j++) { + const km = haversineKm(airports[i].lat, airports[i].lon, airports[j].lat, airports[j].lon); + if (km <= MAX_JOB_LEG_KM && km > bestKm) { + bestI = i; + bestJ = j; + bestKm = km; + } + } + } + expect(bestKm).toBeGreaterThan(8_000); // the dataset really spans near-cap legs + + const floorMs = travelDurationMs(bestKm, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS; + for (const special of ['priority', null] as const) { + let clamped = 0; + for (let seed = 1; seed <= 50; seed++) { + const c = generatePackage({ id: `c-long-${special ?? 'plain'}-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW, originIcao: airports[bestI].icao, destIcao: airports[bestJ].icao, special }); + expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs)); // the invariant + if (c.deadlineSimTime === Math.round(NOW + floorMs)) clamped++; // landed exactly on the floor → the clamp fired + } + // Deterministic for these fixed seeds — proves the clamp actually engages, not just vacuously holds. + expect(clamped).toBeGreaterThan(0); + } + }); + + it('short legs are byte-identical to the pre-v0.54.0 draw (no economy regression)', () => { + // EDDF→EHAM ≈ 370 km → floor ≈ 1.4 h — below even the priority minimum window (3 h), so the clamp never fires here. + for (const seed of [1, 2, 3, 4, 5]) { + const plain = generatePackage({ id: 'c-short', airports: AIRPORTS, rng: mulberry32(seed), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: null }); + expect(plain.deadlineSimTime).toBe(oldDeadlineFor(seed, 1)); + const prio = generatePackage({ id: 'c-short-p', airports: AIRPORTS, rng: mulberry32(seed), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: 'priority' }); + expect(prio.deadlineSimTime).toBe(oldDeadlineFor(seed, SPECIALS.priority.deadlineScale)); + } + }); + + it('forced deadline offsets still skip the floor (e2e expiry hook)', () => { + const c = generatePackage({ id: 'c-floor-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: null, deadlineOffsetMs: 10_000 }); + expect(c.deadlineSimTime).toBe(NOW + 10_000); // the hook's near-term expiry is untouched by the floor + }); +}); diff --git a/lib/game/seed.ts b/lib/game/seed.ts index 0976e01..f73e07e 100644 --- a/lib/game/seed.ts +++ b/lib/game/seed.ts @@ -1,6 +1,7 @@ import { haversineKm } from '@/lib/geo'; +import { travelDurationMs } from '@/lib/sim/movement'; import { WORLD_TIME_SCALE } from '@/lib/worldClock'; -import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog'; +import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog'; import { pickSpecial, SPECIALS } from './specials'; // First-run seed. Everything here is a pure function of (airports, rng, nowMs) @@ -123,6 +124,10 @@ const LEG_CAP_MAX_ATTEMPTS = 25; const DEADLINE_MIN_HOURS = 6; const DEADLINE_MAX_HOURS = 72; +// v0.54.0 (ISS-119): flat slack added to travel time in the expiry floor — a deadline must leave at +// least this much after the fastest plane lands, so dispatch + landing are never instant. +export const EXPIRY_BUFFER_MS = 3_600_000; // 1 h + const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const; // ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo @@ -157,6 +162,9 @@ export function rewardFor(km: number, weightKg: number): number { // v0.38.0 (ISS-072): reputation optionally scales the reward (×(1 + repRate)) at the same spot // as the special multiplier — a pure input, it consumes NO RNG draws and defaults to the // starting value so seed-time behavior is byte-identical. +// v0.54.0 (ISS-119): the drawn deadline window is clamped UP by an expiry floor (travel time at +// FASTEST_CATALOG_SPEED_KMH + EXPIRY_BUFFER_MS) after special scaling, so every package is +// finishable by the fastest catalog plane; short legs are byte-identical to before. export function generatePackage(input: { id: string; airports: SeedAirport[]; @@ -230,8 +238,15 @@ export function generatePackage(input: { // forced value (including explicit null) skips it, same pattern as the other overrides. const special = input.special !== undefined ? input.special : pickSpecial(rng); const mods = special !== null ? SPECIALS[special] : undefined; - // Forced deadlines skip the special scaling — consistent with overrides skipping draws. - const deadlineMs = input.deadlineOffsetMs ?? drawnWindowMs * (mods?.deadlineScale ?? 1); + // v0.54.0 (ISS-119): expiry floor — every package must be finishable by the fastest catalog plane: + // deadline ≥ now + travel time at FASTEST_CATALOG_SPEED_KMH + EXPIRY_BUFFER_MS. The drawn window is a + // 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; + 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. const repBonus = 1 + repRate(input.reputation ?? STARTING_REPUTATION, JOB_BONUS_PER_REP, MAX_JOB_BONUS); diff --git a/package.json b/package.json index f49a7ee..11fd2f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.53.0", + "version": "0.54.0", "private": true, "scripts": { "dev": "next dev -p 3000",