feat(01-03): add idempotent ClassProgression + ClassFeatureOption seed script
- Iterate over D16_CLASS_NAMES; read Foundry pf2e class JSONs; merge with hand-curated overlays - Generic pipeline (Plan 03b appends data to overlay modules; no script changes needed) - Idempotent findUnique → update OR create per row using compound unique keys - Loud failure with SEED-README pointer when Foundry clone is missing - Use Prisma.JsonNull for nullable Json fields per Prisma 7 typed-input contract - No 'any' types; type-clean against tsconfig.json
This commit is contained in:
328
server/prisma/seed-class-progression.ts
Normal file
328
server/prisma/seed-class-progression.ts
Normal file
@@ -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<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());
|
||||
Reference in New Issue
Block a user