feat(game): tickTurn + runTurn — the turn tick: decrement, resolve, FIFO auto-start, one turn per call (#44)
This commit is contained in:
+125
-12
@@ -2,7 +2,7 @@ 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'
|
||||
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.
|
||||
@@ -91,18 +91,20 @@ describe('crew-costs — the §11 tuning surface', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// 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', () => {
|
||||
// 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
|
||||
@@ -162,3 +164,114 @@ describe('queueTask', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user