Files
SpaceWanderer/lib/game/tasks.ts
2026-10-04 02:34:56 +00:00

265 lines
13 KiB
TypeScript

// 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 — queueTask's optional powerOf param feeds the skill band instead (PR B). 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<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.
// The optional powerOf (default: the constant 1) maps each team member to their power for
// the stored duration — the queueing routes pass makePowerOf over the roster's skill rows
// (PR B); callers without a skill context (tests, the immediate actions) use the default.
export function queueTask(state: QueueState, req: QueueTaskRequest, powerOf: (m: CrewMember) => number = () => 1): 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, powerOf)
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 }
}