Compare commits

...

4 Commits

Author SHA1 Message Date
Lukas
57278e0c82 Add PF2e action cost icons to ability names
All checks were successful
CI / check (push) Successful in 2m21s
CI / build-image (push) Successful in 17s
Show Unicode action icons (◆/◆◆/◆◆◆ for actions, ◇ for free,
↺ for reaction) in ability names from the activity field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:31:24 +02:00
Lukas
f9cfaa2570 Include traits on PF2e ability blocks
Parse and display traits (concentrate, divine, polymorph, etc.)
on ability entries, matching how attack traits are already shown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:29:08 +02:00
Lukas
3e62e54274 Strip all angle brackets in PF2e attack traits and damage
All checks were successful
CI / check (push) Successful in 2m23s
CI / build-image (push) Successful in 17s
Broaden stripDiceBrackets to stripAngleBrackets to handle all
PF2e tools angle-bracket formatting (e.g. <10 feet>, <15 feet>),
not just dice notation. Also strip in damage text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:34:28 +02:00
Lukas
12a089dfd7 Fix PF2e condition tooltip descriptions and sort picker alphabetically
Correct inaccurate PF2e condition descriptions against official AoN
rules (blinded, deafened, confused, grabbed, hidden, paralyzed,
unconscious, drained, fascinated, enfeebled, stunned). Sort condition
picker alphabetically per game system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:29:49 +02:00
3 changed files with 217 additions and 22 deletions

View File

