feat: v0.8.0 — player-actions API: accept/dispatch/buy/sell/refuel endpoints (tick-first, full-snapshot responses), spawn coverage rule, test create-contract hook
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { persist, type GameDb } from '@/lib/db/client';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { runAction } from '@/lib/sim/engine';
|
||||
import { getStateSnapshot } from './state';
|
||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
||||
import { HUB_ICAO } from './seed';
|
||||
|
||||
// Player actions (v0.8.0) — the core loop, server-side: accept a contract,
|
||||
// dispatch a plane on it, buy/sell planes, refuel. Each function validates and
|
||||
// mutates ONE throwaway-safe GameDb instance (persisted at the end); routes wrap
|
||||
// them in engine.runAction() so tick + validate + mutate are atomic under the sim
|
||||
// lock. Validation failures throw GameActionError carrying the HTTP status:
|
||||
// 404 — unknown entity id
|
||||
// 409 — valid ids but the current state forbids the action (wrong contract/plane
|
||||
// status, plane not at origin, insufficient fuel/funds, last plane)
|
||||
// 400 — bad input that isn't an existence problem (unknown catalog model)
|
||||
|
||||
export class GameActionError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'GameActionError';
|
||||
}
|
||||
}
|
||||
|
||||
async function airportByIcao(icao: string): Promise<{ icao: string; lat: number; lon: number }> {
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const f = fc.features.find((x) => x.properties.icao === icao);
|
||||
if (!f) throw new Error(`airport ${icao} missing from dataset`); // → 500, cannot happen for seeded data
|
||||
return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon };
|
||||
}
|
||||
|
||||
// --- Accept (POST /api/contracts/:id/accept) ---
|
||||
|
||||
// open → accepted. Single-player, so no ownership tracking beyond the status flip;
|
||||
// an accepted contract is claimed by whoever dispatched it first (dispatch checks
|
||||
// 'accepted' and binds the plane).
|
||||
export async function acceptContract(inst: GameDb, contractId: string): Promise<void> {
|
||||
const rows = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId));
|
||||
const contract = rows[0];
|
||||
if (!contract) throw new GameActionError(404, `contract ${contractId} not found`);
|
||||
if (contract.status !== 'open') {
|
||||
throw new GameActionError(409, `contract is ${contract.status}, only open contracts can be accepted`);
|
||||
}
|
||||
await inst.db.update(schema.contracts).set({ status: 'accepted' }).where(eq(schema.contracts.id, contractId));
|
||||
await persist(inst);
|
||||
}
|
||||
|
||||
// --- Dispatch (POST /api/planes/:planeId/dispatch {contractId}) ---
|
||||
|
||||
// Bind an accepted contract to a grounded plane at its origin. The v0.7.0
|
||||
// finalize path then handles the flight: position/fuel are pure functions of the
|
||||
// clock from departureSimTime, and arrival awards cash + reputation automatically.
|
||||
export async function dispatchPlane(inst: GameDb, planeId: string, contractId: string): Promise<void> {
|
||||
const [planeRows, contractRows] = await Promise.all([
|
||||
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
|
||||
inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId)),
|
||||
]);
|
||||
const contract = contractRows[0];
|
||||
if (!contract) throw new GameActionError(404, `contract ${contractId} not found`);
|
||||
if (contract.status !== 'accepted') {
|
||||
throw new GameActionError(409, `contract is ${contract.status}, it must be accepted before dispatching`);
|
||||
}
|
||||
const plane = planeRows[0];
|
||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||
if (plane.status !== 'available') {
|
||||
throw new GameActionError(409, `plane is ${plane.status}, only available planes can be dispatched`);
|
||||
}
|
||||
if (plane.locationIcao !== contract.originIcao) {
|
||||
throw new GameActionError(
|
||||
409,
|
||||
`plane is at ${plane.locationIcao} but the contract originates at ${contract.originIcao}`,
|
||||
);
|
||||
}
|
||||
|
||||
const type = PLANE_TYPES[plane.model];
|
||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
||||
const origin = await airportByIcao(contract.originIcao);
|
||||
const dest = await airportByIcao(contract.destIcao);
|
||||
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
||||
const burnL = type.burnPerKm * distanceKm;
|
||||
if (burnL > plane.fuel) {
|
||||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(burnL)} L but the plane has ${Math.round(plane.fuel)} L`);
|
||||
}
|
||||
|
||||
// runAction() ticked first, so this is current game time.
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
if (!sim) throw new Error('sim_state row missing');
|
||||
|
||||
await inst.db.update(schema.planes)
|
||||
.set({
|
||||
status: 'flying',
|
||||
contractId,
|
||||
departureSimTime: sim.clockMs,
|
||||
lat: origin.lat,
|
||||
lon: origin.lon,
|
||||
})
|
||||
.where(eq(schema.planes.id, planeId));
|
||||
await inst.db.update(schema.contracts).set({ planeId }).where(eq(schema.contracts.id, contractId));
|
||||
await persist(inst);
|
||||
}
|
||||
|
||||
// --- Market (POST /api/market/buy-plane {model}) ---
|
||||
|
||||
// Buy a catalog model at full price; it arrives 'available' with a full tank at
|
||||
// the home base (EDDF).
|
||||
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> {
|
||||
const type = PLANE_TYPES[model];
|
||||
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
|
||||
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
if (account.cash < type.price) {
|
||||
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${type.price}, you have ${Math.round(account.cash)}`);
|
||||
}
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
// Same hub fallback as the seed (HUB_ICAO, else first airport).
|
||||
const home = fc.features.find((f) => f.properties.icao === HUB_ICAO)?.properties ?? fc.features[0].properties;
|
||||
|
||||
const planeId = randomUUID();
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: planeId,
|
||||
model,
|
||||
status: 'available',
|
||||
fuel: type.maxFuel, // full tank on delivery
|
||||
maxFuel: type.maxFuel,
|
||||
locationIcao: home.icao,
|
||||
contractId: null,
|
||||
lat: home.lat,
|
||||
lon: home.lon,
|
||||
departureSimTime: null,
|
||||
});
|
||||
await inst.db.update(schema.account).set({ cash: account.cash - type.price });
|
||||
await persist(inst);
|
||||
return { planeId };
|
||||
}
|
||||
|
||||
// --- Market (POST /api/market/sell-plane {planeId}) ---
|
||||
|
||||
// Sell a grounded plane for SELL_FRACTION of its catalog price. The last
|
||||
// remaining plane can never be sold — the company needs at least one aircraft.
|
||||
export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout: number }> {
|
||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const plane = rows[0];
|
||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||
if (plane.status !== 'available') {
|
||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
|
||||
}
|
||||
|
||||
const all = await inst.db.select().from(schema.planes);
|
||||
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
|
||||
|
||||
const type = PLANE_TYPES[plane.model];
|
||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
||||
const payout = Math.round(type.price * SELL_FRACTION * 100) / 100;
|
||||
|
||||
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + payout) * 100) / 100 });
|
||||
await persist(inst);
|
||||
return { payout };
|
||||
}
|
||||
|
||||
// --- Refuel (POST /api/planes/:planeId/refuel) ---
|
||||
|
||||
// Fill a grounded plane to maxFuel at FUEL_PRICE_PER_L per missing liter.
|
||||
export async function refuelPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
|
||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const plane = rows[0];
|
||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||
if (plane.status !== 'available') {
|
||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be refueled`);
|
||||
}
|
||||
|
||||
const cost = Math.round((plane.maxFuel - plane.fuel) * FUEL_PRICE_PER_L * 100) / 100;
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
if (cost > 0 && account.cash < cost) {
|
||||
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
|
||||
}
|
||||
|
||||
await inst.db.update(schema.planes).set({ fuel: plane.maxFuel }).where(eq(schema.planes.id, planeId));
|
||||
if (cost > 0) {
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||
}
|
||||
await persist(inst);
|
||||
return { cost };
|
||||
}
|
||||
|
||||
// --- Route helper (response convention, v0.8.0) ---
|
||||
|
||||
// Run an action under the sim lock — tick FIRST so validation/mutation see current
|
||||
// game time — then respond with the FULL current state snapshot (same shape as
|
||||
// GET /api/state), so clients never need a follow-up poll. GameActionError maps to
|
||||
// its own status + {error}; anything unexpected is a 500.
|
||||
export async function performAction(fn: (inst: GameDb) => Promise<unknown>): Promise<Response> {
|
||||
try {
|
||||
await runAction(fn);
|
||||
return Response.json(await getStateSnapshot());
|
||||
} catch (err) {
|
||||
if (err instanceof GameActionError) {
|
||||
return Response.json({ error: err.message }, { status: err.status });
|
||||
}
|
||||
console.error('action failed', err);
|
||||
return Response.json({ error: 'action failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ export interface PlaneType {
|
||||
burnPerKm: number;
|
||||
// Cruise speed, km/h — used by the sim tick to advance planes along routes.
|
||||
speedKmh: number;
|
||||
// Market price in € (v0.8.0): buying costs `price`, selling pays out
|
||||
// SELL_FRACTION × price.
|
||||
price: number;
|
||||
}
|
||||
|
||||
export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||
@@ -19,8 +22,17 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||
maxFuel: 6300,
|
||||
burnPerKm: 1.75, // ~900 kg/h at 510 km/h cruise (Jet-A1 ≈ 0.8 kg/L)
|
||||
speedKmh: 510,
|
||||
// €200k — with the starting cash of €20k and typical hop rewards (~€1–3k),
|
||||
// a second ATR takes sustained play to afford, as intended for the MVP sink.
|
||||
price: 200_000,
|
||||
},
|
||||
};
|
||||
|
||||
// The plane the player starts with.
|
||||
export const STARTER_MODEL = 'atr72';
|
||||
|
||||
// Jet-A1 fuel price in €/liter (v0.8.0): refueling cost = (maxFuel − fuel) × this.
|
||||
export const FUEL_PRICE_PER_L = 0.8;
|
||||
|
||||
// Fraction of the catalog price paid out when selling a plane (v0.8.0).
|
||||
export const SELL_FRACTION = 0.5;
|
||||
|
||||
@@ -163,4 +163,52 @@ describe('generateContract (shared seed/spawn builder)', () => {
|
||||
it('rejects an airport list too small to form a route', () => {
|
||||
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
|
||||
});
|
||||
|
||||
describe('forced endpoints (v0.8.0 spawn coverage + test hook)', () => {
|
||||
it('originIcao forces the origin; dest stays random but distinct', () => {
|
||||
const c = generateContract({ id: 'c-o', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF' });
|
||||
expect(c.originIcao).toBe('EDDF');
|
||||
expect(c.destIcao).not.toBe('EDDF');
|
||||
|
||||
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
||||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
||||
});
|
||||
|
||||
it('destIcao forces the destination; origin stays random but distinct', () => {
|
||||
const c = generateContract({ id: 'c-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, destIcao: 'LEBC' });
|
||||
expect(c.destIcao).toBe('LEBC');
|
||||
expect(c.originIcao).not.toBe('LEBC');
|
||||
});
|
||||
|
||||
it('both forced → the exact requested route with the distance-based reward', () => {
|
||||
const c = generateContract({ id: 'c-b', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM' });
|
||||
expect(c.originIcao).toBe('EDDF');
|
||||
expect(c.destIcao).toBe('EHAM');
|
||||
|
||||
const o = AIRPORTS[0]; // EDDF
|
||||
const d = AIRPORTS[1]; // EHAM
|
||||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
||||
});
|
||||
|
||||
it('is deterministic for a fixed RNG with forced endpoints', () => {
|
||||
const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
|
||||
const b = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('throws on an unknown forced ICAO or identical endpoints', () => {
|
||||
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'XXXX' })).toThrow(/unknown origin/);
|
||||
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, destIcao: 'XXXX' })).toThrow(/unknown destination/);
|
||||
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EDDF' })).toThrow(/must differ/);
|
||||
});
|
||||
|
||||
it('omitting both endpoints keeps the pre-v0.8.0 RNG call order (byte-identical output)', () => {
|
||||
// Same seed as a historical unforced call → same contract, proving forced
|
||||
// endpoints consume no extra RNG draws when absent.
|
||||
const a = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||||
const b = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined });
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+24
-3
@@ -98,19 +98,40 @@ export function rewardForDistanceKm(km: number): number {
|
||||
// deadline now + 6–72h game time (or now + deadlineOffsetMs when given — used by
|
||||
// the e2e test hook to force a near-term expiry). The RNG call order per contract
|
||||
// is fixed: origin, dest, cargoType, cargoCount, [deadline window].
|
||||
// v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule +
|
||||
// the e2e create-contract hook); a forced endpoint consumes no RNG draws, so
|
||||
// callers that omit both get byte-identical behavior to before.
|
||||
export function generateContract(input: {
|
||||
id: string;
|
||||
airports: SeedAirport[];
|
||||
rng: Rng;
|
||||
nowMs: number;
|
||||
deadlineOffsetMs?: number;
|
||||
originIcao?: string;
|
||||
destIcao?: string;
|
||||
}): SeedContract {
|
||||
const { airports, rng, nowMs } = input;
|
||||
if (airports.length < 2) throw new Error('contract generation needs at least two airports');
|
||||
|
||||
const oi = Math.floor(rng() * airports.length);
|
||||
let di = Math.floor(rng() * airports.length);
|
||||
if (di === oi) di = (di + 1) % airports.length;
|
||||
let oi: number;
|
||||
if (input.originIcao !== undefined) {
|
||||
oi = airports.findIndex((a) => a.icao === input.originIcao);
|
||||
if (oi < 0) throw new Error(`unknown origin airport ${input.originIcao}`);
|
||||
} else {
|
||||
oi = Math.floor(rng() * airports.length);
|
||||
}
|
||||
let di: number;
|
||||
if (input.destIcao !== undefined) {
|
||||
di = airports.findIndex((a) => a.icao === input.destIcao);
|
||||
if (di < 0) throw new Error(`unknown destination airport ${input.destIcao}`);
|
||||
// A RANDOM origin may have landed on the forced destination — shift it (no
|
||||
// extra RNG draw), mirroring the unforced collision handling below.
|
||||
if (oi === di && input.originIcao === undefined) oi = (oi + 1) % airports.length;
|
||||
} else {
|
||||
di = Math.floor(rng() * airports.length);
|
||||
if (di === oi) di = (di + 1) % airports.length;
|
||||
}
|
||||
if (oi === di) throw new Error('origin and destination must differ'); // both forced identical
|
||||
const origin = airports[oi];
|
||||
const dest = airports[di];
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ export interface PlaneState {
|
||||
locationIcao: string;
|
||||
locationName: string;
|
||||
contractId: string | null;
|
||||
// Sim-clock departure time of the current flight (v0.8.0) — null when grounded,
|
||||
// set by dispatch while flying so clients can compute progress/ETA from the clock.
|
||||
departureSimTime: number | null;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
@@ -92,6 +95,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
locationIcao: p.locationIcao,
|
||||
locationName: nameOf(p.locationIcao),
|
||||
contractId: p.contractId,
|
||||
departureSimTime: p.departureSimTime,
|
||||
lat: live?.lat ?? p.lat,
|
||||
lon: live?.lon ?? p.lon,
|
||||
};
|
||||
|
||||
+67
-1
@@ -3,9 +3,14 @@ import { describe, expect, it } from 'vitest';
|
||||
import { openApiSpec } from './openapi';
|
||||
|
||||
describe('OpenAPI spec', () => {
|
||||
it('documents exactly the four public endpoints', () => {
|
||||
it('documents exactly the nine public endpoints (4 read + 5 player actions)', () => {
|
||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||
'/api/airports',
|
||||
'/api/contracts/{id}/accept',
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/planes/{planeId}/dispatch',
|
||||
'/api/planes/{planeId}/refuel',
|
||||
'/api/sim',
|
||||
'/api/state',
|
||||
'/api/version',
|
||||
@@ -66,6 +71,7 @@ describe('OpenAPI spec', () => {
|
||||
const plane = openApiSpec.components.schemas.PlaneState.properties;
|
||||
expect(Object.keys(plane).sort()).toEqual([
|
||||
'contractId',
|
||||
'departureSimTime',
|
||||
'fuel',
|
||||
'id',
|
||||
'lat',
|
||||
@@ -122,4 +128,64 @@ describe('OpenAPI spec', () => {
|
||||
const error = openApiSpec.components.schemas.Error.properties;
|
||||
expect(Object.keys(error)).toEqual(['error']);
|
||||
});
|
||||
|
||||
it('documents the five action endpoints: snapshot on 200, Error on their 4xx paths', () => {
|
||||
// The spec object is a literal type; these helpers view it as plain JSON.
|
||||
const post = (p: string) =>
|
||||
((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as {
|
||||
responses: Record<string, unknown>;
|
||||
requestBody?: { content: Record<string, { schema: { $ref: string } }> };
|
||||
parameters?: Array<{ name: string }>;
|
||||
};
|
||||
|
||||
// Every action returns the full StateSnapshot on success (response convention).
|
||||
for (const p of [
|
||||
'/api/contracts/{id}/accept',
|
||||
'/api/planes/{planeId}/dispatch',
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/planes/{planeId}/refuel',
|
||||
]) {
|
||||
const ok = post(p).responses['200'] as { content: Record<string, { schema: { $ref: string } }> };
|
||||
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
|
||||
}
|
||||
|
||||
// Error shapes per endpoint.
|
||||
const errorCodes = (p: string) => Object.keys(post(p).responses).filter((k) => k !== '200').sort();
|
||||
expect(errorCodes('/api/contracts/{id}/accept')).toEqual(['404', '409']);
|
||||
expect(errorCodes('/api/planes/{planeId}/dispatch')).toEqual(['400', '404', '409']);
|
||||
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
|
||||
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
|
||||
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['404', '409']);
|
||||
|
||||
// Request bodies: dispatch/buy/sell take their dedicated schemas; accept/refuel take none.
|
||||
expect(post('/api/planes/{planeId}/dispatch').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||
'#/components/schemas/DispatchRequest',
|
||||
);
|
||||
expect(post('/api/market/buy-plane').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||
'#/components/schemas/BuyPlaneRequest',
|
||||
);
|
||||
expect(post('/api/market/sell-plane').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||
'#/components/schemas/SellPlaneRequest',
|
||||
);
|
||||
expect(post('/api/contracts/{id}/accept').requestBody).toBeUndefined();
|
||||
expect(post('/api/planes/{planeId}/refuel').requestBody).toBeUndefined();
|
||||
|
||||
// Path parameters are declared on the parameterized routes.
|
||||
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
|
||||
expect(paramNames('/api/contracts/{id}/accept')).toEqual(['id']);
|
||||
expect(paramNames('/api/planes/{planeId}/dispatch')).toEqual(['planeId']);
|
||||
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
|
||||
|
||||
// Request schema shapes.
|
||||
const dispatch = openApiSpec.components.schemas.DispatchRequest;
|
||||
expect(dispatch.required).toEqual(['contractId']);
|
||||
expect(dispatch.properties.contractId.type).toBe('string');
|
||||
const buy = openApiSpec.components.schemas.BuyPlaneRequest;
|
||||
expect(buy.required).toEqual(['model']);
|
||||
expect(buy.properties.model.type).toBe('string');
|
||||
const sell = openApiSpec.components.schemas.SellPlaneRequest;
|
||||
expect(sell.required).toEqual(['planeId']);
|
||||
expect(sell.properties.planeId.type).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
+179
-1
@@ -8,7 +8,7 @@ export const openApiSpec = {
|
||||
info: {
|
||||
title: 'Flight Ops API',
|
||||
description:
|
||||
'JSON API for Flight Ops — EU airport data for the map, the live game-state snapshot, and app metadata.',
|
||||
'JSON API for Flight Ops — EU airport data for the map, the live game-state snapshot, player actions (accept / dispatch / buy / sell / refuel), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
|
||||
// Sourced from package.json so it stays in sync with every version bump.
|
||||
version: pkg.version,
|
||||
},
|
||||
@@ -102,6 +102,158 @@ export const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/contracts/{id}/accept': {
|
||||
post: {
|
||||
summary: 'Accept a contract',
|
||||
description:
|
||||
'Claim an open contract: status flips open → accepted (v0.8.0). The sim clock is ticked first, so the returned snapshot reflects current game time. No body.',
|
||||
operationId: 'acceptContract',
|
||||
parameters: [
|
||||
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Accepted — full state snapshot (contract now "accepted")',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown contract id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description: 'Contract is not "open" (accepted / completed / expired)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/planes/{planeId}/dispatch': {
|
||||
post: {
|
||||
summary: 'Dispatch a plane on an accepted contract',
|
||||
description:
|
||||
'Bind an ACCEPTED contract to a grounded plane at its origin (v0.8.0). The plane becomes "flying" with departureSimTime = current sim clock; the engine then flies it analytically and pays out reward + reputation on arrival. Validation: contract must be "accepted", plane "available" and located at the contract origin, with enough fuel for the leg (burnPerKm × great-circle km).',
|
||||
operationId: 'dispatchPlane',
|
||||
parameters: [
|
||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/DispatchRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Dispatched — full state snapshot (plane "flying", departure set)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid body (not an object, unknown field, or missing/non-string contractId)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown plane id or contract id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description:
|
||||
'Contract not "accepted", plane not "available", plane not at the origin, or insufficient fuel for the leg',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/market/buy-plane': {
|
||||
post: {
|
||||
summary: 'Buy a plane from the market',
|
||||
description:
|
||||
'Buy an aircraft at its full catalog price (v0.8.0). The new plane arrives "available" with a full tank at the home base (EDDF). Only models in the in-code catalog are known.',
|
||||
operationId: 'buyPlane',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/BuyPlaneRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Purchased — full state snapshot (new plane, cash reduced)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid body or unknown catalog model',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description: 'Insufficient funds',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/market/sell-plane': {
|
||||
post: {
|
||||
summary: 'Sell a plane on the market',
|
||||
description:
|
||||
'Sell a grounded plane for 50% of its catalog price (v0.8.0). The row is deleted and the payout added to cash. A flying plane cannot be sold, nor can the last remaining plane.',
|
||||
operationId: 'sellPlane',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/SellPlaneRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Sold — full state snapshot (plane gone, cash increased by the payout)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown plane id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description: 'Plane is flying, or it is the last remaining plane',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/planes/{planeId}/refuel': {
|
||||
post: {
|
||||
summary: 'Refuel a grounded plane',
|
||||
description:
|
||||
'Fill a grounded plane to maxFuel (v0.8.0). Cost = (maxFuel − fuel) × fuel price per liter, deducted from cash; no body.',
|
||||
operationId: 'refuelPlane',
|
||||
parameters: [
|
||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Refueled — full state snapshot (fuel at max, cash reduced by the cost)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown plane id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description: 'Plane is flying, or cash is insufficient for the fill-up',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
@@ -202,6 +354,27 @@ export const openApiSpec = {
|
||||
paused: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
DispatchRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
contractId: { type: 'string', description: 'An ACCEPTED contract whose origin the plane is at.' },
|
||||
},
|
||||
required: ['contractId'],
|
||||
},
|
||||
BuyPlaneRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
model: { type: 'string', description: 'Catalog model id (e.g. "atr72").' },
|
||||
},
|
||||
required: ['model'],
|
||||
},
|
||||
SellPlaneRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
planeId: { type: 'string', description: 'A grounded ("available") plane that is not the last remaining one.' },
|
||||
},
|
||||
required: ['planeId'],
|
||||
},
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -220,6 +393,10 @@ export const openApiSpec = {
|
||||
locationIcao: { type: 'string' },
|
||||
locationName: { type: 'string' },
|
||||
contractId: { type: ['string', 'null'] },
|
||||
departureSimTime: {
|
||||
type: ['integer', 'null'],
|
||||
description: 'Sim-clock epoch ms of the current flight (set by dispatch); null when grounded.',
|
||||
},
|
||||
lat: { type: 'number' },
|
||||
lon: { type: 'number' },
|
||||
},
|
||||
@@ -232,6 +409,7 @@ export const openApiSpec = {
|
||||
'locationIcao',
|
||||
'locationName',
|
||||
'contractId',
|
||||
'departureSimTime',
|
||||
'lat',
|
||||
'lon',
|
||||
],
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
finalizeArrivals,
|
||||
MAX_TICK_ELAPSED_MS,
|
||||
reputationGainFor,
|
||||
spawnCoverageContracts,
|
||||
spawnOpenContracts,
|
||||
tickOnce,
|
||||
} from './engine';
|
||||
@@ -257,6 +258,89 @@ describe('spawnOpenContracts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 {
|
||||
|
||||
@@ -75,6 +75,9 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
await expireContracts(inst, clockMs);
|
||||
await finalizeArrivals(inst, clockMs);
|
||||
await spawnOpenContracts(inst, clockMs);
|
||||
// AFTER finalization on purpose: a plane that just landed gets work spawned
|
||||
// at its new location within the same tick.
|
||||
await spawnCoverageContracts(inst, clockMs);
|
||||
}
|
||||
|
||||
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
||||
@@ -180,12 +183,74 @@ export async function spawnOpenContracts(
|
||||
return needed;
|
||||
}
|
||||
|
||||
// Spawn coverage rule (v0.8.0): in addition to topping the market up, every
|
||||
// airport that currently has a grounded 'available' plane must have ≥1 OPEN
|
||||
// contract originating there — so work is always available where the fleet sits
|
||||
// (including onboarding at EDDF). One contract per uncovered airport, generated
|
||||
// with generateContract and origin forced to that airport. Runs after
|
||||
// spawnOpenContracts in tickOnce, so randomly spawned contracts count as coverage
|
||||
// too; and after finalizeArrivals, so a plane that just landed gets work spawned
|
||||
// at its new location within the same tick. Returns the number created.
|
||||
export async function spawnCoverageContracts(
|
||||
inst: GameDb,
|
||||
clockMs: number,
|
||||
rng?: Rng,
|
||||
): Promise<number> {
|
||||
const [planeRows, openRows] = await Promise.all([
|
||||
inst.db.select().from(schema.planes),
|
||||
inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open')),
|
||||
]);
|
||||
|
||||
// Distinct airports with at least one grounded plane (flying planes don't count —
|
||||
// they can't take new work until they land).
|
||||
const groundedIcas = [
|
||||
...new Set(planeRows.filter((p) => p.status === 'available').map((p) => p.locationIcao)),
|
||||
];
|
||||
if (groundedIcas.length === 0) return 0;
|
||||
|
||||
const covered = new Set(openRows.map((c) => c.originIcao));
|
||||
const missing = groundedIcas.filter((icao) => !covered.has(icao));
|
||||
if (missing.length === 0) return 0;
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const airports: SeedAirport[] = fc.features.map((f) => ({
|
||||
icao: f.properties.icao,
|
||||
name: f.properties.name,
|
||||
lat: f.properties.lat,
|
||||
lon: f.properties.lon,
|
||||
}));
|
||||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
||||
|
||||
let n = 0;
|
||||
for (const icao of missing) {
|
||||
if (!airports.some((a) => a.icao === icao)) continue; // defensive — the dataset is the source of all ICACs
|
||||
await inst.db.insert(schema.contracts).values(
|
||||
generateContract({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao }),
|
||||
);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// --- Public API (singleton DB + lock) — call from every read/action endpoint ---
|
||||
|
||||
export function tickNow(): Promise<schema.SimStateRow> {
|
||||
return withLock(async () => tickOnce(await getGameDb()));
|
||||
}
|
||||
|
||||
// Run a game action atomically with respect to ticks (v0.8.0): the clock is
|
||||
// advanced FIRST so validation and mutation see current game time, then fn runs —
|
||||
// all under the same promise lock as tickNow(), so no concurrent request can
|
||||
// interleave between the tick and the mutation. Action functions in
|
||||
// lib/game/actions.ts take an explicit GameDb so unit tests can inject their own.
|
||||
export function runAction<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
|
||||
return withLock(async () => {
|
||||
const inst = await getGameDb();
|
||||
await tickOnce(inst);
|
||||
return fn(inst);
|
||||
});
|
||||
}
|
||||
|
||||
// Tick first so the returned state reflects current time, then apply the new
|
||||
// controls. timeScale is clamped to [1, 60]; validation of raw input happens in
|
||||
// the route handler (400 on non-number / non-boolean).
|
||||
|
||||
Reference in New Issue
Block a user