From f3751b47475aedacf9edd8534bb40ce6091dcfc3 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sun, 4 Oct 2026 02:34:56 +0000 Subject: [PATCH] feat(game): skill XP on task completion + power bands in duration (#45) --- app/api/explore/route.ts | 6 +- app/api/jump/route.ts | 6 +- app/api/research/route.ts | 6 +- app/api/ship/build/route.ts | 6 +- app/api/ship/expand/route.ts | 6 +- app/api/ship/upgrade/route.ts | 6 +- lib/game/save.test.ts | 103 +++++++++++++++++++++++++++++++++- lib/game/save.ts | 51 ++++++++++++++++- lib/game/skills.ts | 18 +++++- lib/game/tasks.test.ts | 9 +++ lib/game/tasks.ts | 14 +++-- lib/openapi.test.ts | 2 +- lib/openapi.ts | 2 +- 13 files changed, 209 insertions(+), 26 deletions(-) diff --git a/app/api/explore/route.ts b/app/api/explore/route.ts index 68e65c4..665b206 100644 --- a/app/api/explore/route.ts +++ b/app/api/explore/route.ts @@ -1,6 +1,7 @@ import { getGameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' import { parseCrewTeam } from '@/app/api/crew-team' -import { checkExploreTarget, ExploreError, getSystemState, queueStateFromSave, saveTaskRow } from '@/lib/game/save' +import { checkExploreTarget, ExploreError, getSystemState, makePowerOfFor, queueStateFromSave, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' // Scan one body of the current system: assign the crew team (lead + helpers) and queue the @@ -29,7 +30,8 @@ export async function POST(req: Request) { const db = await getGameDb() await checkExploreTarget(db, planetId) // existing pre-checks: 404 unknown body · 409 already scanned const state = await queueStateFromSave(db) - const { task } = queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + const [save] = await db.db.select().from(schema.saves).limit(1) + const { task } = queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'EXPLORE')) await saveTaskRow(db, task) // inventory is unchanged until the scan resolves — no hold write return Response.json({ ...(await getSystemState(db)), outcome: [], crewFound: null }) } catch (err) { diff --git a/app/api/jump/route.ts b/app/api/jump/route.ts index f86a812..5d097b8 100644 --- a/app/api/jump/route.ts +++ b/app/api/jump/route.ts @@ -1,6 +1,7 @@ import { getGameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' import { parseCrewTeam } from '@/app/api/crew-team' -import { checkJumpTarget, getWorldState, JumpError, queueStateFromSave, saveTaskRow } from '@/lib/game/save' +import { checkJumpTarget, getWorldState, JumpError, makePowerOfFor, queueStateFromSave, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' // Jump to a system within range: assign the crew team (lead + helpers) and queue the JUMP @@ -28,7 +29,8 @@ export async function POST(req: Request) { const db = await getGameDb() const hops = await checkJumpTarget(db, systemId) // existing pre-checks: 404 unknown · 409 already there / out of range const state = await queueStateFromSave(db) - const { task } = queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + const [save] = await db.db.select().from(schema.saves).limit(1) + const { task } = queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'JUMP')) await saveTaskRow(db, task) // inventory is unchanged for a JUMP — no hold write return Response.json(await getWorldState(db)) } catch (err) { diff --git a/app/api/research/route.ts b/app/api/research/route.ts index b808940..9e5ff85 100644 --- a/app/api/research/route.ts +++ b/app/api/research/route.ts @@ -1,7 +1,8 @@ import { getGameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' import { parseCrewTeam } from '@/app/api/crew-team' import { getTechState, checkResearchPreconditions, ResearchError } from '@/lib/game/research' -import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' +import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' // Research one tech: assign the crew team (lead + helpers) and queue the RESEARCH task — the @@ -30,7 +31,8 @@ export async function POST(req: Request) { const db = await getGameDb() await checkResearchPreconditions(db, techId) // existing pre-checks: 404 unknown · 409 already researched / missing prereq const state = await queueStateFromSave(db) - const { task, inventory } = queueTask(state, { type: 'RESEARCH', payload: { techId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + const [save] = await db.db.select().from(schema.saves).limit(1) + const { task, inventory } = queueTask(state, { type: 'RESEARCH', payload: { techId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'RESEARCH')) await saveTaskRow(db, task) await saveInventory(db, inventory) // the cost was deducted at queue time return Response.json(await getTechState(db)) diff --git a/app/api/ship/build/route.ts b/app/api/ship/build/route.ts index a25d204..706b86e 100644 --- a/app/api/ship/build/route.ts +++ b/app/api/ship/build/route.ts @@ -1,7 +1,8 @@ import { getGameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' import { parseCrewTeam } from '@/app/api/crew-team' import { checkBuildPreconditions, getShipState, ShipError } from '@/lib/game/ship' -import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' +import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' // Build an unbuilt ship system type at base values: assign the crew team (lead + helpers) @@ -30,7 +31,8 @@ export async function POST(req: Request) { const db = await getGameDb() await checkBuildPreconditions(db, type) // existing pre-checks: 404 unknown type · 409 already built / build gate const state = await queueStateFromSave(db) - const { task, inventory } = queueTask(state, { type: 'SHIP_BUILD', payload: { type }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + const [save] = await db.db.select().from(schema.saves).limit(1) + const { task, inventory } = queueTask(state, { type: 'SHIP_BUILD', payload: { type }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'SHIP_BUILD')) await saveTaskRow(db, task) await saveInventory(db, inventory) // the cost was deducted at queue time return Response.json(await getShipState(db)) diff --git a/app/api/ship/expand/route.ts b/app/api/ship/expand/route.ts index bc29815..60ef224 100644 --- a/app/api/ship/expand/route.ts +++ b/app/api/ship/expand/route.ts @@ -1,7 +1,8 @@ import { getGameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' import { parseCrewTeam } from '@/app/api/crew-team' import { checkExpandPreconditions, getShipState, ShipError } from '@/lib/game/ship' -import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' +import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' // Raise a built ship system's capacity by one (capped at max capacity): assign the crew team @@ -30,7 +31,8 @@ export async function POST(req: Request) { const db = await getGameDb() await checkExpandPreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max capacity const state = await queueStateFromSave(db) - const { task, inventory } = queueTask(state, { type: 'SHIP_EXPAND', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + const [save] = await db.db.select().from(schema.saves).limit(1) + const { task, inventory } = queueTask(state, { type: 'SHIP_EXPAND', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'SHIP_EXPAND')) await saveTaskRow(db, task) await saveInventory(db, inventory) // the cost was deducted at queue time return Response.json(await getShipState(db)) diff --git a/app/api/ship/upgrade/route.ts b/app/api/ship/upgrade/route.ts index f404b07..d22c3de 100644 --- a/app/api/ship/upgrade/route.ts +++ b/app/api/ship/upgrade/route.ts @@ -1,7 +1,8 @@ import { getGameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' import { parseCrewTeam } from '@/app/api/crew-team' import { checkUpgradePreconditions, getShipState, ShipError } from '@/lib/game/ship' -import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' +import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' // Level up a built ship system by one (capped at max level): assign the crew team (lead + @@ -30,7 +31,8 @@ export async function POST(req: Request) { const db = await getGameDb() await checkUpgradePreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max level const state = await queueStateFromSave(db) - const { task, inventory } = queueTask(state, { type: 'SHIP_UPGRADE', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + const [save] = await db.db.select().from(schema.saves).limit(1) + const { task, inventory } = queueTask(state, { type: 'SHIP_UPGRADE', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'SHIP_UPGRADE')) await saveTaskRow(db, task) await saveInventory(db, inventory) // the cost was deducted at queue time return Response.json(await getShipState(db)) diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 594953f..a163103 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -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) --------------------------------------------- diff --git a/lib/game/save.ts b/lib/game/save.ts index 948dc87..9f35703 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -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 { 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 = 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 { 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 { 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) +} diff --git a/lib/game/skills.ts b/lib/game/skills.ts index cdbb334..941d478 100644 --- a/lib/game/skills.ts +++ b/lib/game/skills.ts @@ -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) +} diff --git a/lib/game/tasks.test.ts b/lib/game/tasks.test.ts index cbd5b77..c169d68 100644 --- a/lib/game/tasks.test.ts +++ b/lib/game/tasks.test.ts @@ -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) ------------------- diff --git a/lib/game/tasks.ts b/lib/game/tasks.ts index 0c2b2c8..67ed817 100644 --- a/lib/game/tasks.ts +++ b/lib/game/tasks.ts @@ -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 { diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 175bc38..86cbf46 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -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'); diff --git a/lib/openapi.ts b/lib/openapi.ts index 9619ade..a767f17 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -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' }, },