Files
SpaceWanderer/lib/game/tasks.test.ts
2026-10-04 03:22:51 +00:00

294 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { CrewDto } from './save'
import { computeDuration, queueTask, tickTurn, 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.
const NEUTRAL_GOOD = 'FINDS HIDDEN THINGS' // no duration entry → no effect
const NEUTRAL_BAD = 'JINXED IN A DOGFIGHT' // STANDBY combat trait → no duration effect
const crew = (role: string, traitGood = NEUTRAL_GOOD, traitBad = NEUTRAL_BAD): CrewMember => ({ name: 'TEST', role, traitGood, traitBad })
const WORK: Record<TaskType, number> = { JUMP: 3, EXPLORE: 4, SHIP_UPGRADE: 6, SHIP_EXPAND: 6, SHIP_BUILD: 6, RESEARCH: 9 }
const TYPES = Object.keys(WORK) as TaskType[]
const SHIP: TaskType[] = ['SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD']
describe('solo baselines (one crew, power 1, no modifiers)', () => {
it('work points are the duration for the preferred role: EXPLORE 4 · JUMP 3 / 6 · SHIP 6 · RESEARCH 9', () => {
for (const type of TYPES) {
expect(computeDuration(type, 1, crew(PREFERRED_ROLES[type][0]), [])).toBe(WORK[type])
}
expect(computeDuration('JUMP', 2, crew('PILOT'), [])).toBe(6) // two-hop = 2 × 3
})
})
describe('role mismatch (lead only, helpers never penalised)', () => {
it('a lead outside the preferred role adds +1; helpers contribute power only', () => {
expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [])).toBe(4) // 3 + 1 mismatch
expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [crew('PILOT')])).toBe(3) // ceil(3/2) + 1 — power 2, mismatch still on the lead
expect(computeDuration('EXPLORE', 1, crew('SCIENTIST'), [crew('PILOT', 'QUICK HANDS')])).toBe(2) // 4/2 — the helper's trait does not apply
})
it('STEADY UNDER PRESSURE cancels the mismatch penalty and adds nothing of its own', () => {
expect(computeDuration('JUMP', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(3)
expect(computeDuration('EXPLORE', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(4)
})
})
describe('floor', () => {
it('never returns below 1 turn', () => {
const lead = crew('PILOT', 'QUICK HANDS')
expect(computeDuration('JUMP', 1, lead, [crew('PILOT'), crew('PILOT')], () => 3)).toBe(1) // ceil(3/9) − 1 = 0 → floor 1
})
})
// All 15 duration traits from spec §5, each tested once in its own action context (plus its
// non-applicable types). target 'SHIP_UPGRADE' stands for the whole "SHIP work" class.
const TABLE: [trait: string, isGood: boolean, target: TaskType | 'ANY', delta: number][] = [
['RECKLESS COURAGE', true, 'JUMP', -1],
['CALM IN A CRISIS', true, 'EXPLORE', -1],
['FIXES ANYTHING', true, 'SHIP_UPGRADE', -1],
['READS STARS BLIND', true, 'JUMP', -1],
['QUICK HANDS', true, 'ANY', -1],
['IRON WILL', true, 'RESEARCH', -1],
['RIGID — SLOW TO ADAPT', false, 'RESEARCH', 1],
['OVERCAUTIOUS', false, 'ANY', 1],
['SLOWS THE CREW DOWN', false, 'ANY', 1],
['CARELESS WITH FUSES', false, 'SHIP_UPGRADE', 1],
['HOARDS RATIONS', false, 'SHIP_UPGRADE', 1],
['FORGETS TO EAT', false, 'EXPLORE', 1],
['ARGUES WITH EVERYONE', false, 'RESEARCH', 1],
['DOUBTS THE CAPTAIN', false, 'JUMP', 1],
]
describe('the 15 duration traits (spec §5, exact strings from crew.ts)', () => {
const applies = (type: TaskType, target: TaskType | 'ANY') => {
if (target === 'ANY') return true
if (target === type) return true
return SHIP.includes(target) && SHIP.includes(type) // "SHIP work" class: any SHIP type → any SHIP type
}
it('each trait shifts only its own action class, lead only, across all six types', () => {
for (const [trait, isGood, target, delta] of TABLE) {
for (const type of TYPES) {
const lead = isGood ? crew(PREFERRED_ROLES[type][0], trait) : crew(PREFERRED_ROLES[type][0], NEUTRAL_GOOD, trait)
expect(computeDuration(type, 1, lead, []), `${trait} → ${type}`).toBe(WORK[type] + (applies(type, target) ? delta : 0))
}
}
})
})
describe('crew-costs — the §11 tuning surface', () => {
it('pins the balance knobs', () => {
expect(WORK_POINTS).toEqual({ EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 })
expect(XP_THRESHOLDS).toEqual([5, 10, 15, 20, 25])
expect(POWER_BANDS).toEqual([1, 1, 1, 2, 2, 3]) // L0–L2 → 1 · L3–L4 → 2 · L5 → 3
expect(STARTING_LEVELS.PILOT).toEqual({ NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 })
expect(STARTING_LEVELS.SCIENTIST).toEqual({ NAVIGATION: 0, SCANNING: 2, ENGINEERING: 0, RESEARCH: 2 })
expect(STARTING_LEVELS.COMMANDER).toEqual({ NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 })
expect(RECRUIT_CHANCE_BASE).toBe(0.18)
expect(RECRUIT_CHANCE_TALKER).toBe(0.3)
expect(Object.keys(TRAIT_DURATION_MODIFIERS)).toHaveLength(15) // all 15 duration traits of spec §5, nothing else
expect(TRAIT_DURATION_MODIFIERS[MISMATCH_CANCEL_TRAIT]).toEqual({}) // its effect is the cancel, not an additive modifier
})
})
// Shared queue fixtures (file scope — reused by the queueTask and tickTurn blocks): the roster,
// a state factory (turn 10, empty queue) and a one-turn row factory.
// The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` and
// `skills` fill the CrewDto shape (unused by the duration model but part of the type):
// each role's STARTING_LEVELS (all below L3, so every fixture member's title is null).
const skillsOf = (n: number, s: number, e: number, r: number) => [
{ skill: 'NAVIGATION' as const, level: n },
{ skill: 'SCANNING' as const, level: s },
{ skill: 'ENGINEERING' as const, level: e },
{ skill: 'RESEARCH' as const, level: r },
]
const CREW: CrewDto[] = [
{ id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW', skills: skillsOf(2, 0, 0, 0), title: null },
{ id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW', skills: skillsOf(0, 0, 2, 0), title: null },
{ id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW', skills: skillsOf(0, 2, 0, 2), title: null },
]
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 })
describe('queueTask', () => {
// 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)
})
it('power-band param: a powerOf returning 2 halves a solo EXPLORE (ceil(4/2) = 2); the default flat 1 still stores 4 (backward-compatible)', () => {
// CASS (id 3) is a SCIENTIST — the preferred EXPLORE role, so no mismatch penalty; her
// trait pair (CALM IN A CRISIS −1 / FORGETS TO EAT +1) cancels on EXPLORE.
const withPower = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 3, helperCrewIds: [] }, () => 2)
expect(withPower.task.remaining).toBe(2) // ceil(4/2)
const defaultPower = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 3, helperCrewIds: [] })
expect(defaultPower.task.remaining).toBe(4) // no powerOf → the default flat 1
})
})
// --- 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.
describe('tickTurn', () => {
// The queueTask block's file-scope fixture helpers (above): CREW = [1 AVA PILOT,
// 2 BEN ENGINEER, 3 CASS SCIENTIST]; state() → { turn: 10, crew: CREW, tasks: [], … };
// task() → a one-turn row. The tick never parses payloads — the targets below are inert.
const JUMP = (id: number, over: Partial<Task> = {}): Task => task({ id, type: 'JUMP', payload: '{"systemId":2,"hops":1}', ...over })
const EXPLORE = (id: number, planetId: number, over: Partial<Task> = {}): Task => task({ id, type: 'EXPLORE', payload: `{"planetId":${planetId}}`, ...over })
it('decrements every ACTIVE task by one; a task that hits 0 is DONE (its team freed); PENDING rows do not age; the input is never mutated', () => {
const s = state({
tasks: [
JUMP(1, { duration: 3, remaining: 3, leadCrewId: 1, queuedAtTurn: 8 }),
EXPLORE(2, 5, { leadCrewId: 3, helpers: [2], duration: 1, remaining: 1, queuedAtTurn: 9 }),
EXPLORE(3, 6, { leadCrewId: 1, duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 10 }), // member 1 is on ACTIVE 1
],
})
const r = tickTurn(s)
expect(r.turn).toBe(11) // the tick is the increment
expect(r.tasks.find((t) => t.id === 1)!.remaining).toBe(2)
expect(r.tasks.find((t) => t.id === 1)!.status).toBe('ACTIVE')
expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE')
expect(r.completed.map((t) => t.id)).toEqual([2])
expect(r.tasks.find((t) => t.id === 3)!.status).toBe('PENDING')
expect(r.tasks.find((t) => t.id === 3)!.remaining).toBe(4) // pending does not age
expect(s.tasks.find((t) => t.id === 1)!.remaining).toBe(3) // input state untouched
})
it('a 3-deep PENDING chain on one lead: when the lead task completes, exactly ONE successor starts — earliest queuedAtTurn wins (Review Focus 2)', () => {
let s = state({
tasks: [
JUMP(9, { duration: 3, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }),
EXPLORE(10, 5, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }),
EXPLORE(11, 6, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 9 }),
EXPLORE(12, 7, { leadCrewId: 1, duration: 1, remaining: 1, status: 'PENDING', queuedAtTurn: 10 }),
],
})
let r = tickTurn(s)
expect(r.completed.map((t) => t.id)).toEqual([9])
expect(r.tasks.find((t) => t.id === 9)!.status).toBe('DONE')
expect(r.tasks.find((t) => t.id === 10)!.status).toBe('ACTIVE') // earliest queuedAtTurn starts
expect(r.tasks.find((t) => t.id === 10)!.remaining).toBe(2) // full duration — first decrement next tick
expect(r.tasks.find((t) => t.id === 11)!.status).toBe('PENDING') // the others wait for their own tick
expect(r.tasks.find((t) => t.id === 12)!.status).toBe('PENDING')
// Walk the chain to the end: each successor starts on exactly the tick its predecessor completes.
const EXPECT: [completed: number[], active: number[]][] = [
[[], [10]],
[[10], [11]],
[[], [11]],
[[11], [12]],
[[12], []],
]
for (const [done, active] of EXPECT) {
s = { ...s, turn: r.turn, tasks: r.tasks }
r = tickTurn(s)
expect(r.completed.map((t) => t.id)).toEqual(done)
expect(r.tasks.filter((t) => t.status === 'ACTIVE').map((t) => t.id)).toEqual(active)
}
expect(r.turn).toBe(16) // 10 + 1 (the first tick) + 5 (the walk)
expect(r.tasks.every((t) => t.status === 'DONE')).toBe(true)
})
it('same queuedAtTurn ties break by id, and a started task blocks a later one sharing a member — at most one start per freed member (Review Focus 2, corrupt-row half)', () => {
// queueTask forbids a member committed to two PENDINGs (rule 2), so this shape only
// exists on admin-corrupted rows — the tick must still be deterministic about it.
const s = state({
tasks: [
JUMP(19, { duration: 1, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }),
EXPLORE(20, 5, { leadCrewId: 2, helpers: [3], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }),
EXPLORE(21, 6, { leadCrewId: 3, helpers: [2], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }),
],
})
const r = tickTurn(s)
expect(r.tasks.find((t) => t.id === 20)!.status).toBe('ACTIVE') // (8, 20) < (8, 21) — starts first and takes members 2 and 3
expect(r.tasks.find((t) => t.id === 21)!.status).toBe('PENDING')
})
it('a JUMP and an EXPLORE can both resolve on the same tick — completion is location-independent (Review Focus 3, tick half)', () => {
const s = state({
tasks: [
EXPLORE(1, 5, { leadCrewId: 1, duration: 1, remaining: 1, queuedAtTurn: 8 }),
JUMP(2, { leadCrewId: 2, duration: 1, remaining: 1, queuedAtTurn: 8 }),
],
})
const r = tickTurn(s)
expect(r.completed.map((t) => t.id)).toEqual([1, 2]) // both this tick, queue order (explore first)
expect(r.tasks.find((t) => t.id === 1)!.status).toBe('DONE')
expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE')
})
it('free skip: an empty queue advances exactly one turn and changes nothing; a fully-free PENDING still starts (Review Focus 4)', () => {
const r = tickTurn(state())
expect(r.turn).toBe(11)
expect(r.tasks).toEqual([])
expect(r.completed).toEqual([])
// No ACTIVE tasks and a startable PENDING (a corrupt-row shape — a legal PENDING always
// has a busy member): the pass still starts it. Skipping means "no resolution", not
// "no starts".
const r2 = tickTurn(state({ tasks: [EXPLORE(30, 5, { leadCrewId: 1, helpers: [2, 3], duration: 3, remaining: 3, status: 'PENDING', queuedAtTurn: 9 })] }))
expect(r2.turn).toBe(11)
expect(r2.tasks.find((t) => t.id === 30)!.status).toBe('ACTIVE')
})
})