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 { 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 { 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-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings // slow to 1× instead of pausing), and restores the last manually selected speed when this // dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls // + dispatch are atomic under the sim lock. const sets: Partial> = { paused: false }; const remaining = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available')); if (remaining.length === 0) sets.timeScale = sim.lastSelectedTimeScale; await inst.db.update(schema.simState).set(sets); 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 { 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 { 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 }; } // --- Ignore (POST /api/packages/:id/ignore) — ISS-047 part B --- // Dismiss an OPEN job so it leaves the market permanently; no penalty, nothing else // changes. The top-up logic (spawnOpenPackages counts 'open') refills the market with // a new random package on the next tick. 404 unknown id; 409 unless open. export async function ignorePackage(inst: GameDb, packageId: string): Promise { 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 !== 'open') { throw new GameActionError(409, `package is ${pkg.status}, only open packages can be ignored`); } await inst.db.update(schema.packages).set({ status: 'ignored' }).where(eq(schema.packages.id, packageId)); 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, 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`); } // 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`); } 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): Promise { 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 }); } }