diff --git a/lib/db/admin.ts b/lib/db/admin.ts index 6f449d2..6ac57ce 100644 --- a/lib/db/admin.ts +++ b/lib/db/admin.ts @@ -19,6 +19,7 @@ export const ADMIN_TABLES = { missions: schema.missions, events: schema.events, tasks: schema.tasks, + crew_skills: schema.crewSkills, } as const satisfies Record const TABLE_NAMES: Set = new Set(Object.values(ADMIN_TABLES).map((t) => getTableName(t))) diff --git a/lib/db/client.ts b/lib/db/client.ts index ccdc603..f0d8989 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -129,6 +129,16 @@ CREATE TABLE IF NOT EXISTS tasks ( status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE' 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 diff --git a/lib/db/schema.ts b/lib/db/schema.ts index cb204a5..7cc9a08 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -153,3 +153,14 @@ export const tasks = sqliteTable('tasks', { status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE' 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)]) diff --git a/lib/game/crew.ts b/lib/game/crew.ts index a39f9c6..0856a85 100644 --- a/lib/game/crew.ts +++ b/lib/game/crew.ts @@ -22,7 +22,9 @@ const LAST_NAMES = [ '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 = [ 'STEADY UNDER PRESSURE', 'RECKLESS COURAGE', 'CALM IN A CRISIS', 'FIXES ANYTHING', diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 56fad68..1d7b38c 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -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) diff --git a/lib/game/save.ts b/lib/game/save.ts index 0d058f6..f4b2350 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -3,6 +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 { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { checkMissionCompletion, topUpMissionBoard } from './missions' 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 // 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). -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 // unbuilt (level/capacity 0) until a BUILD action. @@ -136,6 +137,22 @@ 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. +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. // Safe to call on every request (single player, one process: check-then-insert cannot race). export async function ensureSave(db: GameDb): Promise { @@ -162,6 +179,7 @@ async function createNewGame(db: GameDb): Promise { const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1]) await insertRoutes(db, region.edges, resolve) await seedStartingCrew(db, save.id, seed) + await seedCrewSkills(db, save.id) await seedStartingShip(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 @@ -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. 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) { await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))