feat(api): ship + research endpoints queue tasks with lead + helpers; mission accept is instant and free (#44)
CI / build + unit + e2e (pull_request) Failing after 3m58s

This commit is contained in:
2026-10-02 18:36:14 +00:00
parent 4e312d054f
commit 03b8b661f6
20 changed files with 772 additions and 103 deletions
+4 -4
View File
@@ -138,7 +138,7 @@ describe('acceptMission', () => {
await expect(acceptMission(db, b.id)).rejects.toMatchObject({ status: 409, message: /One mission at a time/ })
})
it('activates an AVAILABLE contract, costs a turn, writes an event and keeps the board topped up', async () => {
it('activates an AVAILABLE contract, spends no turn, writes an event and keeps the board topped up', async () => {
const { db } = await freshFile()
await getWorldState(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
@@ -149,11 +149,11 @@ describe('acceptMission', () => {
expect(board.find((m) => m.id === a.id)!.state).toBe('ACTIVE')
expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(MIN_AVAILABLE) // topped back up
const [after] = await db.db.select().from(schema.saves).limit(1)
expect(after.turn).toBe(save.turn + 1)
expect(after.turn).toBe(save.turn) // free — no turn cost
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission')!
expect(event.message).toContain('Accepted mission')
expect(event.turn).toBe(save.turn + 1)
expect(event.turn).toBe(save.turn) // stamped at the current turn — accept spends none
})
})
@@ -185,7 +185,7 @@ describe('completion', () => {
expect(reward.qty).toBe(2)
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission' && /complete/i.test(e.message))!
expect(event.turn).toBe(4) // accept (1) + three scans (2–4); completion is written by the third scan
expect(event.turn).toBe(3) // free accept (turn 0) + three scans (1–3); completion is written by the third scan
expect(event.message).toContain('Scan 3 bodies')
})
+10 -7
View File
@@ -34,8 +34,12 @@ export interface MissionSystem {
isCurrent: boolean
}
// Deterministic per (seed, turn) — same anchoring style as world expansion in save.ts. Every
// action costs exactly one turn, so the stream never repeats for two different top-ups.
// Deterministic per (seed, turn) — same anchoring style as world expansion in save.ts.
// Turn-advancing actions re-anchor the stream, so top-ups on different turns generate
// different contracts. Mission accept is free and tops up at the current turn, so it may
// share an anchor with a top-up that already ran at that turn (e.g. the new-game seed at
// turn 0) — the generated contract can then mirror an existing one: cosmetic, never a
// double reward (rewards are per row).
export function missionRng(seed: string, turn: number): () => number {
return mulberry32((Number(seed) + Math.imul(turn, 0x9e3779b1)) >>> 0)
}
@@ -152,8 +156,9 @@ export const REWARD_ITEM_NAME = 'MISSION REWARD'
const kindLabel = (kind: ItemKind): string => (kind === 'resource' ? 'RESOURCES' : 'PARTS')
// Accepts an AVAILABLE contract: it becomes THE active mission (one at a time), costs a turn like
// every other action and writes an event. Returns the fresh board.
// Accepts an AVAILABLE contract: it becomes THE active mission (one at a time). Instant and
// free (JC-19) — no turn, no cost; the event is stamped at the current turn. Returns the fresh
// board.
export async function acceptMission(db: GameDb, missionId: number): Promise<MissionDto[]> {
const [save] = await db.db.select().from(schema.saves).limit(1)
if (!save) throw new MissionError(404, 'No save yet')
@@ -164,10 +169,8 @@ export async function acceptMission(db: GameDb, missionId: number): Promise<Miss
const active = rows.find((m) => m.state === 'ACTIVE')
if (active) throw new MissionError(409, `One mission at a time — finish "${active.description}" first`)
const turn = save.turn + 1
await db.db.update(schema.missions).set({ state: 'ACTIVE' }).where(eq(schema.missions.id, target.id))
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'mission', message: `Accepted mission — ${target.description}`, createdAt: new Date().toISOString() })
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'mission', message: `Accepted mission — ${target.description}`, createdAt: new Date().toISOString() })
await topUpMissionBoard(db) // one contract left the AVAILABLE list — keep the board topped up
await persist(db)
+17
View File
@@ -70,6 +70,23 @@ export async function resolveResearchTask(db: GameDb, task: Task): Promise<void>
await persist(db)
}
// The research pre-checks, extracted for the queueing route (5a(ii)): the same codes and
// messages as researchTech — unknown tech (404), already researched (409), missing prereqs
// (409, naming what is missing). The cost check stays out: the queueing path deducts at
// queue time (queueTask) and the research lands when the task completes on the tick.
export async function checkResearchPreconditions(db: GameDb, techId: number): Promise<void> {
await ensureSave(db)
const rows = await db.db.select().from(schema.tech)
const target = rows.find((t) => t.id === techId)
if (!target) throw new ResearchError(404, `Unknown tech ${techId}`)
const def = TECHS.find((t) => t.slug === target.slug)!
const researched = await researchedSlugs(db)
if (researched.has(def.slug)) throw new ResearchError(409, `${def.name} is already researched`)
const missing = def.prereqs.filter((p) => !researched.has(p)).map((slug) => TECHS.find((t) => t.slug === slug)?.name ?? slug)
if (missing.length > 0) throw new ResearchError(409, `${def.name} requires ${missing.join(' and ')} first`)
}
// Research one tech: validates prerequisites (409, naming what is missing) and the cost (422),
// spends it from the cargo hold, then delegates the unlock to the resolver at the new turn.
export async function researchTech(db: GameDb, techId: number): Promise<TechDto[]> {
+14
View File
@@ -531,6 +531,20 @@ export async function saveTaskRow(db: GameDb, task: Omit<Task, 'id' | 'saveId'>)
return row.id
}
// The route's DB-boundary hold write (the route→save pattern): queueTask's deducted inventory
// replaces the save's rows — array order = oldest first = id order, so spendItems' oldest-row-
// first consumption and the id-ordered reads stay consistent after the rewrite. Persists so
// the deduction survives a crash; the queueing routes call it with queueTask's result.
export async function saveInventory(db: GameDb, items: InventoryItemDto[]): Promise<void> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
await db.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.saveId, save.id))
for (const item of items) {
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind: item.kind, name: item.name, qty: item.qty })
}
await persist(db)
}
export async function runTurn(db: GameDb): Promise<TickResult> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
+36
View File
@@ -131,6 +131,19 @@ export async function resolveShipBuildTask(db: GameDb, task: Task): Promise<void
await persist(db)
}
// The upgrade pre-checks, extracted for the queueing route (5a(ii)): the same codes and
// messages as upgradeSystem — unknown system (404), not built (409), at max level (409).
// Cost, turn and the level-up effect stay out: the queueing path deducts at queue time
// (queueTask) and the effect lands when the task completes on the tick.
export async function checkUpgradePreconditions(db: GameDb, systemId: number): Promise<void> {
await ensureSave(db)
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db))
if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`)
}
// UPGRADE — level up a built system by one (capped at the effective max level, raised by techs).
export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipState> {
await ensureSave(db)
@@ -148,6 +161,17 @@ export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipS
return readShipState(db)
}
// The expand pre-checks, extracted for the queueing route (5a(ii)): the same codes and
// messages as expandSystem — unknown system (404), not built (409), at max capacity (409).
export async function checkExpandPreconditions(db: GameDb, systemId: number): Promise<void> {
await ensureSave(db)
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db))
if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`)
}
// EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs).
export async function expandSystem(db: GameDb, systemId: number): Promise<ShipState> {
await ensureSave(db)
@@ -165,6 +189,18 @@ export async function expandSystem(db: GameDb, systemId: number): Promise<ShipSt
return readShipState(db)
}
// The build pre-checks, extracted for the queueing route (5a(ii)): the same codes and
// messages as buildSystem — unknown type (404), already built (409), build-gate tech
// missing (409). The parts cost stays out: the queueing path deducts it at queue time.
export async function checkBuildPreconditions(db: GameDb, type: string): Promise<void> {
await ensureSave(db)
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type)
if (!target) throw new ShipError(404, `Unknown ship system type ${type}`)
if (target.level > 0) throw new ShipError(409, `${target.type} is already built`)
const gate = buildGate(type, await researchedTechSlugs(db))
if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`)
}
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts.
export async function buildSystem(db: GameDb, type: string): Promise<ShipState> {
await ensureSave(db)