feat: v0.12.0 — contracts → packages: load/fly/abandon gameplay (ATR 72-600 capacity 6, manual flight to any airport, time-scaled abandon penalty 15–60%), one-time DB migration preserving wallet/clock; new endpoints /api/packages/:id/load|abandon + /api/planes/:planeId/fly

This commit is contained in:
2026-09-18 11:02:56 +00:00
parent 0208673da7
commit ab2e9fa54a
49 changed files with 2424 additions and 1409 deletions
+89 -34
View File
@@ -8,7 +8,7 @@ export const openApiSpec = {
info: {
title: 'Flight Ops API',
description:
'JSON API for Flight Ops — EU airport data for the map, the live game-state snapshot, player actions (accept / dispatch / buy / sell / refuel), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
'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,
},
@@ -37,7 +37,7 @@ export const openApiSpec = {
get: {
summary: 'Game state snapshot',
description:
'Full current game state for client polling — account, sim clock, planes and contracts, 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/finalization/spawn are always applied before the snapshot is returned.',
'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: {
@@ -55,7 +55,7 @@ export const openApiSpec = {
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; contract expiry, arrival finalization and market spawn run inside that tick.',
'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,
@@ -102,37 +102,75 @@ export const openApiSpec = {
},
},
},
'/api/contracts/{id}/accept': {
'/api/packages/{id}/load': {
post: {
summary: 'Accept a contract',
summary: 'Load a package onto a plane',
description:
'Claim an open contract: status flips open → accepted (v0.8.0). The sim clock is ticked first, so the returned snapshot reflects current game time. No body.',
operationId: 'acceptContract',
'Bind an OPEN package to a GROUNDED ("available") plane at the package\'s origin (v0.12.0). The plane needs a free slot — capacity per catalog model; expired packages still occupy theirs. The sim clock is ticked first, so the returned snapshot reflects current game time.',
operationId: 'loadPackage',
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/LoadPackageRequest' },
},
},
},
responses: {
200: {
description: 'Accepted — full state snapshot (contract now "accepted")',
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 contract id',
description: 'Unknown package id or plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description: 'Contract is not "open" (accepted / completed / expired)',
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/planes/{planeId}/dispatch': {
'/api/packages/{id}/abandon': {
post: {
summary: 'Dispatch a plane on an accepted contract',
summary: 'Abandon a loaded package',
description:
'Bind an ACCEPTED contract to a grounded plane at its origin (v0.8.0). The plane becomes "flying" with departureSimTime = current sim clock; the engine then flies it analytically and pays out reward + reputation on arrival. Validation: contract must be "accepted", plane "available" and located at the contract origin, with enough fuel for the leg (burnPerKm × great-circle km).',
operationId: 'dispatchPlane',
'Drop a LOADED (or already EXPIRED) package while its plane is GROUNDED (v0.12.0). Penalty = fraction of the payout scaling with time pressure — 15% at creation up to 60% at the deadline; expired packages cost nothing and cash floors at €0. The slot is freed immediately. No body.',
operationId: 'abandonPackage',
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: {
description: 'Abandoned — full state snapshot (package "abandoned", cash reduced by the penalty)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
404: {
description: 'Unknown package id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description: 'Package not "loaded"/"expired", or its plane is in flight',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
'/api/planes/{planeId}/fly': {
post: {
summary: 'Fly a plane to an airport',
description:
'Manual flight (v0.12.0): send a GROUNDED ("available") plane to any known airport — its loaded packages\' destinations or pure repositioning. The plane becomes "flying" with departureSimTime = current sim clock and destIcao set; the engine flies it analytically, delivers bound cargo on arrival, and grounds it there.',
operationId: 'flyPlane',
parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
],
@@ -140,26 +178,26 @@ export const openApiSpec = {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/DispatchRequest' },
schema: { $ref: '#/components/schemas/FlyPlaneRequest' },
},
},
},
responses: {
200: {
description: 'Dispatched — full state snapshot (plane "flying", departure set)',
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 contractId)',
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 or contract id',
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description:
'Contract not "accepted", plane not "available", plane not at the origin, or insufficient fuel for the leg',
description: 'Plane not grounded, or insufficient fuel for the leg (burnPerKm × great-circle km)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
@@ -317,9 +355,9 @@ export const openApiSpec = {
type: 'array',
items: { $ref: '#/components/schemas/PlaneState' },
},
contracts: {
packages: {
type: 'array',
items: { $ref: '#/components/schemas/ContractState' },
items: { $ref: '#/components/schemas/PackageState' },
},
catalog: {
type: 'array',
@@ -329,19 +367,20 @@ export const openApiSpec = {
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', 'contracts', 'catalog', 'fuelPricePerL', 'sellFraction'],
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/dispatch.' },
model: { type: 'string', description: 'Catalog model id (e.g. "atr72") — used by buy/fly.' },
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' },
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
burnPerKm: { type: 'number', description: 'Cruise fuel burn, liters/km.' },
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
capacity: { type: 'integer', description: 'Cargo slot capacity — packages that can be loaded on one plane (v0.12.0).' },
},
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'speedKmh'],
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'speedKmh', 'capacity'],
},
AccountState: {
type: 'object',
@@ -373,12 +412,19 @@ export const openApiSpec = {
paused: { type: 'boolean' },
},
},
DispatchRequest: {
LoadPackageRequest: {
type: 'object',
properties: {
contractId: { type: 'string', description: 'An ACCEPTED contract whose origin the plane is at.' },
planeId: { type: 'string', description: 'A GROUNDED ("available") plane at the package\'s origin with a free slot.' },
},
required: ['contractId'],
required: ['planeId'],
},
FlyPlaneRequest: {
type: 'object',
properties: {
destIcao: { type: 'string', description: 'Any airport ICAO from the dataset — a loaded package\'s destination or repositioning.' },
},
required: ['destIcao'],
},
BuyPlaneRequest: {
type: 'object',
@@ -411,10 +457,17 @@ export const openApiSpec = {
maxFuel: { type: 'number' },
locationIcao: { type: 'string' },
locationName: { type: 'string' },
contractId: { type: ['string', 'null'] },
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 dispatch); null when grounded.',
description: 'Sim-clock epoch ms of the current flight (set by fly); null when grounded.',
},
lat: { type: 'number' },
lon: { type: 'number' },
@@ -427,13 +480,14 @@ export const openApiSpec = {
'maxFuel',
'locationIcao',
'locationName',
'contractId',
'destIcao',
'destName',
'departureSimTime',
'lat',
'lon',
],
},
ContractState: {
PackageState: {
type: 'object',
properties: {
id: { type: 'string' },
@@ -445,8 +499,9 @@ export const openApiSpec = {
cargoType: { type: 'string' },
cargoCount: { type: 'integer' },
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
// 'expired' = an open contract whose deadline passed; accepted/in-flight contracts never expire.
status: { enum: ['open', 'accepted', 'completed', 'expired'] },
// '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' },
},