feat(db): crew_skills table + role-based starting levels — schema v8 (#45)
CI / build + unit + e2e (pull_request) Successful in 2m48s

This commit is contained in:
2026-10-04 00:56:30 +00:00
parent 678cd48a94
commit 449d4b9895
6 changed files with 99 additions and 6 deletions
+1
View File
@@ -19,6 +19,7 @@ export const ADMIN_TABLES = {
missions: schema.missions, missions: schema.missions,
events: schema.events, events: schema.events,
tasks: schema.tasks, tasks: schema.tasks,
crew_skills: schema.crewSkills,
} as const satisfies Record<string, SQLiteTable> } as const satisfies Record<string, SQLiteTable>
const TABLE_NAMES: Set<string> = new Set(Object.values(ADMIN_TABLES).map((t) => getTableName(t))) const TABLE_NAMES: Set<string> = new Set(Object.values(ADMIN_TABLES).map((t) => getTableName(t)))
+10
View File
@@ -129,6 +129,16 @@ CREATE TABLE IF NOT EXISTS tasks (
status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE' status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE'
queued_at_turn INTEGER NOT NULL DEFAULT 0 queued_at_turn INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS crew_skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
save_id INTEGER NOT NULL REFERENCES saves(id),
crew_id INTEGER NOT NULL REFERENCES crew(id),
skill TEXT NOT NULL, -- 'NAVIGATION' | 'SCANNING' | 'ENGINEERING' | 'RESEARCH' (SkillName)
level INTEGER NOT NULL DEFAULT 0, -- 0–5
xp INTEGER NOT NULL DEFAULT 0, -- cumulative completions in this skill (Task 13 grants)
UNIQUE (crew_id, skill)
);
`; `;
// SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead // SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead
+11
View File
@@ -153,3 +153,14 @@ export const tasks = sqliteTable('tasks', {
status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE' status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE'
queuedAtTurn: integer('queued_at_turn').notNull().default(0), queuedAtTurn: integer('queued_at_turn').notNull().default(0),
}) })
// Per-member skill progression (PR B, spec §6): one row per (crew member, skill); seeded to
// STARTING_LEVELS[role][skill] by createNewGame / the v8 backfill; Task 13 adds xp + level-ups.
export const crewSkills = sqliteTable('crew_skills', {
id: integer('id').primaryKey({ autoIncrement: true }),
saveId: integer('save_id').notNull().references(() => saves.id),
crewId: integer('crew_id').notNull().references(() => crew.id),
skill: text('skill').notNull(), // SkillName
level: integer('level').notNull().default(0), // 0–5
xp: integer('xp').notNull().default(0),
}, (t) => [uniqueIndex('crew_skills_crew_skill_unique').on(t.crewId, t.skill)])
+3 -1
View File
@@ -22,7 +22,9 @@ const LAST_NAMES = [
'VALDEZ', 'WHITLOCK', 'YAMADA', 'ZUBEROV', 'ASHFORD', 'BLACKWELL', 'VALDEZ', 'WHITLOCK', 'YAMADA', 'ZUBEROV', 'ASHFORD', 'BLACKWELL',
] ]
const ROLES = ['COMMANDER', 'PILOT', 'ENGINEER', 'MEDIC', 'NAVIGATOR', 'SCIENTIST', 'GUNNER', 'TECHNICIAN'] // The role universe: the starting-crew draw and the recruit roll both pick from here.
// Exported so tests can pin that every role is covered by crew-costs's STARTING_LEVELS.
export const ROLES = ['COMMANDER', 'PILOT', 'ENGINEER', 'MEDIC', 'NAVIGATOR', 'SCIENTIST', 'GUNNER', 'TECHNICIAN']
const GOOD_TRAITS = [ const GOOD_TRAITS = [
'STEADY UNDER PRESSURE', 'RECKLESS COURAGE', 'CALM IN A CRISIS', 'FIXES ANYTHING', 'STEADY UNDER PRESSURE', 'RECKLESS COURAGE', 'CALM IN A CRISIS', 'FIXES ANYTHING',
+51 -4
View File
@@ -5,7 +5,8 @@ import { afterAll, describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { openGameDb, type GameDb } from '@/lib/db/client' import { openGameDb, type GameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema' 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 { MIN_AVAILABLE } from './missions'
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { TECHS } from './tech' import { TECHS } from './tech'
@@ -718,10 +719,10 @@ describe('backfill (old saves)', () => {
const invBefore = await db.db.select().from(schema.inventoryItems) 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 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(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.crew)).toEqual(crewBefore)
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore) expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore)
const tables = db.raw.exec("SELECT name FROM sqlite_master WHERE type = 'table'") 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 const reopened = await openGameDb(file) // restart: the backfill must be a no-op
await getWorldState(reopened) 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) 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 () => { it('seeds the starting ship on a new game: core types built at base values, WEAPONS unbuilt', async () => {
const { db } = await freshFile() const { db } = await freshFile()
await getWorldState(db) await getWorldState(db)
+23 -1
View File
@@ -3,6 +3,7 @@ import type { GameDb } from '@/lib/db/client'
import { persist } from '@/lib/db/client' import { persist } from '@/lib/db/client'
import * as schema from '@/lib/db/schema' import * as schema from '@/lib/db/schema'
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew' import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
import { STARTING_LEVELS, type SkillName } from './crew-costs'
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { checkMissionCompletion, topUpMissionBoard } from './missions' import { checkMissionCompletion, topUpMissionBoard } from './missions'
import { resolveResearchTask, ResearchError } from './research' import { resolveResearchTask, ResearchError } from './research'
@@ -119,7 +120,7 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi
// What each schema version added to a save. Saves created by older code lack the data newer // What each schema version added to a save. Saves created by older code lack the data newer
// versions seed at new-game time, so ensureSave runs these steps in order for anything below // versions seed at new-game time, so ensureSave runs these steps in order for anything below
// current — each step is idempotent (a no-op when its data already exists). // current — each step is idempotent (a no-op when its data already exists).
export const SCHEMA_VERSION = 7 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board · v7: tasks table export const SCHEMA_VERSION = 8 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board · v7: tasks table · v8: crew_skills (role-based starting levels)
// Seeds the starting ship: one row per known type — core types built at base values, the rest // Seeds the starting ship: one row per known type — core types built at base values, the rest
// unbuilt (level/capacity 0) until a BUILD action. // unbuilt (level/capacity 0) until a BUILD action.
@@ -136,6 +137,22 @@ async function seedStartingTech(db: GameDb, saveId: number): Promise<void> {
} }
} }
// 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.
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))
for (const m of crew) {
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 })
}
}
}
// Creates the save + starting region when the DB has none; backfills older saves otherwise. // Creates the save + starting region when the DB has none; backfills older saves otherwise.
// Safe to call on every request (single player, one process: check-then-insert cannot race). // Safe to call on every request (single player, one process: check-then-insert cannot race).
export async function ensureSave(db: GameDb): Promise<void> { export async function ensureSave(db: GameDb): Promise<void> {
@@ -162,6 +179,7 @@ async function createNewGame(db: GameDb): Promise<void> {
const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1]) const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1])
await insertRoutes(db, region.edges, resolve) await insertRoutes(db, region.edges, resolve)
await seedStartingCrew(db, save.id, seed) await seedStartingCrew(db, save.id, seed)
await seedCrewSkills(db, save.id)
await seedStartingShip(db, save.id) await seedStartingShip(db, save.id)
await seedStartingTech(db, save.id) await seedStartingTech(db, save.id)
await topUpMissionBoard(db) // only the start system is discovered yet — no DELIVER targets on a fresh game await topUpMissionBoard(db) // only the start system is discovered yet — no DELIVER targets on a fresh game
@@ -200,6 +218,10 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema
// every file — new or old — so an upgraded save simply starts with an empty queue; stamp. // every file — new or old — so an upgraded save simply starts with an empty queue; stamp.
version = 7 version = 7
} }
if (version < 8) {
await seedCrewSkills(db, save.id) // DDL creates the table at open; seed each member's 4 rows
version = 8
}
if (from !== SCHEMA_VERSION) { if (from !== SCHEMA_VERSION) {
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id)) await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))