api+openapi: GET /api/ship + POST upgrade/expand/build with 400/404/409/422 mapping

This commit is contained in:
2026-09-28 23:38:11 +00:00
parent 5fe306d64d
commit fce27ff6ca
6 changed files with 354 additions and 0 deletions
+28
View File
@@ -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
}
}
+28
View File
@@ -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
}
}
+8
View File
@@ -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))
}
+28
View File
@@ -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
}
}
+25
View File
@@ -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');
+237
View File
@@ -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: {