feat(api): POST /api/turn ticks the queue one turn; an empty queue is a free skip (#44)

This commit is contained in:
2026-10-02 19:12:51 +00:00
parent 03b8b661f6
commit 178de6e4fb
4 changed files with 173 additions and 0 deletions
+20
View File
@@ -20,6 +20,7 @@ describe('OpenAPI spec', () => {
'/api/ship/upgrade',
'/api/system',
'/api/tech',
'/api/turn',
'/api/version',
'/api/world',
]);
@@ -171,6 +172,25 @@ describe('OpenAPI spec', () => {
expect((jumpReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
});
it('describes the turn endpoint: POST /api/turn with no body → WorldState + tasks, 500 on internal error', () => {
const post = openApiSpec.paths['/api/turn'].post;
expect(post).not.toHaveProperty('requestBody');
expect(post.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/TurnResult');
expect(post.responses[500].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
// TurnResult extends WorldState with the task queue after the tick.
const turnResult = openApiSpec.components.schemas.TurnResult as { allOf: unknown[] };
expect((turnResult.allOf[0] as { $ref: string }).$ref).toBe('#/components/schemas/WorldState');
const extra = (turnResult.allOf[1] as { properties: Record<string, unknown> }).properties;
expect(Object.keys(extra)).toEqual(['tasks']);
const props = openApiSpec.components.schemas.Task.properties;
expect(Object.keys(props)).toEqual(['id', 'saveId', 'type', 'payload', 'leadCrewId', 'helpers', 'duration', 'remaining', 'status', 'queuedAtTurn']);
expect((props.type as { enum: string[] }).enum).toEqual(['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH']);
expect((props.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING', 'DONE']);
expect((props.helpers as { items: { type: string } }).items.type).toBe('integer');
});
it('keeps info.version in sync with package.json', () => {
expect(openApiSpec.info.title).toBe('SpaceWanderer API');
expect(openApiSpec.info.version).toBe(pkg.version);
+62
View File
@@ -51,6 +51,32 @@ export const openApiSpec = {
},
},
},
'/api/turn': {
post: {
summary: 'Advance exactly one turn (manual tick)',
description:
"Ticks the queue one turn — no request body: every ACTIVE task ages by one, tasks that reach 0 resolve (jump, scan, ship-work or research effects land, mission checks run for what resolved) and PENDING successors auto-start in FIFO order. An empty queue is a FREE SKIP — turn +1 with nothing else changed, always 200 (the UI's NEXT TURN button is never disabled; a task that fails to resolve simply does not land its effect). Returns the same world snapshot as GET /api/world, plus the task queue after the tick.",
operationId: 'nextTurn',
responses: {
200: {
description: 'World state after the tick, plus the task queue (every task row: ACTIVE, PENDING, DONE)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/TurnResult' },
},
},
},
500: {
description: 'Internal error — no domain failures escape the tick (a failed task is silently dropped, not an error)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
},
'/api/jump': {
post: {
summary: 'Queue a jump to a solar system within range',
@@ -832,6 +858,42 @@ export const openApiSpec = {
},
required: ['systems', 'edges', 'turn'],
},
TurnResult: {
allOf: [
{ $ref: '#/components/schemas/WorldState' },
{
type: 'object',
properties: {
tasks: {
type: 'array',
description:
'Every task row after the tick — ACTIVE (decremented this turn, or auto-started this turn), PENDING (waiting for its team to free) and DONE (resolved this tick or earlier)',
items: { $ref: '#/components/schemas/Task' },
},
},
required: ['tasks'],
},
],
},
Task: {
type: 'object',
properties: {
id: { type: 'integer' },
saveId: { type: 'integer' },
type: { type: 'string', enum: ['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH'] },
payload: {
type: 'string',
description: 'JSON per type: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} · RESEARCH {techId} · EXPLORE {planetId}',
},
leadCrewId: { type: 'integer', description: 'crew.id of the team lead' },
helpers: { type: 'array', description: 'crew.id of the supporting crew (empty when none)', items: { type: 'integer' } },
duration: { type: 'integer' },
remaining: { type: 'integer' },
status: { type: 'string', enum: ['ACTIVE', 'PENDING', 'DONE'] },
queuedAtTurn: { type: 'integer' },
},
required: ['id', 'saveId', 'type', 'payload', 'leadCrewId', 'helpers', 'duration', 'remaining', 'status', 'queuedAtTurn'],
},
JumpRequest: {
type: 'object',
properties: {