Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d53ad41c09 | ||
|
|
82061ed869 | ||
|
|
2a4b341a9c |
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.53.0",
|
||||
"version": "0.54.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user