Files
Flight-Ops/lib/game/seed.test.ts
T
2026-09-21 12:06:45 +00:00

339 lines
17 KiB
TypeScript
Raw Permalink 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 { 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
});
});
});