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
+39 -27
View File
@@ -3,13 +3,14 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => {
it('documents exactly the nine public endpoints (4 read + 5 player actions)', () => {
it('documents exactly the ten public endpoints (4 read + 6 player actions)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/airports',
'/api/contracts/{id}/accept',
'/api/market/buy-plane',
'/api/market/sell-plane',
'/api/planes/{planeId}/dispatch',
'/api/packages/{id}/abandon',
'/api/packages/{id}/load',
'/api/planes/{planeId}/fly',
'/api/planes/{planeId}/refuel',
'/api/sim',
'/api/state',
@@ -57,7 +58,7 @@ describe('OpenAPI spec', () => {
expect(props.version.type).toBe('string');
});
it('describes /api/state as a StateSnapshot with account, simState, planes and contracts', () => {
it('describes /api/state as a StateSnapshot with account, simState, planes and packages', () => {
const schema = openApiSpec.paths['/api/state'].get.responses[200].content['application/json'].schema;
expect(schema.$ref).toBe('#/components/schemas/StateSnapshot');
@@ -65,8 +66,8 @@ describe('OpenAPI spec', () => {
expect(Object.keys(snap).sort()).toEqual([
'account',
'catalog',
'contracts',
'fuelPricePerL',
'packages',
'planes',
'sellFraction',
'simState',
@@ -74,8 +75,8 @@ describe('OpenAPI spec', () => {
expect(openApiSpec.components.schemas.StateSnapshot.required.sort()).toEqual([
'account',
'catalog',
'contracts',
'fuelPricePerL',
'packages',
'planes',
'sellFraction',
'simState',
@@ -83,23 +84,25 @@ describe('OpenAPI spec', () => {
expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
expect(snap.contracts.items.$ref).toBe('#/components/schemas/ContractState');
expect(snap.packages.items.$ref).toBe('#/components/schemas/PackageState');
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
expect(snap.fuelPricePerL.type).toBe('number');
expect(snap.sellFraction.type).toBe('number');
const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
expect(Object.keys(catalog).sort()).toEqual(['burnPerKm', 'label', 'maxFuel', 'model', 'price', 'speedKmh']);
expect(Object.keys(catalog).sort()).toEqual(['burnPerKm', 'capacity', 'label', 'maxFuel', 'model', 'price', 'speedKmh']);
for (const key of ['burnPerKm', 'maxFuel', 'price', 'speedKmh'] as const) {
expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number');
}
expect(catalog.capacity.type).toBe('integer'); // v0.12.0 slot capacity
expect(catalog.model.type).toBe('string');
expect(catalog.label.type).toBe('string');
const plane = openApiSpec.components.schemas.PlaneState.properties;
expect(Object.keys(plane).sort()).toEqual([
'contractId',
'departureSimTime',
'destIcao',
'destName',
'fuel',
'id',
'lat',
@@ -111,8 +114,8 @@ describe('OpenAPI spec', () => {
'status',
]);
const contract = openApiSpec.components.schemas.ContractState.properties;
expect(Object.keys(contract).sort()).toEqual([
const pkg = openApiSpec.components.schemas.PackageState.properties;
expect(Object.keys(pkg).sort()).toEqual([
'cargoCount',
'cargoType',
'createdSimTime',
@@ -130,8 +133,8 @@ describe('OpenAPI spec', () => {
const sim = openApiSpec.components.schemas.SimState.properties;
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']);
const contractStatus = openApiSpec.components.schemas.ContractState.properties.status;
expect(contractStatus.enum.sort()).toEqual(['accepted', 'completed', 'expired', 'open']);
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']);
});
it('describes POST /api/sim as SimControlsRequest → SimState with a 400 error shape', () => {
@@ -157,7 +160,7 @@ describe('OpenAPI spec', () => {
expect(Object.keys(error)).toEqual(['error']);
});
it('documents the five action endpoints: snapshot on 200, Error on their 4xx paths', () => {
it('documents the six action endpoints: snapshot on 200, Error on their 4xx paths', () => {
// The spec object is a literal type; these helpers view it as plain JSON.
const post = (p: string) =>
((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as {
@@ -168,8 +171,9 @@ describe('OpenAPI spec', () => {
// Every action returns the full StateSnapshot on success (response convention).
for (const p of [
'/api/contracts/{id}/accept',
'/api/planes/{planeId}/dispatch',
'/api/packages/{id}/load',
'/api/packages/{id}/abandon',
'/api/planes/{planeId}/fly',
'/api/market/buy-plane',
'/api/market/sell-plane',
'/api/planes/{planeId}/refuel',
@@ -180,15 +184,19 @@ describe('OpenAPI spec', () => {
// Error shapes per endpoint.
const errorCodes = (p: string) => Object.keys(post(p).responses).filter((k) => k !== '200').sort();
expect(errorCodes('/api/contracts/{id}/accept')).toEqual(['404', '409']);
expect(errorCodes('/api/planes/{planeId}/dispatch')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['404', '409']);
// Request bodies: dispatch/buy/sell take their dedicated schemas; accept/refuel take none.
expect(post('/api/planes/{planeId}/dispatch').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/DispatchRequest',
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon/refuel take none.
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/LoadPackageRequest',
);
expect(post('/api/planes/{planeId}/fly').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/FlyPlaneRequest',
);
expect(post('/api/market/buy-plane').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/BuyPlaneRequest',
@@ -196,19 +204,23 @@ describe('OpenAPI spec', () => {
expect(post('/api/market/sell-plane').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/SellPlaneRequest',
);
expect(post('/api/contracts/{id}/accept').requestBody).toBeUndefined();
expect(post('/api/packages/{id}/abandon').requestBody).toBeUndefined();
expect(post('/api/planes/{planeId}/refuel').requestBody).toBeUndefined();
// Path parameters are declared on the parameterized routes.
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
expect(paramNames('/api/contracts/{id}/accept')).toEqual(['id']);
expect(paramNames('/api/planes/{planeId}/dispatch')).toEqual(['planeId']);
expect(paramNames('/api/packages/{id}/load')).toEqual(['id']);
expect(paramNames('/api/packages/{id}/abandon')).toEqual(['id']);
expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']);
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
// Request schema shapes.
const dispatch = openApiSpec.components.schemas.DispatchRequest;
expect(dispatch.required).toEqual(['contractId']);
expect(dispatch.properties.contractId.type).toBe('string');
const load = openApiSpec.components.schemas.LoadPackageRequest;
expect(load.required).toEqual(['planeId']);
expect(load.properties.planeId.type).toBe('string');
const fly = openApiSpec.components.schemas.FlyPlaneRequest;
expect(fly.required).toEqual(['destIcao']);
expect(fly.properties.destIcao.type).toBe('string');
const buy = openApiSpec.components.schemas.BuyPlaneRequest;
expect(buy.required).toEqual(['model']);
expect(buy.properties.model.type).toBe('string');