feat: v0.6.0 — data foundation: Drizzle ORM + SQLite (sql.js/WASM) persistence, first-run seed, read-only GET /api/state snapshot
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
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,
|
||||
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 });
|
||||
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false });
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user