/** * Seed: ClassProgression + ClassFeatureOption from Foundry pf2e + hand-curated overlays. * * Sources: * - Foundry pf2e class JSONs at `server/prisma/data/foundry-pf2e/packs/classes/*.json` * (cloned manually by dev; see SEED-README.md). * - SPELL_SLOT_OVERLAY from `data/spell-slot-overlays.ts` (Pitfall #6 mitigation). * - CLASS_FEATURE_OPTIONS from `data/class-feature-options.ts` (D-19 choices). * * Idempotent — safe to re-run. * * Compound unique keys (generated by Prisma from Plan 01 schema): * - ClassProgression has `@@unique([className, level])` -> field name: `className_level` * - ClassFeatureOption has `@@unique([optionsRef, optionKey])` -> field name: `optionsRef_optionKey` * If Plan 01's `@@unique([...])` declarations are reordered, Prisma swaps the field names. * Verify Plan 01's schema field order before assuming. If the order changes, update the * findUnique calls below accordingly. */ import 'dotenv/config'; import * as fs from 'fs'; import * as path from 'path'; import { PrismaClient, Prisma } from '../src/generated/prisma/client.js'; import { PrismaPg } from '@prisma/adapter-pg'; import { SPELL_SLOT_OVERLAY, type SpellSlotOverlayEntry } from './data/spell-slot-overlays'; import { CLASS_FEATURE_OPTIONS } from './data/class-feature-options'; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const prisma = new PrismaClient({ adapter }); const FOUNDRY_CLASSES_DIR = path.join(__dirname, 'data', 'foundry-pf2e', 'packs', 'classes'); // The 16 D-16 classes. Filename in Foundry = lowercase classname. const D16_CLASS_NAMES = [ 'Alchemist', 'Barbarian', 'Bard', 'Champion', 'Cleric', 'Druid', 'Fighter', 'Investigator', 'Monk', 'Oracle', 'Ranger', 'Rogue', 'Sorcerer', 'Swashbuckler', 'Witch', 'Wizard', ] as const; // Map of (className → choiceType, choiceOptionsRef) for the L1 doctrine/school/etc. step. const L1_CHOICE_MAP: Record = { Cleric: { choiceType: 'doctrine', choiceOptionsRef: 'cleric-doctrine' }, Wizard: { choiceType: 'school', choiceOptionsRef: 'wizard-school' }, Champion: { choiceType: 'cause', choiceOptionsRef: 'champion-cause' }, Druid: { choiceType: 'order', choiceOptionsRef: 'druid-order' }, Sorcerer: { choiceType: 'bloodline', choiceOptionsRef: 'sorcerer-bloodline' }, Bard: { choiceType: 'muse', choiceOptionsRef: 'bard-muse' }, Barbarian: { choiceType: 'instinct', choiceOptionsRef: 'barbarian-instinct' }, Witch: { choiceType: 'patron', choiceOptionsRef: 'witch-patron' }, Oracle: { choiceType: 'mystery', choiceOptionsRef: 'oracle-mystery' }, Investigator: { choiceType: 'methodology', choiceOptionsRef: 'investigator-methodology' }, Ranger: { choiceType: 'edge', choiceOptionsRef: 'ranger-edge' }, Rogue: { choiceType: 'racket', choiceOptionsRef: 'rogue-racket' }, Swashbuckler: { choiceType: 'style', choiceOptionsRef: 'swashbuckler-style' }, Alchemist: { choiceType: 'research-field', choiceOptionsRef: 'alchemist-research-field' }, Fighter: null, // Fighter has no L1 choice; weapon mastery is at L5 Monk: null, // Monk's L1 stance is via class feats, not a choiceType }; // Optional: Fighter L5 weapon-mastery group choice const HIGHER_LEVEL_CHOICES: Array<{ className: string; level: number; choiceType: string; choiceOptionsRef: string; }> = [ { className: 'Fighter', level: 5, choiceType: 'weapon-mastery-group', choiceOptionsRef: 'fighter-weapon-mastery', }, ]; interface FoundryClassItem { level: number; name: string; uuid?: string; img?: string; } interface FoundryClassJson { name: string; type: 'class'; system: { hp: number; keyAbility: string[]; spellcasting: number; savingThrows: { fortitude: number; reflex: number; will: number }; attacks: Record; defenses: Record; classFeatLevels?: { value: number[] }; items: Record; }; } function readFoundryClass(className: string): FoundryClassJson { // Foundry filename convention: lowercase, e.g. 'fighter.json' const filename = `${className.toLowerCase()}.json`; const fullPath = path.join(FOUNDRY_CLASSES_DIR, filename); if (!fs.existsSync(fullPath)) { throw new Error( `Foundry pf2e clone not found at ${fullPath}. ` + `See .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md for setup.`, ); } const raw = fs.readFileSync(fullPath, 'utf-8'); const json = JSON.parse(raw) as FoundryClassJson; if (json.type !== 'class' || !json.name || !json.system) { throw new Error(`Class JSON does not match expected schema: ${fullPath}`); } return json; } /** Map Foundry numeric proficiency rank (0..8) to our Proficiency string enum. */ function profFromValue( v: number | undefined, ): 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY' { if (v === undefined || v === null) return 'UNTRAINED'; if (v >= 8) return 'LEGENDARY'; if (v >= 6) return 'MASTER'; if (v >= 4) return 'EXPERT'; if (v >= 2) return 'TRAINED'; return 'UNTRAINED'; } /** * Aggregate all Foundry items at this level and the spell-slot overlay entries at this level * into a single ClassProgression upsert payload. * * Returns a Prisma-compatible create/update input. Prisma `Json?` fields receive * `Prisma.JsonNull` (the typed-null sentinel) when the row has no value at that level * — passing JS `null` would not compile against `InputJsonValue | NullableJsonNullValueInput`. */ function buildProgressionRow( className: string, level: number, foundry: FoundryClassJson, ): Prisma.ClassProgressionUncheckedCreateInput { // 1. Grants from Foundry items const grants: string[] = Object.values(foundry.system.items) .filter(item => item.level === level) .map(item => item.name); // 2. Proficiency changes — for L1 only, take the class's base proficiency from the JSON. let proficiencyChanges: Record = {}; if (level === 1) { proficiencyChanges = { fortitude: profFromValue(foundry.system.savingThrows.fortitude), reflex: profFromValue(foundry.system.savingThrows.reflex), will: profFromValue(foundry.system.savingThrows.will), }; } // 3. Spell-slot overlay merge const overlayEntries: SpellSlotOverlayEntry[] = (SPELL_SLOT_OVERLAY[className] || []).filter( e => e.level === level, ); const slotEntry = overlayEntries.find(e => e.spellSlotIncrement); const cantripEntry = overlayEntries.find(e => e.cantripIncrement !== undefined); const repertoireEntry = overlayEntries.find(e => e.repertoireIncrement !== undefined); // 4. Choice type for this (class, level) let choiceType: string | null = null; let choiceOptionsRef: string | null = null; if (level === 1 && L1_CHOICE_MAP[className]) { const choice = L1_CHOICE_MAP[className]!; choiceType = choice.choiceType; choiceOptionsRef = choice.choiceOptionsRef; } const higherChoice = HIGHER_LEVEL_CHOICES.find( c => c.className === className && c.level === level, ); if (higherChoice) { choiceType = higherChoice.choiceType; choiceOptionsRef = higherChoice.choiceOptionsRef; } return { className, level, grants, proficiencyChanges, spellSlotIncrement: slotEntry?.spellSlotIncrement ?? Prisma.JsonNull, cantripIncrement: cantripEntry?.cantripIncrement ?? null, repertoireIncrement: repertoireEntry?.repertoireIncrement ?? null, choiceType, choiceOptionsRef, }; } async function seedClassProgression(): Promise<{ created: number; updated: number; errors: number; }> { let created = 0; let updated = 0; let errors = 0; console.log('Seeding ClassProgression for 16 classes x 20 levels...'); for (const className of D16_CLASS_NAMES) { let foundry: FoundryClassJson; try { foundry = readFoundryClass(className); } catch (e) { errors++; console.error(`Failed to load Foundry class ${className}:`, (e as Error).message); continue; } for (let level = 1; level <= 20; level++) { const row = buildProgressionRow(className, level, foundry); try { // Compound unique key field name `className_level` is generated from // `@@unique([className, level])` declared in Plan 01's schema. If that // declaration is reordered, Prisma will name the key `level_className` // instead — verify schema before changing. const existing = await prisma.classProgression.findUnique({ where: { className_level: { className, level } }, }); if (existing) { await prisma.classProgression.update({ where: { id: existing.id }, data: row, }); updated++; } else { await prisma.classProgression.create({ data: row }); created++; } } catch (e) { errors++; console.error(`Failed to seed ${className} L${level}:`, (e as Error).message); } } } console.log(` ClassProgression: ${created} created, ${updated} updated, ${errors} errors`); return { created, updated, errors }; } async function seedClassFeatureOptions(): Promise<{ created: number; updated: number; errors: number; }> { let created = 0; let updated = 0; let errors = 0; console.log(`Seeding ${CLASS_FEATURE_OPTIONS.length} ClassFeatureOption rows...`); for (const opt of CLASS_FEATURE_OPTIONS) { try { // Compound unique key field name `optionsRef_optionKey` is generated from // `@@unique([optionsRef, optionKey])` in Plan 01's schema. Same caveat as above // applies — verify the field order before touching this. const existing = await prisma.classFeatureOption.findUnique({ where: { optionsRef_optionKey: { optionsRef: opt.optionsRef, optionKey: opt.optionKey }, }, }); if (existing) { await prisma.classFeatureOption.update({ where: { id: existing.id }, data: { name: opt.name, nameGerman: opt.nameGerman ?? null, description: opt.description, grants: opt.grants, proficiencyChanges: opt.proficiencyChanges ?? Prisma.JsonNull, }, }); updated++; } else { await prisma.classFeatureOption.create({ data: { optionsRef: opt.optionsRef, optionKey: opt.optionKey, name: opt.name, nameGerman: opt.nameGerman ?? null, description: opt.description, grants: opt.grants, proficiencyChanges: opt.proficiencyChanges ?? Prisma.JsonNull, }, }); created++; } } catch (e) { errors++; console.error( `Failed to seed option ${opt.optionsRef}/${opt.optionKey}:`, (e as Error).message, ); } } console.log(` ClassFeatureOption: ${created} created, ${updated} updated, ${errors} errors`); return { created, updated, errors }; } async function main(): Promise { const cp = await seedClassProgression(); const cfo = await seedClassFeatureOptions(); const totalErrors = cp.errors + cfo.errors; if (totalErrors > 0) { console.error(`\nSeed completed with ${totalErrors} errors.`); process.exit(1); } console.log('\nClassProgression + ClassFeatureOption seed complete.'); } main() .catch(e => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect());