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>
281 lines
8.3 KiB
TypeScript
281 lines
8.3 KiB
TypeScript
// @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();
|
|
});
|
|
});
|
|
});
|