270 lines
12 KiB
TypeScript
270 lines
12 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { and, eq, inArray } 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 { abandonPenalty } from './penalty';
|
|
import { HUB_ICAO } from './seed';
|
|
|
|
// Player actions (v0.8.0, package model v0.12.0) — the core loop, server-side:
|
|
// load a package onto a grounded plane at its origin, fly to any airport, deliver
|
|
// on arrival, abandon for a penalty while grounded; plus buy/sell planes and
|
|
// 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 package/plane
|
|
// status, plane not at origin, no free slot, insufficient fuel/funds, last plane)
|
|
// 400 — bad input that isn't an existence problem (unknown airport/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 };
|
|
}
|
|
|
|
// --- Load (POST /api/packages/:id/load {planeId}) ---
|
|
|
|
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
|
|
// origin with a free slot (slots = packages 'loaded' or 'expired' on it — expired
|
|
// cargo keeps occupying its space until delivered/abandoned).
|
|
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> {
|
|
const [pkgRows, planeRows] = await Promise.all([
|
|
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
|
|
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
|
|
]);
|
|
const pkg = pkgRows[0];
|
|
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
|
|
if (pkg.status !== 'open') {
|
|
throw new GameActionError(409, `package is ${pkg.status}, only open packages can be loaded`);
|
|
}
|
|
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 grounded planes can load cargo`);
|
|
}
|
|
if (plane.locationIcao !== pkg.originIcao) {
|
|
throw new GameActionError(
|
|
409,
|
|
`plane is at ${plane.locationIcao} but the package originates at ${pkg.originIcao}`,
|
|
);
|
|
}
|
|
|
|
const type = PLANE_TYPES[plane.model];
|
|
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
|
const occupied = (
|
|
await inst.db.select().from(schema.packages).where(
|
|
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
|
|
)
|
|
).length;
|
|
if (occupied >= type.capacity) {
|
|
throw new GameActionError(409, `no free slots: the ${type.name} carries ${occupied}/${type.capacity} packages`);
|
|
}
|
|
|
|
await inst.db.update(schema.packages)
|
|
.set({ status: 'loaded', planeId })
|
|
.where(eq(schema.packages.id, packageId));
|
|
await persist(inst);
|
|
}
|
|
|
|
// --- Fly (POST /api/planes/:id/fly {destIcao}) ---
|
|
|
|
// Manual flight (v0.12.0): a grounded plane can fly to ANY known airport — its
|
|
// loaded packages' destinations or pure repositioning. On arrival the engine
|
|
// delivers whatever is bound for that airport. Validation: plane 'available',
|
|
// destination in the airport dataset, enough fuel for the leg.
|
|
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
|
|
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
|
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 grounded planes can fly`);
|
|
}
|
|
|
|
const fc = await loadAirportsGeoJson();
|
|
const destFeature = fc.features.find((f) => f.properties.icao === destIcao);
|
|
if (!destFeature) throw new GameActionError(400, `unknown airport ${destIcao}`);
|
|
const origin = await airportByIcao(plane.locationIcao);
|
|
|
|
const type = PLANE_TYPES[plane.model];
|
|
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
|
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.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',
|
|
destIcao,
|
|
departureSimTime: sim.clockMs,
|
|
lat: origin.lat,
|
|
lon: origin.lon,
|
|
})
|
|
.where(eq(schema.planes.id, planeId));
|
|
await persist(inst);
|
|
}
|
|
|
|
// --- Abandon (POST /api/packages/:id/abandon) ---
|
|
|
|
// Drop a package that is on a GROUNDED plane. Penalty = fraction of the payout
|
|
// scaling with time pressure (15% at creation → 60% at the deadline, see
|
|
// lib/game/penalty.ts); an already-expired package abandons for free. Cash never
|
|
// goes below €0. The slot is freed immediately.
|
|
export async function abandonPackage(inst: GameDb, packageId: string): Promise<{ penalty: number }> {
|
|
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
|
const pkg = pkgRows[0];
|
|
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
|
|
if (pkg.status !== 'loaded' && pkg.status !== 'expired') {
|
|
throw new GameActionError(409, `package is ${pkg.status}, only loaded or expired packages can be abandoned`);
|
|
}
|
|
|
|
const plane = pkg.planeId
|
|
? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0]
|
|
: undefined;
|
|
if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`);
|
|
if (plane.status !== 'available') {
|
|
throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`);
|
|
}
|
|
|
|
const sim = (await inst.db.select().from(schema.simState))[0];
|
|
if (!sim) throw new Error('sim_state row missing');
|
|
const penalty = abandonPenalty(pkg, sim.clockMs);
|
|
|
|
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.max(0, Math.round((account.cash - penalty) * 100) / 100) });
|
|
await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
|
|
await persist(inst);
|
|
return { penalty };
|
|
}
|
|
|
|
// --- 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,
|
|
destIcao: 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 });
|
|
}
|
|
}
|