feat(game): skill XP on task completion + power bands in duration (#45)
CI / build + unit + e2e (pull_request) Successful in 2m48s

This commit is contained in:
2026-10-04 02:34:56 +00:00
parent 5bc411cb76
commit f3751b4747
13 changed files with 209 additions and 26 deletions
+100 -3
View File
@@ -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) ---------------------------------------------