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

265 lines
12 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,
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)),
);
});
});
// 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;
}