From 5bc411cb764c2bdeaf57932e786ce556e0f0108e Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sun, 4 Oct 2026 02:19:36 +0000 Subject: [PATCH] fix(db): consistent starting xp in crew_skills seeder (#45) --- lib/game/save.test.ts | 12 ++++++------ lib/game/save.ts | 12 +++++++----- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 1d7b38c..594953f 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -6,7 +6,7 @@ import { 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' -import { STARTING_LEVELS, type SkillName } from './crew-costs' +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' @@ -745,7 +745,7 @@ describe('backfill (old saves)', () => { return Object.fromEntries(rows.map((r) => [r.skill, { level: r.level, xp: r.xp }])) } - it('v8: a v7-stamped save (crew present, no skills) seeds 4 role-based rows per member, xp 0, state untouched; idempotent across restart', async () => { + it('v8: a v7-stamped save (crew present, no skills) seeds 4 role-based rows per member, xp consistent with level, state untouched; idempotent across restart', async () => { const { db, file } = await freshFile() await getWorldState(db) // fresh v8 save (crew_skills already seeded by createNewGame — JC-21) const save = (await db.db.select().from(schema.saves))[0] @@ -759,11 +759,11 @@ describe('backfill (old saves)', () => { const turnBefore = (await db.db.select().from(schema.saves))[0].turn await getWorldState(db) // ensureSave → backfill v7 → v8 → seedCrewSkills expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(8) - expect(await skillMap(db, pilot.id)).toEqual({ NAVIGATION: { level: 2, xp: 0 }, SCANNING: { level: 0, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) // PILOT - expect(await skillMap(db, scientist.id)).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 2, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 2, xp: 0 } }) // SCIENTIST + expect(await skillMap(db, pilot.id)).toEqual({ NAVIGATION: { level: 2, xp: 10 }, SCANNING: { level: 0, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) // PILOT + expect(await skillMap(db, scientist.id)).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 2, xp: 10 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 2, xp: 10 } }) // SCIENTIST expect(await skillMap(db, gunner.id)).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 0, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) // GUNNER (all L0) - for (const c of crewBefore) { // full invariant: EVERY member (incl. the random starting crew) matches its role map, xp 0 - const expected = Object.fromEntries((Object.keys(STARTING_LEVELS[c.role]) as SkillName[]).map((s) => [s, { level: STARTING_LEVELS[c.role][s], xp: 0 }])) + for (const c of crewBefore) { // full invariant: EVERY member (incl. the random starting crew) matches its role map, xp = the level's threshold (levelFromXp round-trips) + const expected = Object.fromEntries((Object.keys(STARTING_LEVELS[c.role]) as SkillName[]).map((s) => [s, { level: STARTING_LEVELS[c.role][s], xp: STARTING_LEVELS[c.role][s] > 0 ? XP_THRESHOLDS[STARTING_LEVELS[c.role][s] - 1] : 0 }])) expect(await skillMap(db, c.id)).toEqual(expected) } expect((await db.db.select().from(schema.saves))[0].turn).toBe(turnBefore) // Review Focus 1 (v8): turn / crew / inventory diff --git a/lib/game/save.ts b/lib/game/save.ts index f4b2350..948dc87 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -3,7 +3,7 @@ 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, type SkillName } from './crew-costs' +import { 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' @@ -138,9 +138,11 @@ async function seedStartingTech(db: GameDb, saveId: number): Promise { } // Idempotent: for each crew member with no crew_skills rows yet, seed one row per skill -// (level = STARTING_LEVELS[role][skill], xp = 0). Called by createNewGame and the v8 backfill. -// A role missing from STARTING_LEVELS seeds four all-L0 rows instead of throwing — the -// seeder must never brick a save. +// (level = STARTING_LEVELS[role][skill], xp = the threshold that level implies — levelFromXp +// must round-trip: a seeded L2 starts at xp 10, not 0, or Task 14's grantXp (which recomputes +// level from cumulative xp) would regress the member L2 → L0). Called by createNewGame and the +// v8 backfill. A role missing from STARTING_LEVELS seeds four all-L0 rows instead of throwing +// — the seeder must never brick a save. async function seedCrewSkills(db: GameDb, saveId: number) { const crew = await db.db.select().from(schema.crew).where(eq(schema.crew.saveId, saveId)) const have = new Set((await db.db.select({ crewId: schema.crewSkills.crewId }).from(schema.crewSkills)).map((r) => r.crewId)) @@ -148,7 +150,7 @@ async function seedCrewSkills(db: GameDb, saveId: number) { if (have.has(m.id)) continue // already seeded — idempotent const levels = STARTING_LEVELS[m.role] ?? { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 } for (const skill of Object.keys(levels) as SkillName[]) { - await db.db.insert(schema.crewSkills).values({ saveId, crewId: m.id, skill, level: levels[skill], xp: 0 }) + await db.db.insert(schema.crewSkills).values({ saveId, crewId: m.id, skill, level: levels[skill], xp: levels[skill] > 0 ? XP_THRESHOLDS[levels[skill] - 1] : 0 }) } } }