278 lines
17 KiB
TypeScript
278 lines
17 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 { 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` 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 })
|
||
|
||
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)
|
||
})
|
||
})
|
||
|
||
// --- 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')
|
||
})
|
||
})
|