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 enough payload room for the package weight — maxPayloadKg per catalog model; expired packages still occupy theirs (v0.27.0). 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/packages/{id}/ignore': { post: { summary: 'Ignore an open job', description: 'Dismiss an OPEN job so it leaves the market permanently (v0.29.0, ISS-047 part B). No penalty and nothing else changes — the top-up logic spawns a replacement on the next tick. No body.', operationId: 'ignorePackage', parameters: [ { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, ], responses: { 200: { description: 'Ignored — full state snapshot (package "ignored", gone from the open list)', 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 "open"', 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 (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, }, }, }, '/api/planes/{planeId}/queue': { post: { summary: 'Queue a flight leg for a plane', description: 'Append a NOT-yet-started leg to the plane\'s flight queue (v0.25.0, ISS-043). Works whether the plane is grounded or flying — you can pre-build a route; the active leg still starts via /fly and nothing auto-starts here. On landing, the engine auto-chains the first queued leg when fuel covers it (otherwise the plane grounds with the queue kept intact for refuel + re-dispatch).', operationId: 'queueLeg', parameters: [ { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, ], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/QueueLegRequest' }, }, }, }, responses: { 200: { description: 'Queued — full state snapshot (plane.queue now includes the new leg)', 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: 'The destination is already in the flight queue, or (while flying) it equals the active leg\'s destination (v0.34.0, ISS-065)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, }, }, }, '/api/planes/{planeId}/queue/cancel': { post: { summary: 'Cancel a queued flight leg', description: 'Remove a NOT-yet-started leg from the plane\'s queue by index (v0.25.0, ISS-043). Only queued legs are cancellable — the active in-flight leg (destIcao) is never affected.', operationId: 'cancelQueuedLeg', parameters: [ { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, ], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/CancelQueuedLegRequest' }, }, }, }, responses: { 200: { description: 'Cancelled — full state snapshot (plane.queue without the removed leg)', content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, }, 400: { description: 'Invalid body (not an object, unknown field, or non-integer index) or the index is outside [0, queue.length)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 404: { description: 'Unknown plane id', 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: 'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId buys 150% of the weather-adjusted fuel required for that loaded package\'s destination leg, capped at tank capacity — liters = min(maxFuel, max(current fuel, 1.5 × required)) − current fuel, where required is the weight-proportional burn model over the great-circle distance with a ±20% wind adjustment (v0.36.0).', operationId: 'refuelPlane', parameters: [ { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, ], requestBody: { required: false, content: { 'application/json': { schema: { $ref: '#/components/schemas/RefuelRequest' }, }, }, }, responses: { 200: { description: 'Refueled — full state snapshot (fuel raised by exactly the added liters, cash reduced by the cost)', content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, }, 400: { description: 'Invalid body (not an object, unknown field, unknown mode, or forFlight without packageId)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 404: { description: 'Unknown plane id (or unknown package id in forFlight mode)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 409: { description: 'Plane is flying, the package is not loaded on this plane (or is expired), the leg does not fit a full tank, or cash is insufficient for the refuel', 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 at a full tank, liters/km (v0.16.0 weighted model calibrates to this). Actual per-leg burn varies ±20% with wind — departure weather vs route heading (v0.36.0).' }, emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' }, speedKmh: { type: 'number', description: 'Cruise speed, km/h.' }, maxPayloadKg: { type: 'number', description: 'Max payload in kg — total package weight that can be loaded on one plane (v0.27.0, replaces the v0.12.0 slot count).' }, }, required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'emptyWeightKg', 'speedKmh', 'maxPayloadKg'], }, 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: 1000 }, 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: 1000, description: 'Game-time multiplier; values outside [1, 1000] are clamped. The UI offers the ladder 1/2/5/10/25/50/100/250/500/1000.', }, paused: { type: 'boolean' }, }, }, LoadPackageRequest: { type: 'object', properties: { planeId: { type: 'string', description: 'A GROUNDED ("available") plane at the package\'s origin with enough payload room for it (v0.27.0).' }, }, 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'], }, QueueLegRequest: { type: 'object', properties: { destIcao: { type: 'string', description: 'Any airport ICAO from the dataset — appended to the queue as a not-yet-started leg (v0.25.0).' }, }, required: ['destIcao'], }, CancelQueuedLegRequest: { type: 'object', properties: { index: { type: 'integer', description: 'Zero-based position of the queued (not-yet-started) leg to remove; must be within [0, queue.length) (v0.25.0).' }, }, required: ['index'], }, 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'], }, RefuelRequest: { type: 'object', description: 'Optional refuel body (v0.16.0) — omit it entirely or send {} to fill the tank; both fields are optional.', properties: { mode: { enum: ['full', 'forFlight'], description: '"full" (default) fills the tank to maxFuel; "forFlight" buys 150% of the weather-adjusted fuel required for one loaded package\'s destination leg, capped at tank capacity (v0.36.0).', }, packageId: { type: 'string', description: 'Required with mode "forFlight": a package LOADED on this plane — its destIcao is the refuel target.', }, }, }, 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' }, queue: { type: 'array', items: { type: 'string' }, description: 'Upcoming flight legs NOT yet started (v0.25.0) — ordered ICAOs; the active leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.', }, }, required: [ 'id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'departureSimTime', 'lat', 'lon', 'queue', ], }, 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' }, weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' }, special: { type: ['string', 'null'], description: 'Special status from the specials registry (v0.37.0); null = none.' }, 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. // 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market. status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] }, planeId: { type: ['string', 'null'] }, createdSimTime: { type: 'integer' }, }, required: [ 'id', 'originIcao', 'originName', 'destIcao', 'destName', 'reward', 'cargoType', 'cargoCount', 'weightKg', // v0.27.0 (ISS-045) — package weight in kg 'deadlineSimTime', 'status', 'planeId', 'createdSimTime', ], }, }, }, };