World airport dataset + distance cap on generated jobs (ISS-079, v0.44.0)
CI / build + unit + e2e (pull_request) Successful in 11m2s
CI / build + unit + e2e (pull_request) Successful in 11m2s
This commit is contained in:
@@ -11,12 +11,15 @@ import {
|
||||
PACKAGE_COUNT_MIN,
|
||||
generatePackage,
|
||||
HUB_ICAO,
|
||||
MAX_JOB_LEG_KM,
|
||||
pickDestWithinCap,
|
||||
mulberry32,
|
||||
REWARD_PER_KG,
|
||||
REWARD_PER_KM,
|
||||
rewardFor,
|
||||
STARTING_CASH,
|
||||
STARTING_REPUTATION,
|
||||
type Rng,
|
||||
type SeedAirport,
|
||||
} from './seed';
|
||||
|
||||
@@ -336,3 +339,94 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('distance cap (v0.44.0, ISS-079)', () => {
|
||||
// A and B are ~90 km apart; C is ~19000 km from both — every pair touching C exceeds the cap.
|
||||
const FAR: SeedAirport[] = [
|
||||
{ icao: 'AAAA', name: 'Close One', lat: 50, lon: 8 },
|
||||
{ icao: 'BBBB', name: 'Close Two', lat: 50.5, lon: 9 },
|
||||
{ icao: 'CCCC', name: 'Far Away', lat: -35, lon: 175 },
|
||||
];
|
||||
|
||||
// Scripted RNG: each value v yields index floor(v * FAR.length) — 0.x → AAAAA/BBBBB range.
|
||||
const scripted = (values: number[]): { rng: Rng; draws: () => number } => {
|
||||
let i = 0;
|
||||
return { rng: () => values[Math.min(i++, values.length - 1)], draws: () => i };
|
||||
};
|
||||
|
||||
it('re-picks an over-cap pair until the leg fits (both endpoints unforced)', () => {
|
||||
// Attempt 1: C→A (over cap) → rejected; attempt 2: A→B (~90 km) → accepted. Exactly 4 endpoint draws.
|
||||
const { rng, draws } = scripted([0.9, 0.1, 0.1, 0.4]);
|
||||
const c = generatePackage({ id: 'c-cap', airports: FAR, rng, nowMs: NOW, special: null });
|
||||
expect(c.originIcao).toBe('AAAA');
|
||||
expect(c.destIcao).toBe('BBBB');
|
||||
// 4 endpoint draws (origin + dest per attempt — the accepted pair stops the loop) + 4 fixed draws.
|
||||
expect(draws()).toBe(8);
|
||||
});
|
||||
|
||||
it('falls back to a within-cap pick of the last origin when attempts exhaust', () => {
|
||||
// 24 over-cap C→A attempts, then A→C (still over cap) ends the bounded loop; the fallback keeps
|
||||
// origin A and draws one index from its within-cap neighbours [B] → A→B. 25×2 + 1 = 51 draws.
|
||||
const values: number[] = [];
|
||||
for (let i = 0; i < 24; i++) values.push(0.9, 0.1); // C→A ×24
|
||||
values.push(0.1, 0.9); // A→C — attempt 25, still over cap
|
||||
values.push(0.5); // fallback draw → candidates[0] = B
|
||||
const { rng, draws } = scripted(values);
|
||||
const c = generatePackage({ id: 'c-cap-fb', airports: FAR, rng, nowMs: NOW, special: null });
|
||||
expect(c.originIcao).toBe('AAAA');
|
||||
expect(c.destIcao).toBe('BBBB');
|
||||
// 51 endpoint draws (25 attempts × 2 + the fallback draw) + 4 fixed draws.
|
||||
expect(draws()).toBe(55);
|
||||
});
|
||||
|
||||
it('keeps the last over-cap pair when the origin has no within-cap neighbour (no throw mid-tick)', () => {
|
||||
// All 25 attempts C→A; the fallback finds no airport within cap of C → -1, pair kept. No extra draw.
|
||||
const values: number[] = [];
|
||||
for (let i = 0; i < 25; i++) values.push(0.9, 0.1); // C→A ×25
|
||||
const { rng, draws } = scripted(values);
|
||||
const c = generatePackage({ id: 'c-cap-keep', airports: FAR, rng, nowMs: NOW, special: null });
|
||||
expect(c.originIcao).toBe('CCCC');
|
||||
expect(c.destIcao).toBe('AAAA');
|
||||
// 50 endpoint draws (25 attempts × 2) + no fallback draw when there are no candidates + 4 fixed.
|
||||
expect(draws()).toBe(54);
|
||||
});
|
||||
|
||||
it('leaves forced endpoints uncapped and consumes no extra draws (the UNNT→LPPD backstop relies on this)', () => {
|
||||
const c = generatePackage({ id: 'c-cap-forced', airports: FAR, rng: mulberry32(42), nowMs: NOW, originIcao: 'CCCC', destIcao: 'AAAA' });
|
||||
expect(c.originIcao).toBe('CCCC'); // ~19000 km — forced pairs skip the cap entirely
|
||||
expect(c.destIcao).toBe('AAAA');
|
||||
|
||||
const countDraws = (origin?: string, dest?: string): number => {
|
||||
let n = 0;
|
||||
generatePackage({ id: 'c-cap-n', airports: FAR, rng: () => { n += 1; return mulberry32(42)(); }, nowMs: NOW, originIcao: origin, destIcao: dest, special: null });
|
||||
return n;
|
||||
};
|
||||
expect(countDraws('CCCC')).toBe(5); // forced origin + random (uncapped) dest → 1 endpoint draw + 4 fixed
|
||||
expect(countDraws('CCCC', 'AAAA')).toBe(4); // both forced → no endpoint draws at all
|
||||
});
|
||||
|
||||
it('pickDestWithinCap picks uniformly from within-cap neighbours, excludes the origin, and returns -1 without a draw when none fit', () => {
|
||||
const near: SeedAirport[] = [FAR[0], FAR[1]]; // A + B only — both within cap of each other
|
||||
let calls = 0;
|
||||
const countingRng: Rng = () => { calls += 1; return 0.9; };
|
||||
expect(pickDestWithinCap(near, 0, countingRng)).toBe(1); // A's only neighbour is B
|
||||
expect(calls).toBe(1);
|
||||
|
||||
let farCalls = 0;
|
||||
const farRng: Rng = () => { farCalls += 1; return 0.9; };
|
||||
expect(pickDestWithinCap(FAR, 2, farRng)).toBe(-1); // C has no neighbour within the cap
|
||||
expect(farCalls).toBe(0); // -1 path consumes no draw
|
||||
});
|
||||
|
||||
it('every generated leg on the real dataset stays under the cap', 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 <= 50; seed++) {
|
||||
const c = generatePackage({ id: `c-real-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW });
|
||||
const o = airports.find((a) => a.icao === c.originIcao)!;
|
||||
const d = airports.find((a) => a.icao === c.destIcao)!;
|
||||
expect(haversineKm(o.lat, o.lon, d.lat, d.lon)).toBeLessThanOrEqual(MAX_JOB_LEG_KM);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +101,16 @@ export const JOB_BONUS_PER_REP = 0.01;
|
||||
// ponytail: balance-tunable — max job reward bonus (up to ×2 at rep ≥ 110).
|
||||
export const MAX_JOB_BONUS = 1.0;
|
||||
|
||||
// v0.44.0 (ISS-079): generated jobs are capped at this great-circle distance so every spawned
|
||||
// leg is flyable by at least one catalog plane even in worst-case headwind — the b777's full-tank
|
||||
// range is ~13045 km nominal and ~10871 km with the +20% wind factor (maxRangeKm ÷ 1.2), so 9000
|
||||
// leaves margin below it while still spanning most of a continent. ponytail: balance-tunable —
|
||||
// must stay ≤ maxRangeKm(b777) / 1.2 or some generated legs are permanently unflyable.
|
||||
export const MAX_JOB_LEG_KM = 9000;
|
||||
|
||||
// Bounded rejection attempts in generatePackage before the within-cap fallback kicks in.
|
||||
const LEG_CAP_MAX_ATTEMPTS = 25;
|
||||
|
||||
// Package deadlines: now + a plausible game-time window of 6–72 hours.
|
||||
const DEADLINE_MIN_HOURS = 6;
|
||||
const DEADLINE_MAX_HOURS = 72;
|
||||
@@ -171,6 +181,27 @@ export function generatePackage(input: {
|
||||
} else {
|
||||
di = Math.floor(rng() * airports.length);
|
||||
if (di === oi) di = (di + 1) % airports.length;
|
||||
// v0.44.0 (ISS-079): cap on generated legs — when BOTH endpoints are unforced, rejection-sample
|
||||
// the pair until it fits MAX_JOB_LEG_KM so spawned jobs stay flyable by at least one catalog plane.
|
||||
// Forced paths keep their pre-v0.44.0 draw behavior exactly (the engine's coverage spawn caps its
|
||||
// random dest itself via pickDestWithinCap). Under-cap pairs take just the two draws above, so the
|
||||
// RNG stream is byte-identical to before for them.
|
||||
if (input.originIcao === undefined) {
|
||||
let km = haversineKm(airports[oi].lat, airports[oi].lon, airports[di].lat, airports[di].lon);
|
||||
for (let attempt = 1; km > MAX_JOB_LEG_KM && attempt < LEG_CAP_MAX_ATTEMPTS; attempt++) {
|
||||
oi = Math.floor(rng() * airports.length);
|
||||
di = Math.floor(rng() * airports.length);
|
||||
if (di === oi) di = (di + 1) % airports.length;
|
||||
km = haversineKm(airports[oi].lat, airports[oi].lon, airports[di].lat, airports[di].lon);
|
||||
}
|
||||
if (km > MAX_JOB_LEG_KM) {
|
||||
// Exhausted: keep the last origin and pick uniformly from its within-cap neighbours. If none
|
||||
// exist (impossible in the curated dataset — every airport has a neighbour well inside the cap),
|
||||
// keep the over-cap pair rather than throw mid-tick.
|
||||
const fallback = pickDestWithinCap(airports, oi, rng);
|
||||
if (fallback >= 0) di = fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oi === di) throw new Error('origin and destination must differ'); // both forced identical
|
||||
const origin = airports[oi];
|
||||
@@ -213,6 +244,19 @@ export function generatePackage(input: {
|
||||
};
|
||||
}
|
||||
|
||||
// v0.44.0 (ISS-079): uniform random index of an airport within MAX_JOB_LEG_KM of airports[originIdx]
|
||||
// (excluding the origin itself) — one RNG draw, -1 when no neighbour fits. Shared by generatePackage's
|
||||
// exhaustion fallback and the engine's coverage spawn so both cap identically without duplicating the scan.
|
||||
export function pickDestWithinCap(airports: SeedAirport[], originIdx: number, rng: Rng): number {
|
||||
const candidates: number[] = [];
|
||||
for (let j = 0; j < airports.length; j++) {
|
||||
if (j === originIdx) continue;
|
||||
if (haversineKm(airports[originIdx].lat, airports[originIdx].lon, airports[j].lat, airports[j].lon) <= MAX_JOB_LEG_KM) candidates.push(j);
|
||||
}
|
||||
if (candidates.length === 0) return -1;
|
||||
return candidates[Math.floor(rng() * candidates.length)];
|
||||
}
|
||||
|
||||
function pickHub(airports: SeedAirport[]): SeedAirport {
|
||||
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user