Add Recall Knowledge DC and skill to PF2e stat blocks
All checks were successful
CI / check (push) Successful in 2m29s
CI / build-image (push) Successful in 19s

Display Recall Knowledge line below trait tags showing DC (from level
via standard DC-by-level table, adjusted for rarity) and associated
skill derived from creature type trait. Omitted for D&D creatures and
creatures with no recognized type trait.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-04-10 18:43:49 +02:00
parent e161645228
commit e2e8297c95
6 changed files with 299 additions and 1 deletions

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { recallKnowledge } from "../recall-knowledge.js";
describe("recallKnowledge", () => {
it("returns null when no type trait is recognized", () => {
expect(recallKnowledge(5, ["small", "goblin"])).toBeNull();
});
it("calculates DC for a common creature from the DC-by-level table", () => {
const result = recallKnowledge(5, ["humanoid"]);
expect(result).toEqual({ dc: 20, type: "humanoid", skills: ["Society"] });
});
it("calculates DC for level -1", () => {
const result = recallKnowledge(-1, ["humanoid"]);
expect(result).toEqual({ dc: 13, type: "humanoid", skills: ["Society"] });
});
it("calculates DC for level 0", () => {
const result = recallKnowledge(0, ["animal"]);
expect(result).toEqual({ dc: 14, type: "animal", skills: ["Nature"] });
});
it("calculates DC for level 25 (max table entry)", () => {
const result = recallKnowledge(25, ["dragon"]);
expect(result).toEqual({ dc: 50, type: "dragon", skills: ["Arcana"] });
});
it("clamps DC for levels beyond the table", () => {
const result = recallKnowledge(30, ["dragon"]);
expect(result).toEqual({ dc: 50, type: "dragon", skills: ["Arcana"] });
});
it("adjusts DC for uncommon rarity (+2)", () => {
const result = recallKnowledge(5, ["uncommon", "medium", "undead"]);
expect(result).toEqual({
dc: 22,
type: "undead",
skills: ["Religion"],
});
});
it("adjusts DC for rare rarity (+5)", () => {
const result = recallKnowledge(5, ["rare", "large", "dragon"]);
expect(result).toEqual({ dc: 25, type: "dragon", skills: ["Arcana"] });
});
it("adjusts DC for unique rarity (+10)", () => {
const result = recallKnowledge(5, ["unique", "medium", "humanoid"]);
expect(result).toEqual({
dc: 30,
type: "humanoid",
skills: ["Society"],
});
});
it("returns multiple skills for beast type", () => {
const result = recallKnowledge(3, ["beast"]);
expect(result).toEqual({
dc: 18,
type: "beast",
skills: ["Arcana", "Nature"],
});
});
it("returns multiple skills for construct type", () => {
const result = recallKnowledge(1, ["construct"]);
expect(result).toEqual({
dc: 15,
type: "construct",
skills: ["Arcana", "Crafting"],
});
});
it("matches type traits case-insensitively", () => {
const result = recallKnowledge(5, ["Humanoid"]);
expect(result).toEqual({ dc: 20, type: "Humanoid", skills: ["Society"] });
});
it("uses the first matching type trait when multiple are present", () => {
const result = recallKnowledge(7, ["large", "monitor", "protean"]);
expect(result).toEqual({
dc: 23,
type: "monitor",
skills: ["Religion"],
});
});
it("preserves original trait casing in the returned type", () => {
const result = recallKnowledge(1, ["Fey"]);
expect(result?.type).toBe("Fey");
});
it("ignores common rarity (no adjustment)", () => {
// "common" is not included in traits by the normalization pipeline
const result = recallKnowledge(5, ["medium", "humanoid"]);
expect(result?.dc).toBe(20);
});
});

View File

@@ -108,6 +108,10 @@ export {
VALID_PLAYER_COLORS,
VALID_PLAYER_ICONS,
} from "./player-character-types.js";
export {
type RecallKnowledge,
recallKnowledge,
} from "./recall-knowledge.js";
export { rehydrateCombatant } from "./rehydrate-combatant.js";
export { rehydratePlayerCharacter } from "./rehydrate-player-character.js";
export {

View File

@@ -0,0 +1,118 @@
/**
* PF2e Recall Knowledge DC calculation and type-to-skill mapping.
*
* DC is derived from creature level using the standard DC-by-level table
* (Player Core / GM Core), adjusted for rarity.
*/
/** Standard DC-by-level table from PF2e GM Core. Index = level + 1 (level -1 → index 0). */
const DC_BY_LEVEL: readonly number[] = [
13, // level -1
14, // level 0
15, // level 1
16, // level 2
18, // level 3
19, // level 4
20, // level 5
22, // level 6
23, // level 7
24, // level 8
26, // level 9
27, // level 10
28, // level 11
30, // level 12
31, // level 13
32, // level 14
34, // level 15
35, // level 16
36, // level 17
38, // level 18
39, // level 19
40, // level 20
42, // level 21
44, // level 22
46, // level 23
48, // level 24
50, // level 25
];
const RARITY_ADJUSTMENT: Readonly<Record<string, number>> = {
uncommon: 2,
rare: 5,
unique: 10,
};
/**
* Mapping from PF2e creature type traits to the skill(s) used for
* Recall Knowledge. Types that map to multiple skills list all of them.
*/
const TYPE_TO_SKILLS: Readonly<Record<string, readonly string[]>> = {
aberration: ["Occultism"],
animal: ["Nature"],
astral: ["Occultism"],
beast: ["Arcana", "Nature"],
celestial: ["Religion"],
construct: ["Arcana", "Crafting"],
dragon: ["Arcana"],
dream: ["Occultism"],
elemental: ["Arcana", "Nature"],
ethereal: ["Occultism"],
fey: ["Nature"],
fiend: ["Religion"],
fungus: ["Nature"],
giant: ["Society"],
humanoid: ["Society"],
monitor: ["Religion"],
ooze: ["Occultism"],
plant: ["Nature"],
undead: ["Religion"],
};
export interface RecallKnowledge {
readonly dc: number;
readonly type: string;
readonly skills: readonly string[];
}
/**
* Calculate Recall Knowledge DC, type, and skill(s) for a PF2e creature.
*
* Returns `null` when no recognized type trait is found in the creature's
* traits array, indicating the Recall Knowledge line should be omitted.
*/
export function recallKnowledge(
level: number,
traits: readonly string[],
): RecallKnowledge | null {
// Find the first type trait that maps to a skill
let matchedType: string | undefined;
let skills: readonly string[] | undefined;
for (const trait of traits) {
const lower = trait.toLowerCase();
const mapped = TYPE_TO_SKILLS[lower];
if (mapped) {
matchedType = trait;
skills = mapped;
break;
}
}
if (!matchedType || !skills) return null;
// Calculate DC from level
const clampedIndex = Math.max(0, Math.min(level + 1, DC_BY_LEVEL.length - 1));
let dc = DC_BY_LEVEL[clampedIndex];
// Apply rarity adjustment (rarity traits are included in the traits array
// for non-common creatures by the normalization pipeline)
for (const trait of traits) {
const adjustment = RARITY_ADJUSTMENT[trait.toLowerCase()];
if (adjustment) {
dc += adjustment;
break;
}
}
return { dc, type: matchedType, skills };
}