diff --git a/lib/game/tasks.test.ts b/lib/game/tasks.test.ts index 56cebea..5b1fa42 100644 --- a/lib/game/tasks.test.ts +++ b/lib/game/tasks.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import type { CrewMember } from './crew' import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, POWER_BANDS, RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, TRAIT_DURATION_MODIFIERS, WORK_POINTS, XP_THRESHOLDS } from './crew-costs' -import type { TaskType } from './tasks' -import { computeDuration } from './tasks' +import type { CrewDto } from './save' +import { computeDuration, queueTask, TaskError, type QueueState, type Task, type TaskType } from './tasks' // Trait strings copied verbatim from lib/game/crew.ts GOOD_TRAITS/BAD_TRAITS — TRAIT_DURATION_MODIFIERS // is keyed by exactly these strings, so a rename in crew.ts breaks these tests. @@ -90,3 +90,75 @@ describe('crew-costs — the §11 tuning surface', () => { expect(TRAIT_DURATION_MODIFIERS[MISMATCH_CANCEL_TRAIT]).toEqual({}) // its effect is the cancel, not an additive modifier }) }) + +describe('queueTask', () => { + // The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` fills + // the CrewDto shape (unused by the duration model but part of the type). + const CREW: CrewDto[] = [ + { id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }, + { id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW' }, + { id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW' }, + ] + + const state = (over: Partial = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over }) + const task = (over: Partial = {}): Task => ({ id: 90, saveId: 1, type: 'SHIP_UPGRADE', payload: '{}', leadCrewId: 1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 9, ...over }) + + // Sync twin of the rejects.toMatchObject house style — queueTask throws instead of rejecting. + const expectErr = (fn: () => unknown, status: number, message?: RegExp): void => { + let err: unknown + try { + fn() + } catch (e) { + err = e + } + expect(err).toBeInstanceOf(TaskError) + expect((err as TaskError).status).toBe(status) + if (message) expect((err as TaskError).message).toMatch(message) + } + + it('unknown lead or helper id → 404', () => { + expectErr(() => queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 42, helperCrewIds: [] }), 404, /Unknown crew member 42/) + expectErr(() => queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [42] }), 404, /Unknown crew member 42/) + }) + + it('busy lead → 409 — already committed to a PENDING task (as lead or helper of one)', () => { + expectErr(() => queueTask(state({ tasks: [task({ status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /AVA VANCE is already committed to a pending task/) + expectErr(() => queueTask(state({ tasks: [task({ id: 91, leadCrewId: 3, helpers: [1], status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /AVA VANCE is already committed to a pending task/) + }) + + it('busy helper → 409 — on an ACTIVE task, or committed to a PENDING one', () => { + expectErr(() => queueTask(state({ tasks: [task({ leadCrewId: 3, helpers: [2] })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }), 409, /BEN OKONKWO is already busy/) + expectErr(() => queueTask(state({ tasks: [task({ id: 91, leadCrewId: 3, helpers: [2], status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }), 409, /BEN OKONKWO is already committed to a pending task/) + }) + + it('in transit (a JUMP is ACTIVE) → 409 for a non-jump action', () => { + expectErr(() => queueTask(state({ tasks: [task({ id: 91, type: 'JUMP', leadCrewId: 3 })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /in transit/) + }) + + it('second jump → 409 while one is pending', () => { + expectErr(() => queueTask(state({ tasks: [task({ id: 91, type: 'JUMP', leadCrewId: 3, status: 'PENDING' })] }), { type: 'JUMP', payload: { systemId: 2, hops: 1 }, leadCrewId: 1, helperCrewIds: [] }), 409, /A jump is already queued/) + }) + + it('SHIP / RESEARCH cost deducts at queue time; shortfall → the existing 422', () => { + const hold = [{ kind: 'resource' as const, name: 'IRON', qty: 5 }, { kind: 'part' as const, name: 'ALLOY', qty: 4 }] + const systems = [{ id: 7, type: 'REACTOR', level: 2, capacity: 3 }] + const techRows = [{ id: 3, slug: 'REACTOR_OVERCLOCK' }] // the def's cost: 3 resources (TECHS) + const upgraded = queueTask(state({ inventory: hold, shipSystems: systems, tech: techRows }), { type: 'SHIP_UPGRADE', payload: { systemId: 7 }, leadCrewId: 2, helperCrewIds: [] }) + expect(upgraded.inventory).toEqual([{ kind: 'resource', name: 'IRON', qty: 3 }, { kind: 'part', name: 'ALLOY', qty: 4 }]) // upgradeCost(2) = 2, oldest row first + const shortHold = [{ kind: 'resource' as const, name: 'IRON', qty: 2 }] + expectErr(() => queueTask(state({ inventory: shortHold, tech: techRows }), { type: 'RESEARCH', payload: { techId: 3 }, leadCrewId: 2, helperCrewIds: [] }), 422, /Insufficient resources in the cargo hold — need 3/) + }) + + it('ACTIVE-vs-PENDING rule: PENDING while the lead is busy (no turn change); all free → ACTIVE', () => { + const s = state({ tasks: [task({ leadCrewId: 1 })] }) + const pending = queueTask(s, { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }) + expect(pending.task.status).toBe('PENDING') + expect(pending.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]])) + expect(pending.task.queuedAtTurn).toBe(10) + expect(s.turn).toBe(10) // queueing never advances the turn — only the tick does + const active = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }) + expect(active.task.status).toBe('ACTIVE') + expect(active.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]])) + expect(active.task.queuedAtTurn).toBe(10) + }) +}) diff --git a/lib/game/tasks.ts b/lib/game/tasks.ts index 61e6d2d..6b0b0bd 100644 --- a/lib/game/tasks.ts +++ b/lib/game/tasks.ts @@ -3,6 +3,9 @@ // (same precedent as tech.ts / ship-costs.ts). import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, TRAIT_DURATION_MODIFIERS, WORK_POINTS } from './crew-costs' import type { CrewMember } from './crew' +import type { CrewDto, InventoryItemDto } from './save' +import { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from './ship-costs' +import { TECHS } from './tech' export type TaskType = 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH' @@ -63,3 +66,143 @@ export function computeDuration( const traitModifierSum = traitModifier(lead.traitGood, type) + traitModifier(lead.traitBad, type) return Math.max(1, Math.ceil(workPoints(type, hopCount) / totalPower) + mismatchPenalty + traitModifierSum) } + +// --- Queueing: validation + row construction (pure, no DB) ------------------------------- +// +// queueTask runs over a plain snapshot of the save (QueueState) and decides whether a new +// task can be queued and what row it becomes. Pure like crew.ts / ship-costs.ts — the route +// (a later task) maps schema rows into QueueState, calls queueTask, and persists the result: +// insert the task row (adding `id` + `saveId` at the DB boundary) and the deducted hold. + +// Carries an HTTP status so route handlers can map failures to responses without re-parsing +// (same pattern as JumpError / ShipError / ResearchError / ExploreError). +export class TaskError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + } +} + +// The queueing request — the existing endpoints' bodies plus the crew team. Payload field +// names follow the routes (the spec's names differ): +// JUMP { systemId, hops } — the route computes `hops` with jumpToSystem's logic +// (reachableWithin + NAV AI) and passes it in; the body field is `systemId`, not `toSystemId` +// EXPLORE { planetId } +// SHIP_UPGRADE { systemId } · SHIP_EXPAND { systemId } · SHIP_BUILD { type } +// RESEARCH { techId } — the tech row's id, not the slug +export interface QueueTaskRequest { + type: TaskType + payload: Record + leadCrewId: number + helperCrewIds: number[] +} + +// Plain per-save snapshot the decision runs over — the route maps schema rows into these +// (same row↔domain boundary as the tasks table in save.ts). +export interface QueueState { + turn: number + crew: CrewDto[] + tasks: Task[] + inventory: InventoryItemDto[] // cargo hold, id order (oldest first) + shipSystems: { id: number; type: string; level: number; capacity: number }[] // UPGRADE/EXPAND costs scale with the target's current level/capacity + tech: { id: number; slug: string }[] // RESEARCH cost comes from the matching TECHS def, like research.ts +} + +// The new row minus the two values only the DB boundary knows: `id` (autoincrement) and +// `saveId` (filled in by save.ts's row↔Task mapping on insert). +export interface QueueResult { + task: Omit + inventory: InventoryItemDto[] // unchanged for JUMP/EXPLORE; deducted for SHIP_* / RESEARCH +} + +// Queue a task. Validation order: unknown crew (404) → busy (409) → in transit (409, all +// types) → second jump (409) → cost (422, SHIP_* / RESEARCH only). Never advances the turn. +export function queueTask(state: QueueState, req: QueueTaskRequest): QueueResult { + const lead = state.crew.find((c) => c.id === req.leadCrewId) + if (!lead) throw new TaskError(404, `Unknown crew member ${req.leadCrewId}`) + const helpers: CrewDto[] = [] + for (const id of req.helperCrewIds) { + const helper = state.crew.find((c) => c.id === id) + if (!helper) throw new TaskError(404, `Unknown crew member ${id}`) + helpers.push(helper) + } + + // Busy (409). Spec §3: one ACTIVE task per crew member, and a lead may chain one PENDING + // task behind it (FIFO — auto-starts when the current one completes). So: any crew member + // committed to a PENDING task is blocked; a helper on an ACTIVE task is blocked; a lead on + // an ACTIVE task passes and the new task is queued PENDING behind it. + const inStatus = (id: number, status: TaskStatus) => state.tasks.some((t) => t.status === status && (t.leadCrewId === id || t.helpers.includes(id))) + const checkBusy = (c: CrewDto, isLead: boolean) => { + if (inStatus(c.id, 'PENDING')) throw new TaskError(409, `${c.name} is already committed to a pending task`) + if (!isLead && inStatus(c.id, 'ACTIVE')) throw new TaskError(409, `${c.name} is already busy`) + } + checkBusy(lead, true) + for (const helper of helpers) checkBusy(helper, false) + + // In transit (409): while any JUMP is ACTIVE, no new tasks of any type (spec §3). + const jumps = state.tasks.filter((t) => t.type === 'JUMP') + if (jumps.some((t) => t.status === 'ACTIVE')) throw new TaskError(409, 'A jump is in transit — no new tasks can be queued') + // Only one JUMP at a time, active or pending (two arrivals could never resolve on one tick). + // The ACTIVE half is already caught above, so this fires on a pending jump. + if (req.type === 'JUMP' && jumps.some((t) => t.status !== 'DONE')) throw new TaskError(409, 'A jump is already queued') + + // Costs deduct at queue time (spec §3) — the existing cost logic from ship-costs.ts / + // tech.ts and spendItems' 422. JUMP/EXPLORE have no resource cost. Target existence and + // the 409 gates (max level / capacity, not built, already built, build gate, tech prereqs) + // stay in the routes with their existing codes — the route resolves ids before calling. + let inventory = state.inventory + if (req.type === 'SHIP_UPGRADE' || req.type === 'SHIP_EXPAND') { + const system = state.shipSystems.find((s) => s.id === req.payload.systemId)! + inventory = deduct(inventory, 'resource', req.type === 'SHIP_UPGRADE' ? upgradeCost(system.level) : expandCost(system.capacity)) + } else if (req.type === 'SHIP_BUILD') { + inventory = deduct(inventory, 'part', BUILD_PART_COST) + } else if (req.type === 'RESEARCH') { + const def = TECHS.find((t) => t.slug === state.tech.find((r) => r.id === req.payload.techId)?.slug)! + inventory = deduct(inventory, def.costKind, def.costQty) + } + + // Success: duration from the Task-1 formula, status from what the team is doing right now. + // A crew member is "free" when on no ACTIVE/PENDING task; after the checks above only a + // lead chaining behind its own ACTIVE can be non-free. + const free = (id: number) => !inStatus(id, 'ACTIVE') && !inStatus(id, 'PENDING') + const hops = req.type === 'JUMP' ? (req.payload.hops as number) : 0 + const duration = computeDuration(req.type, hops, lead, helpers) + const status: TaskStatus = [lead, ...helpers].every((c) => free(c.id)) ? 'ACTIVE' : 'PENDING' + + return { + task: { + type: req.type, + payload: JSON.stringify(req.payload), + leadCrewId: req.leadCrewId, + helpers: req.helperCrewIds, + duration, + remaining: duration, + status, + queuedAtTurn: state.turn, // queueing is free — the turn advances only on the tick + }, + inventory, + } +} + +// Pure version of spendItems' consumption (lib/game/ship.ts): the total is checked up front +// (shortfall → the same 422 spendItems throws), then the oldest rows first (array order = id +// order); rows that hit zero are dropped. The input array is never mutated. +function deduct(items: InventoryItemDto[], kind: 'resource' | 'part', amount: number): InventoryItemDto[] { + if (totalQty(items, kind) < amount) { + throw new TaskError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`) + } + let remaining = amount + const out: InventoryItemDto[] = [] + for (const item of items) { + if (item.kind !== kind) { + out.push(item) + continue + } + const take = Math.min(item.qty, remaining) + remaining -= take + if (take < item.qty) out.push({ ...item, qty: item.qty - take }) + } + return out +}