Files
SpaceWanderer/lib/game/tasks.test.ts
T

165 lines
10 KiB
TypeScript
Raw 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, 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
})
})
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)
})
})