feat(db): crew_skills table + role-based starting levels — schema v8 (#45)
CI / build + unit + e2e (pull_request) Successful in 2m48s
CI / build + unit + e2e (pull_request) Successful in 2m48s
This commit is contained in:
+51
-4
@@ -5,7 +5,8 @@ import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||
import { ROLES, generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||
import { STARTING_LEVELS, type SkillName } from './crew-costs'
|
||||
import { MIN_AVAILABLE } from './missions'
|
||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||
import { TECHS } from './tech'
|
||||
@@ -718,10 +719,10 @@ describe('backfill (old saves)', () => {
|
||||
const invBefore = await db.db.select().from(schema.inventoryItems)
|
||||
|
||||
await db.db.update(schema.saves).set({ schemaVersion: 6 }) // simulate a save stamped by v0.9.12
|
||||
const world2 = await getWorldState(db) // ensureSave → backfill v6 → v7
|
||||
const world2 = await getWorldState(db) // ensureSave → backfill v6 → v7 → v8
|
||||
|
||||
expect(world2).toEqual(world) // turn, systems, edges — untouched (Review Focus 1)
|
||||
expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(7)
|
||||
expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(SCHEMA_VERSION)
|
||||
expect(await db.db.select().from(schema.crew)).toEqual(crewBefore)
|
||||
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore)
|
||||
const tables = db.raw.exec("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
@@ -730,10 +731,56 @@ describe('backfill (old saves)', () => {
|
||||
|
||||
const reopened = await openGameDb(file) // restart: the backfill must be a no-op
|
||||
await getWorldState(reopened)
|
||||
expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(7)
|
||||
expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(SCHEMA_VERSION)
|
||||
expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0)
|
||||
})
|
||||
|
||||
// Insert one member directly (skill seeding depends only on role — traits can be dummies).
|
||||
const pinCrew = (db: GameDb, saveId: number, name: string, role: string) =>
|
||||
db.db.insert(schema.crew).values({ saveId, name, role, traitGood: 'STEADY UNDER PRESSURE', traitBad: 'IMPULSIVE', origin: 'STARTING CREW' }).returning().then((r) => r[0])
|
||||
// A member's 4 skill rows as { [skill]: { level, xp } }; the toHaveLength(4) asserts one row per skill.
|
||||
const skillMap = async (db: GameDb, crewId: number) => {
|
||||
const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, crewId))
|
||||
expect(rows).toHaveLength(4)
|
||||
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 () => {
|
||||
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]
|
||||
await db.db.delete(schema.crewSkills) // simulate a v0.10.0 save: crew present, no skills yet
|
||||
await db.db.update(schema.saves).set({ schemaVersion: 7 })
|
||||
const pilot = await pinCrew(db, save.id, 'P. VANCE', 'PILOT')
|
||||
const scientist = await pinCrew(db, save.id, 'S. OKAFOR', 'SCIENTIST')
|
||||
const gunner = await pinCrew(db, save.id, 'G. REYES', 'GUNNER')
|
||||
const crewBefore = await db.db.select().from(schema.crew)
|
||||
const invBefore = await db.db.select().from(schema.inventoryItems)
|
||||
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, 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 }]))
|
||||
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
|
||||
expect(await db.db.select().from(schema.crew)).toEqual(crewBefore) // byte-identical
|
||||
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore) // byte-identical
|
||||
const reopened = await openGameDb(file) // restart: the backfill must be a no-op
|
||||
await getWorldState(reopened)
|
||||
expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(8)
|
||||
expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM crew_skills')[0]?.values[0][0] ?? 0)).toBe(4 * (await reopened.db.select().from(schema.crew)).length) // still 4 rows per member
|
||||
})
|
||||
|
||||
it('pins the role universe: every role in crew.ts is a key of STARTING_LEVELS, so the v8 seeder never hits an unknown role', () => {
|
||||
for (const role of ROLES) {
|
||||
expect(STARTING_LEVELS[role], `STARTING_LEVELS[${role}]`).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('seeds the starting ship on a new game: core types built at base values, WEAPONS unbuilt', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getWorldState(db)
|
||||
|
||||
Reference in New Issue
Block a user