From 786a5ec001a50ee6a4092d09a63c4154f38f1eef Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 01:55:41 +0000 Subject: [PATCH] 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. --- app/api/missions/[id]/accept/route.ts | 24 ++++++++ app/api/missions/route.ts | 10 ++++ lib/openapi.test.ts | 19 ++++++ lib/openapi.ts | 85 +++++++++++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 app/api/missions/[id]/accept/route.ts create mode 100644 app/api/missions/route.ts diff --git a/app/api/missions/[id]/accept/route.ts b/app/api/missions/[id]/accept/route.ts new file mode 100644 index 0000000..6e9e2b0 --- /dev/null +++ b/app/api/missions/[id]/accept/route.ts @@ -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 + } +} diff --git a/app/api/missions/route.ts b/app/api/missions/route.ts new file mode 100644 index 0000000..b1915b1 --- /dev/null +++ b/app/api/missions/route.ts @@ -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)) +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 66020e6..1f9cb42 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -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'); diff --git a/lib/openapi.ts b/lib/openapi.ts index 42c54fc..6e01b2e 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -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: {