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:
@@ -21,6 +21,12 @@ test('compendium: browse 5e categories, switch to PF2e, cross-link a spell', asy
|
||||
// Compendium → 5e categories exist (Feats is a new one)
|
||||
await page.getByRole('link', { name: 'Compendium' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Feats' })).toBeVisible();
|
||||
|
||||
// Classes category (Open5e data) lists classes with detail
|
||||
await page.getByRole('button', { name: 'Classes', exact: true }).click();
|
||||
await page.getByRole('button', { name: /^Wizard/ }).first().click();
|
||||
await expect(page.getByText('Hit die')).toBeVisible();
|
||||
await expect(page.getByText(/School of Evocation/)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Conditions' }).click();
|
||||
await expect(page.getByText(/results/)).toBeVisible();
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
||||
/* Scrape Foundry VTT pf2e class packs (OGL/ORC + Paizo Community Use) →
|
||||
* normalized JSON in public/data/pf2e/classes.json.
|
||||
* Run: bunx tsx scripts/fetch_foundry_pf2e.ts */
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { normalizeFoundryClass, type FoundryClass } from '../src/lib/ruleset/normalize';
|
||||
|
||||
const LIST = 'https://api.github.com/repos/foundryvtt/pf2e/contents/packs/classes?ref=master';
|
||||
|
||||
async function main() {
|
||||
const res = await fetch(LIST, { headers: { 'User-Agent': 'ttrpg-manager-scraper', Accept: 'application/vnd.github+json' } });
|
||||
if (!res.ok) throw new Error(`list: ${res.status}`);
|
||||
const files = (await res.json()) as { name: string; download_url: string }[];
|
||||
const classFiles = files.filter((f) => f.name.endsWith('.json'));
|
||||
|
||||
const out = [];
|
||||
for (const f of classFiles) {
|
||||
const raw = (await (await fetch(f.download_url)).json()) as FoundryClass;
|
||||
out.push(normalizeFoundryClass(f.name.replace(/\.json$/, ''), raw));
|
||||
}
|
||||
out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
writeFileSync('public/data/pf2e/classes.json', JSON.stringify(out));
|
||||
console.log(`pf2e: classes ${out.length} (${out.map((c) => c.name).join(', ')})`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,48 @@
|
||||
/* Scrape Open5e (SRD 5.1, CC-BY-4.0 / OGL) → normalized JSON in src/data/srd/.
|
||||
* Run: bunx tsx scripts/fetch_open5e.ts */
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { normalizeOpen5eClass, type Open5eClass } from '../src/lib/ruleset/normalize';
|
||||
|
||||
const BASE = 'https://api.open5e.com/v1';
|
||||
|
||||
async function all<T = Record<string, unknown>>(path: string): Promise<T[]> {
|
||||
let url: string | null = `${BASE}/${path}/?limit=500`;
|
||||
const out: T[] = [];
|
||||
while (url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`${path}: ${r.status}`);
|
||||
const j = (await r.json()) as { results: T[]; next: string | null };
|
||||
out.push(...j.results);
|
||||
url = j.next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const first = (s: string | undefined): string => {
|
||||
const p = (s ?? '').replace(/[#*_`>]/g, '').split('\n').map((x) => x.trim()).filter(Boolean)[0] ?? '';
|
||||
return p.length > 500 ? `${p.slice(0, 497)}…` : p;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const classes = (await all<Open5eClass>('classes')).map(normalizeOpen5eClass).sort((a, b) => a.name.localeCompare(b.name));
|
||||
writeFileSync('src/data/srd/classes.json', JSON.stringify(classes));
|
||||
|
||||
const races = (await all<Record<string, string>>('races')).map((r) => ({
|
||||
slug: r.slug, name: r.name, desc: first(r.desc), asi: r.asi_desc ?? '', speed: r.speed_desc ?? '', vision: r.vision ?? '', traits: first(r.traits),
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
writeFileSync('src/data/srd/races.json', JSON.stringify(races));
|
||||
|
||||
const backgrounds = (await all<Record<string, string>>('backgrounds')).map((b) => ({
|
||||
slug: b.slug, name: b.name, desc: first(b.desc), skills: b.skill_proficiencies ?? '', tools: b.tool_proficiencies ?? '', languages: b.languages ?? '', feature: b.feature ?? '',
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
writeFileSync('src/data/srd/backgrounds.json', JSON.stringify(backgrounds));
|
||||
|
||||
const feats = (await all<Record<string, string>>('feats')).map((f) => ({
|
||||
slug: f.slug, name: f.name, desc: first(f.desc), prerequisite: f.prerequisite ?? '',
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
writeFileSync('src/data/srd/feats.json', JSON.stringify(feats));
|
||||
|
||||
console.log(`5e: classes ${classes.length}, races ${races.length}, backgrounds ${backgrounds.length}, feats ${feats.length}`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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