feat(game): queueTask — queue-time validation, cost deduction, ACTIVE/PENDING row (#44)
This commit is contained in:
+74
-2
@@ -1,8 +1,8 @@
|
||||
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'
|
||||
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.
|
||||
@@ -90,3 +90,75 @@ describe('crew-costs — the §11 tuning surface', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user