- 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
339 lines
12 KiB
TypeScript
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());
|