import { describe, expect, it } from 'vitest'; 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, mulberry32, REWARD_PER_KG, REWARD_PER_KM, rewardFor, STARTING_CASH, STARTING_REPUTATION, 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(NOW); } expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length); }); it('applies the reward formula: round(500 + 2.5 * km + 0.5 * 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 now (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). 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('seeds the starting account and sim state', () => { const seed = seedWith(42); expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION }); // lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0; // lastSelectedTimeScale mirrors the seeded speed (v0.30.0, ISS-057). expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: 1 }); }); 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)); }); }); 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++) { expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: 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' }); 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 }); }); });