From fce27ff6ca67ecd951f211d761038cfda337454f Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 23:38:11 +0000 Subject: [PATCH] api+openapi: GET /api/ship + POST upgrade/expand/build with 400/404/409/422 mapping --- app/api/ship/build/route.ts | 28 ++++ app/api/ship/expand/route.ts | 28 ++++ app/api/ship/route.ts | 8 ++ app/api/ship/upgrade/route.ts | 28 ++++ lib/openapi.test.ts | 25 ++++ lib/openapi.ts | 237 ++++++++++++++++++++++++++++++++++ 6 files changed, 354 insertions(+) create mode 100644 app/api/ship/build/route.ts create mode 100644 app/api/ship/expand/route.ts create mode 100644 app/api/ship/route.ts create mode 100644 app/api/ship/upgrade/route.ts diff --git a/app/api/ship/build/route.ts b/app/api/ship/build/route.ts new file mode 100644 index 0000000..56775ae --- /dev/null +++ b/app/api/ship/build/route.ts @@ -0,0 +1,28 @@ +import { getGameDb } from '@/lib/db/client' +import { ShipError, buildSystem } from '@/lib/game/ship' + +// Build an unbuilt ship system type at base values. Body: { "type": string }. +// Costs inventory items + a turn; returns the fresh ship state. +export async function POST(req: Request) { + let body: unknown + try { + body = await req.json() + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const type = (body as { type?: unknown }).type + if (typeof type !== 'string' || type.length === 0) { + return Response.json({ error: 'type must be a non-empty string' }, { status: 400 }) + } + + try { + const db = await getGameDb() + return Response.json(await buildSystem(db, type)) + } catch (err) { + if (err instanceof ShipError) { + return Response.json({ error: err.message }, { status: err.status }) + } + throw err + } +} diff --git a/app/api/ship/expand/route.ts b/app/api/ship/expand/route.ts new file mode 100644 index 0000000..34a0e82 --- /dev/null +++ b/app/api/ship/expand/route.ts @@ -0,0 +1,28 @@ +import { getGameDb } from '@/lib/db/client' +import { ShipError, expandSystem } from '@/lib/game/ship' + +// Raise a built ship system's capacity by one (capped at max capacity). Body: { "systemId": number }. +// Costs inventory items + a turn; returns the fresh ship state. +export async function POST(req: Request) { + let body: unknown + try { + body = await req.json() + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const systemId = (body as { systemId?: unknown }).systemId + if (typeof systemId !== 'number' || !Number.isInteger(systemId)) { + return Response.json({ error: 'systemId must be an integer' }, { status: 400 }) + } + + try { + const db = await getGameDb() + return Response.json(await expandSystem(db, systemId)) + } catch (err) { + if (err instanceof ShipError) { + return Response.json({ error: err.message }, { status: err.status }) + } + throw err + } +} diff --git a/app/api/ship/route.ts b/app/api/ship/route.ts new file mode 100644 index 0000000..dd70995 --- /dev/null +++ b/app/api/ship/route.ts @@ -0,0 +1,8 @@ +import { getGameDb } from '@/lib/db/client' +import { getShipState } from '@/lib/game/ship' + +// The ship's systems + cargo hold + turn; auto-initializes on first hit. +export async function GET() { + const db = await getGameDb() + return Response.json(await getShipState(db)) +} diff --git a/app/api/ship/upgrade/route.ts b/app/api/ship/upgrade/route.ts new file mode 100644 index 0000000..3e44660 --- /dev/null +++ b/app/api/ship/upgrade/route.ts @@ -0,0 +1,28 @@ +import { getGameDb } from '@/lib/db/client' +import { ShipError, upgradeSystem } from '@/lib/game/ship' + +// Level up a built ship system by one (capped at max level). Body: { "systemId": number }. +// Costs inventory items + a turn; returns the fresh ship state. +export async function POST(req: Request) { + let body: unknown + try { + body = await req.json() + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const systemId = (body as { systemId?: unknown }).systemId + if (typeof systemId !== 'number' || !Number.isInteger(systemId)) { + return Response.json({ error: 'systemId must be an integer' }, { status: 400 }) + } + + try { + const db = await getGameDb() + return Response.json(await upgradeSystem(db, systemId)) + } catch (err) { + if (err instanceof ShipError) { + return Response.json({ error: err.message }, { status: err.status }) + } + throw err + } +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 1538aca..13aa9b3 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -8,12 +8,37 @@ describe('OpenAPI spec', () => { '/api/crew', '/api/explore', '/api/jump', + '/api/ship', + '/api/ship/build', + '/api/ship/expand', + '/api/ship/upgrade', '/api/system', '/api/version', '/api/world', ]); }); + it('describes the ship endpoints as ShipState in and out, with error responses on the actions', () => { + const ship = openApiSpec.paths['/api/ship'].get.responses[200].content['application/json'].schema; + expect(ship.$ref).toBe('#/components/schemas/ShipState'); + + const system = openApiSpec.components.schemas.ShipSystem.properties; + expect(Object.keys(system)).toEqual(['id', 'type', 'level', 'capacity', 'built']); + + for (const [path, request] of [ + ['/api/ship/upgrade', '#/components/schemas/UpgradeRequest'], + ['/api/ship/expand', '#/components/schemas/ExpandRequest'], + ['/api/ship/build', '#/components/schemas/BuildRequest'], + ] as const) { + const post = openApiSpec.paths[path].post; + expect(post.requestBody.content['application/json'].schema.$ref).toBe(request); + expect(post.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/ShipState'); + for (const status of ['400', '404', '409', '422'] as const) { + expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); + } + } + }); + it('describes /api/crew as an array of CrewMember and crewFound on ExploreResult', () => { const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema; expect(crew.type).toBe('array'); diff --git a/lib/openapi.ts b/lib/openapi.ts index 3102655..a3fb3d6 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -186,6 +186,198 @@ export const openApiSpec = { }, }, }, + '/api/ship': { + get: { + summary: 'Ship state', + description: + "The ship's systems (built flag derived from level), the cargo hold and the turn counter. The starting ship is seeded on new game — core types built at base values, the rest unbuilt until a build action. Auto-initializes a new game when no save exists yet.", + operationId: 'getShip', + responses: { + 200: { + description: 'Current ship state', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ShipState' }, + }, + }, + }, + }, + }, + }, + '/api/ship/upgrade': { + post: { + summary: 'Upgrade a ship system', + description: + "Level up a built ship system by one (capped at max level). Costs `level` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.", + operationId: 'upgradeShipSystem', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/UpgradeRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Fresh ship state after the upgrade', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ShipState' }, + }, + }, + }, + 400: { + description: 'Malformed body or non-integer systemId', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: 'Unknown ship system id', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 409: { + description: 'System is not built or already at max level', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 422: { + description: 'Not enough resources in the cargo hold for the cost', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, + '/api/ship/expand': { + post: { + summary: 'Expand a ship system', + description: + "Raise a built ship system's capacity by one (capped at max capacity). Costs `capacity` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.", + operationId: 'expandShipSystem', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ExpandRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Fresh ship state after the expansion', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ShipState' }, + }, + }, + }, + 400: { + description: 'Malformed body or non-integer systemId', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: 'Unknown ship system id', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 409: { + description: 'System is not built or already at max capacity', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 422: { + description: 'Not enough resources in the cargo hold for the cost', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, + '/api/ship/build': { + post: { + summary: 'Build a ship system', + description: + 'Build an unbuilt ship system type at base values. Costs a flat amount of parts, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.', + operationId: 'buildShipSystem', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/BuildRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Fresh ship state after the build', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ShipState' }, + }, + }, + }, + 400: { + description: 'Malformed body or empty type', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: 'Unknown ship system type', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 409: { + description: 'System is already built', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 422: { + description: 'Not enough parts in the cargo hold for the cost', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, }, components: { schemas: { @@ -275,6 +467,51 @@ export const openApiSpec = { }, required: ['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin'], }, + ShipSystem: { + type: 'object', + properties: { + id: { type: 'integer' }, + type: { type: 'string', description: "System type, e.g. REACTOR or WEAPONS" }, + level: { type: 'integer', description: '0 when not built yet' }, + capacity: { type: 'integer', description: '0 when not built yet' }, + built: { type: 'boolean', description: 'Derived — true when level > 0' }, + }, + required: ['id', 'type', 'level', 'capacity', 'built'], + }, + ShipState: { + type: 'object', + properties: { + systems: { + type: 'array', + description: "The ship's systems in display order", + items: { $ref: '#/components/schemas/ShipSystem' }, + }, + inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } }, + turn: { type: 'integer', description: 'Turn counter — one per jump, exploration or ship action' }, + }, + required: ['systems', 'inventory', 'turn'], + }, + UpgradeRequest: { + type: 'object', + properties: { + systemId: { type: 'integer', description: 'ship_systems.id of the built system to level up' }, + }, + required: ['systemId'], + }, + ExpandRequest: { + type: 'object', + properties: { + systemId: { type: 'integer', description: 'ship_systems.id of the built system to expand' }, + }, + required: ['systemId'], + }, + BuildRequest: { + type: 'object', + properties: { + type: { type: 'string', description: "Ship system type to build, e.g. WEAPONS (see ShipSystem.type)" }, + }, + required: ['type'], + }, SystemState: { type: 'object', properties: {