feat(game): skill XP on task completion + power bands in duration (#45)
CI / build + unit + e2e (pull_request) Successful in 2m48s
CI / build + unit + e2e (pull_request) Successful in 2m48s
This commit is contained in:
+100
-3
@@ -2,7 +2,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { ROLES, generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||
@@ -10,9 +10,9 @@ import { STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs'
|
||||
import { MIN_AVAILABLE } from './missions'
|
||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||
import { TECHS } from './tech'
|
||||
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, runTurn, type WorldState } from './save'
|
||||
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, makePowerOfFor, queueStateFromSave, resolveJumpTask, resolveExploreTask, runTurn, saveTaskRow, type WorldState } from './save'
|
||||
import { addCrew, buildWorldFixture, insertTaskRow } from './test-fixtures'
|
||||
import type { Task } from './tasks'
|
||||
import { queueTask, type Task } from './tasks'
|
||||
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -539,6 +539,103 @@ describe('runTurn (tick on the DB)', () => {
|
||||
expect(types.filter((t) => t === 'explore')).toHaveLength(1)
|
||||
expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the ship did not move
|
||||
})
|
||||
|
||||
// PR B (#45): the fixture's crew rows never ran through createNewGame's seeder, so the
|
||||
// tests here establish the crew_skills rows they need directly (insert / patch).
|
||||
const seedRows = async (db: GameDb, saveId: number, crewId: number, rows: [skill: string, level: number, xp: number][]) => {
|
||||
for (const [skill, level, xp] of rows) {
|
||||
await db.db.insert(schema.crewSkills).values({ saveId, crewId, skill, level, xp })
|
||||
}
|
||||
}
|
||||
// addCrew always mints PILOTs — insert a SCIENTIST directly (the pinCrew pattern from the
|
||||
// v8 backfill test above) with duration-neutral traits.
|
||||
const insertScientist = async (db: GameDb, saveId: number) =>
|
||||
db.db.insert(schema.crew).values({ saveId, name: 'AVA VANCE', role: 'SCIENTIST', traitGood: 'FINDS HIDDEN THINGS', traitBad: 'JINXED IN A DOGFIGHT', origin: 'STARTING CREW' }).returning().then((r) => r[0])
|
||||
|
||||
it('a resolved EXPLORE grants +1 SCANNING XP to every assigned member — lead and helpers alike — and no level-up event below L3', async () => {
|
||||
const { db } = await freshFile()
|
||||
const f = await buildWorldFixture(db)
|
||||
const [c1, c2, c3] = await addCrew(db, 3)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
for (const id of [c1, c2, c3]) await seedRows(db, save.id, id, [['NAVIGATION', 0, 0], ['SCANNING', 0, 0], ['ENGINEERING', 0, 0], ['RESEARCH', 0, 0]])
|
||||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [c2, c3], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||
|
||||
const r = await runTurn(db)
|
||||
expect(r.turn).toBe(1)
|
||||
expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE'])
|
||||
|
||||
for (const id of [c1, c2, c3]) { // every assigned member's SCANNING row gained exactly +1 XP (xp 1 < the L1 threshold 5 — still L0); the other skills are untouched
|
||||
const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, id))
|
||||
expect(Object.fromEntries(rows.map((x) => [x.skill, { level: x.level, xp: x.xp }] as const))).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 0, xp: 1 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } })
|
||||
}
|
||||
expect((await db.db.select().from(schema.events)).filter((e) => e.type === 'level_up')).toHaveLength(0) // L1 is a plain level — no event
|
||||
})
|
||||
|
||||
it('a SCIENTIST at SCANNING xp 14 (L2) completing an EXPLORE writes the §6 level-up event at the tick\'s new turn', async () => {
|
||||
const { db } = await freshFile()
|
||||
const f = await buildWorldFixture(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const sc = await insertScientist(db, save.id)
|
||||
await seedRows(db, save.id, sc.id, [['NAVIGATION', 0, 0], ['SCANNING', 2, 14], ['ENGINEERING', 0, 0], ['RESEARCH', 2, 10]]) // one completion from L3
|
||||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: sc.id, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||
|
||||
const r = await runTurn(db)
|
||||
expect(r.turn).toBe(1)
|
||||
expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE'])
|
||||
|
||||
const levelUps = (await db.db.select().from(schema.events)).filter((e) => e.type === 'level_up')
|
||||
expect(levelUps.map((e) => [e.turn, e.message] as const)).toEqual([[1, 'AVA VANCE is now a SENIOR SCIENTIST — SCANNING L3']]) // §6 text, at the tick's new turn
|
||||
const scanning = (await db.db.select().from(schema.crewSkills).where(and(eq(schema.crewSkills.crewId, sc.id), eq(schema.crewSkills.skill, 'SCANNING'))))[0]
|
||||
expect({ level: scanning.level, xp: scanning.xp }).toEqual({ level: 3, xp: 15 })
|
||||
})
|
||||
|
||||
it('§6 duration shift: a SCANNING L0 member queues a solo EXPLORE at stored duration 4; at L3 (xp 15) the next queued EXPLORE stores duration 2 (ceil(4/2))', async () => {
|
||||
const { db } = await freshFile()
|
||||
const f = await buildWorldFixture(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const sc = await insertScientist(db, save.id)
|
||||
await seedRows(db, save.id, sc.id, [['NAVIGATION', 0, 0], ['SCANNING', 0, 0], ['ENGINEERING', 0, 0], ['RESEARCH', 0, 0]]) // rookie — JC-23: SCIENTIST leads, no mismatch penalty
|
||||
|
||||
// Rookie (SCANNING L0 → power 1): the queued EXPLORE stores the full 4 turns.
|
||||
const first = await saveTaskRow(db, queueTask(await queueStateFromSave(db), { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: sc.id, helperCrewIds: [] }, await makePowerOfFor(db, save, 'EXPLORE')).task)
|
||||
expect((await db.db.select().from(schema.tasks)).find((t) => t.id === first)!).toMatchObject({ status: 'ACTIVE', remaining: 4 })
|
||||
|
||||
// After 15 completed EXPLOREs the member is SCANNING L3 (power 2) — the next queued EXPLORE halves to 2.
|
||||
await db.db.update(schema.crewSkills).set({ level: 3, xp: 15 }).where(and(eq(schema.crewSkills.crewId, sc.id), eq(schema.crewSkills.skill, 'SCANNING')))
|
||||
const second = await saveTaskRow(db, queueTask(await queueStateFromSave(db), { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: sc.id, helperCrewIds: [] }, await makePowerOfFor(db, save, 'EXPLORE')).task)
|
||||
expect((await db.db.select().from(schema.tasks)).find((t) => t.id === second)!).toMatchObject({ status: 'PENDING', remaining: 2 }) // PENDING — the lead chains behind its own ACTIVE
|
||||
})
|
||||
|
||||
it('a recruit found on a resolved EXPLORE is seeded with role-based starting skills — the 4-rows-per-member invariant holds (R-P6)', async () => {
|
||||
const { db } = await freshFile()
|
||||
const f = await buildWorldFixture(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const [c1] = await addCrew(db, 1) // a crew id (addCrew returns ids, not rows)
|
||||
await seedRows(db, save.id, c1, [['NAVIGATION', 2, 10], ['SCANNING', 0, 0], ['ENGINEERING', 0, 0], ['RESEARCH', 0, 0]]) // PILOT profile
|
||||
// Seed '3' + planet 1 (Alder) is the fixture's known recruit stream — the crew roll is
|
||||
// recomputed here, mirroring save.ts: the item haul draws first, then the crew roll on the same stream.
|
||||
await db.db.update(schema.saves).set({ seed: '3' })
|
||||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||
|
||||
const r = await runTurn(db)
|
||||
expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE'])
|
||||
|
||||
const crew = await db.db.select().from(schema.crew)
|
||||
expect(crew).toHaveLength(2) // exactly one join
|
||||
const recruit = crew.find((c) => c.id !== c1)!
|
||||
const expectedRng = explorationRng('3', f.p0)
|
||||
rollExploration(expectedRng) // consume the haul draws first, exactly as save.ts does
|
||||
const expectedMember = rollCrewMember(expectedRng, new Set(['CREW 1']))!
|
||||
expect(recruit.name).toBe(expectedMember.name)
|
||||
const levels = STARTING_LEVELS[expectedMember.role]
|
||||
// The recruit gets the same profile as starting crew: role-based levels, xp = the level's threshold (levelFromXp round-trips).
|
||||
const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, recruit.id))
|
||||
expect(Object.fromEntries(rows.map((x) => [x.skill, { level: x.level, xp: x.xp }] as const))).toEqual(
|
||||
Object.fromEntries((Object.keys(levels) as SkillName[]).map((s) => [s, { level: levels[s], xp: levels[s] > 0 ? XP_THRESHOLDS[levels[s] - 1] : 0 }] as const)),
|
||||
)
|
||||
// The seeder is idempotent — the pre-seeded member is not re-seeded: still exactly 4 rows per member.
|
||||
expect((await db.db.select().from(schema.crewSkills)).length).toBe(4 * crew.length)
|
||||
})
|
||||
})
|
||||
|
||||
// --- The GET snapshots' task queue (Task 6) ---------------------------------------------
|
||||
|
||||
+49
-2
@@ -1,13 +1,14 @@
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import type { GameDb } from '@/lib/db/client'
|
||||
import { persist } from '@/lib/db/client'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
|
||||
import { STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs'
|
||||
import { ACTION_SKILL, STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs'
|
||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||
import { checkMissionCompletion, topUpMissionBoard } from './missions'
|
||||
import { resolveResearchTask, ResearchError } from './research'
|
||||
import { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship'
|
||||
import { grantXp, makePowerOf, type CrewIdentity, type SkillRow } from './skills'
|
||||
import { tickTurn, type QueueState, type Task, type TaskStatus, type TickResult, type TaskType } from './tasks'
|
||||
import { TECHS } from './tech'
|
||||
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world'
|
||||
@@ -540,6 +541,7 @@ export async function resolveExploreTask(db: GameDb, task: Task): Promise<{ outc
|
||||
const systems = await db.db.select().from(schema.solarSystems)
|
||||
const systemName = systems.find((s) => s.id === target.systemId)?.name ?? 'unknown system'
|
||||
const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: foundMember.name, role: foundMember.role, traitGood: foundMember.traitGood, traitBad: foundMember.traitBad, origin: `${target.name}, ${systemName}` }).returning()
|
||||
await seedCrewSkills(db, save.id) // the recruit gets role-based starting rows like the starting crew — the seeder is idempotent, so only the new member is seeded (R-P6)
|
||||
crewFound = { id: row.id, name: row.name, role: row.role, traitGood: row.traitGood, traitBad: row.traitBad, origin: row.origin }
|
||||
}
|
||||
|
||||
@@ -664,6 +666,16 @@ export async function runTurn(db: GameDb): Promise<TickResult> {
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const state = await queueStateFromSave(db)
|
||||
|
||||
// PR B (§6): the skill rows this tick's XP grant runs over — string ids end to end (JC-24):
|
||||
// crew_skills' numeric id columns stringify into SkillRow's, and `members` keys off
|
||||
// String(crew.id) so grantXp's crewIds.includes(r.crewId) matches. The snapshot is taken
|
||||
// before any resolution: a recruit who joins mid-tick has no rows in it — and a recruit is
|
||||
// never on a task that completes in the same tick.
|
||||
const skillRows0: SkillRow[] = (await db.db.select().from(schema.crewSkills)).map((r) => ({ saveId: String(r.saveId), crewId: String(r.crewId), skill: r.skill as SkillName, level: r.level, xp: r.xp }))
|
||||
let skillRows = skillRows0
|
||||
const members: Record<string, CrewIdentity> = Object.fromEntries(state.crew.map((c): [string, CrewIdentity] => [String(c.id), { name: c.name, role: c.role }]))
|
||||
const levelUpEvents: string[] = []
|
||||
|
||||
const result = tickTurn(state)
|
||||
|
||||
// (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events
|
||||
@@ -678,12 +690,33 @@ export async function runTurn(db: GameDb): Promise<TickResult> {
|
||||
for (const task of result.completed) {
|
||||
try {
|
||||
await TASK_RESOLVERS[task.type](db, task)
|
||||
// §6: every assigned member — lead and helpers equally — gains +1 XP in the action's
|
||||
// skill. The rows reassign, so a member on several completions this tick accumulates,
|
||||
// and the level-up events collect for the batch write below. A task whose resolver
|
||||
// failed its 4a guard grants nothing: its effect did not land.
|
||||
const { rows, events } = grantXp(skillRows, [String(task.leadCrewId), ...task.helpers.map(String)], ACTION_SKILL[task.type], members)
|
||||
skillRows = rows
|
||||
levelUpEvents.push(...events)
|
||||
} catch (err) {
|
||||
if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// The tick's level-up events as one batch at the new turn, and the crew_skills rows that
|
||||
// changed — both land in the final whole-DB write (JC-25): the resolution, the events and
|
||||
// the skill progression commit together. Only rows whose level or xp moved are written.
|
||||
for (const message of levelUpEvents) {
|
||||
await db.db.insert(schema.events).values({ saveId: save.id, turn: result.turn, type: 'level_up', message, createdAt: new Date().toISOString() })
|
||||
}
|
||||
const beforeSkill = new Map(skillRows0.map((r): [string, SkillRow] => [`${r.crewId}:${r.skill}`, r]))
|
||||
for (const row of skillRows) {
|
||||
const old = beforeSkill.get(`${row.crewId}:${row.skill}`)!
|
||||
if (old.level !== row.level || old.xp !== row.xp) {
|
||||
await db.db.update(schema.crewSkills).set({ level: row.level, xp: row.xp }).where(and(eq(schema.crewSkills.crewId, Number(row.crewId)), eq(schema.crewSkills.skill, row.skill)))
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the tick's queue changes (the resolvers already persisted their own effects) —
|
||||
// one final whole-DB write makes the turn atomic on disk.
|
||||
for (const before of state.tasks) {
|
||||
@@ -696,3 +729,17 @@ export async function runTurn(db: GameDb): Promise<TickResult> {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// The queueing routes' power-band builder (PR B, §6): the save's roster and crew_skills rows
|
||||
// folded into makePowerOf's closure for the action's skill — the same closure the live
|
||||
// duration preview uses, so a queued task's stored duration matches what the crew picker
|
||||
// shows. The single save row is passed in by the route rather than re-selected here.
|
||||
export async function makePowerOfFor(db: GameDb, save: { id: number }, type: TaskType): Promise<(m: CrewMember) => number> {
|
||||
const [crew, skillRows] = await Promise.all([
|
||||
db.db.select().from(schema.crew).where(eq(schema.crew.saveId, save.id)),
|
||||
db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.saveId, save.id)),
|
||||
])
|
||||
const roster: CrewDto[] = crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin }))
|
||||
const rows: SkillRow[] = skillRows.map((r) => ({ saveId: String(r.saveId), crewId: String(r.crewId), skill: r.skill as SkillName, level: r.level, xp: r.xp }))
|
||||
return makePowerOf(roster, rows, type)
|
||||
}
|
||||
|
||||
+16
-2
@@ -1,6 +1,8 @@
|
||||
// Pure skill-progression module (spec §6): XP accrual, level→power, titles. No lib/db imports — operates on plain SkillRow data (Task 12's crew_skills table); the frontend imports it for the CREW-tab display.
|
||||
import type { SkillName } from './crew-costs'
|
||||
import { POWER_BANDS, XP_THRESHOLDS } from './crew-costs'
|
||||
import { ACTION_SKILL, POWER_BANDS, XP_THRESHOLDS, type SkillName } from './crew-costs'
|
||||
import type { CrewMember } from './crew'
|
||||
import type { CrewDto } from './save'
|
||||
import type { TaskType } from './tasks'
|
||||
export interface SkillRow {
|
||||
saveId: string
|
||||
crewId: string
|
||||
@@ -51,3 +53,15 @@ export function grantXp(
|
||||
}
|
||||
return { rows, events }
|
||||
}
|
||||
|
||||
// The duration formula's powerOf (Task 1) driven by the skill band (PR B, §6): each member's
|
||||
// power is the band of that member's level in the action's skill (ACTION_SKILL[type]). Names
|
||||
// map to crew ids (unique per single-player save); an unknown member or a missing row falls
|
||||
// back to L0 → power 1 (Task 1's default). Called by save.ts's makePowerOfFor (the queueing
|
||||
// routes) and — once /api/crew carries each member's skill rows — by the team builder's live
|
||||
// preview (Task 15).
|
||||
export function makePowerOf(roster: CrewDto[], skillRows: SkillRow[], type: TaskType): (m: CrewMember) => number {
|
||||
const idByName = new Map(roster.map((c): [string, string] => [c.name, String(c.id)]))
|
||||
const levelByCrew = new Map(skillRows.filter((r) => r.skill === ACTION_SKILL[type]).map((r): [string, number] => [r.crewId, r.level]))
|
||||
return (m) => powerForLevel(levelByCrew.get(idByName.get(m.name) ?? '') ?? 0)
|
||||
}
|
||||
|
||||
@@ -163,6 +163,15 @@ describe('queueTask', () => {
|
||||
expect(active.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]]))
|
||||
expect(active.task.queuedAtTurn).toBe(10)
|
||||
})
|
||||
|
||||
it('power-band param: a powerOf returning 2 halves a solo EXPLORE (ceil(4/2) = 2); the default flat 1 still stores 4 (backward-compatible)', () => {
|
||||
// CASS (id 3) is a SCIENTIST — the preferred EXPLORE role, so no mismatch penalty; her
|
||||
// trait pair (CALM IN A CRISIS −1 / FORGETS TO EAT +1) cancels on EXPLORE.
|
||||
const withPower = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 3, helperCrewIds: [] }, () => 2)
|
||||
expect(withPower.task.remaining).toBe(2) // ceil(4/2)
|
||||
const defaultPower = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 3, helperCrewIds: [] })
|
||||
expect(defaultPower.task.remaining).toBe(4) // no powerOf → the default flat 1
|
||||
})
|
||||
})
|
||||
|
||||
// --- The turn tick: decrement, resolve, FIFO auto-start (pure — no DB) -------------------
|
||||
|
||||
+9
-5
@@ -51,9 +51,10 @@ function traitModifier(trait: string, type: TaskType): number {
|
||||
|
||||
// duration = max(1, ceil(work / totalPower) + mismatchPenalty + traitModifierSum) — spec §4.
|
||||
// totalPower = Σ powerOf over the WHOLE team (lead + helpers); the default powerOf is the
|
||||
// constant 1 until PR B feeds the skill band. The mismatch penalty and the trait modifiers
|
||||
// apply to the LEAD only — helpers contribute power, are never penalised, and their traits do
|
||||
// not apply. `hopCount` is only meaningful for JUMP (pass 1 for the other types).
|
||||
// constant 1 — queueTask's optional powerOf param feeds the skill band instead (PR B). The
|
||||
// mismatch penalty and the trait modifiers apply to the LEAD only — helpers contribute
|
||||
// power, are never penalised, and their traits do not apply. `hopCount` is only meaningful
|
||||
// for JUMP (pass 1 for the other types).
|
||||
export function computeDuration(
|
||||
type: TaskType,
|
||||
hopCount: number,
|
||||
@@ -119,7 +120,10 @@ export interface QueueResult {
|
||||
|
||||
// Queue a task. Validation order: unknown crew (404) → busy (409) → in transit (409, all
|
||||
// types) → second jump (409) → cost (422, SHIP_* / RESEARCH only). Never advances the turn.
|
||||
export function queueTask(state: QueueState, req: QueueTaskRequest): QueueResult {
|
||||
// The optional powerOf (default: the constant 1) maps each team member to their power for
|
||||
// the stored duration — the queueing routes pass makePowerOf over the roster's skill rows
|
||||
// (PR B); callers without a skill context (tests, the immediate actions) use the default.
|
||||
export function queueTask(state: QueueState, req: QueueTaskRequest, powerOf: (m: CrewMember) => number = () => 1): QueueResult {
|
||||
const lead = state.crew.find((c) => c.id === req.leadCrewId)
|
||||
if (!lead) throw new TaskError(404, `Unknown crew member ${req.leadCrewId}`)
|
||||
const helpers: CrewDto[] = []
|
||||
@@ -168,7 +172,7 @@ export function queueTask(state: QueueState, req: QueueTaskRequest): QueueResult
|
||||
// lead chaining behind its own ACTIVE can be non-free.
|
||||
const free = (id: number) => !inStatus(id, 'ACTIVE') && !inStatus(id, 'PENDING')
|
||||
const hops = req.type === 'JUMP' ? (req.payload.hops as number) : 0
|
||||
const duration = computeDuration(req.type, hops, lead, helpers)
|
||||
const duration = computeDuration(req.type, hops, lead, helpers, powerOf)
|
||||
const status: TaskStatus = [lead, ...helpers].every((c) => free(c.id)) ? 'ACTIVE' : 'PENDING'
|
||||
|
||||
return {
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ describe('OpenAPI spec', () => {
|
||||
|
||||
const props = openApiSpec.components.schemas.Event.properties;
|
||||
expect(Object.keys(props)).toEqual(['id', 'turn', 'type', 'message', 'createdAt']);
|
||||
expect((props.type as { enum: string[] }).enum).toEqual(['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission']);
|
||||
expect((props.type as { enum: string[] }).enum).toEqual(['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission', 'level_up']);
|
||||
|
||||
const get = openApiSpec.paths['/api/events'].get;
|
||||
const limit = (get.parameters ?? []).find((p) => p.name === 'limit');
|
||||
|
||||
+1
-1
@@ -1080,7 +1080,7 @@ export const openApiSpec = {
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
turn: { type: 'integer', description: 'The save turn the event was written (new_game is 0)' },
|
||||
type: { type: 'string', enum: ['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission'] },
|
||||
type: { type: 'string', enum: ['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission', 'level_up'] },
|
||||
message: { type: 'string' },
|
||||
createdAt: { type: 'string', description: 'ISO 8601 timestamp' },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user