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 { 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
|
||||
// system state plus what this scan found. Costs a turn; each body can only be scanned once.
|
||||
// Scan one body of the current system: assign the crew team (lead + helpers) and queue the
|
||||
// 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) {
|
||||
let body: unknown
|
||||
try {
|
||||
@@ -15,12 +20,36 @@ export async function POST(req: Request) {
|
||||
if (typeof planetId !== 'number' || !Number.isInteger(planetId)) {
|
||||
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 {
|
||||
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) {
|
||||
if (err instanceof ExploreError) {
|
||||
if (err instanceof ExploreError || err instanceof TaskError) {
|
||||
return Response.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
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 { 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) {
|
||||
let body: unknown
|
||||
try {
|
||||
@@ -14,12 +19,36 @@ export async function POST(req: Request) {
|
||||
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
|
||||
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 {
|
||||
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) {
|
||||
if (err instanceof JumpError) {
|
||||
if (err instanceof JumpError || err instanceof TaskError) {
|
||||
return Response.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
throw err
|
||||
|
||||
Reference in New Issue
Block a user