feat(game): queueTask — queue-time validation, cost deduction, ACTIVE/PENDING row (#44)
This commit is contained in:
+74
-2
@@ -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> = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over })
|
||||
const task = (over: Partial<Task> = {}): 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<Task, 'id' | 'saveId'>
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user