Show inline on-hit effects on attack lines (e.g., "plus Grab"), frequency limits on abilities (e.g., "(1/day)"), and perception details text alongside senses. Strip redundant frequency lines from Foundry descriptions. Also add resilient PF2e source fetching: batched requests with retry, graceful handling of ad-blocker-blocked creature files (partial success with toast warning and re-fetch prompt for missing creatures). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
775 lines
22 KiB
TypeScript
775 lines
22 KiB
TypeScript
import type {
|
|
CreatureId,
|
|
EquipmentItem,
|
|
Pf2eCreature,
|
|
SpellcastingBlock,
|
|
SpellReference,
|
|
TraitBlock,
|
|
} from "@initiative/domain";
|
|
import { creatureId } from "@initiative/domain";
|
|
import { stripFoundryTags } from "./strip-foundry-tags.js";
|
|
|
|
// -- Raw Foundry VTT types (minimal, for parsing) --
|
|
|
|
interface RawFoundryCreature {
|
|
_id: string;
|
|
name: string;
|
|
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[] };
|
|
};
|
|
items: RawFoundryItem[];
|
|
}
|
|
|
|
interface RawFoundryItem {
|
|
_id: string;
|
|
name: string;
|
|
type: string;
|
|
system: Record<string, unknown>;
|
|
sort?: number;
|
|
}
|
|
|
|
interface MeleeSystem {
|
|
bonus?: { value: number };
|
|
damageRolls?: Record<string, { damage: string; damageType: string }>;
|
|
traits?: { value: string[] };
|
|
attackEffects?: { value: string[] };
|
|
}
|
|
|
|
interface ActionSystem {
|
|
category?: string;
|
|
actionType?: { value: string };
|
|
actions?: { value: number | null };
|
|
traits?: { value: string[] };
|
|
description?: { value: string };
|
|
frequency?: { max: number; per: string };
|
|
}
|
|
|
|
interface SpellcastingEntrySystem {
|
|
tradition?: { value: string };
|
|
prepared?: { value: string };
|
|
spelldc?: { dc: number; value?: number };
|
|
}
|
|
|
|
interface SpellSystem {
|
|
slug?: string;
|
|
location?: {
|
|
value: string;
|
|
heightenedLevel?: number;
|
|
uses?: { max: number; value: number };
|
|
};
|
|
level?: { value: number };
|
|
traits?: { rarity?: string; value: string[]; traditions?: string[] };
|
|
description?: { value: string };
|
|
range?: { value: string };
|
|
target?: { value: string };
|
|
area?: { type?: string; value?: number; details?: string };
|
|
duration?: { value: string; sustained?: boolean };
|
|
time?: { value: string };
|
|
defense?: {
|
|
save?: { statistic: string; basic?: boolean };
|
|
passive?: { statistic: string };
|
|
};
|
|
heightening?:
|
|
| {
|
|
type: "fixed";
|
|
levels: Record<string, { text?: string }>;
|
|
}
|
|
| {
|
|
type: "interval";
|
|
interval: number;
|
|
damage?: { value: string };
|
|
}
|
|
| undefined;
|
|
overlays?: Record<
|
|
string,
|
|
{ name?: string; system?: { description?: { value: string } } }
|
|
>;
|
|
}
|
|
|
|
interface ConsumableSystem {
|
|
level?: { value: number };
|
|
traits?: { value: string[] };
|
|
description?: { value: string };
|
|
category?: string;
|
|
spell?: {
|
|
name: string;
|
|
system?: { level?: { value: number } };
|
|
} | null;
|
|
}
|
|
|
|
const EQUIPMENT_TYPES = new Set(["weapon", "consumable", "equipment", "armor"]);
|
|
|
|
/** Items shown in the Equipment section with popovers. */
|
|
function isDetailedEquipment(item: RawFoundryItem): boolean {
|
|
if (!EQUIPMENT_TYPES.has(item.type)) return false;
|
|
const sys = item.system;
|
|
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
|
|
const traits = (sys.traits as { value: string[] } | undefined)?.value ?? [];
|
|
// All consumables are tactically relevant (potions, scrolls, poisons, etc.)
|
|
if (item.type === "consumable") return true;
|
|
// Magical/invested items
|
|
if (traits.includes("magical") || traits.includes("invested")) return true;
|
|
// Special material armor/equipment
|
|
const material = sys.material as { type: string | null } | undefined;
|
|
if (material?.type) return true;
|
|
// Higher-level items
|
|
if (level > 0) return true;
|
|
return false;
|
|
}
|
|
|
|
/** Items shown on the "Items" line as plain names. */
|
|
function isMundaneItem(item: RawFoundryItem): boolean {
|
|
return EQUIPMENT_TYPES.has(item.type) && !isDetailedEquipment(item);
|
|
}
|
|
|
|
function normalizeEquipmentItem(item: RawFoundryItem): EquipmentItem {
|
|
const sys = item.system;
|
|
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
|
|
const traits = (sys.traits as { value: string[] } | undefined)?.value;
|
|
const rawDesc = (sys.description as { value: string } | undefined)?.value;
|
|
const description = rawDesc
|
|
? stripFoundryTags(rawDesc) || undefined
|
|
: undefined;
|
|
const category = sys.category as string | undefined;
|
|
|
|
let spellName: string | undefined;
|
|
let spellRank: number | undefined;
|
|
if (item.type === "consumable") {
|
|
const spell = (sys as unknown as ConsumableSystem).spell;
|
|
if (spell) {
|
|
spellName = spell.name;
|
|
spellRank = spell.system?.level?.value;
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: item.name,
|
|
level,
|
|
category: category || undefined,
|
|
traits: traits && traits.length > 0 ? traits : undefined,
|
|
description,
|
|
spellName,
|
|
spellRank,
|
|
};
|
|
}
|
|
|
|
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 makeCreatureId(source: string, name: string): CreatureId {
|
|
const slug = name
|
|
.toLowerCase()
|
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
.replaceAll(/(^-|-$)/g, "");
|
|
return creatureId(`${source}:${slug}`);
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
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("-", " ");
|
|
}
|
|
|
|
// -- Formatting --
|
|
|
|
function formatSenses(
|
|
senses: { type: string; acuity?: string; range?: number }[] | undefined,
|
|
): string | undefined {
|
|
if (!senses || senses.length === 0) return undefined;
|
|
return senses
|
|
.map((s) => {
|
|
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(" ");
|
|
})
|
|
.join(", ");
|
|
}
|
|
|
|
function formatLanguages(
|
|
languages: { value?: string[]; details?: string } | undefined,
|
|
): string | undefined {
|
|
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: { type: string; exceptions?: string[] }[] | undefined,
|
|
): string | undefined {
|
|
if (!immunities || immunities.length === 0) return undefined;
|
|
return immunities
|
|
.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:
|
|
| { type: string; value: number; exceptions?: string[] }[]
|
|
| undefined,
|
|
): string | undefined {
|
|
if (!resistances || resistances.length === 0) return undefined;
|
|
return resistances
|
|
.map((r) => {
|
|
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: { type: string; value: number }[] | undefined,
|
|
): string | undefined {
|
|
if (!weaknesses || weaknesses.length === 0) return undefined;
|
|
return weaknesses
|
|
.map((w) => `${capitalize(w.type.replaceAll("-", " "))} ${w.value}`)
|
|
.join(", ");
|
|
}
|
|
|
|
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`);
|
|
}
|
|
}
|
|
const base = parts.join(", ");
|
|
return speed.details ? `${base} (${speed.details})` : base;
|
|
}
|
|
|
|
// -- Attack normalization --
|
|
|
|
/** Format an attack effect slug to display text: "grab" → "Grab", "lich-siphon-life" → "Siphon Life". */
|
|
function formatAttackEffect(slug: string, creatureName: string): string {
|
|
const prefix = `${creatureName.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-`;
|
|
const stripped = slug.startsWith(prefix) ? slug.slice(prefix.length) : slug;
|
|
return stripped.split("-").map(capitalize).join(" ");
|
|
}
|
|
|
|
function normalizeAttack(
|
|
item: RawFoundryItem,
|
|
creatureName: string,
|
|
): 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(", ")})` : "";
|
|
const effects = sys.attackEffects?.value ?? [];
|
|
const effectStr =
|
|
effects.length > 0
|
|
? ` plus ${effects.map((e) => formatAttackEffect(e, creatureName)).join(" and ")}`
|
|
: "";
|
|
return {
|
|
name: capitalize(item.name),
|
|
activity: { number: 1, unit: "action" },
|
|
segments: [
|
|
{
|
|
type: "text",
|
|
value: `+${bonus}${traitStr}, ${damage}${effectStr}`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
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 --
|
|
|
|
const FREQUENCY_LINE = /(<strong>)?Frequency(<\/strong>)?\s+[^\n]+\n*/i;
|
|
|
|
/** Strip the "Frequency once per day" line from ability descriptions when structured frequency data exists. */
|
|
function stripFrequencyLine(text: string): string {
|
|
return text.replace(FREQUENCY_LINE, "").trimStart();
|
|
}
|
|
|
|
function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
|
const sys = item.system as unknown as ActionSystem;
|
|
const actionType = sys.actionType?.value;
|
|
const actionCount = sys.actions?.value;
|
|
let description = stripFoundryTags(sys.description?.value ?? "");
|
|
const traits = sys.traits?.value ?? [];
|
|
|
|
const activity = parseActivity(actionType, actionCount);
|
|
|
|
const frequency =
|
|
sys.frequency?.max != null && sys.frequency.per
|
|
? `${sys.frequency.max}/${sys.frequency.per}`
|
|
: undefined;
|
|
|
|
if (frequency) {
|
|
description = stripFrequencyLine(description);
|
|
}
|
|
|
|
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, frequency, segments };
|
|
}
|
|
|
|
// -- Spellcasting normalization --
|
|
|
|
function formatRange(range: { value: string } | undefined): string | undefined {
|
|
if (!range?.value) return undefined;
|
|
return range.value;
|
|
}
|
|
|
|
function formatArea(
|
|
area: { type?: string; value?: number; details?: string } | undefined,
|
|
): string | undefined {
|
|
if (!area) return undefined;
|
|
if (area.value && area.type) return `${area.value}-foot ${area.type}`;
|
|
return area.details ?? undefined;
|
|
}
|
|
|
|
function formatDefense(defense: SpellSystem["defense"]): string | undefined {
|
|
if (!defense) return undefined;
|
|
if (defense.save) {
|
|
const stat = capitalize(defense.save.statistic);
|
|
return defense.save.basic ? `basic ${stat}` : stat;
|
|
}
|
|
if (defense.passive) return capitalize(defense.passive.statistic);
|
|
return undefined;
|
|
}
|
|
|
|
function formatHeightening(
|
|
heightening: SpellSystem["heightening"],
|
|
): string | undefined {
|
|
if (!heightening) return undefined;
|
|
if (heightening.type === "fixed") {
|
|
const parts = Object.entries(heightening.levels)
|
|
.filter(([, lvl]) => lvl.text)
|
|
.map(
|
|
([rank, lvl]) =>
|
|
`Heightened (${rank}) ${stripFoundryTags(lvl.text as string)}`,
|
|
);
|
|
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
}
|
|
if (heightening.type === "interval") {
|
|
const dmg = heightening.damage?.value
|
|
? ` damage increases by ${heightening.damage.value}`
|
|
: "";
|
|
return `Heightened (+${heightening.interval})${dmg}`;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function formatOverlays(overlays: SpellSystem["overlays"]): string | undefined {
|
|
if (!overlays) return undefined;
|
|
const parts: string[] = [];
|
|
for (const overlay of Object.values(overlays)) {
|
|
const desc = overlay.system?.description?.value;
|
|
if (!desc) continue;
|
|
const label = overlay.name ? `${overlay.name}: ` : "";
|
|
parts.push(`${label}${stripFoundryTags(desc)}`);
|
|
}
|
|
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
}
|
|
|
|
/**
|
|
* Foundry descriptions often include heightening rules inline at the end.
|
|
* When we extract heightening into a structured field, strip that trailing
|
|
* text to avoid duplication.
|
|
*/
|
|
const HEIGHTENED_SUFFIX = /\s*Heightened\s*\([^)]*\)[\s\S]*$/;
|
|
|
|
function normalizeSpell(
|
|
item: RawFoundryItem,
|
|
creatureLevel: number,
|
|
): SpellReference {
|
|
const sys = item.system as unknown as SpellSystem;
|
|
const usesMax = sys.location?.uses?.max;
|
|
const isCantrip = sys.traits?.value?.includes("cantrip") ?? false;
|
|
const rank =
|
|
sys.location?.heightenedLevel ??
|
|
(isCantrip ? Math.ceil(creatureLevel / 2) : (sys.level?.value ?? 0));
|
|
const heightening =
|
|
formatHeightening(sys.heightening) ?? formatOverlays(sys.overlays);
|
|
|
|
let description: string | undefined;
|
|
if (sys.description?.value) {
|
|
let text = stripFoundryTags(sys.description.value);
|
|
// Resolve Foundry Roll formula references to the spell's actual rank.
|
|
// The parenthesized form (e.g., "(@item.level)d4") is most common.
|
|
text = text.replaceAll(/\(?@item\.(?:rank|level)\)?/g, String(rank));
|
|
if (heightening) {
|
|
text = text.replace(HEIGHTENED_SUFFIX, "").trim();
|
|
}
|
|
description = text || undefined;
|
|
}
|
|
|
|
return {
|
|
name: item.name,
|
|
slug: sys.slug,
|
|
rank,
|
|
description,
|
|
traits: sys.traits?.value,
|
|
traditions: sys.traits?.traditions,
|
|
range: formatRange(sys.range),
|
|
target: sys.target?.value || undefined,
|
|
area: formatArea(sys.area),
|
|
duration: sys.duration?.value || undefined,
|
|
defense: formatDefense(sys.defense),
|
|
actionCost: sys.time?.value || undefined,
|
|
heightening,
|
|
usesPerDay: usesMax && usesMax > 1 ? usesMax : undefined,
|
|
};
|
|
}
|
|
|
|
function normalizeSpellcastingEntry(
|
|
entry: RawFoundryItem,
|
|
allSpells: readonly RawFoundryItem[],
|
|
creatureLevel: number,
|
|
): 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, SpellReference[]>();
|
|
const cantrips: SpellReference[] = [];
|
|
|
|
for (const spell of linkedSpells) {
|
|
const ref = normalizeSpell(spell, creatureLevel);
|
|
const isCantrip =
|
|
(spell.system as unknown as SpellSystem).traits?.value?.includes(
|
|
"cantrip",
|
|
) ?? false;
|
|
if (isCantrip) {
|
|
cantrips.push(ref);
|
|
continue;
|
|
}
|
|
const rank = ref.rank ?? 0;
|
|
const existing = byRank.get(rank) ?? [];
|
|
existing.push(ref);
|
|
byRank.set(rank, existing);
|
|
}
|
|
|
|
const daily = [...byRank.entries()]
|
|
.sort(([a], [b]) => b - a)
|
|
.map(([rank, spells]) => ({
|
|
uses: rank,
|
|
each: true,
|
|
spells,
|
|
}));
|
|
|
|
return {
|
|
name,
|
|
headerText,
|
|
atWill: orUndefined(cantrips),
|
|
daily: orUndefined(daily),
|
|
};
|
|
}
|
|
|
|
function normalizeSpellcasting(
|
|
items: readonly RawFoundryItem[],
|
|
creatureLevel: number,
|
|
): SpellcastingBlock[] {
|
|
const entries = items.filter((i) => i.type === "spellcastingEntry");
|
|
const spells = items.filter((i) => i.type === "spell");
|
|
return entries.map((entry) =>
|
|
normalizeSpellcastingEntry(entry, spells, creatureLevel),
|
|
);
|
|
}
|
|
|
|
// -- Main normalization --
|
|
|
|
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 {
|
|
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 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,
|
|
perceptionDetails: sys.perception?.details || undefined,
|
|
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((i) => normalizeAttack(i, r.name)),
|
|
),
|
|
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, sys.details?.level?.value ?? 0),
|
|
),
|
|
items:
|
|
items
|
|
.filter(isMundaneItem)
|
|
.map((i) => i.name)
|
|
.join(", ") || undefined,
|
|
equipment: orUndefined(
|
|
items.filter(isDetailedEquipment).map(normalizeEquipmentItem),
|
|
),
|
|
};
|
|
}
|
|
|
|
export function normalizeFoundryCreatures(
|
|
rawCreatures: unknown[],
|
|
sourceCode?: string,
|
|
sourceDisplayName?: string,
|
|
): Pf2eCreature[] {
|
|
return rawCreatures.map((raw) =>
|
|
normalizeFoundryCreature(raw, sourceCode, sourceDisplayName),
|
|
);
|
|
}
|