@@ -114,8 +114,8 @@ describe("normalizePf2eBestiary", () => {
});
});
describe("attack traits formatting", () => {
it("strips angle-bracket dice notation from traits", () => {
describe("attack formatting", () => {
it("strips angle brackets from traits", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
@@ -140,6 +140,160 @@ describe("normalizePf2eBestiary", () => {
}),
);
});
it("strips angle brackets from reach values in traits", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
attacks: [
{
name: "tentacle",
range: "Melee",
attack: 18,
traits: ["agile", "chaotic", "magical", "reach <10 feet>"],
damage: "2d8+6 piercing",
},
],
}),
],
});
const attack = creature.attacks?.[0];
expect(attack).toBeDefined();
expect(attack?.segments[0]).toEqual(
expect.objectContaining({
type: "text",
value: expect.stringContaining(
"(agile, chaotic, magical, reach 10 feet)",
),
}),
);
});
});
describe("ability formatting", () => {
it("includes traits from abilities in the text", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
abilities: {
bot: [
{
name: "Change Shape",
activity: { number: 1, unit: "action" },
traits: [
"concentrate",
"divine",
"polymorph",
"transmutation",
],
entries: [
"The naunet can take the appearance of any creature.",
],
},
],
},
}),
],
});
const ability = creature.abilitiesBot?.[0];
expect(ability).toBeDefined();
expect(ability?.name).toBe("\u25C6 Change Shape");
expect(ability?.segments[0]).toEqual(
expect.objectContaining({
type: "text",
value: expect.stringContaining(
"(Concentrate, Divine, Polymorph, Transmutation)",
),
}),
);
});
it("shows free action icon", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
abilities: {
bot: [
{
name: "Adaptive Strike",
activity: { number: 1, unit: "free" },
entries: ["The naunet chooses adamantine."],
},
],
},
}),
],
});
expect(creature.abilitiesBot?.[0]?.name).toBe("\u25C7 Adaptive Strike");
});
it("shows reaction icon", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
abilities: {
mid: [
{
name: "Attack of Opportunity",
activity: { number: 1, unit: "reaction" },
entries: ["Trigger description."],
},
],
},
}),
],
});
expect(creature.abilitiesMid?.[0]?.name).toBe(
"\u21BA Attack of Opportunity",
);
});
it("shows multi-action icons", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
abilities: {
bot: [
{
name: "Breath Weapon",
activity: { number: 2, unit: "action" },
entries: ["Fire breath."],
},
],
},
}),
],
});
expect(creature.abilitiesBot?.[0]?.name).toBe(
"\u25C6\u25C6 Breath Weapon",
);
});
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?.segments[0]).toEqual(
expect.objectContaining({
type: "text",
value: "1d8+8 bludgeoning, DC 26",
}),
);
});
});
describe("resistances formatting", () => {

View File

@@ -46,6 +46,8 @@ interface RawDefenses {
interface RawAbility {
name?: string;
activity?: { number?: number; unit?: string };
traits?: string[];
entries?: RawEntry[];
}
@@ -79,8 +81,24 @@ function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function stripDiceBrackets(s: string): string {
return s.replaceAll(/<(\d*d\d+)>/g, "$1");
function formatActivityIcon(
activity: { number?: number; unit?: string } | undefined,
): string {
if (!activity) return "";
switch (activity.unit) {
case "free":
return "\u25C7 ";
case "reaction":
return "\u21BA ";
case "action":
return "\u25C6".repeat(activity.number ?? 1) + " ";
default:
return "";
}
}
function stripAngleBrackets(s: string): string {
return s.replaceAll(/<([^>]+)>/g, "$1");
}
function makeCreatureId(source: string, name: string): CreatureId {
@@ -244,11 +262,29 @@ function normalizeAbilities(
.filter((a) => a.name)
.map((a) => {
const raw = a as Record<string, unknown>;
const icon = formatActivityIcon(a.activity);
const traits =
a.traits && a.traits.length > 0
? `(${a.traits.map((t) => capitalize(stripAngleBrackets(stripTags(t)))).join(", ")}) `
: "";
const body = Array.isArray(a.entries)
? segmentizeEntries(a.entries)
: formatAffliction(raw);
const name = icon + stripTags(a.name as string);
if (traits && body.length > 0 && body[0].type === "text") {
return {
name,
segments: [
{ type: "text" as const, value: traits + body[0].value },
...body.slice(1),
],
};
}
return {
name: stripTags(a.name as string),
segments: Array.isArray(a.entries)
? segmentizeEntries(a.entries)
: formatAffliction(raw),
name,
segments: traits
? [{ type: "text" as const, value: traits }, ...body]
: body,
};
});
}
@@ -263,9 +299,11 @@ function normalizeAttacks(
const attackMod = a.attack == null ? "" : ` +${a.attack}`;
const traits =
a.traits && a.traits.length > 0
? ` (${a.traits.map((t) => stripDiceBrackets(stripTags(t))).join(", ")})`
? ` (${a.traits.map((t) => stripAngleBrackets(stripTags(t))).join(", ")})`
: "";
const damage = a.damage ? `, ${stripTags(a.damage)}` : "";
const damage = a.damage
? `, ${stripAngleBrackets(stripTags(a.damage))}`
: "";
return {
name: capitalize(stripTags(a.name)),
segments: [

View File

@@ -77,7 +77,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description5e:
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
descriptionPf2e:
"Can't see. All terrain is difficult terrain. 4 status penalty to Perception checks involving sight. Immune to visual effects. Auto-fail checks requiring sight. Off-guard.",
"Can't see. All terrain is difficult terrain. Auto-fail checks requiring sight. Immune to visual effects. Overrides dazzled.",
iconName: "EyeOff",
color: "neutral",
},
@@ -98,7 +98,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description: "Can't hear. Auto-fail hearing checks.",
description5e: "Can't hear. Auto-fail hearing checks.",
descriptionPf2e:
"Can't hear. 2 status penalty to Perception checks and Initiative. Auto-fail hearing checks. Immune to auditory effects.",
"Can't hear. Auto-critically-fail hearing checks. 2 status penalty to Perception. Auditory actions require DC 5 flat check. Immune to auditory effects.",
iconName: "EarOff",
color: "neutral",
},
@@ -166,7 +166,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
description5e:
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
descriptionPf2e: "Can't act. Off-guard. 4 status penalty to AC.",
descriptionPf2e:
"Can't act. Off-guard. Can only Recall Knowledge or use mental actions.",
iconName: "ZapOff",
color: "yellow",
},
@@ -243,7 +244,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description5e:
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
descriptionPf2e:
"Can't act. X value to actions per turn while the value counts down.",
"Can't act. Lose X total actions across turns, then the condition ends. Overrides slowed.",
iconName: "Sparkles",
color: "yellow",
valued: true,
@@ -256,7 +257,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description5e:
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
descriptionPf2e:
"Can't act. Off-guard. 4 status penalty to AC. 3 to Perception. Fall prone, drop items.",
"Can't act. Off-guard. Blinded. 4 status penalty to AC, Perception, and Reflex saves. Fall prone, drop items.",
iconName: "Moon",
color: "indigo",
},
@@ -290,7 +291,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description: "",
description5e: "",
descriptionPf2e:
"Off-guard. Can't Delay, Ready, or use reactions. GM determines targets randomly. Flat check DC 11 to act normally each turn.",
"Off-guard. Can't Delay, Ready, or use reactions. Must Strike or cast offensive cantrips at random targets. DC 11 flat check when damaged to end.",
iconName: "CircleHelp",
color: "pink",
systems: ["pf2e"],
@@ -335,7 +336,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description: "",
description5e: "",
descriptionPf2e:
"X status penalty to Con-based checks and DCs. Lose X × Hit Die in max HP. Decreases by 1 on full night's rest.",
"X status penalty to Con-based checks and DCs. Lose X × level in max HP. Decreases by 1 on full night's rest.",
iconName: "Droplets",
color: "red",
systems: ["pf2e"],
@@ -359,7 +360,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description: "",
description5e: "",
descriptionPf2e:
"X status penalty to Str-based rolls, including melee attack and damage rolls.",
"X status penalty to Str-based rolls and DCs, including melee attack and damage rolls and Athletics checks.",
iconName: "TrendingDown",
color: "amber",
systems: ["pf2e"],
@@ -371,7 +372,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description: "",
description5e: "",
descriptionPf2e:
"2 status penalty to all checks. Can't use hostile actions. Ends if hostile action is used against you.",
"2 status penalty to Perception and skill checks. Can't use concentrate actions unless related to the fascination. Ends if hostile action is used against you or allies.",
iconName: "Eye",
color: "violet",
systems: ["pf2e"],
@@ -404,7 +405,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description: "",
description5e: "",
descriptionPf2e:
"Immobilized. Off-guard. Can't use actions with the move trait unless to Break Grapple.",
"Off-guard. Immobilized. Manipulate actions require DC 5 flat check or are wasted.",
iconName: "Hand",
color: "neutral",
systems: ["pf2e"],
@@ -415,7 +416,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description: "",
description5e: "",
descriptionPf2e:
"Known location but can't be seen. DC 11 flat check to target. Can use Seek to find.",
"Known location but can't be seen. Off-guard to that creature. DC 11 flat check to target or miss.",
iconName: "EyeOff",
color: "slate",
systems: ["pf2e"],
@@ -521,5 +522,7 @@ export function getConditionsForEdition(
): readonly ConditionDefinition[] {
return CONDITION_DEFINITIONS.filter(
(d) => d.systems === undefined || d.systems.includes(edition),
);
)
.slice()
.sort((a, b) => a.label.localeCompare(b.label));
}