Clamp generated package expiry to travel time at max catalog speed + buffer so long jobs are finishable (ISS-119)
CI / build + unit + e2e (pull_request) Successful in 9m43s

This commit is contained in:
2026-09-22 15:21:50 +00:00
parent 2a4b341a9c
commit 82061ed869
6 changed files with 184 additions and 5 deletions
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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);