Files
Flight-Ops/lib/sim/engine.test.ts
T

349 lines
16 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 fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { MARKET_TARGET_OPEN_CONTRACTS, mulberry32 } from '@/lib/game/seed';
import { arrivalTimeFor } from './movement';
import {
expireContracts,
finalizeArrivals,
MAX_TICK_ELAPSED_MS,
reputationGainFor,
spawnCoverageContracts,
spawnOpenContracts,
tickOnce,
} from './engine';
// Engine tests run against throwaway DB files (openGameDb) with fake timers so
// Date.now() — the only wall-clock input to a tick — is fully controlled. The
// public tickNow()/applyTimeControls() wrappers (singleton + lock) are covered
// by app/api/sim/route.test.ts and the e2e suite.
let dir: string;
const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open
beforeAll(async () => {
vi.useFakeTimers();
vi.setSystemTime(T0);
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-'));
});
afterAll(async () => {
vi.useRealTimers();
await fs.rm(dir, { recursive: true, force: true });
});
async function freshDb(name: string) {
vi.setSystemTime(T0); // each throwaway DB is seeded at the same fake "now"
return openGameDb(path.join(dir, name));
}
describe('tickOnce — clock advance', () => {
it('advances the clock by elapsed real ms × timeScale', async () => {
const inst = await freshDb('scale.db');
vi.setSystemTime(T0 + 1_000); // one real second later
await inst.db.update(schema.simState).set({ timeScale: 5 });
const sim = await tickOnce(inst);
expect(sim.clockMs).toBe(T0 + 5_000); // 1 s × 5
expect(sim.lastRealTickMs).toBe(T0 + 1_000);
});
it('freezes the clock while paused but keeps tracking real time', async () => {
const inst = await freshDb('pause.db');
await inst.db.update(schema.simState).set({ paused: true, timeScale: 60 });
vi.setSystemTime(T0 + 3_600_000); // an hour of real pause
let sim = await tickOnce(inst);
expect(sim.clockMs).toBe(T0); // frozen — no advance at all
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000);
// Unpause: resumes from where it stopped, with NO catch-up for the pause.
await inst.db.update(schema.simState).set({ paused: false });
vi.setSystemTime(T0 + 3_600_500);
sim = await tickOnce(inst);
expect(sim.clockMs).toBe(T0 + 500 * 60); // only the 500 ms since unpausing × 60
});
it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => {
const inst = await freshDb('clamp.db');
vi.setSystemTime(T0 + 3_600_000); // server sat idle for an hour
const sim = await tickOnce(inst); // timeScale is the seeded 1
expect(sim.clockMs).toBe(T0 + MAX_TICK_ELAPSED_MS); // exactly one clamped tick, not a fast-forward
vi.setSystemTime(T0); // reset for later tests in this file
});
it('does not advance when two ticks land in the same millisecond', async () => {
const inst = await freshDb('same-ms.db');
vi.setSystemTime(T0 + 1_000);
const a = await tickOnce(inst);
const b = await tickOnce(inst); // no real time passed since `a`
expect(b.clockMs).toBe(a.clockMs);
});
});
describe('expireContracts', () => {
it('flips only open contracts past their deadline; accepted ones never expire', async () => {
const inst = await freshDb('expiry.db');
const clock = T0 + 100_000;
await inst.db.insert(schema.contracts).values([
{ id: 'c-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: T0 },
{ id: 'c-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: T0 },
{ id: 'c-accepted-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'accepted', planeId: null, createdSimTime: T0 },
]);
const n = await expireContracts(inst, clock);
expect(n).toBe(1);
const byId = new Map((await inst.db.select().from(schema.contracts)).map((c) => [c.id, c.status]));
expect(byId.get('c-open-past')).toBe('expired');
expect(byId.get('c-open-future')).toBe('open'); // deadline not reached yet
expect(byId.get('c-accepted-past')).toBe('accepted'); // accepted contracts complete, never expire
});
it('a deadline exactly at the clock counts as expired', async () => {
const inst = await freshDb('expiry-edge.db');
await inst.db.insert(schema.contracts).values({
id: 'c-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: T0 + 50_000, status: 'open', planeId: null, createdSimTime: T0,
});
expect(await expireContracts(inst, T0 + 50_000)).toBe(1); // <= is inclusive
});
});
describe('finalizeArrivals', () => {
const FROM = 'EDDF';
const TO = 'LEMD';
async function setupFlight(name: string) {
const inst = await freshDb(name);
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === FROM)!.properties;
const to = fc.features.find((f) => f.properties.icao === TO)!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
// A dispatched flight: contract accepted + bound to the plane, plane flying
// with its fuel frozen at departure (the v0.8.0 dispatch step writes these).
await inst.db.insert(schema.contracts).values({
id: 'c-flight', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Passengers',
cargoCount: 60, deadlineSimTime: T0 + 24 * 3_600_000, status: 'accepted', planeId: 'plane-starter', createdSimTime: T0,
});
await inst.db.update(schema.planes)
.set({ status: 'flying', contractId: 'c-flight', departureSimTime: T0, fuel: type.maxFuel })
.where(eq(schema.planes.id, 'plane-starter'));
return { inst, from, to, km, type };
}
it('does nothing before the arrival time', async () => {
const { inst, km, type } = await setupFlight('finalize-early.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival
expect(await finalizeArrivals(inst, clock)).toBe(0);
const plane = (await inst.db.select().from(schema.planes))[0];
const account = (await inst.db.select().from(schema.account))[0]!;
expect(plane.status).toBe('flying');
expect(account.cash).toBe(accountBefore.cash); // no award before landing
});
it('awards cash + reputation exactly once and grounds the plane at the destination', async () => {
const { inst, to, km, type } = await setupFlight('finalize-late.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
expect(await finalizeArrivals(inst, clock)).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available');
expect(plane.locationIcao).toBe(TO);
expect(plane.lat).toBe(to.lat);
expect(plane.lon).toBe(to.lon);
expect(plane.fuel).toBeCloseTo(Math.max(0, type.maxFuel - type.burnPerKm * km), 6); // remaining fuel
expect(plane.departureSimTime).toBeNull();
const contract = (await inst.db.select().from(schema.contracts)).find((c) => c.id === 'c-flight')!;
expect(contract.status).toBe('completed');
const account = (await inst.db.select().from(schema.account))[0]!;
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // exact reward
expect(account.reputation).toBeCloseTo(
Math.round((accountBefore.reputation + reputationGainFor(km)) * 100) / 100,
9,
);
// A later tick must not award again — the plane is no longer 'flying'.
expect(await finalizeArrivals(inst, clock + 3_600_000)).toBe(0);
const accountAfter = (await inst.db.select().from(schema.account))[0]!;
expect(accountAfter.cash).toBe(account.cash);
expect(accountAfter.reputation).toBe(account.reputation);
});
it('leaves planes without route info alone', async () => {
const inst = await freshDb('finalize-noroute.db');
// Flying plane whose contract row is missing — defensive skip, no crash.
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-missing' });
expect(await finalizeArrivals(inst, T0 + 3_600_000)).toBe(0);
});
});
describe('spawnOpenContracts', () => {
it('tops the market up to MARKET_TARGET_OPEN_CONTRACTS using the shared seed builder', async () => {
const inst = await freshDb('spawn.db');
const clock = T0 + 123_456;
// Leave exactly 3 open contracts (the rest of the seeded set is "done").
const all = await inst.db.select().from(schema.contracts);
for (const c of all.slice(3)) {
await inst.db.update(schema.contracts).set({ status: 'completed' }).where(eq(schema.contracts.id, c.id));
}
const needed = await spawnOpenContracts(inst, clock, mulberry32(7));
expect(needed).toBe(MARKET_TARGET_OPEN_CONTRACTS - 3);
const openRows = (await inst.db.select().from(schema.contracts)).filter((c) => c.status === 'open');
expect(openRows.length).toBe(MARKET_TARGET_OPEN_CONTRACTS);
const fc = await loadAirportsGeoJson();
const icaoSet = new Set(fc.features.map((f) => f.properties.icao));
for (const c of openRows.slice(3)) { // the spawned ones
expect(c.createdSimTime).toBe(clock); // created at game time, not real time
expect(icaoSet.has(c.originIcao)).toBe(true);
expect(icaoSet.has(c.destIcao)).toBe(true);
expect(c.originIcao).not.toBe(c.destIcao);
expect(c.planeId).toBeNull();
// Same deadline window as the seed: now + 6–72h game time.
expect(c.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000);
}
// Already at target → nothing more to spawn.
expect(await spawnOpenContracts(inst, clock, mulberry32(7))).toBe(0);
});
it('is deterministic for an injected RNG (spawn matches the seed builder)', async () => {
const a = await freshDb('spawn-a.db');
const b = await freshDb('spawn-b.db');
const clock = T0 + 999;
// Wipe all contracts in both so both spawn from zero with the same RNG.
for (const inst of [a, b]) {
const rows = await inst.db.select().from(schema.contracts);
for (const r of rows) {
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, r.id));
}
}
expect(await spawnOpenContracts(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_CONTRACTS);
expect(await spawnOpenContracts(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_CONTRACTS);
const strip = (rows: schema.ContractRow[]) =>
rows.filter((c) => c.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort();
expect(strip(await a.db.select().from(schema.contracts))).toEqual(
strip(await b.db.select().from(schema.contracts)),
);
});
});
describe('spawnCoverageContracts (v0.8.0 spawn coverage rule)', () => {
// The seeded starter plane is grounded at EDDF with no seeded contract from
// there, so a fresh DB always has exactly one uncovered airport: EDDF.
it('spawns an open contract originating at the airport of each uncovered grounded plane', async () => {
const inst = await freshDb('coverage.db');
const clock = T0 + 5_000;
// Wipe all seeded contracts so coverage is the only source of new ones.
for (const c of await inst.db.select().from(schema.contracts)) {
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id));
}
const n = await spawnCoverageContracts(inst, clock, mulberry32(7));
expect(n).toBe(1); // only EDDF is uncovered (single grounded plane)
const openRows = (await inst.db.select().from(schema.contracts)).filter((c) => c.status === 'open');
expect(openRows.length).toBe(1);
expect(openRows[0].originIcao).toBe('EDDF'); // origin forced to the plane's airport
expect(openRows[0].createdSimTime).toBe(clock);
});
it('does nothing when every grounded airport already has an open contract', async () => {
const inst = await freshDb('coverage-covered.db');
for (const c of await inst.db.select().from(schema.contracts)) {
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id));
}
// One open contract from EDDF covers the starter plane's airport.
await inst.db.insert(schema.contracts).values({
id: 'c-cover', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: T0 + 3_600_000, status: 'open', planeId: null, createdSimTime: T0,
});
expect(await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7))).toBe(0);
expect((await inst.db.select().from(schema.contracts)).length).toBe(1); // nothing added
});
it('spawns one contract per uncovered airport when planes sit at several airports', async () => {
const inst = await freshDb('coverage-multi.db');
for (const c of await inst.db.select().from(schema.contracts)) {
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id));
}
// A second grounded plane at LEMD; two planes sharing EDDF must not double-spawn.
await inst.db.insert(schema.planes).values([
{ id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', contractId: null, lat: 40.4, lon: -3.6, departureSimTime: null },
{ id: 'p-3', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'EDDF', contractId: null, lat: 50.03, lon: 8.57, departureSimTime: null },
]);
const n = await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7));
expect(n).toBe(2); // EDDF (×2 planes → one contract) and LEMD
const origins = (await inst.db.select().from(schema.contracts))
.filter((c) => c.status === 'open')
.map((c) => c.originIcao)
.sort();
expect(origins).toEqual(['EDDF', 'LEMD']);
});
it('ignores flying planes — they cannot take new work until they land', async () => {
const inst = await freshDb('coverage-flying.db');
for (const c of await inst.db.select().from(schema.contracts)) {
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id));
}
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-gone' })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7))).toBe(0); // no grounded planes
});
it('runs inside tickOnce: a fresh DB gains an EDDF-origin contract after one advancing tick', async () => {
const inst = await freshDb('coverage-tick.db');
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at scale 1
await tickOnce(inst);
const fromEdff = (await inst.db.select().from(schema.contracts)).filter(
(c) => c.status === 'open' && c.originIcao === 'EDDF',
);
expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline
});
});
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
// engine composes arrivalTimeFor; tests just need the duration for fixtures).
function travelMs(km: number, speedKmh: number): number {
return (km / speedKmh) * 3_600_000;
}