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, 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 { 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. 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 { 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 account = (await inst.db.select().from(schema.account))[0]; if (!account) throw new Error('account row missing'); const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100; if (cost > 0 && account.cash < cost) { throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.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 inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 }); } // 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 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 account = (await inst.db.select().from(schema.account))[0]; if (!account) throw new Error('account row missing'); // v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction. const cost = Math.round(type.price * (1 - repRate(account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100; if (account.cash < cost) { throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, 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, health: 100, // new planes arrive at full health (v0.39.0, ISS-069) }); await inst.db.update(schema.account).set({ cash: Math.round((account.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 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 }; } // --- 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 account = (await inst.db.select().from(schema.account))[0]; if (!account) throw new Error('account row missing'); if (account.cash < REPAIR_COST) { throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.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 inst.db.update(schema.account).set({ cash: Math.round((account.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): 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 }); } }