Files
Flight-Ops/lib/game/actions.test.ts
T

291 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, 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<GameDb> {
vi.setSystemTime(T0); // each throwaway DB is seeded at the same fake "now"
return openGameDb(path.join(dir, name));
}
function expectActionError(promise: Promise<unknown>, 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<void> {
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);
});
});