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:
@@ -115,6 +115,7 @@ export function createTestAdapters(options?: {
|
|||||||
getDefaultFetchUrl: (sourceCode) =>
|
getDefaultFetchUrl: (sourceCode) =>
|
||||||
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
|
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
|
||||||
getSourceDisplayName: (sourceCode) => sourceCode,
|
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||||
|
getCreaturePathsForSource: () => [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,359 +1,645 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { normalizePf2eBestiary } from "../pf2e-bestiary-adapter.js";
|
import { normalizeFoundryCreature } from "../pf2e-bestiary-adapter.js";
|
||||||
|
|
||||||
function minimalCreature(overrides?: Record<string, unknown>) {
|
function minimalCreature(overrides?: Record<string, unknown>) {
|
||||||
return {
|
return {
|
||||||
|
_id: "test-id",
|
||||||
name: "Test Creature",
|
name: "Test Creature",
|
||||||
source: "TST",
|
type: "npc",
|
||||||
|
system: {
|
||||||
|
abilities: {
|
||||||
|
str: { mod: 3 },
|
||||||
|
dex: { mod: 2 },
|
||||||
|
con: { mod: 1 },
|
||||||
|
int: { mod: 0 },
|
||||||
|
wis: { mod: -1 },
|
||||||
|
cha: { mod: -2 },
|
||||||
|
},
|
||||||
|
attributes: {
|
||||||
|
ac: { value: 18 },
|
||||||
|
hp: { max: 45 },
|
||||||
|
speed: { value: 25 },
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
level: { value: 3 },
|
||||||
|
languages: { value: ["common"] },
|
||||||
|
publication: {
|
||||||
|
license: "ORC",
|
||||||
|
remaster: true,
|
||||||
|
title: "Test Source",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
perception: { mod: 8 },
|
||||||
|
saves: {
|
||||||
|
fortitude: { value: 10 },
|
||||||
|
reflex: { value: 8 },
|
||||||
|
will: { value: 6 },
|
||||||
|
},
|
||||||
|
skills: {},
|
||||||
|
traits: { rarity: "common", size: { value: "med" }, value: [] },
|
||||||
|
},
|
||||||
|
items: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("normalizePf2eBestiary", () => {
|
describe("normalizeFoundryCreature", () => {
|
||||||
describe("weaknesses formatting", () => {
|
describe("basic fields", () => {
|
||||||
it("formats weakness with numeric amount", () => {
|
it("maps top-level fields correctly", () => {
|
||||||
const [creature] = normalizePf2eBestiary({
|
const creature = normalizeFoundryCreature(minimalCreature());
|
||||||
creature: [
|
expect(creature.system).toBe("pf2e");
|
||||||
minimalCreature({
|
expect(creature.name).toBe("Test Creature");
|
||||||
defenses: {
|
expect(creature.level).toBe(3);
|
||||||
weaknesses: [{ name: "fire", amount: 5 }],
|
expect(creature.ac).toBe(18);
|
||||||
},
|
expect(creature.hp).toBe(45);
|
||||||
}),
|
expect(creature.perception).toBe(8);
|
||||||
],
|
expect(creature.saveFort).toBe(10);
|
||||||
});
|
expect(creature.saveRef).toBe(8);
|
||||||
expect(creature.weaknesses).toBe("Fire 5");
|
expect(creature.saveWill).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats weakness without amount (qualitative)", () => {
|
it("maps ability modifiers", () => {
|
||||||
const [creature] = normalizePf2eBestiary({
|
const creature = normalizeFoundryCreature(minimalCreature());
|
||||||
creature: [
|
expect(creature.abilityMods).toEqual({
|
||||||
minimalCreature({
|
str: 3,
|
||||||
defenses: {
|
dex: 2,
|
||||||
weaknesses: [{ name: "smoke susceptibility" }],
|
con: 1,
|
||||||
},
|
int: 0,
|
||||||
}),
|
wis: -1,
|
||||||
],
|
cha: -2,
|
||||||
});
|
});
|
||||||
expect(creature.weaknesses).toBe("Smoke susceptibility");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats weakness with note and amount", () => {
|
it("maps AC conditional from details", () => {
|
||||||
const [creature] = normalizePf2eBestiary({
|
const creature = normalizeFoundryCreature(
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
minimalCreature({
|
||||||
defenses: {
|
system: {
|
||||||
weaknesses: [
|
...minimalCreature().system,
|
||||||
{ name: "cold iron", amount: 5, note: "except daggers" },
|
attributes: {
|
||||||
],
|
...minimalCreature().system.attributes,
|
||||||
|
ac: { value: 20, details: "+2 with shield raised" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
);
|
||||||
});
|
expect(creature.acConditional).toBe("+2 with shield raised");
|
||||||
expect(creature.weaknesses).toBe("Cold iron 5 (except daggers)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats weakness with note but no amount", () => {
|
|
||||||
const [creature] = normalizePf2eBestiary({
|
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
|
||||||
defenses: {
|
|
||||||
weaknesses: [{ name: "smoke susceptibility", note: "see below" }],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(creature.weaknesses).toBe("Smoke susceptibility (see below)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined when no weaknesses", () => {
|
|
||||||
const [creature] = normalizePf2eBestiary({
|
|
||||||
creature: [minimalCreature({})],
|
|
||||||
});
|
|
||||||
expect(creature.weaknesses).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("senses formatting", () => {
|
describe("senses formatting", () => {
|
||||||
it("strips tags and includes type and range", () => {
|
it("formats darkvision", () => {
|
||||||
const [creature] = normalizePf2eBestiary({
|
const creature = normalizeFoundryCreature(
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
minimalCreature({
|
||||||
senses: [
|
system: {
|
||||||
{
|
...minimalCreature().system,
|
||||||
type: "imprecise",
|
perception: {
|
||||||
name: "{@ability tremorsense}",
|
mod: 8,
|
||||||
range: 30,
|
senses: [{ type: "darkvision" }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
],
|
);
|
||||||
});
|
|
||||||
expect(creature.senses).toBe("Tremorsense (imprecise) 30 feet");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats sense with only a name", () => {
|
|
||||||
const [creature] = normalizePf2eBestiary({
|
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
|
||||||
senses: [{ name: "darkvision" }],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(creature.senses).toBe("Darkvision");
|
expect(creature.senses).toBe("Darkvision");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats sense with name and range but no type", () => {
|
it("formats sense with acuity and range", () => {
|
||||||
const [creature] = normalizePf2eBestiary({
|
const creature = normalizeFoundryCreature(
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
minimalCreature({
|
||||||
senses: [{ name: "scent", range: 60 }],
|
system: {
|
||||||
|
...minimalCreature().system,
|
||||||
|
perception: {
|
||||||
|
mod: 8,
|
||||||
|
senses: [{ type: "tremorsense", acuity: "imprecise", range: 30 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
);
|
||||||
|
expect(creature.senses).toBe("Tremorsense (imprecise) 30 feet");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits precise acuity", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
system: {
|
||||||
|
...minimalCreature().system,
|
||||||
|
perception: {
|
||||||
|
mod: 8,
|
||||||
|
senses: [{ type: "scent", acuity: "precise", range: 60 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(creature.senses).toBe("Scent 60 feet");
|
expect(creature.senses).toBe("Scent 60 feet");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("attack formatting", () => {
|
describe("languages formatting", () => {
|
||||||
it("strips angle brackets from traits", () => {
|
it("formats language list", () => {
|
||||||
const [creature] = normalizePf2eBestiary({
|
const creature = normalizeFoundryCreature(
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
minimalCreature({
|
||||||
attacks: [
|
system: {
|
||||||
|
...minimalCreature().system,
|
||||||
|
details: {
|
||||||
|
...minimalCreature().system.details,
|
||||||
|
languages: { value: ["common", "draconic"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.languages).toBe("Common, Draconic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes details", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
system: {
|
||||||
|
...minimalCreature().system,
|
||||||
|
details: {
|
||||||
|
...minimalCreature().system.details,
|
||||||
|
languages: {
|
||||||
|
value: ["common"],
|
||||||
|
details: "telepathy 100 feet",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.languages).toBe("Common (telepathy 100 feet)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("skills formatting", () => {
|
||||||
|
it("formats and sorts skills", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
system: {
|
||||||
|
...minimalCreature().system,
|
||||||
|
skills: {
|
||||||
|
stealth: { base: 10 },
|
||||||
|
athletics: { base: 8 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.skills).toBe("Athletics +8, Stealth +10");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defenses formatting", () => {
|
||||||
|
it("formats immunities with exceptions", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
system: {
|
||||||
|
...minimalCreature().system,
|
||||||
|
attributes: {
|
||||||
|
...minimalCreature().system.attributes,
|
||||||
|
immunities: [
|
||||||
|
{ type: "paralyzed", exceptions: [] },
|
||||||
{
|
{
|
||||||
name: "stinger",
|
type: "physical",
|
||||||
range: "Melee",
|
exceptions: ["adamantine"],
|
||||||
attack: 11,
|
|
||||||
traits: ["deadly <d8>"],
|
|
||||||
damage: "1d6+4 piercing",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
);
|
||||||
});
|
expect(creature.immunities).toBe(
|
||||||
const attack = creature.attacks?.[0];
|
"Paralyzed, Physical (except Adamantine)",
|
||||||
expect(attack).toBeDefined();
|
|
||||||
expect(attack?.segments[0]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
type: "text",
|
|
||||||
value: expect.stringContaining("(deadly d8)"),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips angle brackets from reach values in traits", () => {
|
it("formats resistances with value", () => {
|
||||||
const [creature] = normalizePf2eBestiary({
|
const creature = normalizeFoundryCreature(
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
minimalCreature({
|
||||||
attacks: [
|
system: {
|
||||||
|
...minimalCreature().system,
|
||||||
|
attributes: {
|
||||||
|
...minimalCreature().system.attributes,
|
||||||
|
resistances: [{ type: "fire", value: 10, exceptions: [] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.resistances).toBe("Fire 10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats weaknesses", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
system: {
|
||||||
|
...minimalCreature().system,
|
||||||
|
attributes: {
|
||||||
|
...minimalCreature().system.attributes,
|
||||||
|
weaknesses: [{ type: "cold-iron", value: 5 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.weaknesses).toBe("Cold iron 5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("speed formatting", () => {
|
||||||
|
it("formats base speed", () => {
|
||||||
|
const creature = normalizeFoundryCreature(minimalCreature());
|
||||||
|
expect(creature.speed).toBe("25 feet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes other speeds", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
system: {
|
||||||
|
...minimalCreature().system,
|
||||||
|
attributes: {
|
||||||
|
...minimalCreature().system.attributes,
|
||||||
|
speed: {
|
||||||
|
value: 40,
|
||||||
|
otherSpeeds: [
|
||||||
|
{ type: "fly", value: 120 },
|
||||||
|
{ type: "swim", value: 40 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.speed).toBe("40 feet, Fly 120 feet, Swim 40 feet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes speed details", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
system: {
|
||||||
|
...minimalCreature().system,
|
||||||
|
attributes: {
|
||||||
|
...minimalCreature().system.attributes,
|
||||||
|
speed: {
|
||||||
|
value: 25,
|
||||||
|
details: "ignores difficult terrain",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.speed).toBe("25 feet (ignores difficult terrain)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("attack normalization", () => {
|
||||||
|
it("normalizes melee attacks with traits and damage", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
name: "tentacle",
|
_id: "atk1",
|
||||||
range: "Melee",
|
name: "dogslicer",
|
||||||
attack: 18,
|
type: "melee",
|
||||||
traits: ["agile", "chaotic", "magical", "reach <10 feet>"],
|
system: {
|
||||||
damage: "2d8+6 piercing",
|
bonus: { value: 7 },
|
||||||
|
damageRolls: {
|
||||||
|
abc: {
|
||||||
|
damage: "1d6",
|
||||||
|
damageType: "slashing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
traits: {
|
||||||
|
value: ["agile", "backstabber", "finesse"],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
);
|
||||||
});
|
|
||||||
const attack = creature.attacks?.[0];
|
const attack = creature.attacks?.[0];
|
||||||
expect(attack).toBeDefined();
|
expect(attack).toBeDefined();
|
||||||
|
expect(attack?.name).toBe("Dogslicer");
|
||||||
|
expect(attack?.activity).toEqual({ number: 1, unit: "action" });
|
||||||
|
expect(attack?.segments[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: "+7 (agile, backstabber, finesse), 1d6 slashing",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands slugified trait names", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "atk1",
|
||||||
|
name: "claw",
|
||||||
|
type: "melee",
|
||||||
|
system: {
|
||||||
|
bonus: { value: 18 },
|
||||||
|
damageRolls: {
|
||||||
|
abc: {
|
||||||
|
damage: "2d8+6",
|
||||||
|
damageType: "slashing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
traits: {
|
||||||
|
value: ["reach-10", "deadly-d10", "versatile-p"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const attack = creature.attacks?.[0];
|
||||||
|
expect(attack?.segments[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: "+18 (reach 10 feet, deadly d10, versatile P), 2d8+6 slashing",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple damage types", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "atk1",
|
||||||
|
name: "flaming sword",
|
||||||
|
type: "melee",
|
||||||
|
system: {
|
||||||
|
bonus: { value: 15 },
|
||||||
|
damageRolls: {
|
||||||
|
abc: {
|
||||||
|
damage: "2d8+5",
|
||||||
|
damageType: "slashing",
|
||||||
|
},
|
||||||
|
def: {
|
||||||
|
damage: "1d6",
|
||||||
|
damageType: "fire",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
traits: { value: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const attack = creature.attacks?.[0];
|
||||||
expect(attack?.segments[0]).toEqual(
|
expect(attack?.segments[0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: "text",
|
type: "text",
|
||||||
value: expect.stringContaining(
|
value: "+15, 2d8+5 slashing plus 1d6 fire",
|
||||||
"(agile, chaotic, magical, reach 10 feet)",
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ability formatting", () => {
|
describe("ability normalization", () => {
|
||||||
it("includes traits from abilities in the text", () => {
|
it("routes abilities by category", () => {
|
||||||
const [creature] = normalizePf2eBestiary({
|
const creature = normalizeFoundryCreature(
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
minimalCreature({
|
||||||
abilities: {
|
items: [
|
||||||
bot: [
|
|
||||||
{
|
{
|
||||||
name: "Change Shape",
|
_id: "a1",
|
||||||
activity: { number: 1, unit: "action" },
|
name: "Sense Motive",
|
||||||
traits: [
|
type: "action",
|
||||||
"concentrate",
|
system: {
|
||||||
"divine",
|
category: "interaction",
|
||||||
"polymorph",
|
actionType: { value: "passive" },
|
||||||
"transmutation",
|
actions: { value: null },
|
||||||
],
|
traits: { value: [] },
|
||||||
entries: [
|
description: { value: "<p>Can sense lies.</p>" },
|
||||||
"The naunet can take the appearance of any creature.",
|
},
|
||||||
],
|
},
|
||||||
|
{
|
||||||
|
_id: "a2",
|
||||||
|
name: "Shield Block",
|
||||||
|
type: "action",
|
||||||
|
system: {
|
||||||
|
category: "defensive",
|
||||||
|
actionType: { value: "reaction" },
|
||||||
|
actions: { value: null },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: {
|
||||||
|
value: "<p>Blocks with shield.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "a3",
|
||||||
|
name: "Breath Weapon",
|
||||||
|
type: "action",
|
||||||
|
system: {
|
||||||
|
category: "offensive",
|
||||||
|
actionType: { value: "action" },
|
||||||
|
actions: { value: 2 },
|
||||||
|
traits: { value: ["arcane", "fire"] },
|
||||||
|
description: {
|
||||||
|
value:
|
||||||
|
"<p>@Damage[8d6[fire]] in a @Template[cone|distance:40].</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const ability = creature.abilitiesBot?.[0];
|
|
||||||
expect(ability).toBeDefined();
|
|
||||||
expect(ability?.name).toBe("Change Shape");
|
|
||||||
expect(ability?.activity).toEqual({ number: 1, unit: "action" });
|
|
||||||
expect(ability?.segments[0]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
type: "text",
|
|
||||||
value: expect.stringContaining(
|
|
||||||
"(Concentrate, Divine, Polymorph, Transmutation)",
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(creature.abilitiesTop).toHaveLength(1);
|
||||||
|
expect(creature.abilitiesTop?.[0]?.name).toBe("Sense Motive");
|
||||||
|
expect(creature.abilitiesTop?.[0]?.activity).toBeUndefined();
|
||||||
|
|
||||||
|
expect(creature.abilitiesMid).toHaveLength(1);
|
||||||
|
expect(creature.abilitiesMid?.[0]?.name).toBe("Shield Block");
|
||||||
|
expect(creature.abilitiesMid?.[0]?.activity).toEqual({
|
||||||
|
number: 1,
|
||||||
|
unit: "reaction",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(creature.abilitiesBot).toHaveLength(1);
|
||||||
|
expect(creature.abilitiesBot?.[0]?.name).toBe("Breath Weapon");
|
||||||
|
expect(creature.abilitiesBot?.[0]?.activity).toEqual({
|
||||||
|
number: 2,
|
||||||
|
unit: "action",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips Foundry enrichment tags from descriptions", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "a1",
|
||||||
|
name: "Flame Burst",
|
||||||
|
type: "action",
|
||||||
|
system: {
|
||||||
|
category: "offensive",
|
||||||
|
actionType: { value: "action" },
|
||||||
|
actions: { value: 2 },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: {
|
||||||
|
value:
|
||||||
|
"<p>Deal @Damage[3d6[fire]] damage, @Check[reflex|dc:20|basic] save.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
|
||||||
|
? creature.abilitiesBot[0].segments[0].value
|
||||||
|
: undefined,
|
||||||
|
).toBe("Deal 3d6 fire damage, DC 20 basic Reflex save.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses free action activity", () => {
|
it("parses free action activity", () => {
|
||||||
const [creature] = normalizePf2eBestiary({
|
const creature = normalizeFoundryCreature(
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
minimalCreature({
|
||||||
abilities: {
|
items: [
|
||||||
bot: [
|
|
||||||
{
|
{
|
||||||
name: "Adaptive Strike",
|
_id: "a1",
|
||||||
activity: { number: 1, unit: "free" },
|
name: "Quick Draw",
|
||||||
entries: ["The naunet chooses adamantine."],
|
type: "action",
|
||||||
|
system: {
|
||||||
|
category: "offensive",
|
||||||
|
actionType: { value: "free" },
|
||||||
|
actions: { value: null },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: { value: "" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const ability = creature.abilitiesBot?.[0];
|
|
||||||
expect(ability?.name).toBe("Adaptive Strike");
|
|
||||||
expect(ability?.activity).toEqual({ number: 1, unit: "free" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses reaction activity", () => {
|
|
||||||
const [creature] = normalizePf2eBestiary({
|
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
|
||||||
abilities: {
|
|
||||||
mid: [
|
|
||||||
{
|
|
||||||
name: "Attack of Opportunity",
|
|
||||||
activity: { number: 1, unit: "reaction" },
|
|
||||||
entries: ["Trigger description."],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const ability = creature.abilitiesMid?.[0];
|
|
||||||
expect(ability?.name).toBe("Attack of Opportunity");
|
|
||||||
expect(ability?.activity).toEqual({ number: 1, unit: "reaction" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses multi-action activity", () => {
|
|
||||||
const [creature] = normalizePf2eBestiary({
|
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
|
||||||
abilities: {
|
|
||||||
bot: [
|
|
||||||
{
|
|
||||||
name: "Breath Weapon",
|
|
||||||
activity: { number: 2, unit: "action" },
|
|
||||||
entries: ["Fire breath."],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const ability = creature.abilitiesBot?.[0];
|
|
||||||
expect(ability?.name).toBe("Breath Weapon");
|
|
||||||
expect(ability?.activity).toEqual({ number: 2, unit: "action" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders ability without activity or traits normally", () => {
|
|
||||||
const [creature] = normalizePf2eBestiary({
|
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
|
||||||
abilities: {
|
|
||||||
bot: [
|
|
||||||
{
|
|
||||||
name: "Constrict",
|
|
||||||
entries: ["1d8+8 bludgeoning, DC 26"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const ability = creature.abilitiesBot?.[0];
|
|
||||||
expect(ability).toBeDefined();
|
|
||||||
expect(ability?.name).toBe("Constrict");
|
|
||||||
expect(ability?.activity).toBeUndefined();
|
|
||||||
expect(ability?.segments[0]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
type: "text",
|
|
||||||
value: "1d8+8 bludgeoning, DC 26",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(creature.abilitiesBot?.[0]?.activity).toEqual({
|
||||||
|
number: 1,
|
||||||
|
unit: "free",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes trigger text before entries", () => {
|
it("includes traits in ability text", () => {
|
||||||
const [creature] = normalizePf2eBestiary({
|
const creature = normalizeFoundryCreature(
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
minimalCreature({
|
||||||
abilities: {
|
items: [
|
||||||
mid: [
|
|
||||||
{
|
{
|
||||||
name: "Wing Deflection",
|
_id: "a1",
|
||||||
activity: { number: 1, unit: "reaction" },
|
name: "Change Shape",
|
||||||
trigger: "The dragon is targeted with an attack.",
|
type: "action",
|
||||||
entries: ["The dragon raises its wing."],
|
system: {
|
||||||
|
category: "offensive",
|
||||||
|
actionType: { value: "action" },
|
||||||
|
actions: { value: 1 },
|
||||||
|
traits: {
|
||||||
|
value: ["concentrate", "polymorph"],
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
value: "<p>Takes a new form.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const ability = creature.abilitiesMid?.[0];
|
|
||||||
expect(ability).toBeDefined();
|
|
||||||
expect(ability?.activity).toEqual({ number: 1, unit: "reaction" });
|
|
||||||
expect(ability?.trigger).toBe("The dragon is targeted with an attack.");
|
|
||||||
expect(ability?.segments[0]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
type: "text",
|
|
||||||
value: "The dragon raises its wing.",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
|
||||||
|
? creature.abilitiesBot[0].segments[0].value
|
||||||
|
: undefined,
|
||||||
|
).toBe("(Concentrate, Polymorph) Takes a new form.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resistances formatting", () => {
|
describe("spellcasting normalization", () => {
|
||||||
it("formats resistance without amount", () => {
|
it("normalizes prepared spells by rank", () => {
|
||||||
const [creature] = normalizePf2eBestiary({
|
const creature = normalizeFoundryCreature(
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
minimalCreature({
|
||||||
defenses: {
|
items: [
|
||||||
resistances: [{ name: "physical" }],
|
{
|
||||||
|
_id: "entry1",
|
||||||
|
name: "Primal Prepared Spells",
|
||||||
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "primal" },
|
||||||
|
prepared: { value: "prepared" },
|
||||||
|
spelldc: { dc: 30, value: 22 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Earthquake",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 6 },
|
||||||
|
traits: { value: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s2",
|
||||||
|
name: "Heal",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 3 },
|
||||||
|
traits: { value: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s3",
|
||||||
|
name: "Detect Magic",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 1 },
|
||||||
|
traits: { value: ["cantrip"] },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
});
|
}),
|
||||||
expect(creature.resistances).toBe("Physical");
|
);
|
||||||
|
expect(creature.spellcasting).toHaveLength(1);
|
||||||
|
const sc = creature.spellcasting?.[0];
|
||||||
|
expect(sc?.name).toBe("Primal Prepared Spells");
|
||||||
|
expect(sc?.headerText).toBe("DC 30, attack +22");
|
||||||
|
expect(sc?.daily).toEqual([
|
||||||
|
{ uses: 6, each: true, spells: ["Earthquake"] },
|
||||||
|
{ uses: 3, each: true, spells: ["Heal"] },
|
||||||
|
]);
|
||||||
|
expect(sc?.atWill).toEqual(["Detect Magic"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats resistance with amount", () => {
|
it("normalizes innate spells with uses", () => {
|
||||||
const [creature] = normalizePf2eBestiary({
|
const creature = normalizeFoundryCreature(
|
||||||
creature: [
|
|
||||||
minimalCreature({
|
minimalCreature({
|
||||||
defenses: {
|
items: [
|
||||||
resistances: [{ name: "fire", amount: 10 }],
|
{
|
||||||
|
_id: "entry1",
|
||||||
|
name: "Divine Innate Spells",
|
||||||
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "divine" },
|
||||||
|
prepared: { value: "innate" },
|
||||||
|
spelldc: { dc: 32 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Sure Strike",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: {
|
||||||
|
value: "entry1",
|
||||||
|
heightenedLevel: 1,
|
||||||
|
uses: { max: 3, value: 3 },
|
||||||
|
},
|
||||||
|
level: { value: 1 },
|
||||||
|
traits: { value: [] },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
});
|
}),
|
||||||
expect(creature.resistances).toBe("Fire 10");
|
);
|
||||||
|
const sc = creature.spellcasting?.[0];
|
||||||
|
expect(sc?.headerText).toBe("DC 32");
|
||||||
|
expect(sc?.daily).toEqual([
|
||||||
|
{
|
||||||
|
uses: 1,
|
||||||
|
each: true,
|
||||||
|
spells: ["Sure Strike (\u00d73)"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const PACK_DIR_PREFIX = /^pathfinder-monster-core\//;
|
||||||
|
const JSON_EXTENSION = /\.json$/;
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getAllPf2eSourceCodes,
|
getAllPf2eSourceCodes,
|
||||||
|
getCreaturePathsForSource,
|
||||||
getDefaultPf2eFetchUrl,
|
getDefaultPf2eFetchUrl,
|
||||||
getPf2eSourceDisplayName,
|
getPf2eSourceDisplayName,
|
||||||
loadPf2eBestiaryIndex,
|
loadPf2eBestiaryIndex,
|
||||||
@@ -30,7 +35,15 @@ describe("loadPf2eBestiaryIndex", () => {
|
|||||||
|
|
||||||
it("contains a substantial number of creatures", () => {
|
it("contains a substantial number of creatures", () => {
|
||||||
const index = loadPf2eBestiaryIndex();
|
const index = loadPf2eBestiaryIndex();
|
||||||
expect(index.creatures.length).toBeGreaterThan(2000);
|
expect(index.creatures.length).toBeGreaterThan(2500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creatures have size and type populated", () => {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
const withSize = index.creatures.filter((c) => c.size !== "");
|
||||||
|
const withType = index.creatures.filter((c) => c.type !== "");
|
||||||
|
expect(withSize.length).toBeGreaterThan(index.creatures.length * 0.9);
|
||||||
|
expect(withType.length).toBeGreaterThan(index.creatures.length * 0.8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the same cached instance on subsequent calls", () => {
|
it("returns the same cached instance on subsequent calls", () => {
|
||||||
@@ -49,20 +62,42 @@ describe("getAllPf2eSourceCodes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getDefaultPf2eFetchUrl", () => {
|
describe("getDefaultPf2eFetchUrl", () => {
|
||||||
it("returns Pf2eTools GitHub URL with lowercase source code", () => {
|
it("returns Foundry VTT PF2e base URL", () => {
|
||||||
const url = getDefaultPf2eFetchUrl("B1");
|
const url = getDefaultPf2eFetchUrl("pathfinder-monster-core");
|
||||||
expect(url).toBe(
|
expect(url).toBe(
|
||||||
"https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/creatures-b1.json",
|
"https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes custom base URL with trailing slash", () => {
|
||||||
|
const url = getDefaultPf2eFetchUrl(
|
||||||
|
"pathfinder-monster-core",
|
||||||
|
"https://example.com/pf2e",
|
||||||
|
);
|
||||||
|
expect(url).toBe("https://example.com/pf2e/");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getPf2eSourceDisplayName", () => {
|
describe("getPf2eSourceDisplayName", () => {
|
||||||
it("returns display name for a known source", () => {
|
it("returns display name for a known source", () => {
|
||||||
expect(getPf2eSourceDisplayName("B1")).toBe("Bestiary");
|
const name = getPf2eSourceDisplayName("pathfinder-monster-core");
|
||||||
|
expect(name).toBe("Monster Core");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to source code for unknown source", () => {
|
it("falls back to source code for unknown source", () => {
|
||||||
expect(getPf2eSourceDisplayName("UNKNOWN")).toBe("UNKNOWN");
|
expect(getPf2eSourceDisplayName("UNKNOWN")).toBe("UNKNOWN");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getCreaturePathsForSource", () => {
|
||||||
|
it("returns file paths for a known source", () => {
|
||||||
|
const paths = getCreaturePathsForSource("pathfinder-monster-core");
|
||||||
|
expect(paths.length).toBeGreaterThan(100);
|
||||||
|
expect(paths[0]).toMatch(PACK_DIR_PREFIX);
|
||||||
|
expect(paths[0]).toMatch(JSON_EXTENSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for unknown source", () => {
|
||||||
|
expect(getCreaturePathsForSource("nonexistent")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
162
apps/web/src/adapters/__tests__/strip-foundry-tags.test.ts
Normal file
162
apps/web/src/adapters/__tests__/strip-foundry-tags.test.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { stripFoundryTags } from "../strip-foundry-tags.js";
|
||||||
|
|
||||||
|
describe("stripFoundryTags", () => {
|
||||||
|
describe("@Damage tags", () => {
|
||||||
|
it("formats damage with type bracket", () => {
|
||||||
|
expect(stripFoundryTags("@Damage[3d6+10[fire]]")).toBe("3d6+10 fire");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers display text when present", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@Damage[3d6+10[fire]]{3d6+10 fire damage}"),
|
||||||
|
).toBe("3d6+10 fire damage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple damage types", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@Damage[2d8+5[slashing]] plus @Damage[1d6[fire]]"),
|
||||||
|
).toBe("2d8+5 slashing plus 1d6 fire");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("@Check tags", () => {
|
||||||
|
it("formats basic saving throw", () => {
|
||||||
|
expect(stripFoundryTags("@Check[reflex|dc:33|basic]")).toBe(
|
||||||
|
"DC 33 basic Reflex",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats non-basic check", () => {
|
||||||
|
expect(stripFoundryTags("@Check[athletics|dc:25]")).toBe(
|
||||||
|
"DC 25 Athletics",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats check without DC", () => {
|
||||||
|
expect(stripFoundryTags("@Check[fortitude]")).toBe("Fortitude");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("@UUID tags", () => {
|
||||||
|
it("extracts display text", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags(
|
||||||
|
"@UUID[Compendium.pf2e.conditionitems.Item.Grabbed]{Grabbed}",
|
||||||
|
),
|
||||||
|
).toBe("Grabbed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts last segment when no display text", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@UUID[Compendium.pf2e.conditionitems.Item.Grabbed]"),
|
||||||
|
).toBe("Grabbed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("@Template tags", () => {
|
||||||
|
it("formats cone template", () => {
|
||||||
|
expect(stripFoundryTags("@Template[cone|distance:40]")).toBe(
|
||||||
|
"40-foot cone",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats emanation template", () => {
|
||||||
|
expect(stripFoundryTags("@Template[emanation|distance:10]")).toBe(
|
||||||
|
"10-foot emanation",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers display text", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@Template[cone|distance:40]{40-foot cone}"),
|
||||||
|
).toBe("40-foot cone");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unknown @Tag patterns", () => {
|
||||||
|
it("uses display text for unknown tags", () => {
|
||||||
|
expect(stripFoundryTags("@Localize[some.key]{Some Text}")).toBe(
|
||||||
|
"Some Text",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips unknown tags without display text", () => {
|
||||||
|
expect(stripFoundryTags("@Localize[some.key]")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML stripping", () => {
|
||||||
|
it("strips paragraph tags", () => {
|
||||||
|
expect(stripFoundryTags("<p>text</p>")).toBe("text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts br to newline", () => {
|
||||||
|
expect(stripFoundryTags("line1<br />line2")).toBe("line1\nline2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts hr to newline", () => {
|
||||||
|
expect(stripFoundryTags("before<hr />after")).toBe("before\nafter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips strong and em tags", () => {
|
||||||
|
expect(stripFoundryTags("<strong>bold</strong> <em>italic</em>")).toBe(
|
||||||
|
"bold italic",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts p-to-p transitions to newlines", () => {
|
||||||
|
expect(stripFoundryTags("<p>first</p><p>second</p>")).toBe(
|
||||||
|
"first\nsecond",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips action-glyph spans", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags('<span class="action-glyph">1</span> Strike'),
|
||||||
|
).toBe("Strike");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML entities", () => {
|
||||||
|
it("decodes &", () => {
|
||||||
|
expect(stripFoundryTags("fire & ice")).toBe("fire & ice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes < and >", () => {
|
||||||
|
expect(stripFoundryTags("<tag>")).toBe("<tag>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes "", () => {
|
||||||
|
expect(stripFoundryTags(""hello"")).toBe('"hello"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("whitespace handling", () => {
|
||||||
|
it("collapses multiple spaces", () => {
|
||||||
|
expect(stripFoundryTags("a b c")).toBe("a b c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses multiple blank lines", () => {
|
||||||
|
expect(stripFoundryTags("a\n\n\nb")).toBe("a\nb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims leading and trailing whitespace", () => {
|
||||||
|
expect(stripFoundryTags(" hello ")).toBe("hello");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combined/edge cases", () => {
|
||||||
|
it("handles enrichment tags inside HTML", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags(
|
||||||
|
"<p>Deal @Damage[2d6[fire]] damage, @Check[reflex|dc:20|basic] save.</p>",
|
||||||
|
),
|
||||||
|
).toBe("Deal 2d6 fire damage, DC 20 basic Reflex save.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty string", () => {
|
||||||
|
expect(stripFoundryTags("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { type IDBPDatabase, openDB } from "idb";
|
|||||||
|
|
||||||
const DB_NAME = "initiative-bestiary";
|
const DB_NAME = "initiative-bestiary";
|
||||||
const STORE_NAME = "sources";
|
const STORE_NAME = "sources";
|
||||||
const DB_VERSION = 4;
|
const DB_VERSION = 5;
|
||||||
|
|
||||||
interface CachedSourceInfo {
|
interface CachedSourceInfo {
|
||||||
readonly sourceCode: string;
|
readonly sourceCode: string;
|
||||||
|
|||||||
@@ -1,389 +1,531 @@
|
|||||||
import type {
|
import type {
|
||||||
CreatureId,
|
CreatureId,
|
||||||
Pf2eCreature,
|
Pf2eCreature,
|
||||||
|
SpellcastingBlock,
|
||||||
TraitBlock,
|
TraitBlock,
|
||||||
TraitSegment,
|
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { creatureId } 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;
|
name: string;
|
||||||
source: string;
|
type: string;
|
||||||
level?: number;
|
system: {
|
||||||
traits?: string[];
|
abilities: Record<string, { mod: number }>;
|
||||||
perception?: { std?: number };
|
attributes: {
|
||||||
senses?: { name?: string; type?: string; range?: number }[];
|
ac: { value: number; details?: string };
|
||||||
languages?: { languages?: string[] };
|
hp: { max: number; details?: string };
|
||||||
skills?: Record<string, { std?: number }>;
|
speed: {
|
||||||
abilityMods?: Record<string, number>;
|
value: number;
|
||||||
items?: string[];
|
otherSpeeds?: { type: string; value: number }[];
|
||||||
defenses?: RawDefenses;
|
details?: string;
|
||||||
speed?: Record<string, number | { number: number }>;
|
|
||||||
attacks?: RawAttack[];
|
|
||||||
abilities?: {
|
|
||||||
top?: RawAbility[];
|
|
||||||
mid?: RawAbility[];
|
|
||||||
bot?: RawAbility[];
|
|
||||||
};
|
};
|
||||||
_copy?: unknown;
|
immunities?: { type: string; exceptions?: string[] }[];
|
||||||
}
|
resistances?: { type: string; value: number; exceptions?: string[] }[];
|
||||||
|
weaknesses?: { type: string; value: number }[];
|
||||||
interface RawDefenses {
|
allSaves?: { value: string };
|
||||||
ac?: Record<string, unknown>;
|
|
||||||
savingThrows?: {
|
|
||||||
fort?: { std?: number };
|
|
||||||
ref?: { std?: number };
|
|
||||||
will?: { std?: number };
|
|
||||||
};
|
};
|
||||||
hp?: { hp?: number }[];
|
details: {
|
||||||
immunities?: (string | { name: string })[];
|
level: { value: number };
|
||||||
resistances?: { amount?: number; name: string; note?: string }[];
|
languages: { value?: string[]; details?: string };
|
||||||
weaknesses?: { amount?: number; name: string; note?: 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 RawAbility {
|
interface RawFoundryItem {
|
||||||
name?: string;
|
_id: string;
|
||||||
activity?: { number?: number; unit?: string };
|
|
||||||
trigger?: string;
|
|
||||||
traits?: string[];
|
|
||||||
entries?: RawEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawAttack {
|
|
||||||
range?: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
attack?: number;
|
type: string;
|
||||||
traits?: string[];
|
system: Record<string, unknown>;
|
||||||
damage?: string;
|
sort?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RawEntry = string | RawEntryObject;
|
interface MeleeSystem {
|
||||||
|
bonus?: { value: number };
|
||||||
interface RawEntryObject {
|
damageRolls?: Record<string, { damage: string; damageType: string }>;
|
||||||
type?: string;
|
traits?: { value: string[] };
|
||||||
items?: (string | { name?: string; entry?: string })[];
|
|
||||||
entries?: RawEntry[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Module state --
|
interface ActionSystem {
|
||||||
|
category?: string;
|
||||||
let sourceDisplayNames: Record<string, string> = {};
|
actionType?: { value: string };
|
||||||
|
actions?: { value: number | null };
|
||||||
export function setPf2eSourceDisplayNames(names: Record<string, string>): void {
|
traits?: { value: string[] };
|
||||||
sourceDisplayNames = names;
|
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 --
|
// -- Helpers --
|
||||||
|
|
||||||
function capitalize(s: string): string {
|
function capitalize(s: string): string {
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
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 {
|
function makeCreatureId(source: string, name: string): CreatureId {
|
||||||
const slug = name
|
const slug = name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
.replaceAll(/(^-|-$)/g, "");
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
return creatureId(`${source.toLowerCase()}:${slug}`);
|
return creatureId(`${source}:${slug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSpeed(
|
const NUMERIC_SLUG = /^(.+)-(\d+)$/;
|
||||||
speed: Record<string, number | { number: number }> | undefined,
|
const LETTER_SLUG = /^(.+)-([a-z])$/;
|
||||||
): string {
|
|
||||||
if (!speed) return "";
|
/** Format rules for traits with a numeric suffix: "reach-10" → "reach 10 feet" */
|
||||||
const parts: string[] = [];
|
const NUMERIC_TRAIT_FORMATS: Record<string, (n: string) => string> = {
|
||||||
for (const [mode, value] of Object.entries(speed)) {
|
reach: (n) => `reach ${n} feet`,
|
||||||
if (typeof value === "number") {
|
range: (n) => `range ${n} feet`,
|
||||||
parts.push(
|
"range-increment": (n) => `range increment ${n} feet`,
|
||||||
mode === "walk" ? `${value} feet` : `${capitalize(mode)} ${value} feet`,
|
versatile: (n) => `versatile ${n}`,
|
||||||
);
|
deadly: (n) => `deadly d${n}`,
|
||||||
} else if (typeof value === "object" && "number" in value) {
|
fatal: (n) => `fatal d${n}`,
|
||||||
parts.push(
|
"fatal-aim": (n) => `fatal aim d${n}`,
|
||||||
mode === "walk"
|
reload: (n) => `reload ${n}`,
|
||||||
? `${value.number} feet`
|
};
|
||||||
: `${capitalize(mode)} ${value.number} feet`,
|
|
||||||
);
|
/** 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 parts.join(", ");
|
return slug.replaceAll("-", " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSkills(
|
// -- Formatting --
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSenses(
|
function formatSenses(
|
||||||
senses:
|
senses: { type: string; acuity?: string; range?: number }[] | undefined,
|
||||||
| readonly { name?: string; type?: string; range?: number }[]
|
|
||||||
| undefined,
|
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!senses || senses.length === 0) return undefined;
|
if (!senses || senses.length === 0) return undefined;
|
||||||
return senses
|
return senses
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const label = stripTags(s.name ?? s.type ?? "");
|
const parts = [capitalize(s.type.replaceAll("-", " "))];
|
||||||
if (!label) return "";
|
if (s.acuity && s.acuity !== "precise") {
|
||||||
const parts = [capitalize(label)];
|
parts.push(`(${s.acuity})`);
|
||||||
if (s.type && s.name) parts.push(`(${s.type})`);
|
}
|
||||||
if (s.range != null) parts.push(`${s.range} feet`);
|
if (s.range != null) parts.push(`${s.range} feet`);
|
||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLanguages(
|
function formatLanguages(
|
||||||
languages: { languages?: string[] } | undefined,
|
languages: { value?: string[]; details?: string } | undefined,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!languages?.languages || languages.languages.length === 0)
|
if (!languages?.value || languages.value.length === 0) return undefined;
|
||||||
return undefined;
|
const list = languages.value.map(capitalize).join(", ");
|
||||||
return languages.languages.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(
|
function formatImmunities(
|
||||||
immunities: readonly (string | { name: string })[] | undefined,
|
immunities: { type: string; exceptions?: string[] }[] | undefined,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!immunities || immunities.length === 0) return undefined;
|
if (!immunities || immunities.length === 0) return undefined;
|
||||||
return immunities
|
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(", ");
|
.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatResistances(
|
function formatResistances(
|
||||||
resistances:
|
resistances:
|
||||||
| readonly { amount?: number; name: string; note?: string }[]
|
| { type: string; value: number; exceptions?: string[] }[]
|
||||||
| undefined,
|
| undefined,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!resistances || resistances.length === 0) return undefined;
|
if (!resistances || resistances.length === 0) return undefined;
|
||||||
return resistances
|
return resistances
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
const base =
|
const base = `${capitalize(r.type.replaceAll("-", " "))} ${r.value}`;
|
||||||
r.amount == null
|
if (r.exceptions && r.exceptions.length > 0) {
|
||||||
? capitalize(r.name)
|
return `${base} (except ${r.exceptions.map((e) => capitalize(e.replaceAll("-", " "))).join(", ")})`;
|
||||||
: `${capitalize(r.name)} ${r.amount}`;
|
}
|
||||||
return r.note ? `${base} (${r.note})` : base;
|
return base;
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatWeaknesses(
|
function formatWeaknesses(
|
||||||
weaknesses:
|
weaknesses: { type: string; value: number }[] | undefined,
|
||||||
| readonly { amount?: number; name: string; note?: string }[]
|
|
||||||
| undefined,
|
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!weaknesses || weaknesses.length === 0) return undefined;
|
if (!weaknesses || weaknesses.length === 0) return undefined;
|
||||||
return weaknesses
|
return weaknesses
|
||||||
.map((w) => {
|
.map((w) => `${capitalize(w.type.replaceAll("-", " "))} ${w.value}`)
|
||||||
const base =
|
|
||||||
w.amount == null
|
|
||||||
? capitalize(w.name)
|
|
||||||
: `${capitalize(w.name)} ${w.amount}`;
|
|
||||||
return w.note ? `${base} (${w.note})` : base;
|
|
||||||
})
|
|
||||||
.join(", ");
|
.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Entry parsing --
|
function formatSpeed(speed: {
|
||||||
|
value: number;
|
||||||
function segmentizeEntries(entries: unknown): TraitSegment[] {
|
otherSpeeds?: { type: string; value: number }[];
|
||||||
if (!Array.isArray(entries)) return [];
|
details?: string;
|
||||||
const segments: TraitSegment[] = [];
|
}): string {
|
||||||
for (const entry of entries) {
|
const parts = [`${speed.value} feet`];
|
||||||
if (typeof entry === "string") {
|
if (speed.otherSpeeds) {
|
||||||
segments.push({ type: "text", value: stripTags(entry) });
|
for (const s of speed.otherSpeeds) {
|
||||||
} else if (typeof entry === "object" && entry !== null) {
|
parts.push(`${capitalize(s.type)} ${s.value} feet`);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
const base = parts.join(", ");
|
||||||
return segments;
|
return speed.details ? `${base} (${speed.details})` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAffliction(a: Record<string, unknown>): TraitSegment[] {
|
// -- Attack normalization --
|
||||||
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("; ") }] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAbilities(
|
function normalizeAttack(item: RawFoundryItem): TraitBlock {
|
||||||
abilities: readonly RawAbility[] | undefined,
|
const sys = item.system as unknown as MeleeSystem;
|
||||||
): TraitBlock[] | undefined {
|
const bonus = sys.bonus?.value ?? 0;
|
||||||
if (!abilities || abilities.length === 0) return undefined;
|
const traits = sys.traits?.value ?? [];
|
||||||
return abilities
|
const damageEntries = Object.values(sys.damageRolls ?? {});
|
||||||
.filter((a) => a.name)
|
const damage = damageEntries
|
||||||
.map((a) => {
|
.map((d) => `${d.damage} ${d.damageType}`)
|
||||||
const raw = a as Record<string, unknown>;
|
.join(" plus ");
|
||||||
const activity = parseActivity(a.activity);
|
const traitStr =
|
||||||
const trigger = a.trigger ? stripTags(a.trigger) : undefined;
|
traits.length > 0 ? ` (${traits.map(formatTrait).join(", ")})` : "";
|
||||||
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 {
|
return {
|
||||||
name,
|
name: capitalize(item.name),
|
||||||
activity,
|
activity: { number: 1, unit: "action" },
|
||||||
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: [
|
segments: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text",
|
||||||
value: `${parts.join(" ")}${attackMod}${traits}${damage}`,
|
value: `+${bonus}${traitStr}, ${damage}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Defenses extraction --
|
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,
|
||||||
|
}));
|
||||||
|
|
||||||
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");
|
|
||||||
return {
|
return {
|
||||||
ac: acStd,
|
name,
|
||||||
acConditional:
|
headerText,
|
||||||
acEntries.length > 0
|
atWill: orUndefined(cantrips),
|
||||||
? acEntries.map(([k, v]) => `${v} ${k}`).join(", ")
|
daily: orUndefined(daily),
|
||||||
: 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),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 --
|
// -- Main normalization --
|
||||||
|
|
||||||
function normalizeCreature(raw: RawPf2eCreature): Pf2eCreature {
|
function orUndefined<T>(arr: T[]): T[] | undefined {
|
||||||
const source = raw.source ?? "";
|
return arr.length > 0 ? arr : undefined;
|
||||||
const defenses = extractDefenses(raw.defenses);
|
}
|
||||||
const mods = raw.abilityMods ?? {};
|
|
||||||
|
|
||||||
|
/** 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 {
|
return {
|
||||||
system: "pf2e",
|
str: mods.str?.mod ?? 0,
|
||||||
id: makeCreatureId(source, raw.name),
|
dex: mods.dex?.mod ?? 0,
|
||||||
name: raw.name,
|
con: mods.con?.mod ?? 0,
|
||||||
source,
|
int: mods.int?.mod ?? 0,
|
||||||
sourceDisplayName: sourceDisplayNames[source] ?? source,
|
wis: mods.wis?.mod ?? 0,
|
||||||
level: raw.level ?? 0,
|
cha: mods.cha?.mod ?? 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),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePf2eBestiary(raw: {
|
export function normalizeFoundryCreature(
|
||||||
creature: unknown[];
|
raw: unknown,
|
||||||
}): Pf2eCreature[] {
|
sourceCode?: string,
|
||||||
return (raw.creature ?? [])
|
sourceDisplayName?: string,
|
||||||
.filter((c: unknown) => {
|
): Pf2eCreature {
|
||||||
const obj = c as { _copy?: unknown };
|
const r = raw as RawFoundryCreature;
|
||||||
return !obj._copy;
|
const sys = r.system;
|
||||||
})
|
const publication = sys.details?.publication;
|
||||||
.map((c) => normalizeCreature(c as RawPf2eCreature));
|
|
||||||
|
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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface CompactCreature {
|
|||||||
readonly pc: number;
|
readonly pc: number;
|
||||||
readonly sz: string;
|
readonly sz: string;
|
||||||
readonly tp: string;
|
readonly tp: string;
|
||||||
|
readonly f: string;
|
||||||
|
readonly li: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompactIndex {
|
interface CompactIndex {
|
||||||
@@ -53,15 +55,18 @@ export function getAllPf2eSourceCodes(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultPf2eFetchUrl(
|
export function getDefaultPf2eFetchUrl(
|
||||||
sourceCode: string,
|
_sourceCode: string,
|
||||||
baseUrl?: string,
|
baseUrl?: string,
|
||||||
): string {
|
): string {
|
||||||
const filename = `creatures-${sourceCode.toLowerCase()}.json`;
|
|
||||||
if (baseUrl !== undefined) {
|
if (baseUrl !== undefined) {
|
||||||
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||||
return `${normalized}${filename}`;
|
|
||||||
}
|
}
|
||||||
return `https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/${filename}`;
|
return "https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCreaturePathsForSource(sourceCode: string): string[] {
|
||||||
|
const compact = rawIndex as unknown as CompactIndex;
|
||||||
|
return compact.creatures.filter((c) => c.s === sourceCode).map((c) => c.f);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPf2eSourceDisplayName(sourceCode: string): string {
|
export function getPf2eSourceDisplayName(sourceCode: string): string {
|
||||||
|
|||||||
@@ -56,4 +56,5 @@ export interface Pf2eBestiaryIndexPort {
|
|||||||
getAllSourceCodes(): string[];
|
getAllSourceCodes(): string[];
|
||||||
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||||
getSourceDisplayName(sourceCode: string): string;
|
getSourceDisplayName(sourceCode: string): string;
|
||||||
|
getCreaturePathsForSource(sourceCode: string): string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,5 +47,6 @@ export const productionAdapters: Adapters = {
|
|||||||
getAllSourceCodes: pf2eBestiaryIndex.getAllPf2eSourceCodes,
|
getAllSourceCodes: pf2eBestiaryIndex.getAllPf2eSourceCodes,
|
||||||
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
||||||
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
||||||
|
getCreaturePathsForSource: pf2eBestiaryIndex.getCreaturePathsForSource,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
99
apps/web/src/adapters/strip-foundry-tags.ts
Normal file
99
apps/web/src/adapters/strip-foundry-tags.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Strips Foundry VTT HTML descriptions with enrichment syntax to plain
|
||||||
|
* readable text. Handles @Damage, @Check, @UUID, @Template and generic
|
||||||
|
* @Tag patterns as well as common HTML elements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// -- Enrichment-param helpers --
|
||||||
|
|
||||||
|
function formatDamage(params: string): string {
|
||||||
|
// "3d6+10[fire]" → "3d6+10 fire"
|
||||||
|
return params.replaceAll(/\[([^\]]*)\]/g, " $1").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCheck(params: string): string {
|
||||||
|
// "reflex|dc:33|basic" → "DC 33 basic Reflex"
|
||||||
|
const parts = params.split("|");
|
||||||
|
const type = parts[0] ?? "";
|
||||||
|
let dc = "";
|
||||||
|
let basic = false;
|
||||||
|
for (const part of parts.slice(1)) {
|
||||||
|
if (part.startsWith("dc:")) {
|
||||||
|
dc = part.slice(3);
|
||||||
|
} else if (part === "basic") {
|
||||||
|
basic = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const label = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
const dcStr = dc ? `DC ${dc} ` : "";
|
||||||
|
const basicStr = basic ? "basic " : "";
|
||||||
|
return `${dcStr}${basicStr}${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTemplate(params: string): string {
|
||||||
|
// "cone|distance:40" → "40-foot cone"
|
||||||
|
const parts = params.split("|");
|
||||||
|
const shape = parts[0] ?? "";
|
||||||
|
let distance = "";
|
||||||
|
for (const part of parts.slice(1)) {
|
||||||
|
if (part.startsWith("distance:")) {
|
||||||
|
distance = part.slice(9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return distance ? `${distance}-foot ${shape}` : shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripFoundryTags(html: string): string {
|
||||||
|
if (typeof html !== "string") return String(html);
|
||||||
|
let result = html;
|
||||||
|
|
||||||
|
// Strip Foundry enrichment tags (with optional display text)
|
||||||
|
// @Tag[params]{display} → display (prefer display text)
|
||||||
|
// @Tag[params] → extracted content
|
||||||
|
|
||||||
|
// @Damage has nested brackets: @Damage[3d6+10[fire]]
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@Damage\[((?:[^[\]]|\[[^\]]*\])*)\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, params: string, display: string | undefined) =>
|
||||||
|
display ?? formatDamage(params),
|
||||||
|
);
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@Check\[([^\]]+)\](?:\{([^}]*)\})?/g,
|
||||||
|
(_, params: string) => formatCheck(params),
|
||||||
|
);
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@UUID\[[^\]]+?([^./\]]+)\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, lastSegment: string, display: string | undefined) =>
|
||||||
|
display ?? lastSegment,
|
||||||
|
);
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@Template\[([^\]]+)\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, params: string, display: string | undefined) =>
|
||||||
|
display ?? formatTemplate(params),
|
||||||
|
);
|
||||||
|
// Catch-all for unknown @Tag patterns
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@\w+\[[^\]]*\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, display: string | undefined) => display ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strip action-glyph spans (content is a number the renderer handles)
|
||||||
|
result = result.replaceAll(/<span class="action-glyph">[^<]*<\/span>/gi, "");
|
||||||
|
|
||||||
|
// Strip HTML tags
|
||||||
|
result = result.replaceAll(/<br\s*\/?>/gi, "\n");
|
||||||
|
result = result.replaceAll(/<hr\s*\/?>/gi, "\n");
|
||||||
|
result = result.replaceAll(/<\/p>\s*<p[^>]*>/gi, "\n");
|
||||||
|
result = result.replaceAll(/<[^>]+>/g, "");
|
||||||
|
|
||||||
|
// Decode common HTML entities
|
||||||
|
result = result.replaceAll("&", "&");
|
||||||
|
result = result.replaceAll("<", "<");
|
||||||
|
result = result.replaceAll(">", ">");
|
||||||
|
result = result.replaceAll(""", '"');
|
||||||
|
|
||||||
|
// Collapse whitespace
|
||||||
|
result = result.replaceAll(/[ \t]+/g, " ");
|
||||||
|
result = result.replaceAll(/\n\s*\n/g, "\n");
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
280
apps/web/src/components/__tests__/pf2e-stat-block.test.tsx
Normal file
280
apps/web/src/components/__tests__/pf2e-stat-block.test.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { Pf2eStatBlock } from "../pf2e-stat-block.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
|
||||||
|
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
|
||||||
|
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
|
||||||
|
const SAVE_CONDITIONAL_ABSENT_REGEX = /status to all saves/;
|
||||||
|
const HP_DETAILS_REGEX = /115.*regeneration 20/;
|
||||||
|
const REGEN_REGEX = /regeneration/;
|
||||||
|
const ATTACK_NAME_REGEX = /Dogslicer/;
|
||||||
|
const ATTACK_DAMAGE_REGEX = /1d6 slashing/;
|
||||||
|
const SPELLCASTING_ENTRY_REGEX = /Divine Innate Spells\./;
|
||||||
|
const ABILITY_MID_NAME_REGEX = /Goblin Scuttle/;
|
||||||
|
const ABILITY_MID_DESC_REGEX = /The goblin Steps\./;
|
||||||
|
const CANTRIPS_REGEX = /Cantrips:/;
|
||||||
|
const AC_REGEX = /16/;
|
||||||
|
|
||||||
|
const GOBLIN_WARRIOR: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("pathfinder-monster-core:goblin-warrior"),
|
||||||
|
name: "Goblin Warrior",
|
||||||
|
source: "pathfinder-monster-core",
|
||||||
|
sourceDisplayName: "Monster Core",
|
||||||
|
level: -1,
|
||||||
|
traits: ["small", "goblin", "humanoid"],
|
||||||
|
perception: 2,
|
||||||
|
senses: "Darkvision",
|
||||||
|
languages: "Common, Goblin",
|
||||||
|
skills: "Acrobatics +5, Athletics +2, Nature +1, Stealth +5",
|
||||||
|
abilityMods: { str: 0, dex: 3, con: 1, int: 0, wis: -1, cha: 1 },
|
||||||
|
ac: 16,
|
||||||
|
saveFort: 5,
|
||||||
|
saveRef: 7,
|
||||||
|
saveWill: 3,
|
||||||
|
hp: 6,
|
||||||
|
speed: "25 feet",
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Dogslicer",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: "+7 (agile, backstabber, finesse), 1d6 slashing",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
abilitiesMid: [
|
||||||
|
{
|
||||||
|
name: "Goblin Scuttle",
|
||||||
|
activity: { number: 1, unit: "reaction" },
|
||||||
|
segments: [{ type: "text", value: "The goblin Steps." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAUNET: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("pathfinder-monster-core-2:naunet"),
|
||||||
|
name: "Naunet",
|
||||||
|
source: "pathfinder-monster-core-2",
|
||||||
|
sourceDisplayName: "Monster Core 2",
|
||||||
|
level: 7,
|
||||||
|
traits: ["large", "monitor", "protean"],
|
||||||
|
perception: 14,
|
||||||
|
senses: "Darkvision",
|
||||||
|
languages: "Chthonian, Empyrean, Protean",
|
||||||
|
skills:
|
||||||
|
"Acrobatics +14, Athletics +16, Intimidation +16, Stealth +14, Survival +12",
|
||||||
|
abilityMods: { str: 5, dex: 3, con: 5, int: 0, wis: 3, cha: 3 },
|
||||||
|
ac: 24,
|
||||||
|
saveFort: 18,
|
||||||
|
saveRef: 14,
|
||||||
|
saveWill: 12,
|
||||||
|
saveConditional: "+1 status to all saves vs. magic",
|
||||||
|
hp: 120,
|
||||||
|
resistances: "Precision 5, Protean anatomy 10",
|
||||||
|
speed: "25 feet, Fly 30 feet, Swim 25 feet (unfettered movement)",
|
||||||
|
spellcasting: [
|
||||||
|
{
|
||||||
|
name: "Divine Innate Spells",
|
||||||
|
headerText: "DC 25, attack +17",
|
||||||
|
daily: [
|
||||||
|
{ uses: 4, each: true, spells: ["Unfettered Movement (Constant)"] },
|
||||||
|
],
|
||||||
|
atWill: ["Detect Magic"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TROLL: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("pathfinder-monster-core:forest-troll"),
|
||||||
|
name: "Forest Troll",
|
||||||
|
source: "pathfinder-monster-core",
|
||||||
|
sourceDisplayName: "Monster Core",
|
||||||
|
level: 5,
|
||||||
|
traits: ["large", "giant", "troll"],
|
||||||
|
perception: 11,
|
||||||
|
senses: "Darkvision",
|
||||||
|
languages: "Jotun",
|
||||||
|
skills: "Athletics +12, Intimidation +12",
|
||||||
|
abilityMods: { str: 5, dex: 2, con: 6, int: -2, wis: 0, cha: -2 },
|
||||||
|
ac: 20,
|
||||||
|
saveFort: 17,
|
||||||
|
saveRef: 11,
|
||||||
|
saveWill: 7,
|
||||||
|
hp: 115,
|
||||||
|
hpDetails: "regeneration 20 (deactivated by acid or fire)",
|
||||||
|
weaknesses: "Fire 10",
|
||||||
|
speed: "30 feet",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderStatBlock(creature: Pf2eCreature) {
|
||||||
|
return render(<Pf2eStatBlock creature={creature} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Pf2eStatBlock", () => {
|
||||||
|
describe("header", () => {
|
||||||
|
it("renders creature name and level", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Goblin Warrior" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Level -1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders traits as tags", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Small")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Humanoid")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders source display name", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Monster Core")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("perception and senses", () => {
|
||||||
|
it("renders perception modifier and senses", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Perception")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(PERCEPTION_SENSES_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders languages", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Languages")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Common, Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders skills", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Skills")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(SKILLS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ability modifiers", () => {
|
||||||
|
it("renders all six ability labels", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
for (const label of ["Str", "Dex", "Con", "Int", "Wis", "Cha"]) {
|
||||||
|
expect(screen.getByText(label)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders positive and negative modifiers", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("+3")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("-1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defenses", () => {
|
||||||
|
it("renders AC and saves", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("AC")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(AC_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Fort")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Ref")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Will")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders HP", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("HP")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("6")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders saveConditional inline with saves", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText(SAVE_CONDITIONAL_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits saveConditional when absent", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(
|
||||||
|
screen.queryByText(SAVE_CONDITIONAL_ABSENT_REGEX),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hpDetails in parentheses after HP", () => {
|
||||||
|
renderStatBlock(TROLL);
|
||||||
|
expect(screen.getByText(HP_DETAILS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits hpDetails when absent", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.queryByText(REGEN_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders resistances and weaknesses", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText("Resistances")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Precision 5, Protean anatomy 10"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("abilities", () => {
|
||||||
|
it("renders mid (defensive) abilities", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText(ABILITY_MID_NAME_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(ABILITY_MID_DESC_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("speed and attacks", () => {
|
||||||
|
it("renders speed", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Speed")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("25 feet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders attacks", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText(ATTACK_NAME_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(ATTACK_DAMAGE_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spellcasting", () => {
|
||||||
|
it("renders spellcasting entry with header", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText(SPELLCASTING_ENTRY_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("DC 25, attack +17")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ranked spells", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText("Rank 4:")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Unfettered Movement (Constant)"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders cantrips", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText("Cantrips:")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Detect Magic")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits spellcasting when absent", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.queryByText(CANTRIPS_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ const DND_BASE_URL =
|
|||||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||||
|
|
||||||
const PF2E_BASE_URL =
|
const PF2E_BASE_URL =
|
||||||
"https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/";
|
"https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/";
|
||||||
|
|
||||||
export function BulkImportPrompt() {
|
export function BulkImportPrompt() {
|
||||||
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||||
|
|||||||
@@ -114,9 +114,11 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
{formatMod(creature.saveRef)},{" "}
|
{formatMod(creature.saveRef)},{" "}
|
||||||
<span className="font-semibold">Will</span>{" "}
|
<span className="font-semibold">Will</span>{" "}
|
||||||
{formatMod(creature.saveWill)}
|
{formatMod(creature.saveWill)}
|
||||||
|
{creature.saveConditional ? `; ${creature.saveConditional}` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">HP</span> {creature.hp}
|
<span className="font-semibold">HP</span> {creature.hp}
|
||||||
|
{creature.hpDetails ? ` (${creature.hpDetails})` : ""}
|
||||||
</div>
|
</div>
|
||||||
<PropertyLine label="Immunities" value={creature.immunities} />
|
<PropertyLine label="Immunities" value={creature.immunities} />
|
||||||
<PropertyLine label="Resistances" value={creature.resistances} />
|
<PropertyLine label="Resistances" value={creature.resistances} />
|
||||||
@@ -138,6 +140,35 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
|
|
||||||
{/* Bottom abilities (active abilities) */}
|
{/* Bottom abilities (active abilities) */}
|
||||||
<TraitSection entries={creature.abilitiesBot} />
|
<TraitSection entries={creature.abilitiesBot} />
|
||||||
|
|
||||||
|
{/* Spellcasting */}
|
||||||
|
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
{creature.spellcasting.map((sc) => (
|
||||||
|
<div key={sc.name} className="space-y-1 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold italic">{sc.name}.</span>{" "}
|
||||||
|
{sc.headerText}
|
||||||
|
</div>
|
||||||
|
{sc.daily?.map((d) => (
|
||||||
|
<div key={d.uses} className="pl-2">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}:
|
||||||
|
</span>{" "}
|
||||||
|
{d.spells.join(", ")}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sc.atWill && sc.atWill.length > 0 && (
|
||||||
|
<div className="pl-2">
|
||||||
|
<span className="font-semibold">Cantrips:</span>{" "}
|
||||||
|
{sc.atWill.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ interface StatBlockPanelProps {
|
|||||||
function extractSourceCode(cId: CreatureId): string {
|
function extractSourceCode(cId: CreatureId): string {
|
||||||
const colonIndex = cId.indexOf(":");
|
const colonIndex = cId.indexOf(":");
|
||||||
if (colonIndex === -1) return "";
|
if (colonIndex === -1) return "";
|
||||||
return cId.slice(0, colonIndex).toUpperCase();
|
const prefix = cId.slice(0, colonIndex);
|
||||||
|
// D&D source codes are short uppercase (e.g. "mm" from "MM").
|
||||||
|
// PF2e source codes use hyphens (e.g. "pathfinder-monster-core").
|
||||||
|
return prefix.includes("-") ? prefix : prefix.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsedTab({
|
function CollapsedTab({
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ import {
|
|||||||
normalizeBestiary,
|
normalizeBestiary,
|
||||||
setSourceDisplayNames,
|
setSourceDisplayNames,
|
||||||
} from "../adapters/bestiary-adapter.js";
|
} from "../adapters/bestiary-adapter.js";
|
||||||
import {
|
import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js";
|
||||||
normalizePf2eBestiary,
|
|
||||||
setPf2eSourceDisplayNames,
|
|
||||||
} from "../adapters/pf2e-bestiary-adapter.js";
|
|
||||||
import { useAdapters } from "../contexts/adapter-context.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
|
||||||
@@ -52,7 +49,6 @@ export function useBestiary(): BestiaryHook {
|
|||||||
setSourceDisplayNames(index.sources as Record<string, string>);
|
setSourceDisplayNames(index.sources as Record<string, string>);
|
||||||
|
|
||||||
const pf2eIndex = pf2eBestiaryIndex.loadIndex();
|
const pf2eIndex = pf2eBestiaryIndex.loadIndex();
|
||||||
setPf2eSourceDisplayNames(pf2eIndex.sources as Record<string, string>);
|
|
||||||
|
|
||||||
if (index.creatures.length > 0 || pf2eIndex.creatures.length > 0) {
|
if (index.creatures.length > 0 || pf2eIndex.creatures.length > 0) {
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
@@ -113,6 +109,30 @@ export function useBestiary(): BestiaryHook {
|
|||||||
|
|
||||||
const fetchAndCacheSource = useCallback(
|
const fetchAndCacheSource = useCallback(
|
||||||
async (sourceCode: string, url: string): Promise<void> => {
|
async (sourceCode: string, url: string): Promise<void> => {
|
||||||
|
let creatures: AnyCreature[];
|
||||||
|
|
||||||
|
if (edition === "pf2e") {
|
||||||
|
// PF2e: url is a base URL; fetch each creature file in parallel
|
||||||
|
const paths = pf2eBestiaryIndex.getCreaturePathsForSource(sourceCode);
|
||||||
|
const baseUrl = url.endsWith("/") ? url : `${url}/`;
|
||||||
|
const responses = await Promise.all(
|
||||||
|
paths.map(async (path) => {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch ${path}: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const displayName = pf2eBestiaryIndex.getSourceDisplayName(sourceCode);
|
||||||
|
creatures = normalizeFoundryCreatures(
|
||||||
|
responses,
|
||||||
|
sourceCode,
|
||||||
|
displayName,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -120,10 +140,9 @@ export function useBestiary(): BestiaryHook {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
const creatures =
|
creatures = normalizeBestiary(json);
|
||||||
edition === "pf2e"
|
}
|
||||||
? normalizePf2eBestiary(json)
|
|
||||||
: normalizeBestiary(json);
|
|
||||||
const displayName =
|
const displayName =
|
||||||
edition === "pf2e"
|
edition === "pf2e"
|
||||||
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
|
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
|
||||||
@@ -149,7 +168,11 @@ export function useBestiary(): BestiaryHook {
|
|||||||
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
||||||
const creatures =
|
const creatures =
|
||||||
edition === "pf2e"
|
edition === "pf2e"
|
||||||
? normalizePf2eBestiary(jsonData as { creature: unknown[] })
|
? normalizeFoundryCreatures(
|
||||||
|
Array.isArray(jsonData) ? jsonData : [jsonData],
|
||||||
|
sourceCode,
|
||||||
|
pf2eBestiaryIndex.getSourceDisplayName(sourceCode),
|
||||||
|
)
|
||||||
: normalizeBestiary(
|
: normalizeBestiary(
|
||||||
jsonData as Parameters<typeof normalizeBestiary>[0],
|
jsonData as Parameters<typeof normalizeBestiary>[0],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"!coverage",
|
"!coverage",
|
||||||
"!.pnpm-store",
|
"!.pnpm-store",
|
||||||
"!.rodney",
|
"!.rodney",
|
||||||
"!.agent-tests"
|
"!.agent-tests",
|
||||||
|
"!data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -134,7 +134,9 @@ export interface Pf2eCreature {
|
|||||||
readonly saveFort: number;
|
readonly saveFort: number;
|
||||||
readonly saveRef: number;
|
readonly saveRef: number;
|
||||||
readonly saveWill: number;
|
readonly saveWill: number;
|
||||||
|
readonly saveConditional?: string;
|
||||||
readonly hp: number;
|
readonly hp: number;
|
||||||
|
readonly hpDetails?: string;
|
||||||
readonly immunities?: string;
|
readonly immunities?: string;
|
||||||
readonly resistances?: string;
|
readonly resistances?: string;
|
||||||
readonly weaknesses?: string;
|
readonly weaknesses?: string;
|
||||||
|
|||||||
@@ -1,123 +1,103 @@
|
|||||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join, relative } from "node:path";
|
||||||
|
|
||||||
// Usage: node scripts/generate-pf2e-bestiary-index.mjs <path-to-Pf2eTools>
|
// Usage: node scripts/generate-pf2e-bestiary-index.mjs <path-to-foundry-pf2e>
|
||||||
//
|
//
|
||||||
// Requires a local clone/checkout of https://github.com/Pf2eToolsOrg/Pf2eTools (dev branch)
|
// Requires a local clone of https://github.com/foundryvtt/pf2e (v13-dev branch).
|
||||||
// with at least data/bestiary/.
|
|
||||||
//
|
//
|
||||||
// Example:
|
// Example:
|
||||||
// git clone --depth 1 --branch dev --sparse https://github.com/Pf2eToolsOrg/Pf2eTools.git /tmp/pf2etools
|
// git clone --depth 1 --branch v13-dev https://github.com/foundryvtt/pf2e.git /tmp/foundry-pf2e
|
||||||
// cd /tmp/pf2etools && git sparse-checkout set data/bestiary data
|
// node scripts/generate-pf2e-bestiary-index.mjs /tmp/foundry-pf2e
|
||||||
// node scripts/generate-pf2e-bestiary-index.mjs /tmp/pf2etools
|
|
||||||
|
|
||||||
const TOOLS_ROOT = process.argv[2];
|
const FOUNDRY_ROOT = process.argv[2];
|
||||||
if (!TOOLS_ROOT) {
|
if (!FOUNDRY_ROOT) {
|
||||||
console.error(
|
console.error(
|
||||||
"Usage: node scripts/generate-pf2e-bestiary-index.mjs <Pf2eTools-path>",
|
"Usage: node scripts/generate-pf2e-bestiary-index.mjs <foundry-pf2e-path>",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROJECT_ROOT = join(import.meta.dirname, "..");
|
const PROJECT_ROOT = join(import.meta.dirname, "..");
|
||||||
const BESTIARY_DIR = join(TOOLS_ROOT, "data/bestiary");
|
const PACKS_DIR = join(FOUNDRY_ROOT, "packs/pf2e");
|
||||||
const OUTPUT_PATH = join(PROJECT_ROOT, "data/bestiary/pf2e-index.json");
|
const OUTPUT_PATH = join(PROJECT_ROOT, "data/bestiary/pf2e-index.json");
|
||||||
|
|
||||||
// --- Source display names ---
|
// Legacy bestiaries superseded by Monster Core / Monster Core 2
|
||||||
// Pf2eTools doesn't have a single books.json with all adventure paths.
|
const EXCLUDED_PACKS = new Set([
|
||||||
// We map known source codes to display names here.
|
"pathfinder-bestiary",
|
||||||
const SOURCE_NAMES = {
|
"pathfinder-bestiary-2",
|
||||||
B1: "Bestiary",
|
"pathfinder-bestiary-3",
|
||||||
B2: "Bestiary 2",
|
|
||||||
B3: "Bestiary 3",
|
|
||||||
CRB: "Core Rulebook",
|
|
||||||
GMG: "Gamemastery Guide",
|
|
||||||
LOME: "Lost Omens: The Mwangi Expanse",
|
|
||||||
LOMM: "Lost Omens: Monsters of Myth",
|
|
||||||
LOIL: "Lost Omens: Impossible Lands",
|
|
||||||
LOCG: "Lost Omens: Character Guide",
|
|
||||||
LOSK: "Lost Omens: Knights of Lastwall",
|
|
||||||
LOTXWG: "Lost Omens: Travel Guide",
|
|
||||||
LOACLO: "Lost Omens: Absalom, City of Lost Omens",
|
|
||||||
LOHh: "Lost Omens: Highhelm",
|
|
||||||
AoA1: "Age of Ashes #1: Hellknight Hill",
|
|
||||||
AoA2: "Age of Ashes #2: Cult of Cinders",
|
|
||||||
AoA3: "Age of Ashes #3: Tomorrow Must Burn",
|
|
||||||
AoA4: "Age of Ashes #4: Fires of the Haunted City",
|
|
||||||
AoA5: "Age of Ashes #5: Against the Scarlet Triad",
|
|
||||||
AoA6: "Age of Ashes #6: Broken Promises",
|
|
||||||
AoE1: "Agents of Edgewatch #1",
|
|
||||||
AoE2: "Agents of Edgewatch #2",
|
|
||||||
AoE3: "Agents of Edgewatch #3",
|
|
||||||
AoE4: "Agents of Edgewatch #4",
|
|
||||||
AoE5: "Agents of Edgewatch #5",
|
|
||||||
AoE6: "Agents of Edgewatch #6",
|
|
||||||
EC1: "Extinction Curse #1",
|
|
||||||
EC2: "Extinction Curse #2",
|
|
||||||
EC3: "Extinction Curse #3",
|
|
||||||
EC4: "Extinction Curse #4",
|
|
||||||
EC5: "Extinction Curse #5",
|
|
||||||
EC6: "Extinction Curse #6",
|
|
||||||
AV1: "Abomination Vaults #1",
|
|
||||||
AV2: "Abomination Vaults #2",
|
|
||||||
AV3: "Abomination Vaults #3",
|
|
||||||
FRP1: "Fists of the Ruby Phoenix #1",
|
|
||||||
FRP2: "Fists of the Ruby Phoenix #2",
|
|
||||||
FRP3: "Fists of the Ruby Phoenix #3",
|
|
||||||
SoT1: "Strength of Thousands #1",
|
|
||||||
SoT2: "Strength of Thousands #2",
|
|
||||||
SoT3: "Strength of Thousands #3",
|
|
||||||
SoT4: "Strength of Thousands #4",
|
|
||||||
SoT5: "Strength of Thousands #5",
|
|
||||||
SoT6: "Strength of Thousands #6",
|
|
||||||
OoA1: "Outlaws of Alkenstar #1",
|
|
||||||
OoA2: "Outlaws of Alkenstar #2",
|
|
||||||
OoA3: "Outlaws of Alkenstar #3",
|
|
||||||
BotD: "Book of the Dead",
|
|
||||||
DA: "Dark Archive",
|
|
||||||
FoP: "The Fall of Plaguestone",
|
|
||||||
LTiBA: "Little Trouble in Big Absalom",
|
|
||||||
Sli: "The Slithering",
|
|
||||||
TiO: "Troubles in Otari",
|
|
||||||
NGD: "Night of the Gray Death",
|
|
||||||
BB: "Beginner Box",
|
|
||||||
SoG1: "Sky King's Tomb #1",
|
|
||||||
SoG2: "Sky King's Tomb #2",
|
|
||||||
SoG3: "Sky King's Tomb #3",
|
|
||||||
GW1: "Gatewalkers #1",
|
|
||||||
GW2: "Gatewalkers #2",
|
|
||||||
GW3: "Gatewalkers #3",
|
|
||||||
WoW1: "Wardens of Wildwood #1",
|
|
||||||
WoW2: "Wardens of Wildwood #2",
|
|
||||||
WoW3: "Wardens of Wildwood #3",
|
|
||||||
SF1: "Season of Ghosts #1",
|
|
||||||
SF2: "Season of Ghosts #2",
|
|
||||||
SF3: "Season of Ghosts #3",
|
|
||||||
POS1: "Pathfinder One-Shots",
|
|
||||||
AFoF: "A Fistful of Flowers",
|
|
||||||
TaL: "Threshold of Knowledge",
|
|
||||||
ToK: "Threshold of Knowledge",
|
|
||||||
DaLl: "Dinner at Lionlodge",
|
|
||||||
MotM: "Monsters of the Multiverse",
|
|
||||||
Mal: "Malevolence",
|
|
||||||
TEC: "The Enmity Cycle",
|
|
||||||
SaS: "Shadows at Sundown",
|
|
||||||
Rust: "Rusthenge",
|
|
||||||
CotT: "Crown of the Kobold King",
|
|
||||||
SoM: "Secrets of Magic",
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Size extraction from traits ---
|
|
||||||
const SIZES = new Set([
|
|
||||||
"tiny",
|
|
||||||
"small",
|
|
||||||
"medium",
|
|
||||||
"large",
|
|
||||||
"huge",
|
|
||||||
"gargantuan",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Creature type traits (PF2e types are lowercase in the traits array)
|
// PFS (Pathfinder Society) scenario packs — organized play content not on
|
||||||
|
// Archives of Nethys, mostly reskinned variants for specific scenarios.
|
||||||
|
const isPfsPack = (name) => name.startsWith("pfs-");
|
||||||
|
|
||||||
|
// Pack directory → display name mapping. Foundry pack directories are stable
|
||||||
|
// identifiers; new ones are added ~2-3 times per year with new AP volumes.
|
||||||
|
// Run the script with an unknown pack to see unmapped entries in the output.
|
||||||
|
const SOURCE_NAMES = {
|
||||||
|
"abomination-vaults-bestiary": "Abomination Vaults",
|
||||||
|
"age-of-ashes-bestiary": "Age of Ashes",
|
||||||
|
"agents-of-edgewatch-bestiary": "Agents of Edgewatch",
|
||||||
|
"battlecry-bestiary": "Battlecry!",
|
||||||
|
"blog-bestiary": "Pathfinder Blog",
|
||||||
|
"blood-lords-bestiary": "Blood Lords",
|
||||||
|
"book-of-the-dead-bestiary": "Book of the Dead",
|
||||||
|
"claws-of-the-tyrant-bestiary": "Claws of the Tyrant",
|
||||||
|
"crown-of-the-kobold-king-bestiary": "Crown of the Kobold King",
|
||||||
|
"curtain-call-bestiary": "Curtain Call",
|
||||||
|
"extinction-curse-bestiary": "Extinction Curse",
|
||||||
|
"fall-of-plaguestone": "The Fall of Plaguestone",
|
||||||
|
"fists-of-the-ruby-phoenix-bestiary": "Fists of the Ruby Phoenix",
|
||||||
|
"gatewalkers-bestiary": "Gatewalkers",
|
||||||
|
"hellbreakers-bestiary": "Hellbreakers",
|
||||||
|
"howl-of-the-wild-bestiary": "Howl of the Wild",
|
||||||
|
"kingmaker-bestiary": "Kingmaker",
|
||||||
|
"lost-omens-bestiary": "Lost Omens",
|
||||||
|
"malevolence-bestiary": "Malevolence",
|
||||||
|
"menace-under-otari-bestiary": "Beginner Box",
|
||||||
|
"myth-speaker-bestiary": "Myth Speaker",
|
||||||
|
"night-of-the-gray-death-bestiary": "Night of the Gray Death",
|
||||||
|
"npc-gallery": "NPC Gallery",
|
||||||
|
"one-shot-bestiary": "One-Shots",
|
||||||
|
"outlaws-of-alkenstar-bestiary": "Outlaws of Alkenstar",
|
||||||
|
"pathfinder-dark-archive": "Dark Archive",
|
||||||
|
"pathfinder-monster-core": "Monster Core",
|
||||||
|
"pathfinder-monster-core-2": "Monster Core 2",
|
||||||
|
"pathfinder-npc-core": "NPC Core",
|
||||||
|
"prey-for-death-bestiary": "Prey for Death",
|
||||||
|
"quest-for-the-frozen-flame-bestiary": "Quest for the Frozen Flame",
|
||||||
|
"rage-of-elements-bestiary": "Rage of Elements",
|
||||||
|
"revenge-of-the-runelords-bestiary": "Revenge of the Runelords",
|
||||||
|
"rusthenge-bestiary": "Rusthenge",
|
||||||
|
"season-of-ghosts-bestiary": "Season of Ghosts",
|
||||||
|
"seven-dooms-for-sandpoint-bestiary": "Seven Dooms for Sandpoint",
|
||||||
|
"shades-of-blood-bestiary": "Shades of Blood",
|
||||||
|
"shadows-at-sundown-bestiary": "Shadows at Sundown",
|
||||||
|
"sky-kings-tomb-bestiary": "Sky King's Tomb",
|
||||||
|
"spore-war-bestiary": "Spore War",
|
||||||
|
"standalone-adventures": "Standalone Adventures",
|
||||||
|
"stolen-fate-bestiary": "Stolen Fate",
|
||||||
|
"strength-of-thousands-bestiary": "Strength of Thousands",
|
||||||
|
"the-enmity-cycle-bestiary": "The Enmity Cycle",
|
||||||
|
"the-slithering-bestiary": "The Slithering",
|
||||||
|
"triumph-of-the-tusk-bestiary": "Triumph of the Tusk",
|
||||||
|
"troubles-in-otari-bestiary": "Troubles in Otari",
|
||||||
|
"war-of-immortals-bestiary": "War of Immortals",
|
||||||
|
"wardens-of-wildwood-bestiary": "Wardens of Wildwood",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Size code mapping from Foundry abbreviations to full names
|
||||||
|
const SIZE_MAP = {
|
||||||
|
tiny: "tiny",
|
||||||
|
sm: "small",
|
||||||
|
med: "medium",
|
||||||
|
lg: "large",
|
||||||
|
huge: "huge",
|
||||||
|
grg: "gargantuan",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Creature type traits
|
||||||
const CREATURE_TYPES = new Set([
|
const CREATURE_TYPES = new Set([
|
||||||
"aberration",
|
"aberration",
|
||||||
"animal",
|
"animal",
|
||||||
@@ -143,64 +123,99 @@ const CREATURE_TYPES = new Set([
|
|||||||
"undead",
|
"undead",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function extractSize(traits) {
|
// --- Helpers ---
|
||||||
if (!Array.isArray(traits)) return "medium";
|
|
||||||
const found = traits.find((t) => SIZES.has(t.toLowerCase()));
|
|
||||||
return found ? found.toLowerCase() : "medium";
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractType(traits) {
|
/** Recursively collect all .json files (excluding _*.json metadata files). */
|
||||||
if (!Array.isArray(traits)) return "";
|
function collectJsonFiles(dir) {
|
||||||
const found = traits.find((t) => CREATURE_TYPES.has(t.toLowerCase()));
|
const results = [];
|
||||||
return found ? found.toLowerCase() : "";
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.name.startsWith("_")) continue;
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
results.push(...collectJsonFiles(full));
|
||||||
|
} else if (entry.name.endsWith(".json")) {
|
||||||
|
results.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Main ---
|
// --- Main ---
|
||||||
|
|
||||||
const files = readdirSync(BESTIARY_DIR).filter(
|
const packDirs = readdirSync(PACKS_DIR, { withFileTypes: true })
|
||||||
(f) => f.startsWith("creatures-") && f.endsWith(".json"),
|
.filter(
|
||||||
);
|
(d) => d.isDirectory() && !EXCLUDED_PACKS.has(d.name) && !isPfsPack(d.name),
|
||||||
|
)
|
||||||
|
.map((d) => d.name)
|
||||||
|
.sort();
|
||||||
|
|
||||||
const creatures = [];
|
const creatures = [];
|
||||||
const seenSources = new Set();
|
const sources = {};
|
||||||
|
const missingData = [];
|
||||||
|
|
||||||
for (const file of files.sort()) {
|
for (const packDir of packDirs) {
|
||||||
const raw = JSON.parse(readFileSync(join(BESTIARY_DIR, file), "utf-8"));
|
const packPath = join(PACKS_DIR, packDir);
|
||||||
const entries = raw.creature ?? [];
|
let files;
|
||||||
|
try {
|
||||||
|
files = collectJsonFiles(packPath).sort();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (const c of entries) {
|
for (const filePath of files) {
|
||||||
// Skip copies/references
|
let raw;
|
||||||
if (c._copy) continue;
|
try {
|
||||||
|
raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const source = c.source ?? "";
|
// Only include NPC-type creatures
|
||||||
seenSources.add(source);
|
if (raw.type !== "npc") continue;
|
||||||
|
|
||||||
const ac = c.defenses?.ac?.std ?? 0;
|
const system = raw.system;
|
||||||
const hp = c.defenses?.hp?.[0]?.hp ?? 0;
|
if (!system) continue;
|
||||||
const perception = c.perception?.std ?? 0;
|
|
||||||
|
const name = raw.name;
|
||||||
|
const level = system.details?.level?.value ?? 0;
|
||||||
|
const ac = system.attributes?.ac?.value ?? 0;
|
||||||
|
const hp = system.attributes?.hp?.max ?? 0;
|
||||||
|
const perception = system.perception?.mod ?? 0;
|
||||||
|
const sizeCode = system.traits?.size?.value ?? "med";
|
||||||
|
const size = SIZE_MAP[sizeCode] ?? "medium";
|
||||||
|
const traits = system.traits?.value ?? [];
|
||||||
|
const type =
|
||||||
|
traits.find((t) => CREATURE_TYPES.has(t.toLowerCase()))?.toLowerCase() ??
|
||||||
|
"";
|
||||||
|
const relativePath = relative(PACKS_DIR, filePath);
|
||||||
|
const license = system.details?.publication?.license ?? "";
|
||||||
|
|
||||||
|
if (!name || ac === 0 || hp === 0) {
|
||||||
|
missingData.push(`${relativePath}: name=${name} ac=${ac} hp=${hp}`);
|
||||||
|
}
|
||||||
|
|
||||||
creatures.push({
|
creatures.push({
|
||||||
n: c.name,
|
n: name,
|
||||||
s: source,
|
s: packDir,
|
||||||
lv: c.level ?? 0,
|
lv: level,
|
||||||
ac,
|
ac,
|
||||||
hp,
|
hp,
|
||||||
pc: perception,
|
pc: perception,
|
||||||
sz: extractSize(c.traits),
|
sz: size,
|
||||||
tp: extractType(c.traits),
|
tp: type,
|
||||||
|
f: relativePath,
|
||||||
|
li: license,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (creatures.some((c) => c.s === packDir)) {
|
||||||
|
sources[packDir] = SOURCE_NAMES[packDir] ?? packDir;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by name then source for stable output
|
// Sort by name then source for stable output
|
||||||
creatures.sort((a, b) => a.n.localeCompare(b.n) || a.s.localeCompare(b.s));
|
creatures.sort((a, b) => a.n.localeCompare(b.n) || a.s.localeCompare(b.s));
|
||||||
|
|
||||||
// Build source map from seen sources
|
|
||||||
const sources = {};
|
|
||||||
for (const code of [...seenSources].sort()) {
|
|
||||||
sources[code] = SOURCE_NAMES[code] ?? code;
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = { sources, creatures };
|
const output = { sources, creatures };
|
||||||
writeFileSync(OUTPUT_PATH, JSON.stringify(output));
|
writeFileSync(OUTPUT_PATH, JSON.stringify(output));
|
||||||
|
|
||||||
@@ -209,7 +224,19 @@ console.log(`Sources: ${Object.keys(sources).length}`);
|
|||||||
console.log(`Creatures: ${creatures.length}`);
|
console.log(`Creatures: ${creatures.length}`);
|
||||||
console.log(`Output size: ${(rawSize / 1024).toFixed(1)} KB`);
|
console.log(`Output size: ${(rawSize / 1024).toFixed(1)} KB`);
|
||||||
|
|
||||||
const unmapped = [...seenSources].filter((s) => !SOURCE_NAMES[s]);
|
const unmapped = Object.keys(sources).filter((s) => !SOURCE_NAMES[s]);
|
||||||
if (unmapped.length > 0) {
|
if (unmapped.length > 0) {
|
||||||
console.log(`Unmapped sources: ${unmapped.sort().join(", ")}`);
|
console.log(
|
||||||
|
`\nUnmapped packs (using directory name as-is): ${unmapped.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingData.length > 0) {
|
||||||
|
console.log(`\nCreatures with missing data (${missingData.length}):`);
|
||||||
|
for (const msg of missingData.slice(0, 20)) {
|
||||||
|
console.log(` ${msg}`);
|
||||||
|
}
|
||||||
|
if (missingData.length > 20) {
|
||||||
|
console.log(` ... and ${missingData.length - 20} more`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The Bestiary feature provides creature search across pre-indexed creature libraries: 3,312+ D&D creatures from 102+ sources and ~2,700+ Pathfinder 2e creatures from 79 Pf2eTools sources. The active game system setting (see `specs/003-combatant-state/spec.md`, FR-096) determines which index the search operates against. Stat block display, source management, and creature pre-fill all adapt to the active game system.
|
The Bestiary feature provides creature search across pre-indexed creature libraries: 3,312+ D&D creatures from 102+ sources and 2,500+ Pathfinder 2e creatures from the Foundry VTT PF2e system (remaster-era content: Monster Core, Monster Core 2, and post-remaster books). The active game system setting (see `specs/003-combatant-state/spec.md`, FR-096) determines which index the search operates against. Stat block display, source management, and creature pre-fill all adapt to the active game system.
|
||||||
|
|
||||||
The feature also includes full creature reference via stat block display during combat, source data management via on-demand fetch or file upload, and a dual-panel UX with collapse/expand and pin capabilities. Creatures can be added individually or in batch from a search dropdown, with stats pre-filled from the index.
|
The feature also includes full creature reference via stat block display during combat, source data management via on-demand fetch or file upload, and a dual-panel UX with collapse/expand and pin capabilities. Creatures can be added individually or in batch from a search dropdown, with stats pre-filled from the index.
|
||||||
|
|
||||||
@@ -113,8 +113,8 @@ As a DM using the app on different devices, I want the layout to adapt between s
|
|||||||
- **FR-064**: PF2e stat blocks MUST display level in place of challenge rating. Level MUST appear in the stat block header.
|
- **FR-064**: PF2e stat blocks MUST display level in place of challenge rating. Level MUST appear in the stat block header.
|
||||||
- **FR-065**: PF2e stat blocks MUST display three saving throws (Fortitude, Reflex, Will) instead of D&D's six ability-based saves.
|
- **FR-065**: PF2e stat blocks MUST display three saving throws (Fortitude, Reflex, Will) instead of D&D's six ability-based saves.
|
||||||
- **FR-066**: PF2e stat blocks MUST display ability modifiers directly (e.g., "Str +4") rather than ability scores with derived modifiers.
|
- **FR-066**: PF2e stat blocks MUST display ability modifiers directly (e.g., "Str +4") rather than ability scores with derived modifiers.
|
||||||
- **FR-067**: PF2e stat blocks MUST organize abilities into three sections: top (above defenses), mid (reactions and auras), and bot (active abilities), matching the Pf2eTools source structure.
|
- **FR-067**: PF2e stat blocks MUST organize abilities into three sections: top (above defenses), mid (reactions and auras), and bot (active abilities), matching the Foundry VTT PF2e item categorization.
|
||||||
- **FR-068**: PF2e stat blocks MUST strip Pf2eTools markup tags (e.g., `{@damage 1d8+7}`, `{@condition frightened}`) and render them as plain readable text, using the same tag-stripping approach as D&D (FR-019).
|
- **FR-068**: PF2e stat blocks MUST strip HTML tags from Foundry VTT ability descriptions and render them as plain readable text. The HTML-to-text conversion serves the same role as the D&D tag-stripping approach (FR-019).
|
||||||
- **FR-062**: Bestiary-linked combatant rows MUST display a small book icon (Lucide `BookOpen`) as the dedicated stat block trigger. The icon MUST have a tooltip ("View stat block") and an `aria-label="View stat block"` for accessibility. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant name always enters inline rename mode. Non-bestiary combatant rows MUST NOT display the book icon.
|
- **FR-062**: Bestiary-linked combatant rows MUST display a small book icon (Lucide `BookOpen`) as the dedicated stat block trigger. The icon MUST have a tooltip ("View stat block") and an `aria-label="View stat block"` for accessibility. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant name always enters inline rename mode. Non-bestiary combatant rows MUST NOT display the book icon.
|
||||||
|
|
||||||
### Acceptance Scenarios
|
### Acceptance Scenarios
|
||||||
@@ -176,7 +176,7 @@ A DM wants to see which sources are cached, find a specific source, clear a spec
|
|||||||
- **FR-034**: An import button (Lucide Import icon) in the top bar MUST open the stat block side panel with the bulk import prompt.
|
- **FR-034**: An import button (Lucide Import icon) in the top bar MUST open the stat block side panel with the bulk import prompt.
|
||||||
- **FR-035**: The bulk import prompt MUST show a descriptive text explaining the operation, including approximate data volume (~12.5 MB) and the dynamic number of sources derived from the bestiary index at runtime.
|
- **FR-035**: The bulk import prompt MUST show a descriptive text explaining the operation, including approximate data volume (~12.5 MB) and the dynamic number of sources derived from the bestiary index at runtime.
|
||||||
- **FR-036**: The system MUST pre-fill a base URL that the user can edit.
|
- **FR-036**: The system MUST pre-fill a base URL that the user can edit.
|
||||||
- **FR-037**: The system MUST construct individual fetch URLs by appending the appropriate filename pattern to the base URL: `bestiary-{sourceCode}.json` for D&D sources, `creatures-{sourceCode}.json` for PF2e sources (matching the Pf2eTools naming convention).
|
- **FR-037**: The system MUST construct individual fetch URLs by appending the appropriate filename pattern to the base URL: `bestiary-{sourceCode}.json` for D&D sources, and the Foundry VTT PF2e per-creature file pattern for PF2e sources.
|
||||||
- **FR-038**: All fetch requests during bulk import MUST fire concurrently (browser handles connection pooling).
|
- **FR-038**: All fetch requests during bulk import MUST fire concurrently (browser handles connection pooling).
|
||||||
- **FR-039**: Already-cached sources MUST be skipped during bulk import.
|
- **FR-039**: Already-cached sources MUST be skipped during bulk import.
|
||||||
- **FR-040**: The system MUST show a text counter ("Loading sources... N/T") and progress bar during bulk import.
|
- **FR-040**: The system MUST show a text counter ("Loading sources... N/T") and progress bar during bulk import.
|
||||||
@@ -189,10 +189,14 @@ A DM wants to see which sources are cached, find a specific source, clear a spec
|
|||||||
- **FR-047**: The app MUST provide a management UI showing cached sources with a filter input for searching by display name and options to clear individual sources or all cached data.
|
- **FR-047**: The app MUST provide a management UI showing cached sources with a filter input for searching by display name and options to clear individual sources or all cached data.
|
||||||
- **FR-048**: The normalization adapter and tag-stripping utility MUST remain the canonical pipeline for all fetched and uploaded data.
|
- **FR-048**: The normalization adapter and tag-stripping utility MUST remain the canonical pipeline for all fetched and uploaded data.
|
||||||
- **FR-049**: The distributed app bundle MUST contain zero copyrighted prose content — only mechanical facts and creature names in the search index.
|
- **FR-049**: The distributed app bundle MUST contain zero copyrighted prose content — only mechanical facts and creature names in the search index.
|
||||||
- **FR-069**: The system MUST use a separate normalization pipeline for PF2e source data, mapping the Pf2eTools JSON structure to the PF2e creature type. Both pipelines (D&D and PF2e) MUST use the canonical tag-stripping utility.
|
- **FR-069**: The system MUST use a separate normalization pipeline for PF2e source data, mapping the Foundry VTT PF2e JSON structure (`system.*` fields and `items[]` array) to the PF2e creature type. Both pipelines (D&D and PF2e) MUST use the canonical tag-stripping utility (HTML-to-text for PF2e, markup-to-text for D&D).
|
||||||
- **FR-070**: The source cache MUST be scoped by game system. D&D and PF2e sources MUST NOT collide in IndexedDB (e.g., both may have a source code "B1" but they are different sources).
|
- **FR-070**: The source cache MUST be scoped by game system. D&D and PF2e sources MUST NOT collide in IndexedDB (e.g., both may have a source code "B1" but they are different sources).
|
||||||
- **FR-071**: The bulk import prompt MUST adapt to the active game system: showing the correct source count, base URL (Pf2eTools for PF2e, 5etools for D&D), and approximate data volume for the active system.
|
- **FR-071**: The bulk import prompt MUST adapt to the active game system: showing the correct source count, base URL (Foundry VTT PF2e repo for PF2e, 5etools for D&D), and approximate data volume for the active system.
|
||||||
- **FR-072**: The source management UI MUST show only sources for the active game system.
|
- **FR-072**: The source management UI MUST show only sources for the active game system.
|
||||||
|
- **FR-073**: The PF2e index generation script MUST read Foundry VTT PF2e one-file-per-creature JSON from the `packs/pf2e/` directory structure.
|
||||||
|
- **FR-074**: The PF2e index MUST exclude legacy/pre-remaster creatures based on the `publication.remaster` field — only remaster-era content is included by default.
|
||||||
|
- **FR-075**: PF2e creature abilities MUST have complete descriptive text in stat blocks. Stubs, generic feat references, and unresolved copy entries are not acceptable.
|
||||||
|
- **FR-076**: The PF2e index SHOULD carry per-creature license tagging (ORC/OGL) derived from the Foundry VTT source data.
|
||||||
|
|
||||||
### Acceptance Scenarios
|
### Acceptance Scenarios
|
||||||
|
|
||||||
@@ -215,7 +219,7 @@ A DM wants to see which sources are cached, find a specific source, clear a spec
|
|||||||
17. **Given** the source management UI is open, **When** the DM clears a single source, **Then** that source's data is removed; stat blocks for its creatures require re-fetching, while other cached sources remain.
|
17. **Given** the source management UI is open, **When** the DM clears a single source, **Then** that source's data is removed; stat blocks for its creatures require re-fetching, while other cached sources remain.
|
||||||
18. **Given** the source management UI is open, **When** the DM clears all cached data, **Then** all source data is removed and all stat blocks require re-fetching.
|
18. **Given** the source management UI is open, **When** the DM clears all cached data, **Then** all source data is removed and all stat blocks require re-fetching.
|
||||||
19. **Given** many sources are cached, **When** the DM types a partial name in the filter input, **Then** only sources whose display name matches (case-insensitive) are shown.
|
19. **Given** many sources are cached, **When** the DM types a partial name in the filter input, **Then** only sources whose display name matches (case-insensitive) are shown.
|
||||||
20. **Given** the game system is Pathfinder 2e, **When** the user clicks the import button, **Then** the bulk import prompt shows the PF2e source count (~79), a Pf2eTools-based URL, and a PF2e-appropriate data volume estimate.
|
20. **Given** the game system is Pathfinder 2e, **When** the user clicks the import button, **Then** the bulk import prompt shows the PF2e source count, a Foundry VTT PF2e-based URL, and a PF2e-appropriate data volume estimate.
|
||||||
21. **Given** the game system is Pathfinder 2e and a PF2e source is cached, **When** the user opens a PF2e creature's stat block from that source, **Then** the PF2e stat block renders correctly from cached data.
|
21. **Given** the game system is Pathfinder 2e and a PF2e source is cached, **When** the user opens a PF2e creature's stat block from that source, **Then** the PF2e stat block renders correctly from cached data.
|
||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
@@ -306,7 +310,7 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
|
|||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
- **SC-001**: All indexed creatures for the active game system (3,312+ D&D or ~2,700+ PF2e) are searchable immediately on app load, with search results appearing within 100ms of typing.
|
- **SC-001**: All indexed creatures for the active game system (3,312+ D&D or 2,500+ PF2e) are searchable immediately on app load, with search results appearing within 100ms of typing.
|
||||||
- **SC-002**: Adding a creature from search to the encounter completes without any network request and within 200ms.
|
- **SC-002**: Adding a creature from search to the encounter completes without any network request and within 200ms.
|
||||||
- **SC-003**: After a source is cached, stat blocks for any creature from that source display within 200ms with no additional prompt.
|
- **SC-003**: After a source is cached, stat blocks for any creature from that source display within 200ms with no additional prompt.
|
||||||
- **SC-004**: The distributed app bundle contains zero copyrighted prose content — only mechanical facts and creature names in the search index.
|
- **SC-004**: The distributed app bundle contains zero copyrighted prose content — only mechanical facts and creature names in the search index.
|
||||||
@@ -323,7 +327,7 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
|
|||||||
- **SC-015**: Users can pin a creature and browse a different creature's stat block simultaneously, without any additional navigation steps.
|
- **SC-015**: Users can pin a creature and browse a different creature's stat block simultaneously, without any additional navigation steps.
|
||||||
- **SC-016**: The pinned panel is never visible on screens narrower than the dual-panel breakpoint, ensuring mobile usability is not degraded.
|
- **SC-016**: The pinned panel is never visible on screens narrower than the dual-panel breakpoint, ensuring mobile usability is not degraded.
|
||||||
- **SC-017**: All collapse/expand and pin/unpin interactions are discoverable through visible button controls without requiring documentation or tooltips.
|
- **SC-017**: All collapse/expand and pin/unpin interactions are discoverable through visible button controls without requiring documentation or tooltips.
|
||||||
- **SC-018**: All ~2,700+ PF2e indexed creatures are searchable when PF2e is the active game system, with search results appearing within 100ms of typing.
|
- **SC-018**: All 2,500+ PF2e indexed creatures (remaster-era content from Foundry VTT PF2e) are searchable when PF2e is the active game system, with search results appearing within 100ms of typing.
|
||||||
- **SC-019**: PF2e stat blocks render the correct layout (level, three saves, ability modifiers, ability sections) for all PF2e creatures — no D&D-specific fields (CR, ability scores, legendary actions) are shown.
|
- **SC-019**: PF2e stat blocks render the correct layout (level, three saves, ability modifiers, ability sections) for all PF2e creatures — no D&D-specific fields (CR, ability scores, legendary actions) are shown.
|
||||||
- **SC-020**: Switching game system immediately changes which creatures appear in search — no page reload required.
|
- **SC-020**: Switching game system immediately changes which creatures appear in search — no page reload required.
|
||||||
- **SC-021**: Both D&D and PF2e search indexes ship bundled with the app; no network fetch is required to search creatures in either system.
|
- **SC-021**: Both D&D and PF2e search indexes ship bundled with the app; no network fetch is required to search creatures in either system.
|
||||||
|
|||||||
Reference in New Issue
Block a user