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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user