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
+49 -2
View File
@@ -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)
}