diff --git a/app/api/research/route.ts b/app/api/research/route.ts new file mode 100644 index 0000000..239ef84 --- /dev/null +++ b/app/api/research/route.ts @@ -0,0 +1,27 @@ +import { getGameDb } from '@/lib/db/client' +import { ResearchError, researchTech } from '@/lib/game/research' + +// Research one tech. Body: { "techId": number }. Costs inventory items + a turn; returns the fresh tech tree 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 techId = (body as { techId?: unknown }).techId + if (typeof techId !== 'number' || !Number.isInteger(techId)) { + return Response.json({ error: 'techId must be an integer' }, { status: 400 }) + } + + try { + const db = await getGameDb() + return Response.json(await researchTech(db, techId)) + } catch (err) { + if (err instanceof ResearchError) { + return Response.json({ error: err.message }, { status: err.status }) + } + throw err + } +} diff --git a/app/api/tech/route.ts b/app/api/tech/route.ts new file mode 100644 index 0000000..cbf79d5 --- /dev/null +++ b/app/api/tech/route.ts @@ -0,0 +1,8 @@ +import { getGameDb } from '@/lib/db/client' +import { getTechState } from '@/lib/game/research' + +// The full tech tree with per-save state (RESEARCHED / AVAILABLE / LOCKED + missing prereqs). Auto-initializes on first hit. +export async function GET() { + const db = await getGameDb() + return Response.json(await getTechState(db)) +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 13aa9b3..66020e6 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -8,11 +8,13 @@ describe('OpenAPI spec', () => { '/api/crew', '/api/explore', '/api/jump', + '/api/research', '/api/ship', '/api/ship/build', '/api/ship/expand', '/api/ship/upgrade', '/api/system', + '/api/tech', '/api/version', '/api/world', ]); @@ -39,6 +41,23 @@ describe('OpenAPI spec', () => { } }); + it('describes the tech endpoints: GET /api/tech → Tech[], POST /api/research with error responses', () => { + const tech = openApiSpec.paths['/api/tech'].get.responses[200].content['application/json'].schema; + expect(tech.type).toBe('array'); + expect((tech.items as { $ref: string }).$ref).toBe('#/components/schemas/Tech'); + + const techProps = openApiSpec.components.schemas.Tech.properties; + expect(Object.keys(techProps)).toEqual(['id', 'slug', 'name', 'costKind', 'costQty', 'prereqs', 'effect', 'state', 'missingPrereqs']); + expect((techProps.state as { enum: string[] }).enum).toEqual(['RESEARCHED', 'AVAILABLE', 'LOCKED']); + + const post = openApiSpec.paths['/api/research'].post; + expect(post.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/ResearchRequest'); + expect(post.responses[200].content['application/json'].schema.items.$ref).toBe('#/components/schemas/Tech'); + 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 a3fb3d6..42c54fc 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -378,6 +378,82 @@ export const openApiSpec = { }, }, }, + '/api/tech': { + get: { + summary: 'Tech tree state', + description: + "The full technology tree with per-save state — RESEARCHED if done, AVAILABLE if all prerequisites are researched, LOCKED otherwise (with the missing prerequisite names). Researching a tech gates or raises ship actions (e.g. building WEAPONS requires the WEAPONS SYSTEMS tech). Auto-initializes a new game when no save exists yet.", + operationId: 'getTech', + responses: { + 200: { + description: 'The tech tree with per-save state', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Tech' } }, + }, + }, + }, + }, + }, + }, + '/api/research': { + post: { + summary: 'Research a tech', + description: + "Validates the prerequisites (409, naming what is missing) and the cost against the cargo hold (422), spends the cost from the oldest row first, marks the tech researched, increments the turn counter and writes a research event. Returns the fresh tech tree state.", + operationId: 'researchTech', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ResearchRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Fresh tech tree state after the research', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Tech' } }, + }, + }, + }, + 400: { + description: 'Malformed body or non-integer techId', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: 'Unknown tech id', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 409: { + description: 'Tech is already researched or a prerequisite is not researched yet (message names the missing prereq)', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 422: { + description: 'Not enough of the required kind in the cargo hold for the cost', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, }, components: { schemas: { @@ -491,6 +567,36 @@ export const openApiSpec = { }, required: ['systems', 'inventory', 'turn'], }, + Tech: { + type: 'object', + properties: { + id: { type: 'integer' }, + slug: { type: 'string', description: "Stable key, e.g. WEAPONS_SYSTEMS" }, + name: { type: 'string', description: "Display name, e.g. 'WEAPONS SYSTEMS'" }, + costKind: { type: 'string', enum: ['resource', 'part'] }, + costQty: { type: 'integer' }, + prereqs: { + type: 'array', + description: 'Prerequisite tech names (empty for roots)', + items: { type: 'string' }, + }, + effect: { type: 'string', description: "Human-readable unlock, e.g. 'Unlocks building the WEAPONS system'" }, + state: { type: 'string', enum: ['RESEARCHED', 'AVAILABLE', 'LOCKED'] }, + missingPrereqs: { + type: 'array', + description: 'Unmet prerequisite names — non-empty only when LOCKED', + items: { type: 'string' }, + }, + }, + required: ['id', 'slug', 'name', 'costKind', 'costQty', 'prereqs', 'effect', 'state', 'missingPrereqs'], + }, + ResearchRequest: { + type: 'object', + properties: { + techId: { type: 'integer', description: 'tech.id of the tech to research' }, + }, + required: ['techId'], + }, UpgradeRequest: { type: 'object', properties: {