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; }