Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d53ad41c09 | ||
|
|
82061ed869 | ||
|
|
2a4b341a9c | ||
|
|
cd2c766895 |
@@ -1,5 +1,14 @@
|
||||
# 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.
|
||||
- **Known limitation (follow-up).** Plane MARKERS still use the snapshot's wrapped lat/lon (`lib/game/state.ts` → `positionAt`), so a plane mid-crossing teleports 360° on its marker at the instant it passes ±180 while its route line stays continuous. Fixing needs airport coordinates inside `planesGeoJson` (signature change + `Map.tsx` call site) — deliberately out of scope for this fix.
|
||||
|
||||
## v0.52.0 — 2026-09-22 10:45
|
||||
- **Fixed world clock, no player time controls (ISS-117).** The pause button and the 1×–1000× scale slider are gone from the left pane (`components/LeftPane.tsx`) — the world now always runs at a fixed `WORLD_TIME_SCALE = 24` (new client-safe `lib/worldClock.ts`, re-exported by the engine). The server advances time in a background tick: new `instrumentation.ts` starts `startBackgroundTick()` (1 s interval, singleton-guarded) under `NEXT_RUNTIME === 'nodejs'`, so the sim moves even with no open browser. **Idle-pause:** while no plane is flying or in repair the clock is frozen exactly — pure `shouldAdvanceTime(planes)` in `lib/sim/engine.ts` gates every tick (no catch-up jump on wake; `lastRealTickMs` still updates). Fresh worlds start at year 2000 (`STARTING_CLOCK_MS = Date.UTC(2000, 0, 1)` in `lib/game/seed.ts`; existing saves untouched — no migration). The client clock (`lib/useSimClock.ts`) extrapolates between polls at the fixed rate and freezes while idle, mirroring the server.
|
||||
- **API.** `POST /api/sim` deleted (route + OpenAPI entry; spec now lists twelve endpoints) and its controls removed from the engine (`applyTimeControls`, `clampTimeScale`, MIN/MAX_TIME_SCALE). The `/api/state` snapshot's `simState` is now `{ clockMs }` only — no `timeScale`/`paused`. New test-only hook `POST /api/test/set-timescale` behind the existing `FLIGHT_TEST_HOOKS=1` gate (sibling of `/api/test/set-health`, same 404-when-disabled convention): body `{ timeScale: finite number in [1, 100_000] }` else 400; ticks first under the sim lock and returns `{ clockMs, timeScale }`. The e2e suite's per-spec `setScale` helpers now call it (and verify from the response); every spec restores the world rate in `afterEach`.
|
||||
|
||||
@@ -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<Pkg> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
+62
-2
@@ -171,6 +171,16 @@ async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
|
||||
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
|
||||
return [f.properties.lon, f.properties.lat] as [number, number];
|
||||
});
|
||||
// ISS-120 (v0.53.0): raw min/max bounds for a pair straddling ±180 span the WRONG side of the globe
|
||||
// (−175…+174 covers the Atlantic, not the Pacific) — unwrap each point relative to its predecessor so
|
||||
// the bounds follow the short arc across the date line. Non-crossing pairs are unchanged.
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
let lon = points[i]![0];
|
||||
const prev = points[i - 1]![0];
|
||||
while (lon - prev > 180) lon -= 360;
|
||||
while (prev - lon > 180) lon += 360;
|
||||
points[i]![0] = lon;
|
||||
}
|
||||
await page.evaluate((pts: [number, number][]) => {
|
||||
const m = (window as any).__flightMap;
|
||||
if (!m) throw new Error('map not ready');
|
||||
@@ -217,8 +227,13 @@ const HOVER_ROUTE_EPS_DEG = 0.05; // ~5 km: above worst-case quantization at any
|
||||
|
||||
async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): Promise<void> {
|
||||
const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]);
|
||||
const near = (a: [number, number], b: [number, number]) =>
|
||||
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
|
||||
// ISS-120 (v0.53.0): antimeridian-crossing traces end UNWRAPPED — the dest-side endpoint is ≡ dest
|
||||
// (mod 360) but sits at e.g. +184.9 instead of −175.1, so compare longitudes on the circle. For any
|
||||
// two lons within ±180 of each other this equals the plain |Δlon| — existing routes are unaffected.
|
||||
const near = (a: [number, number], b: [number, number]) => {
|
||||
const dLon = Math.abs(a[0] - b[0]);
|
||||
return Math.min(dLon, 360 - dLon) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
|
||||
};
|
||||
await expect.poll(async () => {
|
||||
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
|
||||
// Features are plain objects with in-page-evaluated geometry (see renderedFeatures). The filter
|
||||
@@ -344,6 +359,51 @@ test('hovering an open package highlights its endpoint airports', async ({ page
|
||||
}).toBe(0);
|
||||
});
|
||||
|
||||
// ISS-120 (v0.53.0): Auckland → Fua'amotu (Tonga) — both in the world dataset, and their short great
|
||||
// circle crosses ±180 EASTWARD. Before the fix the arc's wrapped samples jumped +179.x → −179.x and
|
||||
// MapLibre connected them with a chord across the whole map; now the trace is one continuous line.
|
||||
test('hovering an antimeridian-crossing package draws one continuous trace, no chord across the map', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
const pkg = await createPackage(page, 'NZAA', 'NFTF');
|
||||
|
||||
await page.goto('/');
|
||||
await waitForAirportCount(page);
|
||||
|
||||
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport.
|
||||
await fitToAirports(page, ['NZAA', 'NFTF']);
|
||||
|
||||
await row.hover();
|
||||
await expectHoverRoute(page, 'NZAA', 'NFTF'); // endpoint check is mod-360 aware now (ISS-120)
|
||||
|
||||
// The regression itself: no rendered segment may contain a ±180 longitude jump — that was the chord.
|
||||
await expect.poll(async () => {
|
||||
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
|
||||
const segs = feats.filter(
|
||||
(f): f is { geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
|
||||
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
|
||||
);
|
||||
if (!segs.length) return false; // none yet — keep polling
|
||||
for (const s of segs) {
|
||||
const c = s.geometry.coordinates;
|
||||
for (let i = 1; i < c.length; i++) {
|
||||
if (Math.abs(c[i]![0] - c[i - 1]![0]) >= 180) return false; // a wrapped jump — the chord bug is back
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}, { timeout: 15_000, message: 'waiting for the hover-route trace to be continuous across the antimeridian' }).toBe(true);
|
||||
|
||||
await unhover(page);
|
||||
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
|
||||
timeout: 15_000,
|
||||
message: 'waiting for the hover route trace to clear after mouseleave',
|
||||
}).toBe(0);
|
||||
});
|
||||
|
||||
test('hovering an on-board cargo row draws its trace and keeps the plane halo', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||||
|
||||
@@ -97,6 +97,11 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||
},
|
||||
};
|
||||
|
||||
// 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';
|
||||
|
||||
|
||||
+84
-1
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
+18
-3
@@ -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);
|
||||
|
||||
@@ -8,8 +8,26 @@ const AIRPORTS = new Map<string, AirportCoord>([
|
||||
['EDDV', { lat: 52.46, lon: 9.68 }],
|
||||
['EHAM', { lat: 52.31, lon: 4.76 }],
|
||||
['LEMD', { lat: 40.47, lon: -3.56 }], // Madrid — ~1400 km from EDDF for the long-route test
|
||||
// ISS-120 (v0.53.0): real dataset pairs whose great circle crosses the antimeridian (±180).
|
||||
['NZAA', { lat: -37.01199, lon: 174.786331 }], // Auckland — east of +170
|
||||
['NFTF', { lat: -21.241438, lon: -175.149164 }], // Fua'amotu (Tonga) — west of −175; NZAA→NFTF crosses ±180 eastward
|
||||
['NZCH', { lat: -43.489029, lon: 172.532065 }], // Christchurch — for the queue-chain joint test
|
||||
]);
|
||||
|
||||
// ISS-120: longitude difference on the circle — two lons are "equal" when they differ by a multiple of 360.
|
||||
const modDiff = (a: number, b: number): number => {
|
||||
const d = Math.abs(a - b) % 360;
|
||||
return Math.min(d, 360 - d);
|
||||
};
|
||||
|
||||
// ISS-120: every consecutive pair of samples must be < 180° apart in longitude — the property that
|
||||
// keeps MapLibre from drawing a chord across the map.
|
||||
function expectContinuousLons(coords: [number, number][]): void {
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
expect(Math.abs(coords[i]![0] - coords[i - 1]![0]), `jump between samples ${i - 1} and ${i}`).toBeLessThan(180);
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
||||
return {
|
||||
account: { cash: 0, reputation: 0 },
|
||||
@@ -320,3 +338,61 @@ describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
|
||||
expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeLessThan(5); // stays close to the chord
|
||||
});
|
||||
});
|
||||
|
||||
describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => {
|
||||
const from = AIRPORTS.get('NZAA')!; // Auckland, lon +174.8
|
||||
const to = AIRPORTS.get('NFTF')!; // Fua'amotu, lon −175.1 — the short arc crosses ±180 EASTWARD
|
||||
|
||||
it('eastward crossing: continuous lons (|Δlon| < 180), endpoints exact mod 360, length ≈ haversine', () => {
|
||||
const arc = greatCircleArc(from, to);
|
||||
expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1);
|
||||
expectContinuousLons(arc); // no wrapped ±180 jump anywhere — the chord bug is gone
|
||||
expect(arc[0]).toEqual([from.lon, from.lat]); // first sample = raw origin, exact
|
||||
const last = arc[arc.length - 1]!;
|
||||
expect(modDiff(last[0], to.lon)).toBeLessThan(1e-9); // last ≡ dest (mod 360) — here unwrapped to +184.8
|
||||
expect(Math.abs(last[1] - to.lat)).toBeLessThan(1e-9);
|
||||
expect(arc.some((c) => c[0] > 180)).toBe(true); // the crossing samples sit on the east world copy
|
||||
|
||||
const dist = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
let sum = 0;
|
||||
for (let i = 1; i < arc.length; i++) sum += haversineKm(arc[i - 1]![1], arc[i - 1]![0], arc[i]![1], arc[i]![0]);
|
||||
expect(Math.abs(sum - dist) / dist).toBeLessThan(0.01); // sampled chord length ≈ true great-circle distance
|
||||
});
|
||||
|
||||
it('westward crossing (reversed pair): continuous lons below −180, endpoints exact mod 360', () => {
|
||||
const arc = greatCircleArc(to, from);
|
||||
expectContinuousLons(arc);
|
||||
expect(arc[0]).toEqual([to.lon, to.lat]); // raw origin, exact
|
||||
const last = arc[arc.length - 1]!;
|
||||
expect(modDiff(last[0], from.lon)).toBeLessThan(1e-9); // ≡ +174.8 (mod 360) — here unwrapped to −185.2
|
||||
expect(Math.abs(last[1] - from.lat)).toBeLessThan(1e-9);
|
||||
expect(arc.some((c) => c[0] < -180)).toBe(true); // the crossing samples sit on the west world copy
|
||||
});
|
||||
|
||||
it('non-crossing routes are untouched: lons stay in [−180, 180]', () => {
|
||||
const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('LEMD')!);
|
||||
expectContinuousLons(arc);
|
||||
for (const c of arc) expect(c[0]).toBeGreaterThanOrEqual(-180);
|
||||
for (const c of arc) expect(c[0]).toBeLessThanOrEqual(180);
|
||||
});
|
||||
|
||||
it('queuedRoutesGeoJson: a chain crossing the antimeridian stays continuous across the joint', () => {
|
||||
// Grounded at NZAA with queue [NFTF, NZCH]: leg 1 crosses ±180 eastward (ends unwrapped at +184.8),
|
||||
// leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°.
|
||||
const fc = queuedRoutesGeoJson(
|
||||
[
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
expect(fc.features).toHaveLength(1);
|
||||
const coords = fc.features[0]!.geometry.coordinates;
|
||||
expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1); // two arcs, joint dropped once
|
||||
expectContinuousLons(coords); // the whole chain — including across the NFTF joint
|
||||
expect(coords[0]).toEqual([from.lon, from.lat]); // starts at the plane's airport, raw
|
||||
const last = coords[coords.length - 1]!;
|
||||
const nzch = AIRPORTS.get('NZCH')!;
|
||||
expect(modDiff(last[0], nzch.lon)).toBeLessThan(1e-9); // ends ≡ NZCH (mod 360)
|
||||
expect(Math.abs(last[1] - nzch.lat)).toBeLessThan(1e-9);
|
||||
});
|
||||
});
|
||||
|
||||
+24
-3
@@ -74,16 +74,34 @@ export interface AirportCoord {
|
||||
// curved path the plane actually flies (same slerp as lib/sim/movement.ts).
|
||||
export const ROUTE_ARC_SEGMENTS = 24;
|
||||
|
||||
// ISS-120 (v0.53.0): positionAt wraps each sample's longitude into [−180, 180], so an arc that
|
||||
// crosses the antimeridian jumps +179.x → −179.x between consecutive samples — and MapLibre draws
|
||||
// LineStrings as-is (no ±180 splitting), rendering a chord across the whole map. Unwrap the sample
|
||||
// sequence into continuous longitudes: keep the first sample's raw lon, then shift each subsequent
|
||||
// one by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180. One line stays one line;
|
||||
// out-of-range values render on MapLibre's adjacent world copies (renderWorldCopies is default-on).
|
||||
function unwrapLons(coords: [number, number][]): [number, number][] {
|
||||
let prev = coords[0]![0];
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
let lon = coords[i]![0];
|
||||
while (lon - prev > 180) lon -= 360;
|
||||
while (prev - lon > 180) lon += 360;
|
||||
coords[i]![0] = lon;
|
||||
prev = lon;
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
// Great-circle arc between two airports as [lon, lat] coordinates: positionAt at
|
||||
// t = i/SEGMENTS for i in 0..SEGMENTS. Pure — endpoints are exactly the airport
|
||||
// coords (positionAt returns them verbatim at t=0 and t=1).
|
||||
// t = i/SEGMENTS for i in 0..SEGMENTS. Pure — the first sample is exactly `from` and the last is
|
||||
// ≡ `to` (mod 360) (positionAt returns them verbatim at t=0 and t=1, unwrapped relative to from).
|
||||
export function greatCircleArc(from: AirportCoord, to: AirportCoord): [number, number][] {
|
||||
const coords: [number, number][] = [];
|
||||
for (let i = 0; i <= ROUTE_ARC_SEGMENTS; i++) {
|
||||
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, i / ROUTE_ARC_SEGMENTS);
|
||||
coords.push([pos.lon, pos.lat]);
|
||||
}
|
||||
return coords;
|
||||
return unwrapLons(coords);
|
||||
}
|
||||
|
||||
// ISS-022/025/027 (v0.22.0): the hover route trace — one great-circle LineString between two
|
||||
@@ -195,6 +213,9 @@ export function queuedRoutesGeoJson(
|
||||
const arc = greatCircleArc(pts[i]!, pts[i + 1]!);
|
||||
coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point
|
||||
}
|
||||
// ISS-120: each leg's arc is unwrapped 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) — re-unify.
|
||||
unwrapLons(coords);
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'LineString', coordinates: coords },
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.52.0",
|
||||
"version": "0.54.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user