diff --git a/server/prisma/seed-class-progression.ts b/server/prisma/seed-class-progression.ts new file mode 100644 index 0000000..da29fab --- /dev/null +++ b/server/prisma/seed-class-progression.ts @@ -0,0 +1,328 @@ +/** + * 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());