Files
Flight-Ops/lib/game/seed.test.ts
T

534 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { haversineKm } from '@/lib/geo';
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,
generatePackage,
generatePoolPackage,
HUB_ICAO,
MAX_JOB_LEG_KM,
pickDestWithinCap,
POOL_BANDS,
poolBandOf,
POOL_TARGET_PER_AIRPORT,
mulberry32,
REWARD_PER_KG,
REWARD_PER_KM,
rewardFor,
STARTING_CASH,
STARTING_CLOCK_MS,
STARTING_REPUTATION,
type Rng,
type SeedAirport,
} from './seed';
const NOW = 1_750_000_000_000;
// Small fixed fixture — includes the hub (EDDF Frankfurt) so the happy path is covered.
const AIRPORTS: SeedAirport[] = [
{ icao: 'EDDF', name: 'Frankfurt Main', lat: 50.0333, lon: 8.5706 },
{ icao: 'EHAM', name: 'Amsterdam Schiphol', lat: 52.31, lon: 4.76 },
{ icao: 'EGLL', name: 'London Heathrow', lat: 51.47, lon: -0.454 },
{ icao: 'LEBC', name: 'Madrid Barajas', lat: 40.47, lon: -3.56 },
{ icao: 'LIRF', name: 'Rome Fiumicino', lat: 41.8, lon: 12.25 },
];
const seedWith = (rngSeed: number, airports: SeedAirport[] = AIRPORTS) =>
buildSeed({ airports, rng: mulberry32(rngSeed), nowMs: NOW });
describe('buildSeed', () => {
it('grounds the starter plane full of fuel at the hub airport', () => {
const seed = seedWith(42);
const type = PLANE_TYPES[STARTER_MODEL];
expect(seed.plane).toEqual({
id: 'plane-starter',
model: STARTER_MODEL,
status: 'available',
fuel: type.maxFuel,
maxFuel: type.maxFuel,
locationIcao: HUB_ICAO,
destIcao: null,
lat: 50.0333,
lon: 8.5706,
queue: [], // v0.25.0 (ISS-043) — no queued legs on first run
health: 100, // v0.39.0 (ISS-069) — full health on first run
});
});
it('falls back to the first airport when the hub ICAO is missing', () => {
const seed = buildSeed({ airports: AIRPORTS.slice(1), rng: mulberry32(42), nowMs: NOW });
expect(seed.plane.locationIcao).toBe(AIRPORTS[1].icao);
});
it('seeds a full pool of POOL_TARGET_PER_AIRPORT open packages at the hub (v0.65.0)', () => {
const seed = seedWith(42);
expect(seed.packages.length).toBe(POOL_TARGET_PER_AIRPORT);
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
const byBand = [0, 0, 0, 0];
for (const c of seed.packages) {
expect(c.originIcao).toBe(HUB_ICAO); // the starter's home hub — its only pool airport
const band = POOL_BANDS[poolBandOf(c.weightKg)];
byBand[poolBandOf(c.weightKg)]++;
expect(c.weightKg).toBeGreaterThanOrEqual(band.minKg); // weight stays in its band…
expect(c.weightKg).toBeLessThanOrEqual(band.maxKg); // …on both ends
expect(icaoSet.has(c.destIcao)).toBe(true);
expect(c.destIcao).not.toBe(HUB_ICAO);
expect(c.status).toBe('open');
expect(c.planeId).toBeNull();
expect(c.createdSimTime).toBe(STARTING_CLOCK_MS); // game-clock origin, not wall time (ISS-117)
expect(c.createdRealMs).toBe(NOW); // …but a fresh real-time lifetime from the seed moment
}
expect(byBand).toEqual(POOL_BANDS.map((b) => b.count));
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
});
it('applies the reward formula (BASE_REWARD + REWARD_PER_KM * km + REWARD_PER_KG * weightKg) × special multiplier', () => {
const seed = seedWith(42);
for (const c of seed.packages) {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
}
});
it('gives every package a positive integer weight, and the board always offers jobs beyond the starter', () => {
const maxPayload = PLANE_TYPES[STARTER_MODEL].maxPayloadKg;
for (const seed of [seedWith(42), seedWith(7)]) {
for (const c of seed.packages) {
expect(Number.isInteger(c.weightKg)).toBe(true);
expect(c.weightKg).toBeGreaterThan(0);
}
// v0.65.0: the heavy bands (18–80 t) deliberately exceed the starter's payload — bigger jobs are
// what you buy better planes for, so the board must always show work it can't yet do alone.
expect(seed.packages.some((c) => c.weightKg > maxPayload)).toBe(true);
}
});
it('sets deadlines inside the 6–72h game-time window after the game-clock origin (scaled by any special status)', () => {
const seed = seedWith(42);
for (const c of seed.packages) {
// v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h).
// ISS-117: the window is anchored at STARTING_CLOCK_MS, not wall time.
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeGreaterThan(STARTING_CLOCK_MS + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(STARTING_CLOCK_MS + 72 * 3_600_000 * scale);
}
});
it('seeds the starting account and sim state', () => {
const seed = seedWith(42);
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
// ISS-117 (v0.52.0): game clock starts at year 2000 with the fixed world rate; lastRealTickMs is
// the REAL seed moment so the first tick-on-read advances ~0. paused/lastSelectedTimeScale are legacy columns.
expect(seed.simState).toEqual({ clockMs: STARTING_CLOCK_MS, timeScale: WORLD_TIME_SCALE, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: WORLD_TIME_SCALE });
});
it('is fully deterministic for a fixed RNG and inputs', () => {
expect(seedWith(42)).toEqual(seedWith(42));
});
it('produces different packages for a different RNG seed', () => {
expect(seedWith(1).packages).not.toEqual(seedWith(2).packages);
});
it('rejects an airport list too small to form a route', () => {
expect(() => buildSeed({ airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
});
it('rewardFor applies the documented formula', () => {
expect(rewardFor(0, 0)).toBe(BASE_REWARD);
expect(rewardFor(1000, 2_000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000 + REWARD_PER_KG * 2_000));
// v0.47.0 (ISS-090): literal pins so future retunes are deliberate — weight must dominate payoff.
expect(rewardFor(1000, 500)).toBe(4_000);
expect(rewardFor(1000, 4_000)).toBe(11_000);
});
});
describe('generatePackage (shared seed/spawn builder)', () => {
it('builds an open package between distinct fixture airports with the reward formula', () => {
const c = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(c.id).toBe('c-x');
expect(c.status).toBe('open');
expect(c.planeId).toBeNull();
expect(c.createdSimTime).toBe(NOW);
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
expect(icaoSet.has(c.originIcao)).toBe(true);
expect(icaoSet.has(c.destIcao)).toBe(true);
expect(c.originIcao).not.toBe(c.destIcao);
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
// Same 6–72h game-time deadline window as the seed — scaled by any special status.
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
});
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, createdRealMs: NOW });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, createdRealMs: NOW });
expect(a).toEqual(b);
const c = generatePackage({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
expect(c.id).toBe('c-2');
expect(c.createdSimTime).toBe(NOW + 1000);
expect(c.deadlineSimTime).not.toBe(a.deadlineSimTime); // different RNG/now → different package
});
it('deadlineOffsetMs pins the deadline to now + offset (e2e expiry hook)', () => {
const c = generatePackage({ id: 'c-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, deadlineOffsetMs: 10_000 });
expect(c.deadlineSimTime).toBe(NOW + 10_000);
});
it('buildSeed packages are exactly the shared pool builder output (spawn matches seed)', () => {
const rngA = mulberry32(42);
const expected: string[] = [];
let n = 1;
for (let bandIdx = 0; bandIdx < POOL_BANDS.length; bandIdx++) {
for (let i = 0; i < POOL_BANDS[bandIdx].count; i++) {
// ISS-117: buildSeed anchors packages at the game-clock origin, not the wall-time input.
expected.push(JSON.stringify(generatePoolPackage({ airports: AIRPORTS, originIcao: HUB_ICAO, bandIdx, rng: rngA, clockMs: STARTING_CLOCK_MS, id: `package-${n++}`, createdRealMs: NOW })));
}
}
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
});
it('rejects an airport list too small to form a route', () => {
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
});
describe('forced endpoints (v0.8.0 spawn coverage + test hook)', () => {
it('originIcao forces the origin; dest stays random but distinct', () => {
const c = generatePackage({ id: 'c-o', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF' });
expect(c.originIcao).toBe('EDDF');
expect(c.destIcao).not.toBe('EDDF');
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
});
it('destIcao forces the destination; origin stays random but distinct', () => {
const c = generatePackage({ id: 'c-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, destIcao: 'LEBC' });
expect(c.destIcao).toBe('LEBC');
expect(c.originIcao).not.toBe('LEBC');
});
it('both forced → the exact requested route with the distance-based reward', () => {
const c = generatePackage({ id: 'c-b', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM' });
expect(c.originIcao).toBe('EDDF');
expect(c.destIcao).toBe('EHAM');
const o = AIRPORTS[0]; // EDDF
const d = AIRPORTS[1]; // EHAM
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
});
it('is deterministic for a fixed RNG with forced endpoints', () => {
const a = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF', createdRealMs: NOW });
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF', createdRealMs: NOW });
expect(a).toEqual(b);
});
it('throws on an unknown forced ICAO or identical endpoints', () => {
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'XXXX' })).toThrow(/unknown origin/);
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, destIcao: 'XXXX' })).toThrow(/unknown destination/);
expect(() => generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EDDF' })).toThrow(/must differ/);
});
it('omitting both endpoints keeps the pre-v0.8.0 RNG call order (byte-identical output)', () => {
// Same seed as a historical unforced call → same package, proving forced
// endpoints consume no extra RNG draws when absent.
const a = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, createdRealMs: NOW });
const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined, createdRealMs: NOW });
expect(a).toEqual(b);
});
});
describe('forced weight (v0.27.0 e2e hook)', () => {
it('weightKg forces the package weight and feeds the payout', () => {
const c = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
expect(c.weightKg).toBe(1_234);
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), 1_234) * mult));
// Deterministic for a fixed RNG…
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234, createdRealMs: c.createdRealMs });
expect(again).toEqual(c);
// …and the forced value consumes no RNG draw — the deadline lands where an unforced call's jitter would.
const unforced = generatePackage({ id: 'c-u', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(c.deadlineSimTime).not.toBe(unforced.deadlineSimTime);
});
});
describe('special status (v0.37.0, ISS-073)', () => {
it('a forced special passes through; an explicit null forces none; omitted rolls a registry key or null', () => {
const base = { id: 'c-sp', airports: AIRPORTS, nowMs: NOW };
expect(generatePackage({ ...base, rng: mulberry32(42), special: 'priority' }).special).toBe('priority');
expect(generatePackage({ ...base, rng: mulberry32(42), special: null }).special).toBeNull();
const rolled = generatePackage({ ...base, rng: mulberry32(42) });
expect(rolled.special === null || SPECIALS[rolled.special] !== undefined).toBe(true);
});
it('applies the registry reward multiplier to the base reward', () => {
const c = generatePackage({ id: 'c-sp-r', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: 'priority' });
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
expect(c.reward).toBe(Math.round(rewardFor(km, 1_000) * SPECIALS.priority.rewardMultiplier));
});
it('scales the drawn deadline window by the registry factor', () => {
const c = generatePackage({ id: 'c-sp-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, special: 'priority' });
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * SPECIALS.priority.deadlineScale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * SPECIALS.priority.deadlineScale);
});
it('a forced deadline offset skips the special scaling', () => {
const c = generatePackage({ id: 'c-sp-f', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, special: 'priority', deadlineOffsetMs: 10_000 });
expect(c.deadlineSimTime).toBe(NOW + 10_000); // forced offset untouched by the scale
});
it('a forced special (including explicit null) skips the appended RNG draw', () => {
const countDraws = (special?: string | null): number => {
let n = 0;
const base = mulberry32(42);
generatePackage({ id: 'c-sp-n', airports: AIRPORTS, rng: () => { n += 1; return base(); }, nowMs: NOW, special });
return n;
};
// origin, dest, cargoType, cargoCount, weight jitter, deadline window — the fixed order minus the appended draw.
expect(countDraws('priority')).toBe(6);
expect(countDraws(null)).toBe(6); // explicit null skips too
expect(countDraws()).toBeGreaterThanOrEqual(7); // pickSpecial draws 1–2 extra
});
});
describe('reputation job bonus (v0.38.0, ISS-072)', () => {
it('multiplies the reward by 1 + repRate at elevated reputation', () => {
const c = generatePackage({ id: 'c-rep', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: null, reputation: 60 });
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
expect(repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS)).toBeCloseTo(0.5, 12); // rep 60 → ×1.5
expect(c.reward).toBe(Math.round(rewardFor(km, 1_000) * (1 + repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS))));
});
it('defaults to the starting reputation when omitted — byte-identical output', () => {
const a = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, createdRealMs: NOW });
const b = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, reputation: STARTING_REPUTATION, createdRealMs: NOW });
expect(a).toEqual(b); // no bonus at the starting value
});
it('composes with the special multiplier (base × jobBonus × special)', () => {
const c = generatePackage({ id: 'c-rep-sp', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: 'priority', reputation: 60 });
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
expect(c.reward).toBe(
Math.round(rewardFor(km, 1_000) * (1 + repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS)) * SPECIALS.priority.rewardMultiplier),
);
});
it('consumes no extra RNG draw', () => {
const countDraws = (reputation?: number): number => {
let n = 0;
const base = mulberry32(42);
generatePackage({ id: 'c-rep-n', airports: AIRPORTS, rng: () => { n += 1; return base(); }, nowMs: NOW, special: null, reputation });
return n;
};
expect(countDraws()).toBe(6); // forced-null special → the fixed order minus the appended draw
expect(countDraws(60)).toBe(6); // the rep hook consumes no draws
});
});
});
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);
}
});
});
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
});
});