Phase 7: structured ruleset data (Open5e 5e + Foundry PF2e classes)

- Scrapers scripts/fetch_open5e.ts (classes/races/backgrounds/feats, CC-BY/OGL) and
  fetch_foundry_pf2e.ts (25 PF2e classes, OGL/ORC) → normalized JSON in src/data/srd
  + public/data/pf2e/classes.json.
- src/lib/ruleset/normalize.ts: pure, tested normalizers → unified RulesetClass
  (hit die, key abilities, saves/ranks, perception, skills, caster, subclasses, profs).
- compendium loaders (loadClasses/loadRaces5e/loadBackgrounds5e) + a "Classes" tab for
  both systems (ClassDetail) with full stats; Bestiary stays the default category.
- Data-source attribution in Settings. Feeds the Phase 5 builder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 15:23:25 +02:00
parent f61fabadb5
commit e1ba3fe1b9
14 changed files with 366 additions and 0 deletions
+33
View File
@@ -1,3 +1,5 @@
import type { SystemId } from '@/lib/rules';
import type { RulesetClass } from '@/lib/ruleset/normalize';
import type {
Monster,
Spell,
@@ -78,6 +80,37 @@ export async function loadConditions5e(): Promise<Condition5e[]> {
return cache.get('c5e') as Condition5e[];
}
// ---- Structured ruleset classes (Open5e + Foundry pf2e; feed builder + compendium) ----
export async function loadClasses(system: SystemId): Promise<RulesetClass[]> {
const key = `classes:${system}`;
if (!cache.has(key)) {
if (system === '5e') {
const mod = await import('@/data/srd/classes.json');
cache.set(key, mod.default as unknown as RulesetClass[]);
} else {
const res = await fetch(`${import.meta.env.BASE_URL}data/pf2e/classes.json`);
cache.set(key, (res.ok ? await res.json() : []) as RulesetClass[]);
}
}
return cache.get(key) as RulesetClass[];
}
export async function loadRaces5e(): Promise<{ slug: string; name: string; desc: string; asi: string; speed: string; traits: string }[]> {
if (!cache.has('races5e')) {
const mod = await import('@/data/srd/races.json');
cache.set('races5e', mod.default as unknown[]);
}
return cache.get('races5e') as { slug: string; name: string; desc: string; asi: string; speed: string; traits: string }[];
}
export async function loadBackgrounds5e(): Promise<{ slug: string; name: string; desc: string; skills: string; feature: string }[]> {
if (!cache.has('bg5e')) {
const mod = await import('@/data/srd/backgrounds.json');
cache.set('bg5e', mod.default as unknown[]);
}
return cache.get('bg5e') as { slug: string; name: string; desc: string; skills: string; feature: string }[];
}
// ---- PF2e categories (static assets fetched at runtime; large files) ----
const pf2eCache = new Map<string, CompendiumEntry[]>();
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { normalizeOpen5eClass, normalizeFoundryClass } from './normalize';
describe('normalizeOpen5eClass', () => {
it('parses hit die, saves, skill choices, caster and subclasses', () => {
const c = normalizeOpen5eClass({
name: 'Wizard', slug: 'wizard', desc: '### Arcane\n\nMasters of magic.',
hit_dice: '1d6', prof_armor: 'None', prof_weapons: 'Daggers, darts, slings',
prof_saving_throws: 'Intelligence, Wisdom',
prof_skills: 'Choose two from Arcana, History, Insight, Investigation, Medicine, and Religion',
spellcasting_ability: 'Intelligence',
archetypes: [{ name: 'School of Evocation', desc: 'Boom.' }],
document__title: 'Systems Reference Document', document__license_url: 'https://...',
});
expect(c.hitDie).toBe(6);
expect(c.keyAbilities).toEqual(['int']);
expect(c.saves).toEqual(['int', 'wis']);
expect(c.skillCount).toBe(2);
expect(c.skillChoices).toContain('Arcana');
expect(c.caster).toBe('int');
expect(c.subclasses[0]!.name).toBe('School of Evocation');
expect(c.system).toBe('5e');
});
it('defaults caster to none when not a spellcaster', () => {
const c = normalizeOpen5eClass({ name: 'Fighter', slug: 'fighter', hit_dice: '1d10', prof_saving_throws: 'Strength, Constitution', prof_skills: 'Choose two from Acrobatics, Athletics' });
expect(c.caster).toBe('none');
expect(c.keyAbilities).toEqual(['str']);
});
});
describe('normalizeFoundryClass', () => {
it('maps PF2e hp/key ability/saves/proficiencies to ranks', () => {
const c = normalizeFoundryClass('fighter', {
name: 'Fighter',
system: {
hp: 10, keyAbility: { value: ['dex', 'str'] }, perception: 2,
savingThrows: { fortitude: 2, reflex: 2, will: 1 },
trainedSkills: { value: [], additional: 3 },
attacks: { simple: 2, martial: 2, advanced: 1, unarmed: 2, other: { name: '', rank: 0 } },
defenses: { unarmored: 1, light: 1, medium: 1, heavy: 1 },
description: { value: '<p>A master of arms.</p>' },
publication: { title: 'Pathfinder Player Core', license: 'ORC' },
},
});
expect(c.system).toBe('pf2e');
expect(c.hitDie).toBe(10);
expect(c.keyAbilities).toEqual(['dex', 'str']);
expect(c.saveRanks).toEqual({ con: 'Expert', dex: 'Expert', wis: 'Trained' });
expect(c.perception).toBe('Expert');
expect(c.skillCount).toBe(3);
expect(c.weapons).toEqual(expect.arrayContaining(['martial', 'simple']));
expect(c.description).toBe('A master of arms.');
});
});
+139
View File
@@ -0,0 +1,139 @@
/** Pure normalizers turning Open5e (5e) and Foundry PF2e raw records into our
* unified RulesetClass shape. Used by the scrapers (scripts/) and unit tests. */
export interface RulesetClass {
system: '5e' | 'pf2e';
slug: string;
name: string;
/** 5e: hit-die size (e.g. 12). PF2e: HP gained per level (e.g. 10). */
hitDie: number;
keyAbilities: string[];
/** abilities that start proficient/trained (lowercase keys) */
saves: string[];
/** PF2e initial save ranks by ability */
saveRanks?: Record<string, string>;
perception?: string;
skillCount: number;
skillChoices: string[];
/** spellcasting ability ('int'/'wis'/'cha') or 'none' */
caster: string;
subclasses: { name: string; desc?: string }[];
armor: string[];
weapons: string[];
description: string;
source: string;
license?: string;
}
const ABBR: Record<string, string> = {
strength: 'str', dexterity: 'dex', constitution: 'con',
intelligence: 'int', wisdom: 'wis', charisma: 'cha',
};
const NUM_WORD: Record<string, number> = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
const RANKS = ['Untrained', 'Trained', 'Expert', 'Master', 'Legendary'];
/** 5e classes don't expose a "key ability"; this is the conventional primary. */
const DND_KEY: Record<string, string> = {
barbarian: 'str', bard: 'cha', cleric: 'wis', druid: 'wis', fighter: 'str', monk: 'dex',
paladin: 'str', ranger: 'dex', rogue: 'dex', sorcerer: 'cha', warlock: 'cha', wizard: 'int',
};
function stripMd(s: string): string {
return (s || '').replace(/[#*_`>]/g, '').replace(/\r/g, '').trim();
}
function firstParagraph(s: string): string {
const p = stripMd(s).split('\n').map((x) => x.trim()).filter(Boolean)[0] ?? '';
return p.length > 400 ? `${p.slice(0, 397)}` : p;
}
function abilList(s: string | undefined): string[] {
return (s ?? '').split(/[,&]| and /).map((x) => ABBR[x.trim().toLowerCase()]).filter(Boolean) as string[];
}
export interface Open5eClass {
name: string; slug: string; desc?: string; hit_dice?: string;
prof_armor?: string; prof_weapons?: string; prof_saving_throws?: string; prof_skills?: string;
spellcasting_ability?: string; archetypes?: { name: string; desc?: string }[];
document__title?: string; document__license_url?: string;
}
export function normalizeOpen5eClass(c: Open5eClass): RulesetClass {
const hitDie = Number(/d(\d+)/.exec(c.hit_dice ?? '')?.[1]) || 8;
// "Choose two from Acrobatics, Athletics, …"
const skillsRaw = c.prof_skills ?? '';
const countWord = /choose (\w+)/i.exec(skillsRaw)?.[1]?.toLowerCase() ?? '';
const skillCount = NUM_WORD[countWord] ?? 2;
const fromPart = /from (.+)$/is.exec(skillsRaw)?.[1] ?? '';
const skillChoices = fromPart.split(',').map((s) => stripMd(s).replace(/\.$/, '').trim()).filter(Boolean).slice(0, 18);
const caster = ABBR[(c.spellcasting_ability ?? '').trim().toLowerCase()] ?? 'none';
return {
system: '5e',
slug: c.slug,
name: c.name,
hitDie,
keyAbilities: DND_KEY[c.slug] ? [DND_KEY[c.slug]!] : [],
saves: abilList(c.prof_saving_throws),
skillCount,
skillChoices,
caster,
subclasses: (c.archetypes ?? []).map((a) => ({ name: a.name, ...(a.desc ? { desc: firstParagraph(a.desc) } : {}) })),
armor: (c.prof_armor ?? '').split(',').map((x) => x.trim()).filter(Boolean),
weapons: (c.prof_weapons ?? '').split(',').map((x) => x.trim()).filter(Boolean),
description: firstParagraph(c.desc ?? ''),
source: c.document__title ?? 'Open5e',
...(c.document__license_url ? { license: c.document__license_url } : {}),
};
}
export interface FoundryClass {
name: string;
system?: {
hp?: number;
keyAbility?: { value?: string[] };
perception?: number;
savingThrows?: { fortitude?: number; reflex?: number; will?: number };
trainedSkills?: { value?: string[]; additional?: number };
attacks?: Record<string, number | { rank?: number; name?: string }>;
defenses?: Record<string, number>;
description?: { value?: string };
publication?: { title?: string; license?: string };
};
}
const PF2E_SAVE_ABIL: Record<string, string> = { fortitude: 'con', reflex: 'dex', will: 'wis' };
export function normalizeFoundryClass(slug: string, c: FoundryClass): RulesetClass {
const s = c.system ?? {};
const saveRanks: Record<string, string> = {};
const saves: string[] = [];
for (const [k, abil] of Object.entries(PF2E_SAVE_ABIL)) {
const n = (s.savingThrows as Record<string, number> | undefined)?.[k] ?? 0;
if (n > 0) { saveRanks[abil] = RANKS[Math.min(4, n)]!; saves.push(abil); }
}
const weaponProfs = Object.entries(s.attacks ?? {})
.filter(([k, v]) => k !== 'other' && (typeof v === 'number' ? v : v?.rank ?? 0) > 0)
.map(([k]) => k);
const armorProfs = Object.entries(s.defenses ?? {}).filter(([, v]) => (v ?? 0) > 0).map(([k]) => k);
return {
system: 'pf2e',
slug,
name: c.name,
hitDie: s.hp ?? 8,
keyAbilities: s.keyAbility?.value ?? [],
saves,
saveRanks,
...(s.perception !== undefined ? { perception: RANKS[Math.min(4, s.perception)]! } : {}),
skillCount: (s.trainedSkills?.additional ?? 0) + (s.trainedSkills?.value?.length ?? 0),
skillChoices: [],
caster: 'none',
subclasses: [],
armor: armorProfs,
weapons: weaponProfs,
description: firstParagraph(stripHtml(s.description?.value ?? '')),
source: s.publication?.title ?? 'Pathfinder 2e (Foundry VTT)',
license: s.publication?.license ?? 'OGL/ORC',
};
}
function stripHtml(s: string): string {
return (s || '').replace(/<[^>]+>/g, ' ').replace(/&[a-z]+;/g, ' ').replace(/\s+/g, ' ').trim();
}