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
+23 -1
View File
@@ -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<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.
// Safe to call on every request (single player, one process: check-then-insert cannot race).
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])
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))