import { randomUUID } from 'node:crypto'; import { eq } 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 { HUB_ICAO } from './seed'; // Player actions (v0.8.0) — the core loop, server-side: accept a contract, // dispatch a plane on it, buy/sell planes, 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 contract/plane // status, plane not at origin, insufficient fuel/funds, last plane) // 400 — bad input that isn't an existence problem (unknown catalog 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 }; } // --- Accept (POST /api/contracts/:id/accept) --- // open → accepted. Single-player, so no ownership tracking beyond the status flip; // an accepted contract is claimed by whoever dispatched it first (dispatch checks // 'accepted' and binds the plane). export async function acceptContract(inst: GameDb, contractId: string): Promise { const rows = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId)); const contract = rows[0]; if (!contract) throw new GameActionError(404, `contract ${contractId} not found`); if (contract.status !== 'open') { throw new GameActionError(409, `contract is ${contract.status}, only open contracts can be accepted`); } await inst.db.update(schema.contracts).set({ status: 'accepted' }).where(eq(schema.contracts.id, contractId)); await persist(inst); } // --- Dispatch (POST /api/planes/:planeId/dispatch {contractId}) --- // Bind an accepted contract to a grounded plane at its origin. The v0.7.0 // finalize path then handles the flight: position/fuel are pure functions of the // clock from departureSimTime, and arrival awards cash + reputation automatically. export async function dispatchPlane(inst: GameDb, planeId: string, contractId: string): Promise { const [planeRows, contractRows] = await Promise.all([ inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)), inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId)), ]); const contract = contractRows[0]; if (!contract) throw new GameActionError(404, `contract ${contractId} not found`); if (contract.status !== 'accepted') { throw new GameActionError(409, `contract is ${contract.status}, it must be accepted before dispatching`); } 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 available planes can be dispatched`); } if (plane.locationIcao !== contract.originIcao) { throw new GameActionError( 409, `plane is at ${plane.locationIcao} but the contract originates at ${contract.originIcao}`, ); } 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(contract.originIcao); const dest = await airportByIcao(contract.destIcao); const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.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', contractId, departureSimTime: sim.clockMs, lat: origin.lat, lon: origin.lon, }) .where(eq(schema.planes.id, planeId)); await inst.db.update(schema.contracts).set({ planeId }).where(eq(schema.contracts.id, contractId)); 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, contractId: 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): 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 }); } }