Improve PF2e stat block action icons, triggers, and tag handling

- Replace unicode action cost chars with custom SVG icons (diamond
  with chevron for actions, outlined diamond for free, curved arrow
  for reaction) rendered inline via ActivityCost on TraitBlock
- Add activity icons to attacks (all Strikes default to single action)
- Add trigger/effect rendering for reaction abilities (bold labels)
- Fix nested tag stripping ({@b ...{@spell ...}...}) by looping
- Move icon after ability name to match AoN format

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-04-08 10:36:30 +02:00
parent 57278e0c82
commit 0c235112ee
7 changed files with 187 additions and 44 deletions

View File

@@ -197,7 +197,8 @@ describe("normalizePf2eBestiary", () => {
});
const ability = creature.abilitiesBot?.[0];
expect(ability).toBeDefined();
expect(ability?.name).toBe("\u25C6 Change Shape");
expect(ability?.name).toBe("Change Shape");
expect(ability?.activity).toEqual({ number: 1, unit: "action" });
expect(ability?.segments[0]).toEqual(
expect.objectContaining({
type: "text",
@@ -208,7 +209,7 @@ describe("normalizePf2eBestiary", () => {
);
});
it("shows free action icon", () => {
it("parses free action activity", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
@@ -224,10 +225,12 @@ describe("normalizePf2eBestiary", () => {
}),
],
});
expect(creature.abilitiesBot?.[0]?.name).toBe("\u25C7 Adaptive Strike");
const ability = creature.abilitiesBot?.[0];
expect(ability?.name).toBe("Adaptive Strike");
expect(ability?.activity).toEqual({ number: 1, unit: "free" });
});
it("shows reaction icon", () => {
it("parses reaction activity", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
@@ -243,12 +246,12 @@ describe("normalizePf2eBestiary", () => {
}),
],
});
expect(creature.abilitiesMid?.[0]?.name).toBe(
"\u21BA Attack of Opportunity",
);
const ability = creature.abilitiesMid?.[0];
expect(ability?.name).toBe("Attack of Opportunity");
expect(ability?.activity).toEqual({ number: 1, unit: "reaction" });
});
it("shows multi-action icons", () => {
it("parses multi-action activity", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
@@ -264,9 +267,9 @@ describe("normalizePf2eBestiary", () => {
}),
],
});
expect(creature.abilitiesBot?.[0]?.name).toBe(
"\u25C6\u25C6 Breath Weapon",
);
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", () => {
@@ -287,6 +290,7 @@ describe("normalizePf2eBestiary", () => {
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",
@@ -294,6 +298,35 @@ describe("normalizePf2eBestiary", () => {
}),
);
});
it("includes trigger text before entries", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
abilities: {
mid: [
{
name: "Wing Deflection",
activity: { number: 1, unit: "reaction" },
trigger: "The dragon is targeted with an attack.",
entries: ["The dragon raises its wing."],
},
],
},
}),
],
});
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.",
}),
);
});
});
describe("resistances formatting", () => {