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
+30
View File
@@ -7,6 +7,36 @@ import type {
Condition5e,
CompendiumEntry,
} from '@/lib/compendium/types';
import type { RulesetClass } from '@/lib/ruleset/normalize';
const cap = (s: string): string => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
function ClassRow({ label, value }: { label: string; value: string }) {
return <div className="flex gap-2"><span className="w-28 shrink-0 text-xs uppercase tracking-wide text-muted">{label}</span><span className="text-ink">{value}</span></div>;
}
export function ClassDetail({ c }: { c: RulesetClass }) {
return (
<div className="space-y-2 text-sm">
{c.description && <p className="text-muted">{c.description}</p>}
<ClassRow label={c.system === 'pf2e' ? 'HP / level' : 'Hit die'} value={c.system === 'pf2e' ? String(c.hitDie) : `d${c.hitDie}`} />
{c.keyAbilities.length > 0 && <ClassRow label="Key ability" value={c.keyAbilities.map(cap).join(' or ')} />}
{c.saves.length > 0 && <ClassRow label="Saves" value={c.saves.map((s) => (c.saveRanks?.[s] ? `${cap(s)} (${c.saveRanks[s]})` : cap(s))).join(', ')} />}
{c.perception && <ClassRow label="Perception" value={c.perception} />}
<ClassRow label="Skills" value={`${c.skillCount}${c.skillChoices.length ? ` — from ${c.skillChoices.join(', ')}` : ''}`} />
{c.caster !== 'none' && <ClassRow label="Spellcasting" value={cap(c.caster)} />}
{c.weapons.length > 0 && <ClassRow label="Weapons" value={c.weapons.map(cap).join(', ')} />}
{c.armor.length > 0 && <ClassRow label="Armor" value={c.armor.map(cap).join(', ')} />}
{c.subclasses.length > 0 && (
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">{c.system === 'pf2e' ? 'Options' : 'Subclasses'}</div>
<ul className="list-disc space-y-0.5 pl-5 text-ink">{c.subclasses.slice(0, 16).map((s) => <li key={s.name}>{s.name}</li>)}</ul>
</div>
)}
<p className="pt-1 text-xs text-muted">Source: {c.source}{c.license ? ` · ${c.license}` : ''}</p>
</div>
);
}
function Header({ title, subtitle }: { title: string; subtitle?: string | undefined }) {
return (
+16
View File
@@ -10,9 +10,11 @@ import {
loadArmor5e,
loadFeats5e,
loadConditions5e,
loadClasses,
loadPf2e,
crLabel,
} from '@/lib/compendium';
import type { RulesetClass } from '@/lib/ruleset/normalize';
import { MonsterDetail } from './MonsterDetail';
import {
Spell5eDetail,
@@ -22,6 +24,7 @@ import {
Feat5eDetail,
Condition5eDetail,
Pf2eDetail,
ClassDetail,
} from './details';
export type Entry = CompendiumEntry;
@@ -141,6 +144,17 @@ function pf2eCategory(id: string, label: string, file: string, opts: { level?: b
};
}
function classCategory(system: SystemId): CategoryDef {
return {
id: `${system}-classes`, system, label: 'Classes',
load: () => loadClasses(system) as unknown as Promise<Entry[]>,
searchKeys: ['name'],
meta: (e) => { const c = e as unknown as RulesetClass; return c.system === 'pf2e' ? `${c.hitDie} HP` : `d${c.hitDie}`; },
detail: (e) => <ClassDetail c={e as unknown as RulesetClass} />,
filters: [],
};
}
export const CATEGORIES: CategoryDef[] = [
// ---------- D&D 5e ----------
{
@@ -164,6 +178,7 @@ export const CATEGORIES: CategoryDef[] = [
{ key: 'crMax', label: 'Max CR', kind: 'lte', get: (e) => e.cr as number, options: (d) => numericOptions(d, (e) => e.cr, (n) => `CR ≤ ${crNum(n)}`) },
],
},
classCategory('5e'),
{
id: '5e-spells', system: '5e', label: 'Spells', linkAs: 'spell',
load: () => loadSpells() as unknown as Promise<Entry[]>,
@@ -235,6 +250,7 @@ export const CATEGORIES: CategoryDef[] = [
...(e.level !== undefined ? { level: Number(e.level) } : {}),
}),
},
classCategory('pf2e'),
pf2eCategory('spells', 'Spells', 'spells', { linkAs: 'spell' }),
pf2eCategory('feats', 'Feats', 'feats'),
pf2eCategory('equipment', 'Equipment', 'equipment', { linkAs: 'item' }),
+9
View File
@@ -67,6 +67,15 @@ export function SettingsPage() {
<AssistantSettings />
<section className="rounded-lg border border-line bg-panel p-4">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data sources &amp; licenses</h2>
<ul className="space-y-1 text-sm text-muted">
<li><span className="text-ink">D&amp;D 5e</span> SRD content via <a className="text-accent hover:underline" href="https://open5e.com" target="_blank" rel="noreferrer">Open5e</a> (OGL 1.0a / CC-BY-4.0).</li>
<li><span className="text-ink">Pathfinder 2e</span> content from the <a className="text-accent hover:underline" href="https://github.com/foundryvtt/pf2e" target="_blank" rel="noreferrer">Foundry VTT pf2e</a> project &amp; Archives of Nethys (OGL / ORC / Paizo Community Use).</li>
<li className="text-xs">Map import follows the Universal VTT (.dd2vtt) format used by Dungeondraft, Foundry &amp; others.</li>
</ul>
</section>
<section className="rounded-lg border border-danger/40 bg-danger/5 p-4">
<h2 className="mb-1 text-sm font-semibold text-danger">Danger zone</h2>
<p className="mb-3 text-sm text-muted">Permanently delete all campaigns and data on this device.</p>