import pkg from '@/package.json'; // OpenAPI 3.1 document for the app's public JSON API, kept in code so it can be // served machine-readable at /api/openapi (see AGENTS.md "## API"). Must be updated // whenever an endpoint changes. export const openApiSpec = { openapi: '3.1.0', info: { title: 'Flight Ops API', description: 'JSON API for Flight Ops — EU airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell / refuel), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.', // Sourced from package.json so it stays in sync with every version bump. version: pkg.version, }, // Empty url = current origin (the spec is served from the same host that runs the API). servers: [{ url: '' }], paths: { '/api/airports': { get: { summary: 'List EU airports', description: 'All fixed-wing EU airports (large, medium and seaplane bases) as a GeoJSON FeatureCollection.', operationId: 'listAirports', responses: { 200: { description: 'GeoJSON FeatureCollection of EU airports', content: { 'application/json': { schema: { $ref: '#/components/schemas/AirportFeatureCollection' }, }, }, }, }, }, }, '/api/state': { get: { summary: 'Game state snapshot', description: 'Full current game state for client polling — account, sim clock, planes and packages, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell/refuel previews locally. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/delivery/spawn are always applied before the snapshot is returned.', operationId: 'getState', responses: { 200: { description: 'Current game state', content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' }, }, }, }, }, }, }, '/api/sim': { post: { summary: 'Set sim time controls', description: 'Advance the sim clock (tick-on-read) and set the time scale / pause state. The clock is advanced first, so the returned simState already reflects current game time; package expiry, arrival delivery and market spawn run inside that tick.', operationId: 'setSimControls', requestBody: { required: false, content: { 'application/json': { schema: { $ref: '#/components/schemas/SimControlsRequest' }, }, }, }, responses: { 200: { description: 'Updated sim state', content: { 'application/json': { schema: { $ref: '#/components/schemas/SimState' }, }, }, }, 400: { description: 'Invalid body (not an object, unknown field, or wrong types)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, }, }, }, }, }, }, '/api/version': { get: { summary: 'App version', description: 'The running app version, read from package.json.', operationId: 'getVersion', responses: { 200: { description: 'Current app version', content: { 'application/json': { schema: { $ref: '#/components/schemas/AppVersion' }, }, }, }, }, }, }, '/api/packages/{id}/load': { post: { summary: 'Load a package onto a plane', description: 'Bind an OPEN package to a GROUNDED ("available") plane at the package\'s origin (v0.12.0). The plane needs a free slot — capacity per catalog model; expired packages still occupy theirs. The sim clock is ticked first, so the returned snapshot reflects current game time.', operationId: 'loadPackage', parameters: [ { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, ], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/LoadPackageRequest' }, }, }, }, responses: { 200: { description: 'Loaded — full state snapshot (package now "loaded" on that plane)', content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, }, 400: { description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 404: { description: 'Unknown package id or plane id', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 409: { description: 'Package not "open", plane not grounded, plane not at the origin, or no free slot on the plane', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, }, }, }, '/api/packages/{id}/abandon': { post: { summary: 'Abandon a loaded package', description: 'Drop a LOADED (or already EXPIRED) package while its plane is GROUNDED (v0.12.0). Penalty = fraction of the payout scaling with time pressure — 15% at creation up to 60% at the deadline; expired packages cost nothing and cash floors at €0. The slot is freed immediately. No body.', operationId: 'abandonPackage', parameters: [ { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, ], responses: { 200: { description: 'Abandoned — full state snapshot (package "abandoned", cash reduced by the penalty)', content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, }, 404: { description: 'Unknown package id', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 409: { description: 'Package not "loaded"/"expired", or its plane is in flight', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, }, }, }, '/api/planes/{planeId}/fly': { post: { summary: 'Fly a plane to an airport', description: 'Manual flight (v0.12.0): send a GROUNDED ("available") plane to any known airport — its loaded packages\' destinations or pure repositioning. The plane becomes "flying" with departureSimTime = current sim clock and destIcao set; the engine flies it analytically, delivers bound cargo on arrival, and grounds it there.', operationId: 'flyPlane', parameters: [ { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, ], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/FlyPlaneRequest' }, }, }, }, responses: { 200: { description: 'Flying — full state snapshot (plane "flying", departure + destination set)', content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, }, 400: { description: 'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 404: { description: 'Unknown plane id', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 409: { description: 'Plane not grounded, or insufficient fuel for the leg (burnPerKm × great-circle km)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, }, }, }, '/api/market/buy-plane': { post: { summary: 'Buy a plane from the market', description: 'Buy an aircraft at its full catalog price (v0.8.0). The new plane arrives "available" with a full tank at the home base (EDDF). Only models in the in-code catalog are known.', operationId: 'buyPlane', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/BuyPlaneRequest' }, }, }, }, responses: { 200: { description: 'Purchased — full state snapshot (new plane, cash reduced)', content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, }, 400: { description: 'Invalid body or unknown catalog model', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 409: { description: 'Insufficient funds', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, }, }, }, '/api/market/sell-plane': { post: { summary: 'Sell a plane on the market', description: 'Sell a grounded plane for 50% of its catalog price (v0.8.0). The row is deleted and the payout added to cash. A flying plane cannot be sold, nor can the last remaining plane.', operationId: 'sellPlane', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/SellPlaneRequest' }, }, }, }, responses: { 200: { description: 'Sold — full state snapshot (plane gone, cash increased by the payout)', content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, }, 400: { description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 404: { description: 'Unknown plane id', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 409: { description: 'Plane is flying, or it is the last remaining plane', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, }, }, }, '/api/planes/{planeId}/refuel': { post: { summary: 'Refuel a grounded plane', description: 'Fill a grounded plane to maxFuel (v0.8.0). Cost = (maxFuel − fuel) × fuel price per liter, deducted from cash; no body.', operationId: 'refuelPlane', parameters: [ { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, ], responses: { 200: { description: 'Refueled — full state snapshot (fuel at max, cash reduced by the cost)', content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, }, 404: { description: 'Unknown plane id', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 409: { description: 'Plane is flying, or cash is insufficient for the fill-up', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, }, }, }, }, components: { schemas: { AppVersion: { type: 'object', properties: { version: { type: 'string', description: 'Semver of the running build' }, }, required: ['version'], }, AirportFeatureCollection: { type: 'object', properties: { type: { const: 'FeatureCollection' }, features: { type: 'array', items: { $ref: '#/components/schemas/AirportFeature' }, }, }, required: ['type', 'features'], }, AirportFeature: { type: 'object', properties: { type: { const: 'Feature' }, geometry: { $ref: '#/components/schemas/PointGeometry' }, properties: { $ref: '#/components/schemas/AirportProperties' }, }, required: ['type', 'geometry', 'properties'], }, PointGeometry: { type: 'object', properties: { type: { const: 'Point' }, coordinates: { type: 'array', description: '[longitude, latitude]', items: { type: 'number' }, minItems: 2, maxItems: 2, }, }, required: ['type', 'coordinates'], }, AirportProperties: { type: 'object', properties: { name: { type: 'string' }, icao: { type: 'string' }, lat: { type: 'number' }, lon: { type: 'number' }, }, required: ['name', 'icao', 'lat', 'lon'], }, StateSnapshot: { type: 'object', properties: { account: { $ref: '#/components/schemas/AccountState' }, simState: { $ref: '#/components/schemas/SimState' }, planes: { type: 'array', items: { $ref: '#/components/schemas/PlaneState' }, }, packages: { type: 'array', items: { $ref: '#/components/schemas/PackageState' }, }, catalog: { type: 'array', description: 'Aircraft catalog (v0.10.0) — static per build, one entry per plane type.', items: { $ref: '#/components/schemas/CatalogEntry' }, }, fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; refuel cost = (maxFuel − fuel) × this.' }, sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' }, }, required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'], }, CatalogEntry: { type: 'object', properties: { model: { type: 'string', description: 'Catalog model id (e.g. "atr72") — used by buy/fly.' }, label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' }, price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' }, maxFuel: { type: 'number', description: 'Fuel capacity, liters.' }, burnPerKm: { type: 'number', description: 'Cruise fuel burn, liters/km.' }, speedKmh: { type: 'number', description: 'Cruise speed, km/h.' }, capacity: { type: 'integer', description: 'Cargo slot capacity — packages that can be loaded on one plane (v0.12.0).' }, }, required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'speedKmh', 'capacity'], }, AccountState: { type: 'object', properties: { cash: { type: 'number' }, reputation: { type: 'number' }, }, required: ['cash', 'reputation'], }, SimState: { type: 'object', properties: { clockMs: { type: 'integer', description: 'Sim-clock epoch ms (game time)' }, timeScale: { type: 'number', minimum: 1, maximum: 64 }, paused: { type: 'boolean' }, }, required: ['clockMs', 'timeScale', 'paused'], }, SimControlsRequest: { type: 'object', description: 'Partial update — omit a field to leave it unchanged.', properties: { timeScale: { type: 'number', minimum: 1, maximum: 64, description: 'Game-time multiplier; values outside [1, 64] are clamped. The UI offers the power-of-two ladder 1/2/4/8/16/32/64.', }, paused: { type: 'boolean' }, }, }, LoadPackageRequest: { type: 'object', properties: { planeId: { type: 'string', description: 'A GROUNDED ("available") plane at the package\'s origin with a free slot.' }, }, required: ['planeId'], }, FlyPlaneRequest: { type: 'object', properties: { destIcao: { type: 'string', description: 'Any airport ICAO from the dataset — a loaded package\'s destination or repositioning.' }, }, required: ['destIcao'], }, BuyPlaneRequest: { type: 'object', properties: { model: { type: 'string', description: 'Catalog model id (e.g. "atr72").' }, }, required: ['model'], }, SellPlaneRequest: { type: 'object', properties: { planeId: { type: 'string', description: 'A grounded ("available") plane that is not the last remaining one.' }, }, required: ['planeId'], }, Error: { type: 'object', properties: { error: { type: 'string' }, }, required: ['error'], }, PlaneState: { type: 'object', properties: { id: { type: 'string' }, model: { type: 'string' }, status: { enum: ['available', 'flying'] }, fuel: { type: 'number' }, maxFuel: { type: 'number' }, locationIcao: { type: 'string' }, locationName: { type: 'string' }, destIcao: { type: ['string', 'null'], description: 'Current flight destination (set by fly, cleared on arrival); null when grounded.', }, destName: { type: ['string', 'null'], description: 'Display name of destIcao; null when grounded.', }, departureSimTime: { type: ['integer', 'null'], description: 'Sim-clock epoch ms of the current flight (set by fly); null when grounded.', }, lat: { type: 'number' }, lon: { type: 'number' }, }, required: [ 'id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'departureSimTime', 'lat', 'lon', ], }, PackageState: { type: 'object', properties: { id: { type: 'string' }, originIcao: { type: 'string' }, originName: { type: 'string' }, destIcao: { type: 'string' }, destName: { type: 'string' }, reward: { type: 'number' }, cargoType: { type: 'string' }, cargoCount: { type: 'integer' }, deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' }, // 'expired' = an open OR loaded package whose deadline passed on a tick; // loaded expired packages keep their slot, deliver no reward, abandon free. status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }, planeId: { type: ['string', 'null'] }, createdSimTime: { type: 'integer' }, }, required: [ 'id', 'originIcao', 'originName', 'destIcao', 'destName', 'reward', 'cargoType', 'cargoCount', 'deadlineSimTime', 'status', 'planeId', 'createdSimTime', ], }, }, }, };