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:
@@ -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[]>();
|
||||
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user