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, type GameDb } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; import { loadAirportsGeoJson } from '@/lib/airports'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog'; import { HUB_ICAO } from './seed'; import { acceptContract, buyPlane, dispatchPlane, GameActionError, refuelPlane, sellPlane, } from './actions'; // Action tests run against throwaway DB files (openGameDb) with fake timers so // the seeded sim clock is a known constant. Actions are called directly (no tick — // engine.runAction() wraps them in production; its lock/tick behavior is covered // by the route tests and e2e). 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-actions-')); }); afterAll(async () => { vi.useRealTimers(); await fs.rm(dir, { recursive: true, force: true }); }); async function freshDb(name: string): Promise { vi.setSystemTime(T0); // each throwaway DB is seeded at the same fake "now" return openGameDb(path.join(dir, name)); } function expectActionError(promise: Promise, status: number, messagePart?: RegExp) { return promise.then( () => { throw new Error(`expected GameActionError(${status}) but the action succeeded`); }, (err) => { expect(err).toBeInstanceOf(GameActionError); expect((err as GameActionError).status).toBe(status); if (messagePart) expect((err as GameActionError).message).toMatch(messagePart); }, ); } // Insert an accepted contract EDDF → LEMD bound to nothing, for dispatch tests. async function insertAcceptedContract(inst: GameDb, id = 'c-acc'): Promise { await inst.db.insert(schema.contracts).values({ id, originIcao: HUB_ICAO, destIcao: 'LEMD', reward: 5_000, cargoType: 'Passengers', cargoCount: 60, deadlineSimTime: T0 + 24 * 3_600_000, status: 'accepted', planeId: null, createdSimTime: T0, }); } describe('acceptContract', () => { it('flips an open contract to accepted and persists it', async () => { const inst = await freshDb('acc-open.db'); await acceptContract(inst, 'contract-1'); // seeded ids are contract-N const row = (await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, 'contract-1')))[0]; expect(row.status).toBe('accepted'); }); it.each(['accepted', 'completed', 'expired'] as const)('rejects a %s contract with 409', async (status) => { const inst = await freshDb(`acc-${status}.db`); await inst.db.update(schema.contracts).set({ status }).where(eq(schema.contracts.id, 'contract-1')); await expectActionError(acceptContract(inst, 'contract-1'), 409, new RegExp(status)); }); it('rejects an unknown id with 404', async () => { const inst = await freshDb('acc-unknown.db'); await expectActionError(acceptContract(inst, 'nope'), 404); }); }); describe('dispatchPlane', () => { it('binds the plane: flying + departure at current clock + origin coords, contract bound to plane', async () => { const inst = await freshDb('disp-ok.db'); await insertAcceptedContract(inst); await dispatchPlane(inst, 'plane-starter', 'c-acc'); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('flying'); expect(plane.contractId).toBe('c-acc'); expect(plane.departureSimTime).toBe(T0); // seeded clock, no tick in direct calls const fc = await loadAirportsGeoJson(); const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties; expect(plane.lat).toBe(origin.lat); expect(plane.lon).toBe(origin.lon); // Fuel is frozen at the departure value while flying (live fuel is analytic). expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); const contract = (await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, 'c-acc')))[0]; expect(contract.planeId).toBe('plane-starter'); }); it.each(['open', 'completed', 'expired'] as const)('rejects a %s contract with 409 (must be accepted)', async (status) => { const inst = await freshDb(`disp-c${status}.db`); await inst.db.insert(schema.contracts).values({ id: 'c-x', originIcao: HUB_ICAO, destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 3_600_000, status, planeId: null, createdSimTime: T0, }); await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-x'), 409, new RegExp(status)); }); it('rejects an unknown contract id with 404', async () => { const inst = await freshDb('disp-cunknown.db'); await expectActionError(dispatchPlane(inst, 'plane-starter', 'nope'), 404); }); it('rejects an unknown plane id with 404', async () => { const inst = await freshDb('disp-punknown.db'); await insertAcceptedContract(inst); await expectActionError(dispatchPlane(inst, 'ghost', 'c-acc'), 404); }); it('rejects a flying plane with 409', async () => { const inst = await freshDb('disp-flying.db'); await insertAcceptedContract(inst); await inst.db.update(schema.planes) .set({ status: 'flying', departureSimTime: T0, contractId: 'c-other' }) .where(eq(schema.planes.id, 'plane-starter')); await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 409); }); it('rejects a plane that is not at the origin with 409', async () => { const inst = await freshDb('disp-notatorigin.db'); await insertAcceptedContract(inst); // originates at EDDF await inst.db.update(schema.planes) .set({ locationIcao: 'LEMD' }) .where(eq(schema.planes.id, 'plane-starter')); await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 409, /not at|at LEMD/); }); it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => { const inst = await freshDb('disp-nofuel.db'); await insertAcceptedContract(inst); // EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L await inst.db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter')); await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 409, /insufficient fuel/i); }); }); describe('buyPlane', () => { it('deducts the full price and delivers a full-tank plane at the home base', async () => { const inst = await freshDb('buy-ok.db'); await inst.db.update(schema.account).set({ cash: 500_000 }); const before = (await inst.db.select().from(schema.planes)).length; const { planeId } = await buyPlane(inst, 'atr72'); const account = (await inst.db.select().from(schema.account))[0]; expect(account.cash).toBe(500_000 - PLANE_TYPES.atr72.price); const planes = await inst.db.select().from(schema.planes); expect(planes.length).toBe(before + 1); const bought = planes.find((p) => p.id === planeId)!; expect(bought.model).toBe('atr72'); expect(bought.status).toBe('available'); expect(bought.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // full tank on delivery expect(bought.maxFuel).toBe(PLANE_TYPES.atr72.maxFuel); expect(bought.locationIcao).toBe(HUB_ICAO); const fc = await loadAirportsGeoJson(); const hub = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties; expect(bought.lat).toBe(hub.lat); expect(bought.lon).toBe(hub.lon); }); it('rejects an unknown catalog model with 400', async () => { const inst = await freshDb('buy-unknown.db'); await expectActionError(buyPlane(inst, 'boeing747'), 400, /unknown plane model/); }); it('rejects the purchase when cash is below the price with 409', async () => { const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i); }); }); describe('sellPlane', () => { it('pays out SELL_FRACTION of the catalog price and deletes the row', async () => { const inst = await freshDb('sell-ok.db'); // A second plane so the starter is not "the last remaining one". await inst.db.insert(schema.planes).values({ id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: HUB_ICAO, contractId: null, lat: 50, lon: 8, departureSimTime: null, }); const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const { payout } = await sellPlane(inst, 'plane-extra'); expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100); const account = (await inst.db.select().from(schema.account))[0]; expect(account.cash).toBeCloseTo(cashBefore + payout, 6); const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id); expect(ids).not.toContain('plane-extra'); // row deleted expect(ids).toContain('plane-starter'); }); it('rejects selling a flying plane with 409', async () => { const inst = await freshDb('sell-flying.db'); await inst.db.insert(schema.planes).values({ id: 'plane-extra', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: HUB_ICAO, contractId: null, lat: 50, lon: 8, departureSimTime: T0, }); await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/); }); it('rejects selling the last remaining plane with 409', async () => { const inst = await freshDb('sell-last.db'); // only the starter exists await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/); }); it('rejects an unknown id with 404', async () => { const inst = await freshDb('sell-unknown.db'); await expectActionError(sellPlane(inst, 'ghost'), 404); }); }); describe('refuelPlane', () => { it('fills to maxFuel and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => { const inst = await freshDb('refuel-ok.db'); await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter')); const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const { cost } = await refuelPlane(inst, 'plane-starter'); expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); const account = (await inst.db.select().from(schema.account))[0]; expect(account.cash).toBeCloseTo(cashBefore - cost, 6); }); it('is a free no-op when the tank is already full', async () => { const inst = await freshDb('refuel-full.db'); // seeded starter is full const cashBefore = (await inst.db.select().from(schema.account))[0].cash; const { cost } = await refuelPlane(inst, 'plane-starter'); expect(cost).toBe(0); expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); }); it('rejects a flying plane with 409', async () => { const inst = await freshDb('refuel-flying.db'); await inst.db.update(schema.planes) .set({ status: 'flying', departureSimTime: T0, contractId: 'c-x' }) .where(eq(schema.planes.id, 'plane-starter')); await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /flying/); }); it('rejects the fill-up when cash is below the cost with 409', async () => { const inst = await freshDb('refuel-poor.db'); await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.account).set({ cash: 10 }); // cost is ~4960 await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /insufficient funds/i); }); it('rejects an unknown id with 404', async () => { const inst = await freshDb('refuel-unknown.db'); await expectActionError(refuelPlane(inst, 'ghost'), 404); }); });