93 lines
5.2 KiB
TypeScript
93 lines
5.2 KiB
TypeScript
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 { TaskType } from './tasks'
|
||
import { computeDuration } 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
|
||
})
|
||
})
|