feat(game): queueTask — queue-time validation, cost deduction, ACTIVE/PENDING row (#44)

This commit is contained in:
2026-10-02 13:25:25 +00:00
parent 6c51c3cec8
commit d4491e082e
2 changed files with 217 additions and 2 deletions
+143
View File
@@ -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
}