396 lines
20 KiB
TypeScript
396 lines
20 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
||
import { and, eq, inArray } from 'drizzle-orm';
|
||
import * as schema from '@/lib/db/schema';
|
||
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
||
import { persist, type GameDb } from '@/lib/db/client';
|
||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||
import { weatherFor } from '@/lib/weather';
|
||
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||
import { runAction } from '@/lib/sim/engine';
|
||
import { getStateSnapshot, parseIcaoQueue } from './state';
|
||
import {
|
||
FUEL_DISCOUNT_PER_REP,
|
||
FUEL_PRICE_PER_L,
|
||
MAX_FUEL_DISCOUNT,
|
||
MAX_PLANE_DISCOUNT,
|
||
PLANE_DISCOUNT_PER_REP,
|
||
PLANE_TYPES,
|
||
REPAIR_COST,
|
||
REPAIR_DURATION_MS,
|
||
repRate,
|
||
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 (dispatch
|
||
// auto-refuels first — ISS-082), deliver on arrival, abandon for a penalty while
|
||
// grounded; plus buy/sell planes. 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`);
|
||
}
|
||
|
||
// v0.65.0: taking the job starts its slot's refill cooldown — the pool spawner holds this
|
||
// (origin, band) empty for the delay after now so the board visibly refreshes.
|
||
await inst.db.update(schema.packages)
|
||
.set({ status: 'loaded', planeId, slotFreeAtRealMs: Date.now() })
|
||
.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. ISS-082 (v0.42.0):
|
||
// dispatch AUTO-refuels first — the tank is topped up to min(maxFuel, max(fuel, 1.5 × required))
|
||
// at FUEL_PRICE_PER_L per liter minus the rep-scaled discount, deducted from cash; a leg that
|
||
// doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s
|
||
// with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it
|
||
// 409s before any refuel math, so fuel and cash stay untouched.
|
||
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`);
|
||
}
|
||
if (plane.health <= 0) {
|
||
throw new GameActionError(409, 'the plane is broken (health 0) — repair it first');
|
||
}
|
||
|
||
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; wind-
|
||
// adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
|
||
const w = weatherFor(plane.locationIcao);
|
||
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon));
|
||
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
||
|
||
// ISS-082 (v0.42.0): dispatch auto-refuels — top up to 150% of the requirement (capped at tank
|
||
// capacity) so burn variance doesn't strand the plane; cost = liters × FUEL_PRICE_PER_L minus
|
||
// the rep-scaled discount (same price term as the removed manual refuel). All checks run BEFORE
|
||
// any mutation, so a 409 leaves fuel and cash untouched.
|
||
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
|
||
const liters = Math.max(0, target - plane.fuel);
|
||
const player = await getLocalPlayer(inst);
|
||
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(player.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
||
if (cost > 0 && player.cash < cost) {
|
||
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(player.cash)}`);
|
||
}
|
||
// Backstop: a leg that doesn't fit even a full tank is unflyable (target caps at maxFuel).
|
||
if (requiredL > target) {
|
||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(target)} 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');
|
||
|
||
// v0.46.0 (ISS-088): flying to the FIRST queued leg consumes it — otherwise the engine would
|
||
// chain a zero-distance self-hop on landing (queue[0] === the airport just reached). Skip-ahead
|
||
// to a non-first queued dest is an explicit override: the queue stays intact, FIFO preserved.
|
||
const queue = parseIcaoQueue(plane.queue);
|
||
if (queue.length > 0 && queue[0] === destIcao) queue.shift();
|
||
|
||
await inst.db.update(schema.planes)
|
||
.set({
|
||
status: 'flying',
|
||
destIcao,
|
||
departureSimTime: sim.clockMs,
|
||
lat: origin.lat,
|
||
lon: origin.lon,
|
||
fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed)
|
||
queue: JSON.stringify(queue),
|
||
})
|
||
.where(eq(schema.planes.id, planeId));
|
||
if (cost > 0) {
|
||
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - cost) * 100) / 100 });
|
||
}
|
||
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}`);
|
||
}
|
||
|
||
// ISS-065: the same route can't be queued twice — re-queuing the active leg's destination or a
|
||
// leg already in the queue would only produce pointless repeated hops on landing.
|
||
if (plane.status === 'flying' && plane.destIcao === destIcao) {
|
||
throw new GameActionError(409, `already flying to ${destIcao}`);
|
||
}
|
||
|
||
const queue = parseIcaoQueue(plane.queue);
|
||
if (queue.includes(destIcao)) {
|
||
throw new GameActionError(409, `${destIcao} is already in the flight 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 player = await getLocalPlayer(inst);
|
||
await updateLocalPlayerEconomy(inst, { cash: Math.max(0, Math.round((player.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 its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
|
||
// reputation, exactly full price at the starting value); 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 player = await getLocalPlayer(inst);
|
||
// v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction.
|
||
const cost = Math.round(type.price * (1 - repRate(player.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
|
||
if (player.cash < cost) {
|
||
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(player.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,
|
||
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
|
||
});
|
||
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - cost) * 100) / 100 });
|
||
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`);
|
||
}
|
||
|
||
// v0.28.0: selling a plane that still carries cargo would orphan it — the packages stay
|
||
// 'loaded'/'expired' with a dangling planeId and could never deliver (same query pattern as loadPackage).
|
||
const carrying = await inst.db.select({ id: schema.packages.id }).from(schema.packages).where(
|
||
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
|
||
);
|
||
if (carrying.length > 0) {
|
||
throw new GameActionError(409, `cannot sell a plane carrying cargo`);
|
||
}
|
||
|
||
// v0.46.0 (ISS-088): selling a plane with queued legs would silently drop them — force an explicit cancel first.
|
||
if (parseIcaoQueue(plane.queue).length > 0) {
|
||
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
|
||
}
|
||
|
||
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 player = await getLocalPlayer(inst);
|
||
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + payout) * 100) / 100 });
|
||
await persist(inst);
|
||
return { payout };
|
||
}
|
||
|
||
// --- Repair (POST /api/planes/:planeId/repair) — v0.39.0 (ISS-069) ---
|
||
|
||
// Ground a worn-out plane in 'repairing' status for the flat REPAIR_COST (v0.39.0, ISS-069).
|
||
// Since v0.45.0 (ISS-089) the repair is GRADUAL: health stays where it is and climbs back to 100
|
||
// over REPAIR_DURATION_MS of sim time — the engine's finishRepairs pass applies the curve each tick
|
||
// and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
|
||
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
|
||
// plane; 409 not grounded / insufficient funds.
|
||
export async function repairPlane(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 repaired`);
|
||
}
|
||
// Free no-op when the plane is already at full health — nothing to repair.
|
||
if (plane.health >= 100) return { cost: 0 };
|
||
|
||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||
if (!sim) throw new Error('sim_state row missing');
|
||
const player = await getLocalPlayer(inst);
|
||
if (player.cash < REPAIR_COST) {
|
||
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(player.cash)}`);
|
||
}
|
||
|
||
// Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate.
|
||
await inst.db.update(schema.planes)
|
||
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
|
||
.where(eq(schema.planes.id, planeId));
|
||
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - REPAIR_COST) * 100) / 100 });
|
||
await persist(inst);
|
||
return { cost: REPAIR_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 });
|
||
}
|
||
}
|