Files
Flight-Ops/lib/game/seed.test.ts
T
kamilkrzywda 69b708dad3
CI / build + unit + e2e (pull_request) Failing after 6m37s
Flight queue backend: planes hold a queue of upcoming legs, auto-chain on landing (ISS-043 part 1)
- planes.queue TEXT NOT NULL DEFAULT '[]' — ordered JSON array of not-yet-started
  leg ICACs; active leg stays destIcao + departureSimTime. DDL + guarded ALTER
  migration for existing DBs; seed sets queue: [] explicitly.
- deliverPackages() auto-chains on arrival: with a non-empty queue and fuel
  covering the first queued leg (same requiredFuelFor rule as /fly), the plane
  immediately starts that leg from the arrival airport (stays 'flying',
  departureSimTime = arrival instant, queue shortened). Short tank → grounds
  safely with the queue kept intact; empty queue → unchanged grounding. Cargo
  delivery for the reached airport runs first and is unaffected. Ordering kept:
  delivery still runs before tickOnce's pause check, so a chained plane counts
  as still flying — no premature last-plane pause (ISS-041 preserved).
- New endpoints via performAction(): POST /api/planes/{planeId}/queue {destIcao}
  (append; works grounded or flying; 400 bad body/unknown ICAO, 404 unknown
  plane) and POST /api/planes/{planeId}/queue/cancel {index} (remove queued leg
  by index; active in-flight leg never affected; 400 non-integer/out-of-range,
  404 unknown plane). Both return the full state snapshot.
- PlaneState.queue: string[] in /api/state (null/malformed-safe parse helper
  shared with the engine); OpenAPI updated (PlaneState.queue, QueueLegRequest,
  CancelQueuedLegRequest, both paths) and openapi.test.ts expectations extended.
- Tests: auto-chain block in engine.test.ts (chain / short-fuel ground / empty
  queue) + chained-plane-no-pause regression guard; queueLeg/cancelQueuedLeg
  blocks in actions.test.ts; type-compat queue: [] fixtures in deriveEvents,
  right-pane, mapData and seed tests.

v0.25.0 — part 1 (backend) of ISS-043; UI/map rendering lands in a follow-up.
2026-09-20 10:39:00 +00:00

216 lines
9.6 KiB
TypeScript
Raw 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, STARTER_MODEL } from './catalog';
import {
BASE_REWARD,
buildSeed,
PACKAGE_COUNT_MAX,
PACKAGE_COUNT_MIN,
generatePackage,
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,
destIcao: null,
lat: 50.0333,
lon: 8.5706,
queue: [], // v0.25.0 (ISS-043) — no queued legs 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('scales reward by haversine distance: round(500 + 2.5 * km)', () => {
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)!;
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.packages) {
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 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('rewardForDistanceKm applies the documented formula', () => {
expect(rewardForDistanceKm(0)).toBe(BASE_REWARD);
expect(rewardForDistanceKm(1000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000));
});
});
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)!;
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 = 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)!;
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 = 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
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 = 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);
});
});
});