feat(api): jump + explore endpoints queue tasks with lead + helpers instead of resolving instantly (#44)
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
// Handler-level tests for the queueing explore route (Task 5a): POST /api/explore with a crew
|
||||||
|
// team queues an EXPLORE row instead of resolving instantly — same system snapshot (findings key
|
||||||
|
// kept, empty), same pre-check codes, no turn spent at queue time.
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { getGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { getSystemState, type SystemState } from '@/lib/game/save'
|
||||||
|
import { addCrew, buildWorldFixture, insertTaskRow } from '@/lib/game/test-fixtures'
|
||||||
|
import { POST } from './route'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
// The route talks to the app-process singleton (getGameDb), so each test points it at a
|
||||||
|
// fresh temp file and uses that very instance for fixtures and assertions — two in-memory
|
||||||
|
// sql.js copies of one file would never see each other's writes.
|
||||||
|
const g = globalThis as unknown as { __spaceWandererGameDb?: Promise<GameDb> }
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-explore-route-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
process.env.SPACE_WANDERER_DB_PATH = file
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
return { db: await getGameDb(), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.SPACE_WANDERER_DB_PATH
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const post = (body: unknown) => POST(new Request('http://localhost/api/explore', { method: 'POST', body: JSON.stringify(body) }))
|
||||||
|
|
||||||
|
describe('POST /api/explore (5a)', () => {
|
||||||
|
it('queues an EXPLORE: row with type/payload, turn unchanged, planet not yet scanned, no haul', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2] = await addCrew(db, 2)
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
const invBefore = (await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)
|
||||||
|
|
||||||
|
const res = await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
const sys = await getSystemState(db)
|
||||||
|
for (const k of Object.keys(sys) as (keyof SystemState)[]) expect(body[k]).toEqual(sys[k]) // today's system snapshot part unchanged; the findings key stays, empty (JC-2)
|
||||||
|
const [row] = await db.db.select().from(schema.tasks)
|
||||||
|
expect(row).toMatchObject({ type: 'EXPLORE', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn })
|
||||||
|
expect(JSON.parse(row.helpers)).toEqual([c2]) // helpers is a JSON text column
|
||||||
|
expect(JSON.parse(row.payload)).toEqual({ planetId: f.p0 })
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
|
||||||
|
expect((await db.db.select().from(schema.planets)).find((p) => p.id === f.p0)!.explored).toBe(false) // the effect waits for the tick
|
||||||
|
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)).toEqual(invBefore) // no haul yet
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unknown crew → 404 (queueTask)', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
expect((await post({ planetId: f.p0, leadCrewId: 999 })).status).toBe(404)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('busy helper → 409, nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2] = await addCrew(db, 2)
|
||||||
|
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||||
|
expect((await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('already scanned → 409 (existing pre-check, existing code)', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, f.p0))
|
||||||
|
expect((await post({ planetId: f.p0, leadCrewId: c1 })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { getGameDb } from '@/lib/db/client'
|
import { getGameDb } from '@/lib/db/client'
|
||||||
import { ExploreError, explorePlanet } from '@/lib/game/save'
|
import { MAX_TEAM_SIZE } from '@/lib/game/crew'
|
||||||
|
import { checkExploreTarget, ExploreError, getSystemState, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
|
||||||
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
// Scan one body of the current system. Body: { "planetId": number }. Returns the fresh
|
// Scan one body of the current system: assign the crew team (lead + helpers) and queue the
|
||||||
// system state plus what this scan found. Costs a turn; each body can only be scanned once.
|
// EXPLORE task — the haul, the scanned flag, the crew roll and the event land when the task
|
||||||
|
// completes on the turn tick (runTurn). Body: { planetId, leadCrewId, helperCrewIds? }.
|
||||||
|
// Queueing spends no turn; returns the same system snapshot as the instant scan, with the
|
||||||
|
// findings key empty until the scan resolves (JC-2).
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
let body: unknown
|
let body: unknown
|
||||||
try {
|
try {
|
||||||
@@ -15,12 +20,36 @@ export async function POST(req: Request) {
|
|||||||
if (typeof planetId !== 'number' || !Number.isInteger(planetId)) {
|
if (typeof planetId !== 'number' || !Number.isInteger(planetId)) {
|
||||||
return Response.json({ error: 'planetId must be an integer' }, { status: 400 })
|
return Response.json({ error: 'planetId must be an integer' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
const leadCrewId = (body as { leadCrewId?: unknown }).leadCrewId
|
||||||
|
if (typeof leadCrewId !== 'number' || !Number.isInteger(leadCrewId)) {
|
||||||
|
return Response.json({ error: 'leadCrewId must be an integer' }, { status: 400 })
|
||||||
|
}
|
||||||
|
let helperCrewIds: number[] = []
|
||||||
|
const rawHelpers = (body as { helperCrewIds?: unknown }).helperCrewIds
|
||||||
|
if (rawHelpers !== undefined) {
|
||||||
|
if (!Array.isArray(rawHelpers) || !rawHelpers.every((h) => typeof h === 'number' && Number.isInteger(h))) {
|
||||||
|
return Response.json({ error: 'helperCrewIds must be an array of integers' }, { status: 400 })
|
||||||
|
}
|
||||||
|
helperCrewIds = rawHelpers
|
||||||
|
}
|
||||||
|
// Team rules (route layer, not queueTask's): one task team, distinct members, size cap.
|
||||||
|
const team = [leadCrewId, ...helperCrewIds]
|
||||||
|
if (new Set(team).size !== team.length) {
|
||||||
|
return Response.json({ error: 'crew members must be unique' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (team.length > MAX_TEAM_SIZE) {
|
||||||
|
return Response.json({ error: `a task team holds at most ${MAX_TEAM_SIZE} crew members` }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = await getGameDb()
|
const db = await getGameDb()
|
||||||
return Response.json(await explorePlanet(db, planetId))
|
await checkExploreTarget(db, planetId) // existing pre-checks: 404 unknown body · 409 already scanned
|
||||||
|
const state = await queueStateFromSave(db)
|
||||||
|
const { task } = queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId, helperCrewIds })
|
||||||
|
await saveTaskRow(db, task) // inventory is unchanged until the scan resolves — no hold write
|
||||||
|
return Response.json({ ...(await getSystemState(db)), outcome: [], crewFound: null })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ExploreError) {
|
if (err instanceof ExploreError || err instanceof TaskError) {
|
||||||
return Response.json({ error: err.message }, { status: err.status })
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Handler-level tests for the queueing jump route (Task 5a): POST /api/jump with a crew
|
||||||
|
// team queues a JUMP row instead of resolving instantly — same snapshots, same pre-check
|
||||||
|
// codes, no turn spent at queue time.
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { getGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { getWorldState } from '@/lib/game/save'
|
||||||
|
import { addCrew, buildWorldFixture, insertTaskRow } from '@/lib/game/test-fixtures'
|
||||||
|
import { POST } from './route'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
// The route talks to the app-process singleton (getGameDb), so each test points it at a
|
||||||
|
// fresh temp file and uses that very instance for fixtures and assertions — two in-memory
|
||||||
|
// sql.js copies of one file would never see each other's writes.
|
||||||
|
const g = globalThis as unknown as { __spaceWandererGameDb?: Promise<GameDb> }
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-jump-route-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
process.env.SPACE_WANDERER_DB_PATH = file
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
return { db: await getGameDb(), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.SPACE_WANDERER_DB_PATH
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const post = (body: unknown) => POST(new Request('http://localhost/api/jump', { method: 'POST', body: JSON.stringify(body) }))
|
||||||
|
|
||||||
|
describe('POST /api/jump (5a)', () => {
|
||||||
|
it('queues a JUMP: row with type/payload/status, turn unchanged, snapshot preserved, ship not moved', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2] = await addCrew(db, 2)
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
|
||||||
|
const res = await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual(await getWorldState(db)) // the same fresh snapshot as today — queueing changes nothing else
|
||||||
|
const [row] = await db.db.select().from(schema.tasks)
|
||||||
|
expect(row).toMatchObject({ type: 'JUMP', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn })
|
||||||
|
expect(JSON.parse(row.helpers)).toEqual([c2]) // helpers is a JSON text column
|
||||||
|
expect(JSON.parse(row.payload)).toEqual({ systemId: f.sys1, hops: 1 }) // route-computed via the existing reachability logic
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment
|
||||||
|
expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the effect waits for the tick
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unknown crew → 404 (queueTask)', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: 999 })).status).toBe(404)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('busy helper → 409, nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2] = await addCrew(db, 2)
|
||||||
|
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a jump in transit → 409 (blocks any new task)', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2] = await addCrew(db, 2)
|
||||||
|
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c2, helpers: [], duration: 3, remaining: 3, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: c1 })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a jump already pending → second jump 409', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2] = await addCrew(db, 2)
|
||||||
|
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||||
|
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 1 }) // lead chains behind its own ACTIVE
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: c2 })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unreachable target → 409 (existing pre-check, existing code)', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
expect((await post({ systemId: f.farSystem, leadCrewId: c1 })).status).toBe(409) // JC-4: a beyond-reach system in the fixture; add it to buildWorldFixture if absent
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('malformed crew fields → 400, nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
expect((await post({ systemId: f.sys1 })).status).toBe(400) // leadCrewId is required
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: 'x' })).status).toBe(400)
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: 1.5 })).status).toBe(400)
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: 1, helperCrewIds: 'nope' })).status).toBe(400)
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: 1, helperCrewIds: [1, 'x'] })).status).toBe(400)
|
||||||
|
expect((await post('not json at all' as unknown)).status).toBe(400)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
+33
-4
@@ -1,7 +1,12 @@
|
|||||||
import { getGameDb } from '@/lib/db/client'
|
import { getGameDb } from '@/lib/db/client'
|
||||||
import { JumpError, jumpToSystem } from '@/lib/game/save'
|
import { MAX_TEAM_SIZE } from '@/lib/game/crew'
|
||||||
|
import { checkJumpTarget, getWorldState, JumpError, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
|
||||||
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
// Jump to an adjacent solar system. Body: { "systemId": number }. Returns the fresh world state.
|
// Jump to a system within range: assign the crew team (lead + helpers) and queue the JUMP
|
||||||
|
// task — the move lands when the task completes on the turn tick (runTurn). Body:
|
||||||
|
// { systemId, leadCrewId, helperCrewIds? }. Queueing spends no turn; returns the same fresh
|
||||||
|
// world state as the instant jump did.
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
let body: unknown
|
let body: unknown
|
||||||
try {
|
try {
|
||||||
@@ -14,12 +19,36 @@ export async function POST(req: Request) {
|
|||||||
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
|
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
|
||||||
return Response.json({ error: 'systemId must be an integer' }, { status: 400 })
|
return Response.json({ error: 'systemId must be an integer' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
const leadCrewId = (body as { leadCrewId?: unknown }).leadCrewId
|
||||||
|
if (typeof leadCrewId !== 'number' || !Number.isInteger(leadCrewId)) {
|
||||||
|
return Response.json({ error: 'leadCrewId must be an integer' }, { status: 400 })
|
||||||
|
}
|
||||||
|
let helperCrewIds: number[] = []
|
||||||
|
const rawHelpers = (body as { helperCrewIds?: unknown }).helperCrewIds
|
||||||
|
if (rawHelpers !== undefined) {
|
||||||
|
if (!Array.isArray(rawHelpers) || !rawHelpers.every((h) => typeof h === 'number' && Number.isInteger(h))) {
|
||||||
|
return Response.json({ error: 'helperCrewIds must be an array of integers' }, { status: 400 })
|
||||||
|
}
|
||||||
|
helperCrewIds = rawHelpers
|
||||||
|
}
|
||||||
|
// Team rules (route layer, not queueTask's): one task team, distinct members, size cap.
|
||||||
|
const team = [leadCrewId, ...helperCrewIds]
|
||||||
|
if (new Set(team).size !== team.length) {
|
||||||
|
return Response.json({ error: 'crew members must be unique' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (team.length > MAX_TEAM_SIZE) {
|
||||||
|
return Response.json({ error: `a task team holds at most ${MAX_TEAM_SIZE} crew members` }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = await getGameDb()
|
const db = await getGameDb()
|
||||||
return Response.json(await jumpToSystem(db, systemId))
|
const hops = await checkJumpTarget(db, systemId) // existing pre-checks: 404 unknown · 409 already there / out of range
|
||||||
|
const state = await queueStateFromSave(db)
|
||||||
|
const { task } = queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId, helperCrewIds })
|
||||||
|
await saveTaskRow(db, task) // inventory is unchanged for a JUMP — no hold write
|
||||||
|
return Response.json(await getWorldState(db))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof JumpError) {
|
if (err instanceof JumpError || err instanceof TaskError) {
|
||||||
return Response.json({ error: err.message }, { status: err.status })
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ const STARTING_CREW_MIN = 2
|
|||||||
const STARTING_CREW_SPREAD = 2 // 2..3 starting members
|
const STARTING_CREW_SPREAD = 2 // 2..3 starting members
|
||||||
export const CREW_CHANCE = 0.18 // a scan finds a crew member who joins the ship (~1 in 5-6 scans)
|
export const CREW_CHANCE = 0.18 // a scan finds a crew member who joins the ship (~1 in 5-6 scans)
|
||||||
|
|
||||||
|
// A task team is one lead plus up to this many members total — the queueing routes reject
|
||||||
|
// a bigger team with a 400 (spec §3 leaves the cap to balance; 4 lets a full crew bring
|
||||||
|
// RESEARCH's 9 work points down to 3 turns, matching a solo L5 specialist).
|
||||||
|
export const MAX_TEAM_SIZE = 4
|
||||||
|
|
||||||
function title(word: string): string {
|
function title(word: string): string {
|
||||||
return word.charAt(0) + word.slice(1).toLowerCase()
|
return word.charAt(0) + word.slice(1).toLowerCase()
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-35
@@ -10,6 +10,7 @@ import { MIN_AVAILABLE } from './missions'
|
|||||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||||
import { TECHS } from './tech'
|
import { TECHS } from './tech'
|
||||||
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, runTurn, type WorldState } from './save'
|
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, runTurn, type WorldState } from './save'
|
||||||
|
import { addCrew, buildWorldFixture, insertTaskRow } from './test-fixtures'
|
||||||
import type { Task } from './tasks'
|
import type { Task } from './tasks'
|
||||||
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
||||||
|
|
||||||
@@ -308,25 +309,14 @@ describe('explorePlanet crew finds', () => {
|
|||||||
|
|
||||||
// --- A/B: resolvers vs the legacy immediate actions (Task 4a) -------------------------
|
// --- A/B: resolvers vs the legacy immediate actions (Task 4a) -------------------------
|
||||||
//
|
//
|
||||||
// Each test builds the identical deterministic fixture in two fresh files (seed '42',
|
// Each test builds the identical deterministic fixture (buildWorldFixture — the shared
|
||||||
// stamped save → no backfill; ids line up across worlds) and compares their full state:
|
// fixture suite in ./test-fixtures: seed '42', stamped save → no backfill; ids line up
|
||||||
|
// across worlds) in two fresh files and compares their full state:
|
||||||
// A = the legacy immediate action (validates, costs a turn, applies the effect);
|
// A = the legacy immediate action (validates, costs a turn, applies the effect);
|
||||||
// B = the queued shape — the tick has advanced the turn (Task 4b) and the resolver
|
// B = the queued shape — the tick has advanced the turn (Task 4b) and the resolver
|
||||||
// applies the effect (ship/research rows deduct the cost up front, as queueTask did at
|
// applies the effect (ship/research rows deduct the cost up front, as queueTask did at
|
||||||
// queue time — Task 3). snapshot() normalizes every game table (wall-clock columns
|
// queue time — Task 3). snapshot() normalizes every game table (wall-clock columns
|
||||||
// excluded), so the two end states must be byte-identical.
|
// excluded), so the two end states must be byte-identical.
|
||||||
const T0 = '2026-10-01T00:00:00.000Z'
|
|
||||||
|
|
||||||
async function buildWorldFixture(db: GameDb, outpostDiscovered = false): Promise<{ sys1: number; p0: number; p1: number }> {
|
|
||||||
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
|
|
||||||
const [s0] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Origin', x: 50, y: 50, discovered: true, isCurrent: true }).returning({ id: schema.solarSystems.id })
|
|
||||||
const [s1] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Outpost', x: 55, y: 50, discovered: outpostDiscovered }).returning({ id: schema.solarSystems.id })
|
|
||||||
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0.id, toSystem: s1.id })
|
|
||||||
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s0.id })
|
|
||||||
const [p0] = await db.db.insert(schema.planets).values({ systemId: s0.id, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false }).returning({ id: schema.planets.id })
|
|
||||||
const [p1] = await db.db.insert(schema.planets).values({ systemId: s1.id, parentId: null, name: 'Basalt', type: 'GAS', hazard: false }).returning({ id: schema.planets.id })
|
|
||||||
return { sys1: s1.id, p0: p0.id, p1: p1.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
// The resolvers only read `type` + `payload` — tests mint stand-in rows with zeroed
|
// The resolvers only read `type` + `payload` — tests mint stand-in rows with zeroed
|
||||||
// identity fields, the same way the public functions' delegation does.
|
// identity fields, the same way the public functions' delegation does.
|
||||||
@@ -374,7 +364,7 @@ describe('resolveJumpTask (A/B against jumpToSystem)', () => {
|
|||||||
await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 }))
|
await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 }))
|
||||||
|
|
||||||
expect(await snapshot(b)).toEqual(await snapshot(a))
|
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||||||
expect(await b.db.select().from(schema.solarSystems)).toHaveLength(2) // no ring added
|
expect(await b.db.select().from(schema.solarSystems)).toHaveLength(3) // the fixture's three systems, no ring added
|
||||||
})
|
})
|
||||||
|
|
||||||
it('guards: 404 unknown system, 409 already-current', async () => {
|
it('guards: 404 unknown system, 409 already-current', async () => {
|
||||||
@@ -447,26 +437,9 @@ describe('resolveExploreTask (A/B against explorePlanet)', () => {
|
|||||||
// effects and the mission hooks (their contract — invoked here, at the tick's new turn,
|
// effects and the mission hooks (their contract — invoked here, at the tick's new turn,
|
||||||
// before the final persist); this function owns the table writes.
|
// before the final persist); this function owns the table writes.
|
||||||
describe('runTurn (tick on the DB)', () => {
|
describe('runTurn (tick on the DB)', () => {
|
||||||
// buildWorldFixture (the 4a block above) seeds the save (seed '42', stamped — no
|
// buildWorldFixture (the shared suite in ./test-fixtures) seeds the save + Origin /
|
||||||
// backfill) + Origin (s0, id 1, current) / Outpost (s1) + Alder (p0, in s0) /
|
// Outpost / Farpoint + Alder / Basalt + the route chain — but no crew or tasks; the tick
|
||||||
// Basalt (p1, in s1) + the route pair — but no crew or tasks; the tick needs both, so
|
// needs both, so each test here mints its own rows via addCrew / insertTaskRow.
|
||||||
// this block adds its own rows.
|
|
||||||
async function addCrew(db: GameDb, count: number): Promise<number[]> {
|
|
||||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
|
||||||
const ids: number[] = []
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
|
|
||||||
ids.push(row.id)
|
|
||||||
}
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
async function insertTaskRow(db: GameDb, over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number }): Promise<number> {
|
|
||||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
|
||||||
const [row] = await db.db.insert(schema.tasks).values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn }).returning({ id: schema.tasks.id })
|
|
||||||
return row.id
|
|
||||||
}
|
|
||||||
|
|
||||||
it('free skip: an empty queue costs exactly one turn and touches nothing else (Review Focus 4)', async () => {
|
it('free skip: an empty queue costs exactly one turn and touches nothing else (Review Focus 4)', async () => {
|
||||||
const { db } = await freshFile()
|
const { db } = await freshFile()
|
||||||
await buildWorldFixture(db)
|
await buildWorldFixture(db)
|
||||||
|
|||||||
+57
-2
@@ -293,6 +293,27 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
|
|||||||
return getWorldState(db)
|
return getWorldState(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The jump pre-checks, extracted for the queueing route (5a): the same codes and messages
|
||||||
|
// as jumpToSystem — unknown system (404), already there (409), outside range (409) — and
|
||||||
|
// the hop count (reachableWithin + NAV AI, the existing reachability logic) the route puts
|
||||||
|
// in the queued JUMP payload.
|
||||||
|
export async function checkJumpTarget(db: GameDb, systemId: number): Promise<number> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const systems = await db.db.select().from(schema.solarSystems)
|
||||||
|
|
||||||
|
const target = systems.find((s) => s.id === systemId)
|
||||||
|
if (!target) throw new JumpError(404, `Unknown system ${systemId}`)
|
||||||
|
const current = systems.find((s) => s.isCurrent)!
|
||||||
|
if (current.id === target.id) throw new JumpError(409, 'Already at this system')
|
||||||
|
|
||||||
|
const routes = await db.db.select().from(schema.jumpRoutes)
|
||||||
|
// #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops.
|
||||||
|
const navAi = (await db.db.select().from(schema.tech)).some((t) => t.slug === 'NAV_AI' && t.researched)
|
||||||
|
const hopCount = reachableWithin(routes.map((r) => [r.fromSystem, r.toSystem]), current.id, navAi ? 2 : 1).get(target.id)
|
||||||
|
if (hopCount === undefined) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
|
||||||
|
return hopCount
|
||||||
|
}
|
||||||
|
|
||||||
// --- Local system + exploration ---------------------------------------------
|
// --- Local system + exploration ---------------------------------------------
|
||||||
|
|
||||||
export interface PlanetDto {
|
export interface PlanetDto {
|
||||||
@@ -438,6 +459,16 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
|
|||||||
return { ...(await readSystemState(db)), outcome, crewFound }
|
return { ...(await readSystemState(db)), outcome, crewFound }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The explore pre-checks, extracted for the queueing route (5a): the same codes and messages
|
||||||
|
// as explorePlanet — unknown body (404), already scanned (409).
|
||||||
|
export async function checkExploreTarget(db: GameDb, planetId: number): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const planets = await db.db.select().from(schema.planets)
|
||||||
|
const target = planets.find((p) => p.id === planetId)
|
||||||
|
if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`)
|
||||||
|
if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
|
||||||
|
}
|
||||||
|
|
||||||
// --- The turn tick on the DB (POST /api/turn — Task 5 wires the route) ---------------------
|
// --- The turn tick on the DB (POST /api/turn — Task 5 wires the route) ---------------------
|
||||||
//
|
//
|
||||||
// The route→save pattern: ensureSave first, unscoped single-player reads, persist last.
|
// The route→save pattern: ensureSave first, unscoped single-player reads, persist last.
|
||||||
@@ -462,7 +493,10 @@ function rowToTask(row: { id: number; saveId: number; type: string; payload: str
|
|||||||
return { id: row.id, saveId: row.saveId, type: row.type as TaskType, payload: row.payload, leadCrewId: row.leadCrewId, helpers: JSON.parse(row.helpers) as number[], duration: row.duration, remaining: row.remaining, status: row.status as TaskStatus, queuedAtTurn: row.queuedAtTurn }
|
return { id: row.id, saveId: row.saveId, type: row.type as TaskType, payload: row.payload, leadCrewId: row.leadCrewId, helpers: JSON.parse(row.helpers) as number[], duration: row.duration, remaining: row.remaining, status: row.status as TaskStatus, queuedAtTurn: row.queuedAtTurn }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runTurn(db: GameDb): Promise<TickResult> {
|
// The plain queue snapshot Task 3's engine runs over, mapped from the schema's rows: crew,
|
||||||
|
// tasks, inventory, shipSystems and tech, all in id order (single save, unscoped). The route
|
||||||
|
// never touches lib/db/schema, so the single save row is resolved here, not passed in.
|
||||||
|
export async function queueStateFromSave(db: GameDb): Promise<QueueState> {
|
||||||
await ensureSave(db)
|
await ensureSave(db)
|
||||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([
|
const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([
|
||||||
@@ -472,7 +506,7 @@ export async function runTurn(db: GameDb): Promise<TickResult> {
|
|||||||
db.db.select().from(schema.shipSystems),
|
db.db.select().from(schema.shipSystems),
|
||||||
db.db.select().from(schema.tech),
|
db.db.select().from(schema.tech),
|
||||||
])
|
])
|
||||||
const state: QueueState = {
|
return {
|
||||||
turn: save.turn,
|
turn: save.turn,
|
||||||
crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })),
|
crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })),
|
||||||
tasks: taskRows.map(rowToTask),
|
tasks: taskRows.map(rowToTask),
|
||||||
@@ -480,6 +514,27 @@ export async function runTurn(db: GameDb): Promise<TickResult> {
|
|||||||
shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })),
|
shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })),
|
||||||
tech: tech.map((t) => ({ id: t.id, slug: t.slug })),
|
tech: tech.map((t) => ({ id: t.id, slug: t.slug })),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The route's DB-boundary insert (the route→save pattern): queueTask's row minus the two
|
||||||
|
// values only the DB boundary knows (id autoincrement, saveId = the single save's id).
|
||||||
|
// payload is already a JSON string from queueTask (passed through); the crew ids serialize
|
||||||
|
// to the JSON column. Persists so the queue survives a crash; returns the new row id.
|
||||||
|
export async function saveTaskRow(db: GameDb, task: Omit<Task, 'id' | 'saveId'>): Promise<number> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [row] = await db.db
|
||||||
|
.insert(schema.tasks)
|
||||||
|
.values({ saveId: save.id, type: task.type, payload: task.payload, leadCrewId: task.leadCrewId, helpers: JSON.stringify(task.helpers), duration: task.duration, remaining: task.remaining, status: task.status, queuedAtTurn: task.queuedAtTurn })
|
||||||
|
.returning({ id: schema.tasks.id })
|
||||||
|
await persist(db)
|
||||||
|
return row.id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTurn(db: GameDb): Promise<TickResult> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const state = await queueStateFromSave(db)
|
||||||
|
|
||||||
const result = tickTurn(state)
|
const result = tickTurn(state)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Shared fixture suite for the game tests (save.test.ts, the 5a route tests, and the later
|
||||||
|
// ship/research route tests): one deterministic world — seed '42', save stamped at the
|
||||||
|
// current schema version so no backfill runs — plus the crew and task-row minters the
|
||||||
|
// queueing tests use. Ids line up across worlds built in fresh files (same insert order),
|
||||||
|
// so two fixture worlds are comparable row for row.
|
||||||
|
import type { GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { SCHEMA_VERSION } from './save'
|
||||||
|
|
||||||
|
// Fixed so a fixture's event rows carry a stable created_at (tests never assert on it, but
|
||||||
|
// a wall-clock Date would make two fixture worlds differ byte-for-byte).
|
||||||
|
export const T0 = '2026-10-01T00:00:00.000Z'
|
||||||
|
|
||||||
|
// Origin (s0, id 1, current) — Outpost (s1, one hop: the jump target) — Farpoint (s2, two
|
||||||
|
// hops: beyond one-hop range, so the unreachable pre-check has a target to 409 on; with
|
||||||
|
// NAVIGATION AI researched it would be reachable). p0 'Alder' in s0, p1 'Basalt' in s1.
|
||||||
|
// No crew, tasks, ship, or tech rows — the caller mints exactly what a test needs.
|
||||||
|
export async function buildWorldFixture(
|
||||||
|
db: GameDb,
|
||||||
|
outpostDiscovered = false,
|
||||||
|
): Promise<{ sys1: number; p0: number; p1: number; farSystem: number }> {
|
||||||
|
const [save] = await db.db
|
||||||
|
.insert(schema.saves)
|
||||||
|
.values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION })
|
||||||
|
.returning()
|
||||||
|
const [s0] = await db.db
|
||||||
|
.insert(schema.solarSystems)
|
||||||
|
.values({ saveId: save.id, name: 'Origin', x: 50, y: 50, discovered: true, isCurrent: true })
|
||||||
|
.returning({ id: schema.solarSystems.id })
|
||||||
|
const [s1] = await db.db
|
||||||
|
.insert(schema.solarSystems)
|
||||||
|
.values({ saveId: save.id, name: 'Outpost', x: 55, y: 50, discovered: outpostDiscovered })
|
||||||
|
.returning({ id: schema.solarSystems.id })
|
||||||
|
const [s2] = await db.db
|
||||||
|
.insert(schema.solarSystems)
|
||||||
|
.values({ saveId: save.id, name: 'Farpoint', x: 60, y: 50, discovered: false })
|
||||||
|
.returning({ id: schema.solarSystems.id })
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0.id, toSystem: s1.id })
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s0.id })
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s2.id })
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s2.id, toSystem: s1.id })
|
||||||
|
const [p0] = await db.db
|
||||||
|
.insert(schema.planets)
|
||||||
|
.values({ systemId: s0.id, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false })
|
||||||
|
.returning({ id: schema.planets.id })
|
||||||
|
const [p1] = await db.db
|
||||||
|
.insert(schema.planets)
|
||||||
|
.values({ systemId: s1.id, parentId: null, name: 'Basalt', type: 'GAS', hazard: false })
|
||||||
|
.returning({ id: schema.planets.id })
|
||||||
|
return { sys1: s1.id, p0: p0.id, p1: p1.id, farSystem: s2.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// `count` identical crew members, in insertion order — the queueing tests need real crew
|
||||||
|
// ids to lead and help with.
|
||||||
|
export async function addCrew(db: GameDb, count: number): Promise<number[]> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const ids: number[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const [row] = await db.db
|
||||||
|
.insert(schema.crew)
|
||||||
|
.values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' })
|
||||||
|
.returning({ id: schema.crew.id })
|
||||||
|
ids.push(row.id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint one tasks-table row with a chosen state — the queueing/tick tests seed the queue
|
||||||
|
// directly instead of going through the routes (which would run the same validation).
|
||||||
|
export async function insertTaskRow(
|
||||||
|
db: GameDb,
|
||||||
|
over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number },
|
||||||
|
): Promise<number> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [row] = await db.db
|
||||||
|
.insert(schema.tasks)
|
||||||
|
.values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn })
|
||||||
|
.returning({ id: schema.tasks.id })
|
||||||
|
return row.id
|
||||||
|
}
|
||||||
@@ -122,6 +122,12 @@ describe('OpenAPI spec', () => {
|
|||||||
for (const status of ['400', '404', '409'] as const) {
|
for (const status of ['400', '404', '409'] as const) {
|
||||||
expect(explore.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
expect(explore.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The queued-scan request: the scan target plus the crew team (lead required, helpers optional).
|
||||||
|
const exploreReq = openApiSpec.components.schemas.ExploreRequest as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(exploreReq.properties)).toEqual(['planetId', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect(exploreReq.required).toEqual(['planetId', 'leadCrewId']);
|
||||||
|
expect((exploreReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('describes the world endpoints as WorldState in and out', () => {
|
it('describes the world endpoints as WorldState in and out', () => {
|
||||||
@@ -134,6 +140,12 @@ describe('OpenAPI spec', () => {
|
|||||||
for (const status of ['400', '404', '409'] as const) {
|
for (const status of ['400', '404', '409'] as const) {
|
||||||
expect(jump.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
expect(jump.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The queued-jump request: the jump target plus the crew team (lead required, helpers optional).
|
||||||
|
const jumpReq = openApiSpec.components.schemas.JumpRequest as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(jumpReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect(jumpReq.required).toEqual(['systemId', 'leadCrewId']);
|
||||||
|
expect((jumpReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps info.version in sync with package.json', () => {
|
it('keeps info.version in sync with package.json', () => {
|
||||||
|
|||||||
+33
-20
@@ -1,4 +1,5 @@
|
|||||||
import pkg from '@/package.json';
|
import pkg from '@/package.json';
|
||||||
|
import { MAX_TEAM_SIZE } from './game/crew';
|
||||||
|
|
||||||
// OpenAPI 3.1 document for the app's public JSON API, kept in code so it can be
|
// OpenAPI 3.1 document for the app's public JSON API, kept in code so it can be
|
||||||
// served machine-readable at /api/openapi (see AGENTS.md "## API"). Must be updated
|
// served machine-readable at /api/openapi (see AGENTS.md "## API"). Must be updated
|
||||||
@@ -8,7 +9,7 @@ export const openApiSpec = {
|
|||||||
info: {
|
info: {
|
||||||
title: 'SpaceWanderer API',
|
title: 'SpaceWanderer API',
|
||||||
description:
|
description:
|
||||||
'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state and jump between solar systems within range (adjacent by default; two hops once NAVIGATION AI is researched).',
|
'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state. Jump and exploration queue a crew team (lead + helpers) and their effects land when the task completes on the turn tick — queueing spends no turn (jump range is adjacent by default, two hops once NAVIGATION AI is researched); ship work, research and mission accept still resolve instantly, each costing a turn.',
|
||||||
// Sourced from package.json so it stays in sync with every version bump.
|
// Sourced from package.json so it stays in sync with every version bump.
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
},
|
},
|
||||||
@@ -52,9 +53,9 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
'/api/jump': {
|
'/api/jump': {
|
||||||
post: {
|
post: {
|
||||||
summary: 'Jump to a solar system within range',
|
summary: 'Queue a jump to a solar system within range',
|
||||||
description:
|
description:
|
||||||
'Validates that the target is within jump range of the current system — one hop away, or two hops once NAVIGATION AI is researched (#35) — sets it as current (discovering it if new and generating its neighbours), advances the turn counter by the hop count and writes a jump event. Returns the fresh world state.',
|
'Queues a JUMP task with the given crew team (lead + helpers): validates that the target is within jump range of the current system (one hop away, or two hops once NAVIGATION AI is researched — #35) and that the team is free, then the move — target becomes current, discovered if new with its neighbours generated, jump event — lands when the task completes on the turn tick. Queueing spends no turn. Returns the fresh world state.',
|
||||||
operationId: 'jumpToSystem',
|
operationId: 'jumpToSystem',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -66,7 +67,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: 'Fresh world state after the jump',
|
description: 'Fresh world state — the jump itself lands when the task completes on the turn tick',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/WorldState' },
|
schema: { $ref: '#/components/schemas/WorldState' },
|
||||||
@@ -74,7 +75,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
400: {
|
400: {
|
||||||
description: 'Malformed body or non-integer systemId',
|
description: 'Malformed body, a non-integer systemId/leadCrewId, a non-integer-array helperCrewIds, duplicate crew ids, or a team over the size cap',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -82,7 +83,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown system id',
|
description: 'Unknown system id or unknown crew member',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -90,7 +91,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
409: {
|
409: {
|
||||||
description: 'Target is outside jump range of the current system — more than one hop away, or more than two hops without NAVIGATION AI researched (or is the current system itself)',
|
description: 'Target is outside jump range of the current system (more than one hop away, or more than two without NAVIGATION AI; or is the current system itself) — or the crew team is blocked (a member committed to a pending task, a helper already busy, a jump in transit, or a jump already queued)',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -120,9 +121,9 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
'/api/explore': {
|
'/api/explore': {
|
||||||
post: {
|
post: {
|
||||||
summary: 'Explore a planet or moon',
|
summary: 'Queue a scan of a planet or moon',
|
||||||
description:
|
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.',
|
'Queues an EXPLORE task with the given crew team (lead + helpers): validates that the body exists and has not been scanned yet, and that the team is free, then the scan — a deterministic haul into the cargo hold, the explored flag, the ~18% crew roll and the explore event — lands when the task completes on the turn tick. Queueing spends no turn. Returns the fresh system state, with the outcome key empty and crewFound null until the scan resolves.',
|
||||||
operationId: 'explorePlanet',
|
operationId: 'explorePlanet',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -134,7 +135,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: 'Fresh system state plus the scan outcome',
|
description: 'Fresh system state — the scan itself lands when the task completes on the turn tick (outcome empty, crewFound null)',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/ExploreResult' },
|
schema: { $ref: '#/components/schemas/ExploreResult' },
|
||||||
@@ -142,7 +143,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
400: {
|
400: {
|
||||||
description: 'Malformed body or non-integer planetId',
|
description: 'Malformed body, a non-integer planetId/leadCrewId, a non-integer-array helperCrewIds, duplicate crew ids, or a team over the size cap',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -150,7 +151,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
404: {
|
404: {
|
||||||
description: 'Unknown planet id',
|
description: 'Unknown planet id or unknown crew member',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -158,7 +159,7 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
409: {
|
409: {
|
||||||
description: 'Body was already explored (each can only be scanned once)',
|
description: 'Body was already explored (each can only be scanned once) — or the crew team is blocked (a member committed to a pending task, a helper already busy, a jump in transit, or a jump already queued)',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: { $ref: '#/components/schemas/Error' },
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
@@ -827,7 +828,7 @@ export const openApiSpec = {
|
|||||||
maxItems: 2,
|
maxItems: 2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
turn: { type: 'integer', description: 'Turn counter — one per jump' },
|
turn: { type: 'integer', description: 'Turn counter — advances on the turn tick and on the still-instant actions (ship work, research, mission accept); a queued jump or exploration costs no turn' },
|
||||||
},
|
},
|
||||||
required: ['systems', 'edges', 'turn'],
|
required: ['systems', 'edges', 'turn'],
|
||||||
},
|
},
|
||||||
@@ -835,8 +836,14 @@ export const openApiSpec = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
systemId: { type: 'integer', description: 'solar_systems.id of the jump target' },
|
systemId: { type: 'integer', description: 'solar_systems.id of the jump target' },
|
||||||
|
leadCrewId: { type: 'integer', description: 'crew.id of the team lead — the member the queued task is committed to first' },
|
||||||
|
helperCrewIds: {
|
||||||
|
type: 'array',
|
||||||
|
description: `Optional supporting crew (crew.id) — the whole team, lead + helpers, holds at most ${MAX_TEAM_SIZE} distinct members, all of them free`,
|
||||||
|
items: { type: 'integer' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['systemId'],
|
required: ['systemId', 'leadCrewId'],
|
||||||
},
|
},
|
||||||
Planet: {
|
Planet: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -900,7 +907,7 @@ export const openApiSpec = {
|
|||||||
items: { $ref: '#/components/schemas/ShipSystem' },
|
items: { $ref: '#/components/schemas/ShipSystem' },
|
||||||
},
|
},
|
||||||
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
|
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
|
||||||
turn: { type: 'integer', description: 'Turn counter — one per jump, exploration or ship action' },
|
turn: { type: 'integer', description: 'Turn counter — one per ship action (research and mission accept cost one too; a queued jump or exploration costs no turn)' },
|
||||||
},
|
},
|
||||||
required: ['systems', 'inventory', 'turn'],
|
required: ['systems', 'inventory', 'turn'],
|
||||||
},
|
},
|
||||||
@@ -993,7 +1000,7 @@ export const openApiSpec = {
|
|||||||
items: { $ref: '#/components/schemas/Planet' },
|
items: { $ref: '#/components/schemas/Planet' },
|
||||||
},
|
},
|
||||||
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
|
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
|
||||||
turn: { type: 'integer', description: 'Turn counter — one per jump or exploration' },
|
turn: { type: 'integer', description: 'Turn counter — advances on the turn tick and on the still-instant actions (ship work, research, mission accept); a queued jump or exploration costs no turn' },
|
||||||
},
|
},
|
||||||
required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'],
|
required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'],
|
||||||
},
|
},
|
||||||
@@ -1001,8 +1008,14 @@ export const openApiSpec = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
planetId: { type: 'integer', description: 'planets.id of the body to scan (planet or moon)' },
|
planetId: { type: 'integer', description: 'planets.id of the body to scan (planet or moon)' },
|
||||||
|
leadCrewId: { type: 'integer', description: 'crew.id of the team lead — the member the queued task is committed to first' },
|
||||||
|
helperCrewIds: {
|
||||||
|
type: 'array',
|
||||||
|
description: `Optional supporting crew (crew.id) — the whole team, lead + helpers, holds at most ${MAX_TEAM_SIZE} distinct members, all of them free`,
|
||||||
|
items: { type: 'integer' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['planetId'],
|
required: ['planetId', 'leadCrewId'],
|
||||||
},
|
},
|
||||||
ExploreResult: {
|
ExploreResult: {
|
||||||
allOf: [
|
allOf: [
|
||||||
@@ -1012,11 +1025,11 @@ export const openApiSpec = {
|
|||||||
properties: {
|
properties: {
|
||||||
outcome: {
|
outcome: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'What this scan found — may be empty (some scans find nothing)',
|
description: 'What this scan found — always empty in the queueing response; populated only once the scan resolves on the turn tick (a resolved scan may still find nothing)',
|
||||||
items: { $ref: '#/components/schemas/FoundItem' },
|
items: { $ref: '#/components/schemas/FoundItem' },
|
||||||
},
|
},
|
||||||
crewFound: {
|
crewFound: {
|
||||||
description: 'A crew member who joined the ship on this scan (~18% of scans), null when none',
|
description: 'A crew member who joined the ship on this scan (~18% of scans), null when none — always null in the queueing response',
|
||||||
anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }],
|
anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user