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' } } }, }, }, }, }, // v1.1.0 — multiplayer step 3 (ISS-107): self-service account creation. '/api/register': { post: { summary: 'Create an account', description: 'Register a new local account (v1.1.0, ISS-107): username 3–20 chars of [a-z0-9_-] (case-insensitive input, stored lowercase), password at least 8 chars — else 400; 409 when the username is taken. Creates the users row (scrypt) AND a players row with the starter economy (the exact values a fresh world seeds: €20,000 cash, reputation 10). Mints a session immediately and returns it, so no second round-trip is needed.', operationId: 'register', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/RegisterRequest' }, }, }, }, responses: { 200: { description: 'Registered — new session id + the user (same shape as login)', content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } }, }, 400: { description: 'Invalid body (not an object, unknown field, bad username charset/length, or password shorter than 8 chars)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 409: { description: 'Username is already taken', 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'], }, // v1.1.0 — multiplayer step 3 (ISS-107). RegisterRequest: { type: 'object', properties: { username: { type: 'string', description: '3–20 chars of [a-z0-9_-]; input is lowercased before validation.' }, password: { type: 'string', description: 'Plaintext, at least 8 chars — stored as a scrypt digest server-side.' }, }, required: ['username', 'password'], }, // 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 ], }, }, }, };