CI / build + unit + e2e (pull_request) Successful in 8m2s
Packages carry a real weightKg (unit weight x count with +/-30% jitter), plane capacity becomes maxPayloadKg instead of 6 slots, load validation sums cargo weight against the payload, and the payout gains a per-kg part. Loadout display shows tonnage; OpenAPI swaps CatalogEntry.capacity for maxPayloadKg and adds PackageState.weightKg.
361 lines
17 KiB
TypeScript
361 lines
17 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 { requiredFuelFor } from '@/lib/sim/movement';
|
||
import { runAction } from '@/lib/sim/engine';
|
||
import { getStateSnapshot, parseIcaoQueue } 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 payload room, 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 enough payload room (v0.27.0: used kg = sum of weightKg over 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({ weightKg: schema.packages.weightKg }).from(schema.packages).where(
|
||
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
|
||
);
|
||
const usedKg = occupied.reduce((sum, r) => sum + (r.weightKg ?? 0), 0);
|
||
if (usedKg + pkg.weightKg > type.maxPayloadKg) {
|
||
throw new GameActionError(409, `no payload room: the ${type.name} carries ${Math.round(usedKg)}/${type.maxPayloadKg} kg`);
|
||
}
|
||
|
||
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);
|
||
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty.
|
||
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
||
if (requiredL > plane.fuel) {
|
||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} 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));
|
||
// ISS-041: starting a flight resumes a paused sim (the last landing pauses it). Runs inside
|
||
// runAction(), so tick + unpause + dispatch are atomic under the sim lock.
|
||
await inst.db.update(schema.simState).set({ paused: false });
|
||
await persist(inst);
|
||
}
|
||
|
||
// --- Queue (POST /api/planes/:id/queue {destIcao}) — ISS-043 part 1 ---
|
||
|
||
// Append a NOT-yet-started leg to the plane's queue. Works whether the plane is
|
||
// grounded or flying (you can pre-build a route); nothing auto-starts here — the
|
||
// active leg still starts via flyPlane, and queued legs chain on landing when fuel
|
||
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
|
||
// in the airport dataset.
|
||
export async function queueLeg(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`);
|
||
|
||
const fc = await loadAirportsGeoJson();
|
||
if (!fc.features.some((f) => f.properties.icao === destIcao)) {
|
||
throw new GameActionError(400, `unknown airport ${destIcao}`);
|
||
}
|
||
|
||
const queue = parseIcaoQueue(plane.queue);
|
||
queue.push(destIcao);
|
||
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
|
||
await persist(inst);
|
||
}
|
||
|
||
// --- Queue cancel (POST /api/planes/:id/queue/cancel {index}) — ISS-043 part 1 ---
|
||
|
||
// Remove a NOT-yet-started leg from the queue by index. Only queued legs are
|
||
// cancellable — the active in-flight leg (destIcao) is never affected. Validation:
|
||
// known plane, integer index within [0, queue.length).
|
||
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number): 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`);
|
||
|
||
const queue = parseIcaoQueue(plane.queue);
|
||
if (index < 0 || index >= queue.length) {
|
||
throw new GameActionError(400, `queue index ${index} out of range — the queue holds ${queue.length} legs`);
|
||
}
|
||
queue.splice(index, 1);
|
||
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).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) ---
|
||
|
||
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' adds just
|
||
// enough fuel for one loaded package's destination leg — required liters per the
|
||
// weighted burn model, independent of how much is already in the tank.
|
||
export interface RefuelOptions {
|
||
mode?: 'full' | 'forFlight';
|
||
packageId?: string;
|
||
}
|
||
|
||
// Fill a grounded plane to maxFuel at FUEL_PRICE_PER_L per missing liter — or, with
|
||
// {mode:'forFlight', packageId}, top it up just enough to fly the loaded package's
|
||
// destination leg (409 if the package isn't loaded on this plane, is expired, or the
|
||
// leg doesn't fit a full tank).
|
||
export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOptions = {}): 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 mode = opts.mode ?? 'full';
|
||
let targetFuel: number; // tank level after the refuel
|
||
if (mode === 'forFlight') {
|
||
if (!opts.packageId) throw new GameActionError(400, 'packageId is required when mode is "forFlight"');
|
||
|
||
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, opts.packageId));
|
||
const pkg = pkgRows[0];
|
||
if (!pkg) throw new GameActionError(404, `package ${opts.packageId} not found`);
|
||
if (pkg.status !== 'loaded' || pkg.planeId !== plane.id) {
|
||
const where = pkg.planeId === plane.id ? '' : ', and it is bound to another plane';
|
||
throw new GameActionError(409, `only a package loaded on this plane can be refueled for (it is ${pkg.status}${where})`);
|
||
}
|
||
|
||
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(plane.locationIcao);
|
||
const dest = await airportByIcao(pkg.destIcao);
|
||
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
||
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
||
if (requiredL > plane.maxFuel) {
|
||
throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`);
|
||
}
|
||
// Land EXACTLY on the leg requirement when topping up: flyPlane re-computes this same value and
|
||
// compares with strict `>`, so fuel := requiredL guarantees the leg is feasible. The alternative
|
||
// (fuel + liters) can round a hair BELOW R in IEEE754 — f + fl(R − f) is not guaranteed ≥ R — and
|
||
// would 409 on an otherwise-feasible leg. Already enough → free no-op, tank untouched.
|
||
targetFuel = Math.max(plane.fuel, requiredL);
|
||
} else {
|
||
// Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality.
|
||
targetFuel = plane.maxFuel;
|
||
}
|
||
|
||
const liters = Math.max(0, targetFuel - plane.fuel);
|
||
const cost = Math.round(liters * 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: targetFuel }).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 });
|
||
}
|
||
}
|