Switch PF2e data source from Pf2eTools to Foundry VTT PF2e
Replace the stagnant Pf2eTools bestiary with Foundry VTT PF2e system data (github.com/foundryvtt/pf2e, v13-dev branch). This gives us 4,355 remaster-era creatures across 49 sources including Monster Core 1+2 and all adventure paths. Changes: - Rewrite index generation script to walk Foundry pack directories - Rewrite PF2e normalization adapter for Foundry JSON shape (system.* fields, items[] for attacks/abilities/spells) - Add stripFoundryTags utility for Foundry HTML + enrichment syntax - Implement multi-file source fetching (one request per creature file) - Add spellcasting section to PF2e stat block (ranked spells + cantrips) - Add saveConditional and hpDetails to PF2e domain type and stat block - Add size and rarity to PF2e trait tags - Filter redundant glossary abilities (healing when in hp.details, spell mechanic reminders, allSaves duplicates) - Add PF2e stat block component tests (22 tests) - Bump IndexedDB cache version to 5 for clean migration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,389 +1,531 @@
|
||||
import type {
|
||||
CreatureId,
|
||||
Pf2eCreature,
|
||||
SpellcastingBlock,
|
||||
TraitBlock,
|
||||
TraitSegment,
|
||||
} from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
import { stripTags } from "./strip-tags.js";
|
||||
import { stripFoundryTags } from "./strip-foundry-tags.js";
|
||||
|
||||
// -- Raw Pf2eTools types (minimal, for parsing) --
|
||||
// -- Raw Foundry VTT types (minimal, for parsing) --
|
||||
|
||||
interface RawPf2eCreature {
|
||||
interface RawFoundryCreature {
|
||||
_id: string;
|
||||
name: string;
|
||||
source: string;
|
||||
level?: number;
|
||||
traits?: string[];
|
||||
perception?: { std?: number };
|
||||
senses?: { name?: string; type?: string; range?: number }[];
|
||||
languages?: { languages?: string[] };
|
||||
skills?: Record<string, { std?: number }>;
|
||||
abilityMods?: Record<string, number>;
|
||||
items?: string[];
|
||||
defenses?: RawDefenses;
|
||||
speed?: Record<string, number | { number: number }>;
|
||||
attacks?: RawAttack[];
|
||||
abilities?: {
|
||||
top?: RawAbility[];
|
||||
mid?: RawAbility[];
|
||||
bot?: RawAbility[];
|
||||
type: string;
|
||||
system: {
|
||||
abilities: Record<string, { mod: number }>;
|
||||
attributes: {
|
||||
ac: { value: number; details?: string };
|
||||
hp: { max: number; details?: string };
|
||||
speed: {
|
||||
value: number;
|
||||
otherSpeeds?: { type: string; value: number }[];
|
||||
details?: string;
|
||||
};
|
||||
immunities?: { type: string; exceptions?: string[] }[];
|
||||
resistances?: { type: string; value: number; exceptions?: string[] }[];
|
||||
weaknesses?: { type: string; value: number }[];
|
||||
allSaves?: { value: string };
|
||||
};
|
||||
details: {
|
||||
level: { value: number };
|
||||
languages: { value?: string[]; details?: string };
|
||||
publication: { license: string; remaster: boolean; title: string };
|
||||
};
|
||||
perception: {
|
||||
mod: number;
|
||||
details?: string;
|
||||
senses?: { type: string; acuity?: string; range?: number }[];
|
||||
};
|
||||
saves: {
|
||||
fortitude: { value: number; saveDetail?: string };
|
||||
reflex: { value: number; saveDetail?: string };
|
||||
will: { value: number; saveDetail?: string };
|
||||
};
|
||||
skills: Record<string, { base: number; note?: string }>;
|
||||
traits: { rarity: string; size: { value: string }; value: string[] };
|
||||
};
|
||||
_copy?: unknown;
|
||||
items: RawFoundryItem[];
|
||||
}
|
||||
|
||||
interface RawDefenses {
|
||||
ac?: Record<string, unknown>;
|
||||
savingThrows?: {
|
||||
fort?: { std?: number };
|
||||
ref?: { std?: number };
|
||||
will?: { std?: number };
|
||||
};
|
||||
hp?: { hp?: number }[];
|
||||
immunities?: (string | { name: string })[];
|
||||
resistances?: { amount?: number; name: string; note?: string }[];
|
||||
weaknesses?: { amount?: number; name: string; note?: string }[];
|
||||
}
|
||||
|
||||
interface RawAbility {
|
||||
name?: string;
|
||||
activity?: { number?: number; unit?: string };
|
||||
trigger?: string;
|
||||
traits?: string[];
|
||||
entries?: RawEntry[];
|
||||
}
|
||||
|
||||
interface RawAttack {
|
||||
range?: string;
|
||||
interface RawFoundryItem {
|
||||
_id: string;
|
||||
name: string;
|
||||
attack?: number;
|
||||
traits?: string[];
|
||||
damage?: string;
|
||||
type: string;
|
||||
system: Record<string, unknown>;
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
type RawEntry = string | RawEntryObject;
|
||||
|
||||
interface RawEntryObject {
|
||||
type?: string;
|
||||
items?: (string | { name?: string; entry?: string })[];
|
||||
entries?: RawEntry[];
|
||||
interface MeleeSystem {
|
||||
bonus?: { value: number };
|
||||
damageRolls?: Record<string, { damage: string; damageType: string }>;
|
||||
traits?: { value: string[] };
|
||||
}
|
||||
|
||||
// -- Module state --
|
||||
|
||||
let sourceDisplayNames: Record<string, string> = {};
|
||||
|
||||
export function setPf2eSourceDisplayNames(names: Record<string, string>): void {
|
||||
sourceDisplayNames = names;
|
||||
interface ActionSystem {
|
||||
category?: string;
|
||||
actionType?: { value: string };
|
||||
actions?: { value: number | null };
|
||||
traits?: { value: string[] };
|
||||
description?: { value: string };
|
||||
}
|
||||
|
||||
interface SpellcastingEntrySystem {
|
||||
tradition?: { value: string };
|
||||
prepared?: { value: string };
|
||||
spelldc?: { dc: number; value?: number };
|
||||
}
|
||||
|
||||
interface SpellSystem {
|
||||
location?: {
|
||||
value: string;
|
||||
heightenedLevel?: number;
|
||||
uses?: { max: number; value: number };
|
||||
};
|
||||
level?: { value: number };
|
||||
traits?: { value: string[] };
|
||||
}
|
||||
|
||||
const SIZE_MAP: Record<string, string> = {
|
||||
tiny: "tiny",
|
||||
sm: "small",
|
||||
med: "medium",
|
||||
lg: "large",
|
||||
huge: "huge",
|
||||
grg: "gargantuan",
|
||||
};
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function parseActivity(
|
||||
activity: { number?: number; unit?: string } | undefined,
|
||||
): { number: number; unit: "action" | "free" | "reaction" } | undefined {
|
||||
if (!activity?.unit) return undefined;
|
||||
const unit = activity.unit;
|
||||
if (unit === "action" || unit === "free" || unit === "reaction") {
|
||||
return { number: activity.number ?? 1, unit };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stripAngleBrackets(s: string): string {
|
||||
return s.replaceAll(/<([^>]+)>/g, "$1");
|
||||
}
|
||||
|
||||
function makeCreatureId(source: string, name: string): CreatureId {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
return creatureId(`${source.toLowerCase()}:${slug}`);
|
||||
return creatureId(`${source}:${slug}`);
|
||||
}
|
||||
|
||||
function formatSpeed(
|
||||
speed: Record<string, number | { number: number }> | undefined,
|
||||
): string {
|
||||
if (!speed) return "";
|
||||
const parts: string[] = [];
|
||||
for (const [mode, value] of Object.entries(speed)) {
|
||||
if (typeof value === "number") {
|
||||
parts.push(
|
||||
mode === "walk" ? `${value} feet` : `${capitalize(mode)} ${value} feet`,
|
||||
);
|
||||
} else if (typeof value === "object" && "number" in value) {
|
||||
parts.push(
|
||||
mode === "walk"
|
||||
? `${value.number} feet`
|
||||
: `${capitalize(mode)} ${value.number} feet`,
|
||||
);
|
||||
}
|
||||
const NUMERIC_SLUG = /^(.+)-(\d+)$/;
|
||||
const LETTER_SLUG = /^(.+)-([a-z])$/;
|
||||
|
||||
/** Format rules for traits with a numeric suffix: "reach-10" → "reach 10 feet" */
|
||||
const NUMERIC_TRAIT_FORMATS: Record<string, (n: string) => string> = {
|
||||
reach: (n) => `reach ${n} feet`,
|
||||
range: (n) => `range ${n} feet`,
|
||||
"range-increment": (n) => `range increment ${n} feet`,
|
||||
versatile: (n) => `versatile ${n}`,
|
||||
deadly: (n) => `deadly d${n}`,
|
||||
fatal: (n) => `fatal d${n}`,
|
||||
"fatal-aim": (n) => `fatal aim d${n}`,
|
||||
reload: (n) => `reload ${n}`,
|
||||
};
|
||||
|
||||
/** Format rules for traits with a letter suffix: "versatile-p" → "versatile P" */
|
||||
const LETTER_TRAIT_FORMATS: Record<string, (l: string) => string> = {
|
||||
versatile: (l) => `versatile ${l.toUpperCase()}`,
|
||||
deadly: (l) => `deadly d${l}`,
|
||||
};
|
||||
|
||||
/** Expand slugified trait names: "reach-10" → "reach 10 feet" */
|
||||
function formatTrait(slug: string): string {
|
||||
const numMatch = NUMERIC_SLUG.exec(slug);
|
||||
if (numMatch) {
|
||||
const [, base, num] = numMatch;
|
||||
const fmt = NUMERIC_TRAIT_FORMATS[base];
|
||||
return fmt ? fmt(num) : `${base} ${num}`;
|
||||
}
|
||||
return parts.join(", ");
|
||||
const letterMatch = LETTER_SLUG.exec(slug);
|
||||
if (letterMatch) {
|
||||
const [, base, letter] = letterMatch;
|
||||
const fmt = LETTER_TRAIT_FORMATS[base];
|
||||
if (fmt) return fmt(letter);
|
||||
}
|
||||
return slug.replaceAll("-", " ");
|
||||
}
|
||||
|
||||
function formatSkills(
|
||||
skills: Record<string, { std?: number }> | undefined,
|
||||
): string | undefined {
|
||||
if (!skills) return undefined;
|
||||
const parts = Object.entries(skills)
|
||||
.map(([name, val]) => `${capitalize(name)} +${val.std ?? 0}`)
|
||||
.sort();
|
||||
return parts.length > 0 ? parts.join(", ") : undefined;
|
||||
}
|
||||
// -- Formatting --
|
||||
|
||||
function formatSenses(
|
||||
senses:
|
||||
| readonly { name?: string; type?: string; range?: number }[]
|
||||
| undefined,
|
||||
senses: { type: string; acuity?: string; range?: number }[] | undefined,
|
||||
): string | undefined {
|
||||
if (!senses || senses.length === 0) return undefined;
|
||||
return senses
|
||||
.map((s) => {
|
||||
const label = stripTags(s.name ?? s.type ?? "");
|
||||
if (!label) return "";
|
||||
const parts = [capitalize(label)];
|
||||
if (s.type && s.name) parts.push(`(${s.type})`);
|
||||
const parts = [capitalize(s.type.replaceAll("-", " "))];
|
||||
if (s.acuity && s.acuity !== "precise") {
|
||||
parts.push(`(${s.acuity})`);
|
||||
}
|
||||
if (s.range != null) parts.push(`${s.range} feet`);
|
||||
return parts.join(" ");
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatLanguages(
|
||||
languages: { languages?: string[] } | undefined,
|
||||
languages: { value?: string[]; details?: string } | undefined,
|
||||
): string | undefined {
|
||||
if (!languages?.languages || languages.languages.length === 0)
|
||||
return undefined;
|
||||
return languages.languages.map(capitalize).join(", ");
|
||||
if (!languages?.value || languages.value.length === 0) return undefined;
|
||||
const list = languages.value.map(capitalize).join(", ");
|
||||
return languages.details ? `${list} (${languages.details})` : list;
|
||||
}
|
||||
|
||||
function formatSkills(
|
||||
skills: Record<string, { base: number; note?: string }> | undefined,
|
||||
): string | undefined {
|
||||
if (!skills) return undefined;
|
||||
const entries = Object.entries(skills);
|
||||
if (entries.length === 0) return undefined;
|
||||
return entries
|
||||
.map(([name, val]) => {
|
||||
const label = capitalize(name.replaceAll("-", " "));
|
||||
return `${label} +${val.base}`;
|
||||
})
|
||||
.sort()
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatImmunities(
|
||||
immunities: readonly (string | { name: string })[] | undefined,
|
||||
immunities: { type: string; exceptions?: string[] }[] | undefined,
|
||||
): string | undefined {
|
||||
if (!immunities || immunities.length === 0) return undefined;
|
||||
return immunities
|
||||
.map((i) => capitalize(typeof i === "string" ? i : i.name))
|
||||
.map((i) => {
|
||||
const base = capitalize(i.type.replaceAll("-", " "));
|
||||
if (i.exceptions && i.exceptions.length > 0) {
|
||||
return `${base} (except ${i.exceptions.map((e) => capitalize(e.replaceAll("-", " "))).join(", ")})`;
|
||||
}
|
||||
return base;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatResistances(
|
||||
resistances:
|
||||
| readonly { amount?: number; name: string; note?: string }[]
|
||||
| { type: string; value: number; exceptions?: string[] }[]
|
||||
| undefined,
|
||||
): string | undefined {
|
||||
if (!resistances || resistances.length === 0) return undefined;
|
||||
return resistances
|
||||
.map((r) => {
|
||||
const base =
|
||||
r.amount == null
|
||||
? capitalize(r.name)
|
||||
: `${capitalize(r.name)} ${r.amount}`;
|
||||
return r.note ? `${base} (${r.note})` : base;
|
||||
const base = `${capitalize(r.type.replaceAll("-", " "))} ${r.value}`;
|
||||
if (r.exceptions && r.exceptions.length > 0) {
|
||||
return `${base} (except ${r.exceptions.map((e) => capitalize(e.replaceAll("-", " "))).join(", ")})`;
|
||||
}
|
||||
return base;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatWeaknesses(
|
||||
weaknesses:
|
||||
| readonly { amount?: number; name: string; note?: string }[]
|
||||
| undefined,
|
||||
weaknesses: { type: string; value: number }[] | undefined,
|
||||
): string | undefined {
|
||||
if (!weaknesses || weaknesses.length === 0) return undefined;
|
||||
return weaknesses
|
||||
.map((w) => {
|
||||
const base =
|
||||
w.amount == null
|
||||
? capitalize(w.name)
|
||||
: `${capitalize(w.name)} ${w.amount}`;
|
||||
return w.note ? `${base} (${w.note})` : base;
|
||||
})
|
||||
.map((w) => `${capitalize(w.type.replaceAll("-", " "))} ${w.value}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
// -- Entry parsing --
|
||||
|
||||
function segmentizeEntries(entries: unknown): TraitSegment[] {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const segments: TraitSegment[] = [];
|
||||
for (const entry of entries) {
|
||||
if (typeof entry === "string") {
|
||||
segments.push({ type: "text", value: stripTags(entry) });
|
||||
} else if (typeof entry === "object" && entry !== null) {
|
||||
const obj = entry as RawEntryObject;
|
||||
if (obj.type === "list" && Array.isArray(obj.items)) {
|
||||
segments.push({
|
||||
type: "list",
|
||||
items: obj.items.map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return { text: stripTags(item) };
|
||||
}
|
||||
return { label: item.name, text: stripTags(item.entry ?? "") };
|
||||
}),
|
||||
});
|
||||
} else if (Array.isArray(obj.entries)) {
|
||||
segments.push(...segmentizeEntries(obj.entries));
|
||||
}
|
||||
function formatSpeed(speed: {
|
||||
value: number;
|
||||
otherSpeeds?: { type: string; value: number }[];
|
||||
details?: string;
|
||||
}): string {
|
||||
const parts = [`${speed.value} feet`];
|
||||
if (speed.otherSpeeds) {
|
||||
for (const s of speed.otherSpeeds) {
|
||||
parts.push(`${capitalize(s.type)} ${s.value} feet`);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
const base = parts.join(", ");
|
||||
return speed.details ? `${base} (${speed.details})` : base;
|
||||
}
|
||||
|
||||
function formatAffliction(a: Record<string, unknown>): TraitSegment[] {
|
||||
const parts: string[] = [];
|
||||
if (a.note) parts.push(stripTags(String(a.note)));
|
||||
if (a.DC) parts.push(`DC ${a.DC}`);
|
||||
if (a.savingThrow) parts.push(String(a.savingThrow));
|
||||
const stages = a.stages as
|
||||
| { stage: number; entry: string; duration: string }[]
|
||||
| undefined;
|
||||
if (stages) {
|
||||
for (const s of stages) {
|
||||
parts.push(`Stage ${s.stage}: ${stripTags(s.entry)} (${s.duration})`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? [{ type: "text", value: parts.join("; ") }] : [];
|
||||
}
|
||||
// -- Attack normalization --
|
||||
|
||||
function normalizeAbilities(
|
||||
abilities: readonly RawAbility[] | undefined,
|
||||
): TraitBlock[] | undefined {
|
||||
if (!abilities || abilities.length === 0) return undefined;
|
||||
return abilities
|
||||
.filter((a) => a.name)
|
||||
.map((a) => {
|
||||
const raw = a as Record<string, unknown>;
|
||||
const activity = parseActivity(a.activity);
|
||||
const trigger = a.trigger ? stripTags(a.trigger) : undefined;
|
||||
const traits =
|
||||
a.traits && a.traits.length > 0
|
||||
? `(${a.traits.map((t) => capitalize(stripAngleBrackets(stripTags(t)))).join(", ")}) `
|
||||
: "";
|
||||
const prefix = traits;
|
||||
const body = Array.isArray(a.entries)
|
||||
? segmentizeEntries(a.entries)
|
||||
: formatAffliction(raw);
|
||||
const name = stripTags(a.name as string);
|
||||
if (prefix && body.length > 0 && body[0].type === "text") {
|
||||
return {
|
||||
name,
|
||||
activity,
|
||||
trigger,
|
||||
segments: [
|
||||
{ type: "text" as const, value: prefix + body[0].value },
|
||||
...body.slice(1),
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
name,
|
||||
activity,
|
||||
trigger,
|
||||
segments: prefix
|
||||
? [{ type: "text" as const, value: prefix }, ...body]
|
||||
: body,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAttacks(
|
||||
attacks: readonly RawAttack[] | undefined,
|
||||
): TraitBlock[] | undefined {
|
||||
if (!attacks || attacks.length === 0) return undefined;
|
||||
return attacks.map((a) => {
|
||||
const parts: string[] = [];
|
||||
if (a.range) parts.push(a.range);
|
||||
const attackMod = a.attack == null ? "" : ` +${a.attack}`;
|
||||
const traits =
|
||||
a.traits && a.traits.length > 0
|
||||
? ` (${a.traits.map((t) => stripAngleBrackets(stripTags(t))).join(", ")})`
|
||||
: "";
|
||||
const damage = a.damage
|
||||
? `, ${stripAngleBrackets(stripTags(a.damage))}`
|
||||
: "";
|
||||
return {
|
||||
name: capitalize(stripTags(a.name)),
|
||||
activity: { number: 1, unit: "action" as const },
|
||||
segments: [
|
||||
{
|
||||
type: "text" as const,
|
||||
value: `${parts.join(" ")}${attackMod}${traits}${damage}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// -- Defenses extraction --
|
||||
|
||||
function extractDefenses(defenses: RawDefenses | undefined) {
|
||||
const acRecord = defenses?.ac ?? {};
|
||||
const acStd = (acRecord.std as number | undefined) ?? 0;
|
||||
const acEntries = Object.entries(acRecord).filter(([k]) => k !== "std");
|
||||
function normalizeAttack(item: RawFoundryItem): TraitBlock {
|
||||
const sys = item.system as unknown as MeleeSystem;
|
||||
const bonus = sys.bonus?.value ?? 0;
|
||||
const traits = sys.traits?.value ?? [];
|
||||
const damageEntries = Object.values(sys.damageRolls ?? {});
|
||||
const damage = damageEntries
|
||||
.map((d) => `${d.damage} ${d.damageType}`)
|
||||
.join(" plus ");
|
||||
const traitStr =
|
||||
traits.length > 0 ? ` (${traits.map(formatTrait).join(", ")})` : "";
|
||||
return {
|
||||
ac: acStd,
|
||||
acConditional:
|
||||
acEntries.length > 0
|
||||
? acEntries.map(([k, v]) => `${v} ${k}`).join(", ")
|
||||
: undefined,
|
||||
saveFort: defenses?.savingThrows?.fort?.std ?? 0,
|
||||
saveRef: defenses?.savingThrows?.ref?.std ?? 0,
|
||||
saveWill: defenses?.savingThrows?.will?.std ?? 0,
|
||||
hp: defenses?.hp?.[0]?.hp ?? 0,
|
||||
immunities: formatImmunities(defenses?.immunities),
|
||||
resistances: formatResistances(defenses?.resistances),
|
||||
weaknesses: formatWeaknesses(defenses?.weaknesses),
|
||||
name: capitalize(item.name),
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [
|
||||
{
|
||||
type: "text",
|
||||
value: `+${bonus}${traitStr}, ${damage}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function parseActivity(
|
||||
actionType: string | undefined,
|
||||
actionCount: number | null | undefined,
|
||||
): { number: number; unit: "action" | "free" | "reaction" } | undefined {
|
||||
if (actionType === "action") {
|
||||
return { number: actionCount ?? 1, unit: "action" };
|
||||
}
|
||||
if (actionType === "reaction") {
|
||||
return { number: 1, unit: "reaction" };
|
||||
}
|
||||
if (actionType === "free") {
|
||||
return { number: 1, unit: "free" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// -- Ability normalization --
|
||||
|
||||
function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
||||
const sys = item.system as unknown as ActionSystem;
|
||||
const actionType = sys.actionType?.value;
|
||||
const actionCount = sys.actions?.value;
|
||||
const description = stripFoundryTags(sys.description?.value ?? "");
|
||||
const traits = sys.traits?.value ?? [];
|
||||
|
||||
const activity = parseActivity(actionType, actionCount);
|
||||
|
||||
const traitStr =
|
||||
traits.length > 0
|
||||
? `(${traits.map((t) => capitalize(formatTrait(t))).join(", ")}) `
|
||||
: "";
|
||||
|
||||
const text = traitStr ? `${traitStr}${description}` : description;
|
||||
const segments: { type: "text"; value: string }[] = text
|
||||
? [{ type: "text", value: text }]
|
||||
: [];
|
||||
|
||||
return { name: item.name, activity, segments };
|
||||
}
|
||||
|
||||
// -- Spellcasting normalization --
|
||||
|
||||
function classifySpell(spell: RawFoundryItem): {
|
||||
isCantrip: boolean;
|
||||
rank: number;
|
||||
label: string;
|
||||
} {
|
||||
const sys = spell.system as unknown as SpellSystem;
|
||||
const isCantrip = (sys.traits?.value ?? []).includes("cantrip");
|
||||
const rank = sys.location?.heightenedLevel ?? sys.level?.value ?? 0;
|
||||
const uses = sys.location?.uses;
|
||||
const label =
|
||||
uses && uses.max > 1 ? `${spell.name} (\u00d7${uses.max})` : spell.name;
|
||||
return { isCantrip, rank, label };
|
||||
}
|
||||
|
||||
function normalizeSpellcastingEntry(
|
||||
entry: RawFoundryItem,
|
||||
allSpells: readonly RawFoundryItem[],
|
||||
): SpellcastingBlock {
|
||||
const sys = entry.system as unknown as SpellcastingEntrySystem;
|
||||
const tradition = capitalize(sys.tradition?.value ?? "");
|
||||
const prepared = sys.prepared?.value ?? "";
|
||||
const dc = sys.spelldc?.dc ?? 0;
|
||||
const attack = sys.spelldc?.value ?? 0;
|
||||
|
||||
const name = entry.name || `${tradition} ${capitalize(prepared)} Spells`;
|
||||
const headerText = `DC ${dc}${attack ? `, attack +${attack}` : ""}`;
|
||||
|
||||
const linkedSpells = allSpells.filter(
|
||||
(s) => (s.system as unknown as SpellSystem).location?.value === entry._id,
|
||||
);
|
||||
|
||||
const byRank = new Map<number, string[]>();
|
||||
const cantrips: string[] = [];
|
||||
|
||||
for (const spell of linkedSpells) {
|
||||
const { isCantrip, rank, label } = classifySpell(spell);
|
||||
if (isCantrip) {
|
||||
cantrips.push(spell.name);
|
||||
continue;
|
||||
}
|
||||
const existing = byRank.get(rank) ?? [];
|
||||
existing.push(label);
|
||||
byRank.set(rank, existing);
|
||||
}
|
||||
|
||||
const daily = [...byRank.entries()]
|
||||
.sort(([a], [b]) => b - a)
|
||||
.map(([rank, spellNames]) => ({
|
||||
uses: rank,
|
||||
each: true,
|
||||
spells: spellNames,
|
||||
}));
|
||||
|
||||
return {
|
||||
name,
|
||||
headerText,
|
||||
atWill: orUndefined(cantrips),
|
||||
daily: orUndefined(daily),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSpellcasting(
|
||||
items: readonly RawFoundryItem[],
|
||||
): SpellcastingBlock[] {
|
||||
const entries = items.filter((i) => i.type === "spellcastingEntry");
|
||||
const spells = items.filter((i) => i.type === "spell");
|
||||
return entries.map((entry) => normalizeSpellcastingEntry(entry, spells));
|
||||
}
|
||||
|
||||
// -- Main normalization --
|
||||
|
||||
function normalizeCreature(raw: RawPf2eCreature): Pf2eCreature {
|
||||
const source = raw.source ?? "";
|
||||
const defenses = extractDefenses(raw.defenses);
|
||||
const mods = raw.abilityMods ?? {};
|
||||
function orUndefined<T>(arr: T[]): T[] | undefined {
|
||||
return arr.length > 0 ? arr : undefined;
|
||||
}
|
||||
|
||||
/** Build display traits: [rarity (if not common), size, ...type traits] */
|
||||
function buildTraits(traits: {
|
||||
rarity: string;
|
||||
size: { value: string };
|
||||
value: string[];
|
||||
}): string[] {
|
||||
const result: string[] = [];
|
||||
if (traits.rarity && traits.rarity !== "common") {
|
||||
result.push(traits.rarity);
|
||||
}
|
||||
const size = SIZE_MAP[traits.size.value] ?? "medium";
|
||||
result.push(size);
|
||||
result.push(...traits.value);
|
||||
return result;
|
||||
}
|
||||
|
||||
const HEALING_GLOSSARY =
|
||||
/^<p>@Localize\[PF2E\.NPC\.Abilities\.Glossary\.(FastHealing|Regeneration|NegativeHealing)\]/;
|
||||
|
||||
/** Glossary-only abilities that duplicate structured data shown elsewhere. */
|
||||
const REDUNDANT_GLOSSARY =
|
||||
/^<p>@Localize\[PF2E\.NPC\.Abilities\.Glossary\.(ConstantSpells|AtWillSpells)\]/;
|
||||
|
||||
const STRIP_GLOSSARY_AND_P = /<p>@Localize\[[^\]]+\]<\/p>|<\/?p>/g;
|
||||
|
||||
/** True when the description has no user-visible content beyond glossary tags. */
|
||||
function isGlossaryOnly(desc: string | undefined): boolean {
|
||||
if (!desc) return true;
|
||||
return desc.replace(STRIP_GLOSSARY_AND_P, "").trim() === "";
|
||||
}
|
||||
|
||||
function isRedundantAbility(
|
||||
item: RawFoundryItem,
|
||||
excludeName: string | undefined,
|
||||
hpDetails: string | undefined,
|
||||
): boolean {
|
||||
const sys = item.system as unknown as ActionSystem;
|
||||
const desc = sys.description?.value;
|
||||
// Ability duplicates the allSaves line — suppress only if glossary-only
|
||||
if (excludeName && item.name.toLowerCase() === excludeName.toLowerCase()) {
|
||||
return isGlossaryOnly(desc);
|
||||
}
|
||||
if (!desc) return false;
|
||||
// Healing/regen glossary when hp.details already shows the info
|
||||
if (hpDetails && HEALING_GLOSSARY.test(desc)) return true;
|
||||
// Spell mechanic glossary reminders shown in the spellcasting section
|
||||
if (REDUNDANT_GLOSSARY.test(desc)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function actionsByCategory(
|
||||
items: readonly RawFoundryItem[],
|
||||
category: string,
|
||||
excludeName?: string,
|
||||
hpDetails?: string,
|
||||
): TraitBlock[] {
|
||||
return items
|
||||
.filter(
|
||||
(a) =>
|
||||
a.type === "action" &&
|
||||
(a.system as unknown as ActionSystem).category === category &&
|
||||
!isRedundantAbility(a, excludeName, hpDetails),
|
||||
)
|
||||
.map(normalizeAbility);
|
||||
}
|
||||
|
||||
function extractAbilityMods(
|
||||
mods: Record<string, { mod: number }>,
|
||||
): Pf2eCreature["abilityMods"] {
|
||||
return {
|
||||
system: "pf2e",
|
||||
id: makeCreatureId(source, raw.name),
|
||||
name: raw.name,
|
||||
source,
|
||||
sourceDisplayName: sourceDisplayNames[source] ?? source,
|
||||
level: raw.level ?? 0,
|
||||
traits: raw.traits ?? [],
|
||||
perception: raw.perception?.std ?? 0,
|
||||
senses: formatSenses(raw.senses),
|
||||
languages: formatLanguages(raw.languages),
|
||||
skills: formatSkills(raw.skills),
|
||||
abilityMods: {
|
||||
str: mods.str ?? 0,
|
||||
dex: mods.dex ?? 0,
|
||||
con: mods.con ?? 0,
|
||||
int: mods.int ?? 0,
|
||||
wis: mods.wis ?? 0,
|
||||
cha: mods.cha ?? 0,
|
||||
},
|
||||
...defenses,
|
||||
speed: formatSpeed(raw.speed),
|
||||
attacks: normalizeAttacks(raw.attacks),
|
||||
abilitiesTop: normalizeAbilities(raw.abilities?.top),
|
||||
abilitiesMid: normalizeAbilities(raw.abilities?.mid),
|
||||
abilitiesBot: normalizeAbilities(raw.abilities?.bot),
|
||||
str: mods.str?.mod ?? 0,
|
||||
dex: mods.dex?.mod ?? 0,
|
||||
con: mods.con?.mod ?? 0,
|
||||
int: mods.int?.mod ?? 0,
|
||||
wis: mods.wis?.mod ?? 0,
|
||||
cha: mods.cha?.mod ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePf2eBestiary(raw: {
|
||||
creature: unknown[];
|
||||
}): Pf2eCreature[] {
|
||||
return (raw.creature ?? [])
|
||||
.filter((c: unknown) => {
|
||||
const obj = c as { _copy?: unknown };
|
||||
return !obj._copy;
|
||||
})
|
||||
.map((c) => normalizeCreature(c as RawPf2eCreature));
|
||||
export function normalizeFoundryCreature(
|
||||
raw: unknown,
|
||||
sourceCode?: string,
|
||||
sourceDisplayName?: string,
|
||||
): Pf2eCreature {
|
||||
const r = raw as RawFoundryCreature;
|
||||
const sys = r.system;
|
||||
const publication = sys.details?.publication;
|
||||
|
||||
const source = sourceCode ?? publication?.title ?? "";
|
||||
const items = r.items ?? [];
|
||||
const allSavesText = sys.attributes.allSaves?.value ?? "";
|
||||
|
||||
return {
|
||||
system: "pf2e",
|
||||
id: makeCreatureId(source, r.name),
|
||||
name: r.name,
|
||||
source,
|
||||
sourceDisplayName: sourceDisplayName ?? publication?.title ?? "",
|
||||
level: sys.details?.level?.value ?? 0,
|
||||
traits: buildTraits(sys.traits),
|
||||
perception: sys.perception?.mod ?? 0,
|
||||
senses: formatSenses(sys.perception?.senses),
|
||||
languages: formatLanguages(sys.details?.languages),
|
||||
skills: formatSkills(sys.skills),
|
||||
abilityMods: extractAbilityMods(sys.abilities ?? {}),
|
||||
ac: sys.attributes.ac.value,
|
||||
acConditional: sys.attributes.ac.details || undefined,
|
||||
saveFort: sys.saves.fortitude.value,
|
||||
saveRef: sys.saves.reflex.value,
|
||||
saveWill: sys.saves.will.value,
|
||||
saveConditional: allSavesText || undefined,
|
||||
hp: sys.attributes.hp.max,
|
||||
hpDetails: sys.attributes.hp.details || undefined,
|
||||
immunities: formatImmunities(sys.attributes.immunities),
|
||||
resistances: formatResistances(sys.attributes.resistances),
|
||||
weaknesses: formatWeaknesses(sys.attributes.weaknesses),
|
||||
speed: formatSpeed(sys.attributes.speed),
|
||||
attacks: orUndefined(
|
||||
items.filter((i) => i.type === "melee").map(normalizeAttack),
|
||||
),
|
||||
abilitiesTop: orUndefined(actionsByCategory(items, "interaction")),
|
||||
abilitiesMid: orUndefined(
|
||||
actionsByCategory(
|
||||
items,
|
||||
"defensive",
|
||||
allSavesText || undefined,
|
||||
sys.attributes.hp.details || undefined,
|
||||
),
|
||||
),
|
||||
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
|
||||
spellcasting: orUndefined(normalizeSpellcasting(items)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeFoundryCreatures(
|
||||
rawCreatures: unknown[],
|
||||
sourceCode?: string,
|
||||
sourceDisplayName?: string,
|
||||
): Pf2eCreature[] {
|
||||
return rawCreatures.map((raw) =>
|
||||
normalizeFoundryCreature(raw, sourceCode, sourceDisplayName),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user