feat(api): GET /api/missions + POST /api/missions/{id}/accept

Routes call ensureSave first (mission functions assume an initialized save),
map MissionError to 404/409 and non-integer path ids to 400. OpenAPI spec in
code gains both paths + the Mission schema (12 -> 14 paths); openapi.test.ts
covers the new shapes.
This commit is contained in:
2026-09-29 01:55:41 +00:00
parent f356ffbe93
commit 786a5ec001
4 changed files with 138 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
import { getGameDb } from '@/lib/db/client'
import { ensureSave } from '@/lib/game/save'
import { acceptMission, MissionError } from '@/lib/game/missions'
// Accept an AVAILABLE mission — it becomes the single active one. Costs a turn; returns the fresh board.
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const db = await getGameDb()
await ensureSave(db) // acceptMission assumes a save exists
const { id } = await ctx.params
const missionId = Number(id)
if (!Number.isInteger(missionId)) {
return Response.json({ error: 'mission id must be an integer' }, { status: 400 })
}
try {
return Response.json(await acceptMission(db, missionId))
} catch (err) {
if (err instanceof MissionError) {
return Response.json({ error: err.message }, { status: err.status })
}
throw err
}
}
+10
View File
@@ -0,0 +1,10 @@
import { getGameDb } from '@/lib/db/client'
import { ensureSave } from '@/lib/game/save'
import { getMissions } from '@/lib/game/missions'
// The full mission board for the single active save; auto-initializes on first hit.
export async function GET() {
const db = await getGameDb()
await ensureSave(db) // top-up inside getMissions assumes a save exists
return Response.json(await getMissions(db))
}
+19
View File
@@ -8,6 +8,8 @@ describe('OpenAPI spec', () => {
'/api/crew',
'/api/explore',
'/api/jump',
'/api/missions',
'/api/missions/{id}/accept',
'/api/research',
'/api/ship',
'/api/ship/build',
@@ -58,6 +60,23 @@ describe('OpenAPI spec', () => {
}
});
it('describes the mission endpoints: GET /api/missions → Mission[], POST accept with error responses', () => {
const missions = openApiSpec.paths['/api/missions'].get.responses[200].content['application/json'].schema;
expect(missions.type).toBe('array');
expect((missions.items as { $ref: string }).$ref).toBe('#/components/schemas/Mission');
const props = openApiSpec.components.schemas.Mission.properties;
expect(Object.keys(props)).toEqual(['id', 'type', 'description', 'targetSystemId', 'conditionKind', 'progressTarget', 'progress', 'rewardKind', 'rewardQty', 'state']);
expect((props.type as { enum: string[] }).enum).toEqual(['DELIVER', 'EXPLORE', 'FIND']);
expect((props.state as { enum: string[] }).enum).toEqual(['AVAILABLE', 'ACTIVE', 'DONE']);
const post = openApiSpec.paths['/api/missions/{id}/accept'].post;
expect(post.responses[200].content['application/json'].schema.items.$ref).toBe('#/components/schemas/Mission');
for (const status of ['400', '404', '409'] 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');
+85
View File
@@ -454,6 +454,75 @@ export const openApiSpec = {
},
},
},
'/api/missions': {
get: {
summary: 'Mission board',
description:
"The save's mission contracts — generated deterministically from the seed, always topped up to at least three AVAILABLE. One mission is ACTIVE at a time; completion is checked after jumps and scans (DELIVER on arrival at its target system, EXPLORE after N scans, FIND when a haul contains its kind) and grants the reward into the cargo hold. Auto-initializes a new game when no save exists yet.",
operationId: 'getMissions',
responses: {
200: {
description: 'The mission board',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/Mission' } },
},
},
},
},
},
},
'/api/missions/{id}/accept': {
post: {
summary: 'Accept a mission',
description:
"Makes an AVAILABLE contract the single ACTIVE one (409 while another is active, or when it isn't AVAILABLE). Costs a turn and writes a mission event. Returns the fresh board.",
operationId: 'acceptMission',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'integer' },
description: 'missions.id of the contract to accept',
},
],
responses: {
200: {
description: 'Fresh mission board after the acceptance',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/Mission' } },
},
},
},
400: {
description: 'Non-integer id in the path',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: 'Unknown mission id',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
409: {
description: "Mission isn't AVAILABLE, or another mission is already ACTIVE (one at a time)",
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
},
},
components: {
schemas: {
@@ -597,6 +666,22 @@ export const openApiSpec = {
},
required: ['techId'],
},
Mission: {
type: 'object',
properties: {
id: { type: 'integer' },
type: { type: 'string', enum: ['DELIVER', 'EXPLORE', 'FIND'] },
description: { type: 'string', description: "Human-readable objective, e.g. 'Jump to Kepler Drift'" },
targetSystemId: { type: ['integer', 'null'], nullable: true, description: 'DELIVER only — destination system (NULL otherwise)' },
conditionKind: { type: ['string', 'null'], enum: ['resource', 'part', null], description: 'FIND only — item kind to haul in (NULL otherwise)' },
progressTarget: { type: 'integer', description: 'EXPLORE: scans required; 1 for the others' },
progress: { type: 'integer', description: 'Scans counted since acceptance; 0/1 for DELIVER/FIND' },
rewardKind: { type: 'string', enum: ['resource', 'part'] },
rewardQty: { type: 'integer', description: 'Reward quantity (1–3), granted into the cargo hold on completion' },
state: { type: 'string', enum: ['AVAILABLE', 'ACTIVE', 'DONE'] },
},
required: ['id', 'type', 'description', 'targetSystemId', 'conditionKind', 'progressTarget', 'progress', 'rewardKind', 'rewardQty', 'state'],
},
UpgradeRequest: {
type: 'object',
properties: {