CI / build + unit + e2e (pull_request) Failing after 12m10s
The world always runs at WORLD_TIME_SCALE = 24 (lib/worldClock.ts). A server
background tick (instrumentation.ts -> startBackgroundTick, 1 s interval)
advances the sim with no open browser; idle-pause freezes the clock exactly
while no plane is flying or in repair. Fresh worlds start year 2000
(STARTING_CLOCK_MS in seed.ts).
- Removed: POST /api/sim + OpenAPI entry, applyTimeControls/clampTimeScale,
lib/timeScale.ts, LeftPane pause button + scale slider machinery,
ISS-057 slow-on-landing/resume in flyPlane.
- Snapshot simState is now { clockMs } only; client extrapolates at the fixed
rate between polls and freezes while idle (useSimClock).
- New test-only hook POST /api/test/set-timescale behind FLIGHT_TEST_HOOKS=1
(sibling of set-health); all e2e setScale helpers call it, afterEach restores
the world rate. E2E pause tests replaced by idle-pause assertions; sim.spec.ts
activates the world via set-health + /repair for advance/expiry tests.
440 lines
23 KiB
TypeScript
440 lines
23 KiB
TypeScript
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 { SPECIALS } from './specials';
|
||
import {
|
||
BASE_REWARD,
|
||
buildSeed,
|
||
JOB_BONUS_PER_REP,
|
||
MAX_JOB_BONUS,
|
||
PACKAGE_COUNT_MAX,
|
||
PACKAGE_COUNT_MIN,
|
||
generatePackage,
|
||
HUB_ICAO,
|
||
MAX_JOB_LEG_KM,
|
||
pickDestWithinCap,
|
||
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 8–12 open packages between distinct fixture airports', () => {
|
||
const seed = seedWith(42);
|
||
expect(seed.packages.length).toBeGreaterThanOrEqual(PACKAGE_COUNT_MIN);
|
||
expect(seed.packages.length).toBeLessThanOrEqual(PACKAGE_COUNT_MAX);
|
||
|
||
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
|
||
for (const c of seed.packages) {
|
||
expect(icaoSet.has(c.originIcao)).toBe(true);
|
||
expect(icaoSet.has(c.destIcao)).toBe(true);
|
||
expect(c.originIcao).not.toBe(c.destIcao);
|
||
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(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 that fits the starter plane alone (balance constraint)', () => {
|
||
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);
|
||
// max count × max jitter (1.3×) must still fit the ATR alone — see UNIT_WEIGHT_KG.
|
||
expect(c.weightKg).toBeLessThanOrEqual(maxPayload);
|
||
}
|
||
}
|
||
});
|
||
|
||
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 });
|
||
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: 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 builder output (spawn matches seed)', () => {
|
||
const rngA = mulberry32(42);
|
||
const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
||
const expected: string[] = [];
|
||
for (let i = 0; i < count; i++) {
|
||
// ISS-117: buildSeed anchors packages at the game-clock origin, not the wall-time input.
|
||
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: STARTING_CLOCK_MS })));
|
||
}
|
||
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' });
|
||
const b = generatePackage({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
|
||
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 });
|
||
const b = generatePackage({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined });
|
||
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 });
|
||
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 });
|
||
const b = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, reputation: STARTING_REPUTATION });
|
||
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);
|
||
}
|
||
});
|
||
});
|