feat(api): jump + explore endpoints queue tasks with lead + helpers instead of resolving instantly (#44)

This commit is contained in:
2026-10-02 15:47:31 +00:00
parent d7faf739a9
commit 4e312d054f
10 changed files with 455 additions and 66 deletions
+57 -2
View File
@@ -293,6 +293,27 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
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 ---------------------------------------------
export interface PlanetDto {
@@ -438,6 +459,16 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
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 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 }
}
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)
const [save] = await db.db.select().from(schema.saves).limit(1)
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.tech),
])
const state: QueueState = {
return {
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 })),
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 })),
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)