From e2e8297c95c6ee99216c9ec43b54bb453b3a5b74 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 10 Apr 2026 18:43:49 +0200 Subject: [PATCH] Add Recall Knowledge DC and skill to PF2e stat blocks 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) --- .../__tests__/pf2e-stat-block.test.tsx | 53 ++++++++ apps/web/src/components/pf2e-stat-block.tsx | 10 +- .../src/__tests__/recall-knowledge.test.ts | 99 +++++++++++++++ packages/domain/src/index.ts | 4 + packages/domain/src/recall-knowledge.ts | 118 ++++++++++++++++++ specs/004-bestiary/spec.md | 16 +++ 6 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 packages/domain/src/__tests__/recall-knowledge.test.ts create mode 100644 packages/domain/src/recall-knowledge.ts diff --git a/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx b/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx index c95d220..59f6cb0 100644 --- a/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx +++ b/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx @@ -25,6 +25,12 @@ const ABILITY_MID_NAME_REGEX = /Goblin Scuttle/; const ABILITY_MID_DESC_REGEX = /The goblin Steps\./; const CANTRIPS_REGEX = /Cantrips:/; const AC_REGEX = /16/; +const RK_DC_13_REGEX = /DC 13/; +const RK_DC_15_REGEX = /DC 15/; +const RK_DC_25_REGEX = /DC 25/; +const RK_HUMANOID_SOCIETY_REGEX = /Humanoid \(Society\)/; +const RK_UNDEAD_RELIGION_REGEX = /Undead \(Religion\)/; +const RK_BEAST_SKILLS_REGEX = /Beast \(Arcana\/Nature\)/; const GOBLIN_WARRIOR: Pf2eCreature = { system: "pf2e", @@ -154,6 +160,53 @@ describe("Pf2eStatBlock", () => { }); }); + describe("recall knowledge", () => { + it("renders Recall Knowledge line for a creature with a recognized type trait", () => { + renderStatBlock(GOBLIN_WARRIOR); + expect(screen.getByText("Recall Knowledge")).toBeInTheDocument(); + expect(screen.getByText(RK_DC_13_REGEX)).toBeInTheDocument(); + expect(screen.getByText(RK_HUMANOID_SOCIETY_REGEX)).toBeInTheDocument(); + }); + + it("adjusts DC for uncommon rarity", () => { + const uncommonCreature: Pf2eCreature = { + ...GOBLIN_WARRIOR, + traits: ["uncommon", "small", "humanoid"], + }; + renderStatBlock(uncommonCreature); + expect(screen.getByText(RK_DC_15_REGEX)).toBeInTheDocument(); + }); + + it("adjusts DC for rare rarity", () => { + const rareCreature: Pf2eCreature = { + ...GOBLIN_WARRIOR, + level: 5, + traits: ["rare", "medium", "undead"], + }; + renderStatBlock(rareCreature); + expect(screen.getByText(RK_DC_25_REGEX)).toBeInTheDocument(); + expect(screen.getByText(RK_UNDEAD_RELIGION_REGEX)).toBeInTheDocument(); + }); + + it("shows multiple skills for types with dual skill mapping", () => { + const beastCreature: Pf2eCreature = { + ...GOBLIN_WARRIOR, + traits: ["small", "beast"], + }; + renderStatBlock(beastCreature); + expect(screen.getByText(RK_BEAST_SKILLS_REGEX)).toBeInTheDocument(); + }); + + it("omits Recall Knowledge when no type trait is recognized", () => { + const noTypeCreature: Pf2eCreature = { + ...GOBLIN_WARRIOR, + traits: ["small", "goblin"], + }; + renderStatBlock(noTypeCreature); + expect(screen.queryByText("Recall Knowledge")).not.toBeInTheDocument(); + }); + }); + describe("perception and senses", () => { it("renders perception modifier and senses", () => { renderStatBlock(GOBLIN_WARRIOR); diff --git a/apps/web/src/components/pf2e-stat-block.tsx b/apps/web/src/components/pf2e-stat-block.tsx index 2ec8194..a4daf49 100644 --- a/apps/web/src/components/pf2e-stat-block.tsx +++ b/apps/web/src/components/pf2e-stat-block.tsx @@ -1,5 +1,5 @@ import type { Pf2eCreature, SpellReference } from "@initiative/domain"; -import { formatInitiativeModifier } from "@initiative/domain"; +import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain"; import { useCallback, useRef, useState } from "react"; import { SpellDetailPopover } from "./spell-detail-popover.js"; import { @@ -113,6 +113,8 @@ export function Pf2eStatBlock({ creature }: Readonly) { ); const handleCloseSpell = useCallback(() => setOpenSpell(null), []); + const rk = recallKnowledge(creature.level, creature.traits); + const abilityEntries = [ { label: "Str", mod: creature.abilityMods.str }, { label: "Dex", mod: creature.abilityMods.dex }, @@ -147,6 +149,12 @@ export function Pf2eStatBlock({ creature }: Readonly) {

{creature.sourceDisplayName}

+ {rk && ( +

+ Recall Knowledge DC {rk.dc}{" "} + • {capitalize(rk.type)} ({rk.skills.join("/")}) +

+ )} diff --git a/packages/domain/src/__tests__/recall-knowledge.test.ts b/packages/domain/src/__tests__/recall-knowledge.test.ts new file mode 100644 index 0000000..72d43e1 --- /dev/null +++ b/packages/domain/src/__tests__/recall-knowledge.test.ts @@ -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); + }); +}); diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 3d9cbc0..bfd37a5 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -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 { diff --git a/packages/domain/src/recall-knowledge.ts b/packages/domain/src/recall-knowledge.ts new file mode 100644 index 0000000..1c51e65 --- /dev/null +++ b/packages/domain/src/recall-knowledge.ts @@ -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> = { + 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> = { + 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 }; +} diff --git a/specs/004-bestiary/spec.md b/specs/004-bestiary/spec.md index e773bd4..cdd32f9 100644 --- a/specs/004-bestiary/spec.md +++ b/specs/004-bestiary/spec.md @@ -103,6 +103,11 @@ As a DM running a PF2e encounter, I want to click a spell name in a creature's s A click on any spell name in the spellcasting section opens a popover (desktop) or bottom sheet (mobile) showing the spell's description, level, traits, range, action cost, target/area, duration, defense/save, and heightening rules. The data is read directly from the cached creature data (already embedded in NPC JSON from Foundry VTT) — no additional network fetch is required, and the feature works offline once the source has been loaded. Dismiss with click-outside, Escape, or (on mobile) swipe-down. +**US-D5 — View Recall Knowledge DC and Skill (P2)** +As a DM running a PF2e encounter, I want to see the Recall Knowledge DC and associated skill on a creature's stat block so I can quickly tell players the DC and which skill to roll without looking it up in external tools. + +The Recall Knowledge line appears below the trait tags, showing the DC (calculated from the creature's level using the PF2e standard DC-by-level table, adjusted for rarity) and the skill determined by the creature's type trait. The line is omitted for creatures with no recognized type trait and never shown for D&D creatures. + ### Requirements - **FR-016**: The system MUST display a stat block panel with full creature information when a creature is selected. @@ -148,6 +153,10 @@ A click on any spell name in the spellcasting section opens a popover (desktop) 15. **Given** the spell description bottom sheet is open on mobile, **When** the DM swipes the sheet down, **Then** the sheet dismisses. 16. **Given** a creature from a legacy (non-remastered) PF2e source has spells with pre-remaster names (e.g., "Magic Missile", "True Strike"), **When** the DM clicks one of those spell names, **Then** the spell description still displays correctly using the embedded data. 17. **Given** a spell name appears as "Heal (×3)" in the stat block, **When** the DM looks at the rendered output, **Then** "Heal" is the clickable element and "(×3)" appears as plain text next to it. +18. **Given** a PF2e creature with level 5 and common rarity is displayed, **When** the DM views the stat block, **Then** a "Recall Knowledge" line appears below the trait tags showing the DC calculated from level 5 (DC 20) and the skill derived from the creature's type trait. +19. **Given** a PF2e creature with rare rarity is displayed, **When** the DM views the stat block, **Then** the Recall Knowledge DC is the standard DC for its level +5. +20. **Given** a PF2e creature with the "Undead" type trait is displayed, **When** the DM views the stat block, **Then** the Recall Knowledge line shows "Religion" as the associated skill. +21. **Given** a D&D creature is displayed, **When** the DM views the stat block, **Then** no Recall Knowledge line is shown. ### Edge Cases @@ -156,6 +165,8 @@ A click on any spell name in the spellcasting section opens a popover (desktop) - Viewport resized from wide to narrow while stat block is open: the layout transitions from panel to drawer. - Embedded spell item missing description text: the popover/sheet shows the available metadata (level, traits, range, etc.) and a placeholder note for the missing description. - Cached source data from before the spell description feature was added: existing cached entries lack the new per-spell data fields. The IndexedDB schema version MUST be bumped to invalidate old caches and trigger re-fetch (re-normalization from raw Foundry data is not possible because the original raw JSON is not retained). +- Creature with no recognized type trait (e.g., a creature whose only traits are not in the type-to-skill mapping): the Recall Knowledge line is omitted entirely. +- Creature with a type trait that maps to multiple skills (e.g., Beast → Arcana/Nature): both skills are shown. --- @@ -217,6 +228,11 @@ A DM wants to see which sources are cached, find a specific source, clear a spec - **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. - **FR-084**: The PF2e normalization pipeline MUST preserve per-spell data (slug, level, traits, range, time, target, area, duration, defense, description, heightening/overlays) from embedded `items[type=spell]` entries on NPCs, in addition to the spell name. This data MUST be stored in the cached source data and persisted across browser sessions. +- **FR-085**: PF2e stat blocks MUST display a "Recall Knowledge" line below the trait tags showing the DC and the associated skill (e.g., "Recall Knowledge DC 18 • Undead (Religion)"). +- **FR-086**: The Recall Knowledge DC MUST be calculated from the creature's level using the PF2e standard DC-by-level table, adjusted for rarity: uncommon +2, rare +5, unique +10. +- **FR-087**: The Recall Knowledge skill MUST be derived from the creature's type trait using the standard PF2e mapping (e.g., 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). +- **FR-088**: Creatures with no recognized type trait MUST omit the Recall Knowledge line entirely rather than showing incorrect data. +- **FR-089**: The Recall Knowledge line MUST NOT be shown for D&D creatures. ### Acceptance Scenarios