// Pure task-engine model (spec §3–§4): the task types and the duration formula. No lib/db // imports — the frontend imports this for the live duration preview in the crew picker // (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' export type TaskStatus = 'ACTIVE' | 'PENDING' | 'DONE' // One queued/active/done action (spec §3). `payload` is a JSON string whose shape depends on // the TaskType: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} · // RESEARCH {techId} · EXPLORE {planetId}. export interface Task { id: number saveId: number type: TaskType payload: string leadCrewId: number helpers: number[] duration: number remaining: number status: TaskStatus queuedAtTurn: number } // Work points per action (spec §4): JUMP scales with hop count, the rest are flat. function workPoints(type: TaskType, hopCount: number): number { switch (type) { case 'JUMP': return hopCount * WORK_POINTS.JUMP_PER_HOP case 'EXPLORE': return WORK_POINTS.EXPLORE case 'RESEARCH': return WORK_POINTS.RESEARCH default: return WORK_POINTS.SHIP_WORK // SHIP_UPGRADE | SHIP_EXPAND | SHIP_BUILD } } // One trait's modifier for a lead on an action type: its per-type entry plus the 'ANY' entry. // Traits without a map entry contribute 0. function traitModifier(trait: string, type: TaskType): number { const mods = TRAIT_DURATION_MODIFIERS[trait] if (!mods) return 0 return (mods[type] ?? 0) + (mods['ANY'] ?? 0) } // duration = max(1, ceil(work / totalPower) + mismatchPenalty + traitModifierSum) — spec §4. // totalPower = Σ powerOf over the WHOLE team (lead + helpers); the default powerOf is the // constant 1 until PR B feeds the skill band. The mismatch penalty and the trait modifiers // apply to the LEAD only — helpers contribute power, are never penalised, and their traits do // not apply. `hopCount` is only meaningful for JUMP (pass 1 for the other types). export function computeDuration( type: TaskType, hopCount: number, lead: CrewMember, helpers: CrewMember[], powerOf: (m: CrewMember) => number = () => 1, ): number { const totalPower = Math.max(1, [lead, ...helpers].reduce((sum, m) => sum + powerOf(m), 0)) const mismatchPenalty = lead.traitGood === MISMATCH_CANCEL_TRAIT || PREFERRED_ROLES[type].includes(lead.role) ? 0 : 1 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 } // --- The turn tick: decrement, resolve, FIFO auto-start (pure — no DB) ------------------- // // tickTurn is the whole §3 tick over the same plain QueueState queueTask consumes. It owns // the turn increment and nothing else: it applies no effect and runs no mission logic — // the save.ts orchestrator (runTurn) calls the 4a resolvers for the returned `completed` // tasks, in the order returned. export interface TickResult { turn: number // state.turn + 1 — the tick IS the turn increment tasks: Task[] // every task, updated: ACTIVE decremented, finished → DONE, started PENDING → ACTIVE completed: Task[] // the tasks that hit 0 this tick, in resolution order } const byQueueOrder = (a: Task, b: Task) => a.queuedAtTurn - b.queuedAtTurn || a.id - b.id export function tickTurn(state: QueueState): TickResult { const turn = state.turn + 1 const tasks = state.tasks.map((t) => ({ ...t })) // never mutate the input (house style — deduct) // (a) every ACTIVE task ages by one; PENDING/DONE rows do not age. for (const t of tasks) if (t.status === 'ACTIVE') t.remaining -= 1 // (b) everything that hit 0 resolves this tick. Marking DONE frees the whole team // (busyness is derived from the tasks table — there is no crew status column to update). // <= 0, not === 0: a corrupt row at or below zero still completes on the next tick // instead of dead-ending the queue (spec focus 4: no dead ends). const completed = tasks .filter((t) => t.status === 'ACTIVE' && t.remaining <= 0) .sort(byQueueOrder) for (const t of completed) t.status = 'DONE' // (c) auto-start: ONE forward pass over PENDING in (queuedAtTurn ASC, id ASC). A task starts // (PENDING → ACTIVE, remaining unchanged — it keeps its full duration; its first decrement // lands on the next tick) when its FULL team (lead + all helpers) is free: no member on an // ACTIVE task, and no member committed to an EARLIER (queuedAtTurn, id) PENDING that is still // pending. A member on a LATER PENDING does not block — this task has first claim, and once // started it holds that member via the ACTIVE clause, blocking the later PENDING in the same // pass. Under queueTask's invariants (one PENDING per member) the PENDING clause only bites // on admin-corrupted rows, where it keeps the pass deterministic: of a contested member's // PENDINGs the earliest (queuedAtTurn, id) always gets first claim, so at most ONE of their // PENDINGs starts per tick (the FIFO chain of spec §3), and no PENDING starves behind a peer. // A start only makes its team busier — never frees anyone — so a task that cannot start at // its position cannot start later in the same pass: one pass is complete. for (const t of tasks.filter((t) => t.status === 'PENDING').sort(byQueueOrder)) { const free = (id: number) => !tasks.some((o) => o.status === 'ACTIVE' && (o.leadCrewId === id || o.helpers.includes(id))) && !tasks.some((o) => o.status === 'PENDING' && o.id !== t.id && byQueueOrder(o, t) < 0 && (o.leadCrewId === id || o.helpers.includes(id))) if ([t.leadCrewId, ...t.helpers].every(free)) t.status = 'ACTIVE' } return { turn, tasks, completed } }