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:
@@ -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 (
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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 & licenses</h2>
|
||||
<ul className="space-y-1 text-sm text-muted">
|
||||
<li><span className="text-ink">D&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 & 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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user