931 lines
40 KiB
TypeScript
931 lines
40 KiB
TypeScript
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 — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), admin-panel auth + whole-database tooling (v0.71.0), 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 world airports',
|
||
description:
|
||
'The curated world airport dataset — one marker per metro area (large airports only, close clusters merged to the biggest; see scripts/build-world-airports.mjs) — as a GeoJSON FeatureCollection.',
|
||
operationId: 'listAirports',
|
||
responses: {
|
||
200: {
|
||
description: 'GeoJSON FeatureCollection of world 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 previews locally. The world clock advances on a ~1 s background tick at a fixed rate (v0.52.0, ISS-117: no player-facing time controls), clamped per tick; while no plane is flying or in repair the clock stays frozen. Each call ticks first, 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/events': {
|
||
get: {
|
||
summary: 'Live state stream (SSE)',
|
||
description:
|
||
'Server-Sent Events feed of full game-state snapshots (v0.57.0, ISS-129) — the current snapshot is sent immediately on connect and a fresh one after every sim tick (~1 s), so clients can subscribe instead of polling /api/state. Each event\'s data payload is JSON with exactly the StateSnapshot shape; comment lines starting with ":" are heartbeats.',
|
||
operationId: 'subscribeToEvents',
|
||
responses: {
|
||
200: {
|
||
description: 'SSE stream of state snapshots (text/event-stream)',
|
||
content: {
|
||
'text/event-stream': { schema: { type: 'string' } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
'/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/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. ISS-082 (v0.42.0): dispatch auto-refuels first — the tank is topped up to min(maxFuel, max(fuel, 1.5 × required)) at fuelPricePerL per liter minus a reputation-scaled discount, deducted from cash; no separate refuel endpoint exists anymore.',
|
||
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, plane broken (health ≤ 0 — repair it first, v0.45.0), or the leg does not fit even a FULL tank (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty; dispatch auto-refuels up to min(maxFuel, max(fuel, 1.5 × required)) first, ISS-082), or cash is insufficient for that top-up',
|
||
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 and the plane is not broken by the landing\'s wear (v0.45.0) — with a short tank it auto-refuels to 150% of the requirement (capped at tank capacity) when cash allows (ISS-082); only an unaffordable top-up (or a broken plane) grounds the plane with the queue kept intact for a later 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 catalog price minus a reputation-scaled discount (v0.38.0, ISS-072 — higher reputation buys cheaper; no discount at the starting reputation). 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}/repair': {
|
||
post: {
|
||
summary: 'Repair a grounded plane',
|
||
description:
|
||
'Ground a worn-out plane in "repairing" status for the flat repair cost (v0.39.0, ISS-069): its health climbs back to 100 over 3 sim days — the engine\'s tick applies the curve each tick and flips it back to "available" exactly when full health is reached (v0.45.0, ISS-089). While repairing it cannot load, fly or be sold; a broken plane (health ≤ 0) cannot fly at all until repaired. A full-health repair is a free no-op (cost 0). No body.',
|
||
operationId: 'repairPlane',
|
||
parameters: [
|
||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||
],
|
||
responses: {
|
||
200: {
|
||
description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat 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 not grounded (flying or already repairing), or cash is insufficient for the repair cost',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
// v0.71.0 — admin panel: session auth + generic whole-database CRUD behind the admin flag.
|
||
'/api/login': {
|
||
post: {
|
||
summary: 'Log in',
|
||
description:
|
||
'Verify username/password (scrypt) and mint a 7-day session. The client stores the returned sessionId in localStorage and sends it as the X-Flight-Session header on later requests. An unknown username still runs one full scrypt against a fixed dummy digest, so timing stays uniform.',
|
||
operationId: 'login',
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
'application/json': {
|
||
schema: { $ref: '#/components/schemas/LoginRequest' },
|
||
},
|
||
},
|
||
},
|
||
responses: {
|
||
200: {
|
||
description: 'Authenticated — new session id + the user',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } },
|
||
},
|
||
400: {
|
||
description: 'Invalid body (not an object, unknown field, or missing/non-string username/password)',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
401: {
|
||
description: 'Unknown username or wrong password',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
'/api/logout': {
|
||
post: {
|
||
summary: 'Log out',
|
||
description:
|
||
'Delete the session named by the X-Flight-Session header (no body). Idempotent — deleting an unknown or already-deleted session is still 200.',
|
||
operationId: 'logout',
|
||
responses: {
|
||
200: {
|
||
description: 'Session deleted (or was already gone)',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
|
||
},
|
||
401: {
|
||
description: 'Missing X-Flight-Session header',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
'/api/me': {
|
||
get: {
|
||
summary: 'Current user',
|
||
description:
|
||
'Resolve the X-Flight-Session header to its user, or 401 when the session is missing/unknown/expired. The client uses it to decide what the Admin modal shows on open.',
|
||
operationId: 'getMe',
|
||
responses: {
|
||
200: {
|
||
description: 'The authenticated user',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/SessionUser' } } },
|
||
},
|
||
401: {
|
||
description: 'Missing, unknown or expired session',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
'/api/admin/tables': {
|
||
get: {
|
||
summary: 'List database tables (admin)',
|
||
description:
|
||
'Every user table as [{ name }] in name order, excluding SQLite internals. Requires a valid X-Flight-Session of an ADMIN user — 401 without a live session, 403 for non-admins.',
|
||
operationId: 'listAdminTables',
|
||
responses: {
|
||
200: {
|
||
description: 'The table names',
|
||
content: {
|
||
'application/json': {
|
||
schema: { type: 'array', items: { $ref: '#/components/schemas/AdminTable' } },
|
||
},
|
||
},
|
||
},
|
||
401: {
|
||
description: 'Missing, unknown or expired session',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
403: {
|
||
description: 'Valid session but the user is not an admin',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
'/api/admin/tables/{name}/rows': {
|
||
get: {
|
||
summary: 'Read table rows (admin)',
|
||
description:
|
||
'Column headers + up to `limit` rows of the named table (default 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (sim_state) are editable like any other. Table names are validated against sqlite_master and values are always bound as parameters.',
|
||
operationId: 'listAdminRows',
|
||
parameters: [
|
||
{ name: 'name', in: 'path', required: true, schema: { type: 'string' } },
|
||
{ name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 1000 } },
|
||
],
|
||
responses: {
|
||
200: {
|
||
description: 'Column headers + rows (each row carries its __rowid)',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/AdminRowsResponse' } } },
|
||
},
|
||
400: {
|
||
description: 'limit is not a positive integer',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
401: {
|
||
description: 'Missing, unknown or expired session',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
403: {
|
||
description: 'Valid session but the user is not an admin',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
404: {
|
||
description: 'Unknown table name',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
},
|
||
},
|
||
put: {
|
||
summary: 'Update a row (admin)',
|
||
description:
|
||
'Body { rowid, values }: update only the given columns of that row. Column names are validated against PRAGMA table_info; values are always bound as parameters.',
|
||
operationId: 'updateAdminRow',
|
||
parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }],
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
'application/json': {
|
||
schema: { $ref: '#/components/schemas/UpdateRowRequest' },
|
||
},
|
||
},
|
||
},
|
||
responses: {
|
||
200: {
|
||
description: 'Updated',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
|
||
},
|
||
400: {
|
||
description: 'Invalid body, non-integer rowid, unknown column, or empty values',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
401: {
|
||
description: 'Missing, unknown or expired session',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
403: {
|
||
description: 'Valid session but the user is not an admin',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
404: {
|
||
description: 'Unknown table name',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
},
|
||
},
|
||
post: {
|
||
summary: 'Insert a row (admin)',
|
||
description:
|
||
'Body { values }: insert a new row with exactly the given columns (others take their defaults or NULL). Returns the new rowid.',
|
||
operationId: 'insertAdminRow',
|
||
parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }],
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
'application/json': {
|
||
schema: { $ref: '#/components/schemas/InsertRowRequest' },
|
||
},
|
||
},
|
||
},
|
||
responses: {
|
||
200: {
|
||
description: 'Inserted — the new rowid',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/InsertRowResponse' } } },
|
||
},
|
||
400: {
|
||
description: 'Invalid body, unknown column, or empty values',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
401: {
|
||
description: 'Missing, unknown or expired session',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
403: {
|
||
description: 'Valid session but the user is not an admin',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
404: {
|
||
description: 'Unknown table name',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
409: {
|
||
description: 'Database constraint violation (NOT NULL / UNIQUE)',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
},
|
||
},
|
||
delete: {
|
||
summary: 'Delete a row (admin)',
|
||
description:
|
||
'Query parameter rowid=N deletes that one row. Idempotent — deleting a non-existent rowid is still 200.',
|
||
operationId: 'deleteAdminRow',
|
||
parameters: [
|
||
{ name: 'name', in: 'path', required: true, schema: { type: 'string' } },
|
||
{ name: 'rowid', in: 'query', required: true, schema: { type: 'integer' } },
|
||
],
|
||
responses: {
|
||
200: {
|
||
description: 'Deleted (or was already gone)',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
|
||
},
|
||
400: {
|
||
description: 'Missing or non-integer rowid query parameter',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
401: {
|
||
description: 'Missing, unknown or expired session',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
403: {
|
||
description: 'Valid session but the user is not an admin',
|
||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||
},
|
||
404: {
|
||
description: 'Unknown table name',
|
||
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; the automatic dispatch top-up costs liters × this, reduced by a reputation-scaled discount at refuel time (v0.38.0) — ISS-082 (v0.42.0): it now applies to the auto-refuel on /fly and the engine\'s chained-leg top-ups.' },
|
||
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 € — the full catalog list price. Buying costs this minus a reputation-scaled discount at purchase time (v0.38.0); selling 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', description: 'Reputation — grows with delivered packages; drives the purchase/fuel discounts and job reward quality (v0.38.0, ISS-072).' },
|
||
},
|
||
required: ['cash', 'reputation'],
|
||
},
|
||
SimState: {
|
||
type: 'object',
|
||
properties: {
|
||
clockMs: { type: 'integer', description: 'Sim-clock epoch ms (game time). The world runs at a fixed rate with no player-facing controls (v0.52.0, ISS-117).' },
|
||
},
|
||
required: ['clockMs'],
|
||
},
|
||
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'],
|
||
},
|
||
// v0.71.0 — admin panel auth + DB tooling schemas.
|
||
LoginRequest: {
|
||
type: 'object',
|
||
properties: {
|
||
username: { type: 'string', description: 'Account username (non-empty).' },
|
||
password: { type: 'string', description: 'Plaintext password — verified server-side with scrypt, never stored.' },
|
||
},
|
||
required: ['username', 'password'],
|
||
},
|
||
SessionUser: {
|
||
type: 'object',
|
||
properties: {
|
||
username: { type: 'string' },
|
||
admin: { type: 'boolean', description: 'Whether this account may use the /api/admin/* endpoints.' },
|
||
},
|
||
required: ['username', 'admin'],
|
||
},
|
||
LoginResponse: {
|
||
type: 'object',
|
||
properties: {
|
||
sessionId: { type: 'string', description: 'Opaque session id — store it and send it back as the X-Flight-Session header. Valid 7 days.' },
|
||
user: { $ref: '#/components/schemas/SessionUser' },
|
||
},
|
||
required: ['sessionId', 'user'],
|
||
},
|
||
OkResponse: {
|
||
type: 'object',
|
||
properties: {
|
||
ok: { const: true },
|
||
},
|
||
required: ['ok'],
|
||
},
|
||
AdminTable: {
|
||
type: 'object',
|
||
properties: {
|
||
name: { type: 'string', description: 'SQLite table name (sqlite_% internals excluded).' },
|
||
},
|
||
required: ['name'],
|
||
},
|
||
AdminRowsResponse: {
|
||
type: 'object',
|
||
properties: {
|
||
columns: {
|
||
type: 'array',
|
||
items: { type: 'string' },
|
||
description: 'Column names; the first entry is always __rowid (the rowid pseudo-column).',
|
||
},
|
||
rows: {
|
||
type: 'array',
|
||
items: { type: 'object', additionalProperties: true },
|
||
description: 'Up to `limit` rows, each keyed by column name including __rowid.',
|
||
},
|
||
},
|
||
required: ['columns', 'rows'],
|
||
},
|
||
UpdateRowRequest: {
|
||
type: 'object',
|
||
properties: {
|
||
rowid: { type: 'integer', description: 'The __rowid of the row to update.' },
|
||
values: {
|
||
type: 'object',
|
||
additionalProperties: true,
|
||
description: 'Only the columns to change (validated against PRAGMA table_info); others are untouched.',
|
||
},
|
||
},
|
||
required: ['rowid', 'values'],
|
||
},
|
||
InsertRowRequest: {
|
||
type: 'object',
|
||
properties: {
|
||
values: {
|
||
type: 'object',
|
||
additionalProperties: true,
|
||
description: 'Exactly the columns to set; others take their defaults or NULL.',
|
||
},
|
||
},
|
||
required: ['values'],
|
||
},
|
||
InsertRowResponse: {
|
||
type: 'object',
|
||
properties: {
|
||
ok: { const: true },
|
||
rowid: { type: 'integer', description: 'The __rowid of the inserted row.' },
|
||
},
|
||
required: ['ok', 'rowid'],
|
||
},
|
||
Error: {
|
||
type: 'object',
|
||
properties: {
|
||
error: { type: 'string' },
|
||
},
|
||
required: ['error'],
|
||
},
|
||
PlaneState: {
|
||
type: 'object',
|
||
properties: {
|
||
id: { type: 'string' },
|
||
model: { type: 'string' },
|
||
status: { enum: ['available', 'flying', 'repairing'] }, // v0.39.0 (ISS-069) — "repairing" = grounded in the shop until repairUntilSimTime passes
|
||
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.',
|
||
},
|
||
health: {
|
||
type: 'number',
|
||
description:
|
||
'Structural health in [0, 100] (v0.39.0) — wears down by a fixed amount per km of landed legs; at 0 the plane is broken and cannot fly until repaired (v0.45.0). While "repairing" this doubles as repair progress: it climbs back to 100 over the repair duration.',
|
||
},
|
||
repairUntilSimTime: {
|
||
type: ['integer', 'null'],
|
||
description:
|
||
'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.',
|
||
},
|
||
},
|
||
required: [
|
||
'id',
|
||
'model',
|
||
'status',
|
||
'fuel',
|
||
'maxFuel',
|
||
'locationIcao',
|
||
'locationName',
|
||
'destIcao',
|
||
'destName',
|
||
'departureSimTime',
|
||
'lat',
|
||
'lon',
|
||
'queue',
|
||
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||
],
|
||
},
|
||
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.' },
|
||
distanceKm: { type: 'number', description: 'Great-circle leg distance in km (v0.48.0) — computed server-side from airport coordinates, not stored.' },
|
||
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' },
|
||
expiresRealMs: { type: ['integer', 'null'], description: 'Wall-clock epoch ms when an OPEN job expires (v0.65.0) — createdRealMs + lifetime; null for every non-open status.' },
|
||
},
|
||
required: [
|
||
'id',
|
||
'originIcao',
|
||
'originName',
|
||
'destIcao',
|
||
'destName',
|
||
'reward',
|
||
'cargoType',
|
||
'cargoCount',
|
||
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
|
||
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
|
||
'deadlineSimTime',
|
||
'status',
|
||
'planeId',
|
||
'createdSimTime',
|
||
'expiresRealMs', // v0.65.0 — wall-clock expiry of open jobs; null otherwise
|
||
],
|
||
},
|
||
},
|
||
},
|
||
};
|