feat(game): tickTurn + runTurn — the turn tick: decrement, resolve, FIFO auto-start, one turn per call (#44)

This commit is contained in:
2026-10-02 14:30:49 +00:00
parent bbcd77c41b
commit d7faf739a9
4 changed files with 385 additions and 14 deletions
+125 -12
View File
@@ -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')
})
})