Files
Dimension-47/server/prisma/seed-class-progression.ts
Alexander Zielonka ce214ab70c fix(01-03): align Foundry path with pf2e-8.0.3 layout
- Foundry pf2e v8 nests pf2e packs under packs/pf2e/ (v7 had packs/classes/ directly)
- Update FOUNDRY_CLASSES_DIR path to packs/pf2e/classes/
- Document v8 layout in SEED-README and update failure-mode path string
- Pitfall #5 mitigation: comment in seed script explains version-specific path

Worked-example verification (Wizard end-to-end):
- 320 ClassProgression rows seeded (16 classes x 20 levels)
- Wizard L1..L19 carry ARCANE spell-slot increments + L1 has 5 cantrips
- Wizard L1 has choiceType=school, choiceOptionsRef=wizard-school
- 1 ClassFeatureOption row for wizard-school (battle-magic / School of Battle Magic)
- Idempotent on re-run: 0 created, 320 updated, 0 errors
2026-04-27 14:53:46 +02:00

339 lines
12 KiB
TypeScript

/**
* 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 });
// Foundry pf2e v8 nests packs under a system subdirectory: `packs/pf2e/classes/`.
// (RESEARCH §Pitfall 5: Foundry pf2e data shape may shift across major versions.
// In v7 this was `packs/classes/`. SEED-README pins us to pf2e-8.0.3.)
const FOUNDRY_CLASSES_DIR = path.join(
__dirname,
'data',
'foundry-pf2e',
'packs',
'pf2e',
'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<string, { choiceType: string; choiceOptionsRef: string } | null> = {
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<string, number | { rank: number; name?: string }>;
defenses: Record<string, number>;
classFeatLevels?: { value: number[] };
items: Record<string, FoundryClassItem>;
};
}
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<string, string> = {};
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<void> {
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());