From 8153007668cc600f80a9c08838d51ed0b95f61f4 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:11:01 +0000 Subject: [PATCH] api: GET /api/system + POST /api/explore, OpenAPI spec updated in code --- app/api/explore/route.ts | 28 ++++++++ app/api/system/route.ts | 8 +++ lib/openapi.test.ts | 23 ++++++- lib/openapi.ts | 136 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 app/api/explore/route.ts create mode 100644 app/api/system/route.ts diff --git a/app/api/explore/route.ts b/app/api/explore/route.ts new file mode 100644 index 0000000..a8e644c --- /dev/null +++ b/app/api/explore/route.ts @@ -0,0 +1,28 @@ +import { getGameDb } from '@/lib/db/client' +import { ExploreError, explorePlanet } from '@/lib/game/save' + +// Scan one body of the current system. Body: { "planetId": number }. Returns the fresh +// system state plus what this scan found. Costs a turn; each body can only be scanned once. +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 planetId = (body as { planetId?: unknown }).planetId + if (typeof planetId !== 'number' || !Number.isInteger(planetId)) { + return Response.json({ error: 'planetId must be an integer' }, { status: 400 }) + } + + try { + const db = await getGameDb() + return Response.json(await explorePlanet(db, planetId)) + } catch (err) { + if (err instanceof ExploreError) { + return Response.json({ error: err.message }, { status: err.status }) + } + throw err + } +} diff --git a/app/api/system/route.ts b/app/api/system/route.ts new file mode 100644 index 0000000..4ab46f2 --- /dev/null +++ b/app/api/system/route.ts @@ -0,0 +1,8 @@ +import { getGameDb } from '@/lib/db/client' +import { getSystemState } from '@/lib/game/save' + +// The current system's bodies (planets + moons) and the save's inventory; auto-initializes on first hit. +export async function GET() { + const db = await getGameDb() + return Response.json(await getSystemState(db)) +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 07cc610..4da6060 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -4,7 +4,28 @@ import { openApiSpec } from './openapi'; describe('OpenAPI spec', () => { it('documents every public endpoint', () => { - expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/jump', '/api/version', '/api/world']); + expect(Object.keys(openApiSpec.paths).sort()).toEqual([ + '/api/explore', + '/api/jump', + '/api/system', + '/api/version', + '/api/world', + ]); + }); + + it('describes the system endpoints as SystemState in and out', () => { + const system = openApiSpec.paths['/api/system'].get.responses[200].content['application/json'].schema; + expect(system.$ref).toBe('#/components/schemas/SystemState'); + + const explore = openApiSpec.paths['/api/explore'].post; + expect(explore.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreRequest'); + expect(explore.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreResult'); + // ExploreResult extends SystemState with the scan outcome. + const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: { $ref?: string }[] }; + expect(exploreResult.allOf[0].$ref).toBe('#/components/schemas/SystemState'); + for (const status of ['400', '404', '409'] as const) { + expect(explore.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); + } }); it('describes the world endpoints as WorldState in and out', () => { diff --git a/lib/openapi.ts b/lib/openapi.ts index 3c7450e..cb22c07 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -100,6 +100,74 @@ export const openApiSpec = { }, }, }, + '/api/system': { + get: { + summary: 'Current system state', + description: + 'The bodies of the current solar system — planets and their moons (nested via parent) with hazard/explored flags — plus the save inventory and turn counter. Auto-initializes a new game when no save exists yet.', + operationId: 'getSystem', + responses: { + 200: { + description: 'Current system state', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SystemState' }, + }, + }, + }, + }, + }, + }, + '/api/explore': { + post: { + summary: 'Explore a planet or moon', + description: + 'Scans one body of the save with a deterministic haul (a resource and/or a part, or nothing), marks it explored, increments the turn counter, accumulates finds into inventory and writes an explore event. Returns the fresh system state plus this scan outcome.', + operationId: 'explorePlanet', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ExploreRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Fresh system state plus the scan outcome', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ExploreResult' }, + }, + }, + }, + 400: { + description: 'Malformed body or non-integer planetId', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: 'Unknown planet id', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 409: { + description: 'Body was already explored (each can only be scanned once)', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, }, components: { schemas: { @@ -147,6 +215,74 @@ export const openApiSpec = { }, required: ['systemId'], }, + Planet: { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + type: { type: 'string', description: 'Body class, e.g. TERRESTRIAL or GAS GIANT' }, + hazard: { type: 'boolean' }, + explored: { type: 'boolean', description: 'True once scanned — each body can only be explored once' }, + parent: { type: ['integer', 'null'], nullable: true, description: 'NULL for a planet, the parent planet id for a moon' }, + }, + required: ['id', 'name', 'type', 'hazard', 'explored', 'parent'], + }, + InventoryItem: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['resource', 'part'] }, + name: { type: 'string' }, + qty: { type: 'integer' }, + }, + required: ['kind', 'name', 'qty'], + }, + FoundItem: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['resource', 'part'] }, + name: { type: 'string' }, + qty: { type: 'integer' }, + }, + required: ['kind', 'name', 'qty'], + }, + SystemState: { + type: 'object', + properties: { + systemId: { type: 'integer' }, + systemName: { type: 'string' }, + planets: { + type: 'array', + description: 'Planets and moons in display order — a moon always follows its parent row', + items: { $ref: '#/components/schemas/Planet' }, + }, + inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } }, + turn: { type: 'integer', description: 'Turn counter — one per jump or exploration' }, + }, + required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'], + }, + ExploreRequest: { + type: 'object', + properties: { + planetId: { type: 'integer', description: 'planets.id of the body to scan (planet or moon)' }, + }, + required: ['planetId'], + }, + ExploreResult: { + allOf: [ + { $ref: '#/components/schemas/SystemState' }, + { + type: 'object', + properties: { + outcome: { + type: 'array', + description: 'What this scan found — may be empty (some scans find nothing)', + items: { $ref: '#/components/schemas/FoundItem' }, + }, + }, + required: ['outcome'], + }, + ], + }, Error: { type: 'object', properties: {