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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user