215 lines
9.6 KiB
TypeScript
215 lines
9.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { haversineKm } from '@/lib/geo';
|
||
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
|
||
import {
|
||
BASE_REWARD,
|
||
buildSeed,
|
||
CONTRACT_COUNT_MAX,
|
||
CONTRACT_COUNT_MIN,
|
||
generateContract,
|
||
HUB_ICAO,
|
||
mulberry32,
|
||
REWARD_PER_KM,
|
||
rewardForDistanceKm,
|
||
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,
|
||
contractId: null,
|
||
lat: 50.0333,
|
||
lon: 8.5706,
|
||
});
|
||
});
|
||
|
||
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 contracts between distinct fixture airports', () => {
|
||
const seed = seedWith(42);
|
||
expect(seed.contracts.length).toBeGreaterThanOrEqual(CONTRACT_COUNT_MIN);
|
||
expect(seed.contracts.length).toBeLessThanOrEqual(CONTRACT_COUNT_MAX);
|
||
|
||
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
|
||
for (const c of seed.contracts) {
|
||
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.contracts.map((c) => c.id)).size).toBe(seed.contracts.length);
|
||
});
|
||
|
||
it('scales reward by haversine distance: round(500 + 2.5 * km)', () => {
|
||
const seed = seedWith(42);
|
||
for (const c of seed.contracts) {
|
||
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
||
}
|
||
});
|
||
|
||
it('sets deadlines inside the 6–72h game-time window after now', () => {
|
||
const seed = seedWith(42);
|
||
for (const c of seed.contracts) {
|
||
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
|
||
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
|
||
}
|
||
});
|
||
|
||
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.
|
||
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW });
|
||
});
|
||
|
||
it('is fully deterministic for a fixed RNG and inputs', () => {
|
||
expect(seedWith(42)).toEqual(seedWith(42));
|
||
});
|
||
|
||
it('produces different contracts for a different RNG seed', () => {
|
||
expect(seedWith(1).contracts).not.toEqual(seedWith(2).contracts);
|
||
});
|
||
|
||
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('rewardForDistanceKm applies the documented formula', () => {
|
||
expect(rewardForDistanceKm(0)).toBe(BASE_REWARD);
|
||
expect(rewardForDistanceKm(1000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000));
|
||
});
|
||
});
|
||
|
||
describe('generateContract (shared seed/spawn builder)', () => {
|
||
it('builds an open contract between distinct fixture airports with the reward formula', () => {
|
||
const c = generateContract({ 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)!;
|
||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
||
|
||
// Same 6–72h game-time deadline window as the seed.
|
||
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
|
||
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
|
||
});
|
||
|
||
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
|
||
const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
|
||
const b = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
|
||
expect(a).toEqual(b);
|
||
|
||
const c = generateContract({ 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 contract
|
||
});
|
||
|
||
it('deadlineOffsetMs pins the deadline to now + offset (e2e expiry hook)', () => {
|
||
const c = generateContract({ id: 'c-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, deadlineOffsetMs: 10_000 });
|
||
expect(c.deadlineSimTime).toBe(NOW + 10_000);
|
||
});
|
||
|
||
it('buildSeed contracts are exactly the shared builder output (spawn matches seed)', () => {
|
||
const rngA = mulberry32(42);
|
||
const count = CONTRACT_COUNT_MIN + Math.floor(rngA() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1));
|
||
const expected: string[] = [];
|
||
for (let i = 0; i < count; i++) {
|
||
expected.push(JSON.stringify(generateContract({ id: `contract-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW })));
|
||
}
|
||
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||
expect(seed.contracts.map((c) => JSON.stringify(c))).toEqual(expected);
|
||
});
|
||
|
||
it('rejects an airport list too small to form a route', () => {
|
||
expect(() => generateContract({ 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 = generateContract({ 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)!;
|
||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
||
});
|
||
|
||
it('destIcao forces the destination; origin stays random but distinct', () => {
|
||
const c = generateContract({ 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 = generateContract({ 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
|
||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
||
});
|
||
|
||
it('is deterministic for a fixed RNG with forced endpoints', () => {
|
||
const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
|
||
const b = generateContract({ 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(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'XXXX' })).toThrow(/unknown origin/);
|
||
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, destIcao: 'XXXX' })).toThrow(/unknown destination/);
|
||
expect(() => generateContract({ 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 contract, proving forced
|
||
// endpoints consume no extra RNG draws when absent.
|
||
const a = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||
const b = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined });
|
||
expect(a).toEqual(b);
|
||
});
|
||
});
|
||
});
|