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
CI / build + unit + e2e (pull_request) Failing after 3m58s
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user