Skip to content

Commit

Permalink
Convert NPC skills and skill variants to system data (foundryvtt#15033)
Browse files Browse the repository at this point in the history
  • Loading branch information
CarlosFdez committed Jun 24, 2024
1 parent e54b6a6 commit 2b53aa6
Show file tree
Hide file tree
Showing 17 changed files with 476 additions and 142 deletions.
1 change: 0 additions & 1 deletion src/module/actor/character/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,6 @@ class CharacterPF2e<TParent extends TokenDocumentPF2e | null = TokenDocumentPF2e
}

private prepareSkills() {
// rebuild the skills object to clear out any deleted or renamed skills from previous iterations
const { synthetics, system, wornArmor } = this;

this.skills = R.mapToObj([...SKILL_SLUGS], (skillSlug) => {
Expand Down
13 changes: 7 additions & 6 deletions src/module/actor/modifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,8 @@ class StatisticModifier {
rollOptions = rollOptions instanceof Set ? rollOptions : new Set(rollOptions);
this.slug = slug;

// De-duplication. Prefer higher valued
// De-duplication. Prefer higher valued, and deprioritize disabled ones
// This behavior is used by kingmaker to create "custom modifier types" as well special skill modifiers when rolling manually
const seen = modifiers.reduce((result: Record<string, ModifierPF2e>, modifier) => {
const existing = result[modifier.slug];
if (!existing?.enabled || Math.abs(modifier.modifier) > Math.abs(result[modifier.slug].modifier)) {
Expand Down Expand Up @@ -780,17 +781,17 @@ class DamageDicePF2e {
interface RawDamageDice extends Required<DamageDiceParameters> {}

export {
adjustModifiers,
applyStackingRules,
CheckModifier,
createAttributeModifier,
createProficiencyModifier,
DamageDicePF2e,
ensureProficiencyOption,
MODIFIER_TYPES,
ModifierPF2e,
PROFICIENCY_RANK_OPTION,
StatisticModifier,
adjustModifiers,
applyStackingRules,
createAttributeModifier,
createProficiencyModifier,
ensureProficiencyOption,
};
export type {
DamageDiceOverride,
Expand Down
39 changes: 33 additions & 6 deletions src/module/actor/npc/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ import type {
} from "@actor/data/base.ts";
import { InitiativeTraceData } from "@actor/initiative.ts";
import type { ModifierPF2e, StatisticModifier } from "@actor/modifiers.ts";
import type { ActorAlliance, SaveType } from "@actor/types.ts";
import type { ActorAlliance, SaveType, SkillSlug } from "@actor/types.ts";
import type { MeleePF2e } from "@item";
import type { PublicationData } from "@module/data.ts";
import type { RawPredicate } from "@system/predication.ts";

type NPCSource = BaseCreatureSource<"npc", NPCSystemSource> & {
flags: DeepPartial<NPCFlags>;
Expand All @@ -50,6 +51,8 @@ interface NPCSystemSource extends CreatureSystemSource {
/** Any special attributes for this NPC, such as AC or health. */
attributes: NPCAttributesSource;

skills: Partial<Record<SkillSlug, NPCSkillSource>>;

/** Modifier of the perception statistic */
perception: NPCPerceptionSource;

Expand All @@ -71,6 +74,22 @@ interface NPCSystemSource extends CreatureSystemSource {
resources: CreatureResourcesSource;
}

interface NPCSkillSource {
base: number;
/** Any special restriction or clarification */
note?: string;
/** All saved special skill modifiers */
special?: NPCSpecialSkillSource[];
}

/** Source data for special skill modifiers (such as +9 to climb) */
interface NPCSpecialSkillSource {
label: string;
base: number;
/** A predicate that will automatically enable this variant if satisfied */
predicate?: RawPredicate;
}

interface NPCAttributesSource extends Required<ActorAttributesSource> {
ac: {
value: number;
Expand Down Expand Up @@ -217,14 +236,20 @@ interface NPCHitPoints extends HitPointsStatistic {
base?: number;
}

/** System Data for skill special modifiers (such as +9 to climb) */
interface NPCSpecialSkill extends NPCSpecialSkillSource {
mod: number;
}

/** Skill data with a "base" value and whether the skill should be rendered (visible) */
interface NPCSkillData extends AttributeBasedTraceData {
base: number;
itemId: string | null;
lore: boolean;
interface NPCSkillData extends NPCSkillSource, AttributeBasedTraceData {
mod: number;
variants: { label: string; options: string }[];
visible: boolean;
/** Is this skill a Lore skill? */
lore?: boolean;
/** If this is a lore skill, what item it came from */
itemID?: string;
special: NPCSpecialSkill[];
}

interface NPCSpeeds extends CreatureSpeeds {
Expand All @@ -240,7 +265,9 @@ export type {
NPCPerceptionSource,
NPCSaveData,
NPCSkillData,
NPCSkillSource,
NPCSource,
NPCSpecialSkillSource,
NPCStrike,
NPCSystemData,
NPCSystemSource,
Expand Down
123 changes: 94 additions & 29 deletions src/module/actor/npc/document.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { CreaturePF2e } from "@actor";
import type { Abilities } from "@actor/creature/data.ts";
import type { CreatureUpdateOperation } from "@actor/creature/index.ts";
import { setHitPointsRollOptions, strikeFromMeleeItem } from "@actor/helpers.ts";
import { ActorInitiative } from "@actor/initiative.ts";
import { ModifierPF2e, StatisticModifier } from "@actor/modifiers.ts";
import type { AttributeString, SaveType } from "@actor/types.ts";
import type { SaveType } from "@actor/types.ts";
import { SAVE_TYPES, SKILL_EXPANDED, SKILL_SLUGS } from "@actor/values.ts";
import type { ItemPF2e, LorePF2e, MeleePF2e } from "@item";
import type { ItemType } from "@item/base/data/index.ts";
import { calculateDC } from "@module/dc.ts";
import { RollNotePF2e } from "@module/notes.ts";
import { CreatureIdentificationData, creatureIdentificationDCs } from "@module/recall-knowledge.ts";
import { extractModifierAdjustments, extractModifiers } from "@module/rules/helpers.ts";
import type { UserPF2e } from "@module/user/document.ts";
import type { TokenDocumentPF2e } from "@scene";
import { ArmorStatistic, PerceptionStatistic, Statistic } from "@system/statistic/index.ts";
import { createHTMLElement, signedInteger, sluggify } from "@util";
Expand Down Expand Up @@ -302,58 +304,99 @@ class NPCPF2e<TParent extends TokenDocumentPF2e | null = TokenDocumentPF2e | nul
this.saves = saves as Record<SaveType, Statistic>;
}

private prepareSkills(): void {
private prepareSkills() {
const modifierAdjustments = this.synthetics.modifierAdjustments;

const trainedSkills = R.mapToObj(this.itemTypes.lore, (s) => [sluggify(s.name), s]);
const coreSkillSlugs = Array.from(SKILL_SLUGS);
const skillOrNull = {
...R.mapToObj(coreSkillSlugs, (s) => [s, trainedSkills[s] ?? null]),
...R.omit(trainedSkills, coreSkillSlugs),
};
const slugToAttribute: Record<string, { attribute: AttributeString }> = SKILL_EXPANDED;

this.skills = R.mapValues(skillOrNull, (item, slug) => {
const { label, attribute, lore } =
slug in slugToAttribute
? { label: CONFIG.PF2E.skillList[slug], attribute: slugToAttribute[slug].attribute, lore: false }
: { label: item?.name ?? "", attribute: "int" as const, lore: true };
this.skills = R.mapToObj([...SKILL_SLUGS], (skillSlug) => {
const skill = this._source.system.skills[skillSlug];
const attribute = SKILL_EXPANDED[skillSlug].attribute;
const label = CONFIG.PF2E.skillList[skillSlug] ?? skillSlug;
const domains = [skillSlug, `${attribute}-based`, "skill-check", `${attribute}-skill-check`, "all"];

// Get predicated variants as modifiers that trigger when the predicate is met
const specialModifiers =
skill?.special
?.filter((v) => v.predicate?.length)
.map(
(special) =>
new ModifierPF2e({
slug: "variant",
label: special.label,
modifier: special.base - skill.base,
predicate: special.predicate,
hideIfDisabled: true,
domains,
}),
) ?? [];

const domains = [slug, `${attribute}-based`, "skill-check", `${attribute}-skill-check`, "all"];

return new Statistic(this, {
slug,
const statistic = new Statistic(this, {
slug: skillSlug,
label,
attribute,
domains,
modifiers: [
new ModifierPF2e({
slug: "base",
label: "PF2E.ModifierTitle",
modifier: item?.system.mod.value ?? this.system.abilities[attribute].mod,
modifier: skill?.base ?? this.system.abilities[attribute].mod,
adjustments: extractModifierAdjustments(modifierAdjustments, domains, "base"),
}),
...specialModifiers,
],
lore,
proficient: !!item,
lore: false,
proficient: skillSlug in this._source.system.skills,
check: { type: "skill-check" },
});

return [skillSlug, statistic];
});

// Create trace skill data in system data and omit unprepared skills
// Lore skills
for (const loreItem of this.itemTypes.lore) {
// normalize skill name to lower-case and dash-separated words
const longForm = sluggify(loreItem.name);
const domains = [longForm, "skill-check", "lore-skill-check", "int-skill-check", "all"];

const statistic = new Statistic(this, {
slug: longForm,
label: loreItem.name,
attribute: "int",
domains,
modifiers: [
new ModifierPF2e({
slug: "base",
label: "PF2E.ModifierTitle",
modifier: loreItem.system.mod.value,
}),
],
lore: true,
proficient: true,
check: { type: "skill-check" },
});

this.skills[longForm] = statistic;
}

// Create trace data in system data
type GappyLoreItems = Partial<Record<string, LorePF2e<this>>>;
const loreItems: GappyLoreItems = R.mapToObj(this.itemTypes.lore, (l) => [sluggify(l.name), l]);
this.system.skills = R.mapToObj(Object.entries(this.skills), ([key, statistic]) => {
const loreItem = loreItems[statistic.slug];
const data = {
const loreItem = statistic.lore ? loreItems[statistic.slug] : null;
const baseData = this.system.skills[key] ?? { base: loreItem?.system.mod.value ?? 0 };
const data = fu.mergeObject(baseData, {
...statistic.getTraceData(),
base: loreItem?.system.mod.value ?? 0,
mod: statistic.check.mod,
itemId: loreItem?.id ?? null,
itemID: loreItem?.id ?? null,
lore: !!statistic.lore,
variants: Object.values(loreItem?.system.variants ?? {}),
visible: statistic.proficient,
};
});

// Recalculate displayed variant modifiers
data.special ??= [];
for (const variant of data.special) {
variant.mod = variant.base + (statistic.check.mod - baseData.base);
}

return [key, data];
});
}
Expand Down Expand Up @@ -522,6 +565,28 @@ class NPCPF2e<TParent extends TokenDocumentPF2e | null = TokenDocumentPF2e | nul

return this.clone(changes, { save: params.save, keepId: params.keepId });
}

protected override async _preUpdate(
changed: DeepPartial<NPCSource>,
operation: CreatureUpdateOperation<TParent>,
user: UserPF2e,
): Promise<boolean | void> {
const isFullReplace = !((operation.diff ?? true) && (operation.recursive ?? true));
if (isFullReplace) return super._preUpdate(changed, operation, user);

if (changed.system?.skills) {
for (const [key, skill] of Object.entries(changed.system.skills)) {
if (key.startsWith("-=") || !skill) continue;

if (skill.note === "") {
delete skill.note;
fu.mergeObject(skill, { "-=note": null });
}
}
}

return super._preUpdate(changed, operation, user);
}
}

interface NPCPF2e<TParent extends TokenDocumentPF2e | null = TokenDocumentPF2e | null> extends CreaturePF2e<TParent> {
Expand Down
30 changes: 30 additions & 0 deletions src/module/actor/npc/sheet.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { NPCPF2e } from "@actor";
import { CreatureSheetPF2e, type CreatureSheetData } from "@actor/creature/sheet.ts";
import { ModifierPF2e } from "@actor/modifiers.ts";
import { NPCSkillsEditor } from "@actor/npc/skills-editor.ts";
import { SheetClickActionHandlers } from "@actor/sheet/base.ts";
import { RecallKnowledgePopup } from "@actor/sheet/popups/recall-knowledge-popup.ts";
Expand All @@ -8,7 +9,9 @@ import { ATTRIBUTE_ABBREVIATIONS, MOVEMENT_TYPES, SAVE_TYPES } from "@actor/valu
import { createTagifyTraits } from "@module/sheet/helpers.ts";
import type { UserPF2e } from "@module/user/document.ts";
import { DicePF2e } from "@scripts/dice.ts";
import { eventToRollParams } from "@scripts/sheet-util.ts";
import type { HTMLTagifyTagsElement } from "@system/html-elements/tagify-tags.ts";
import type { StatisticRollParameters } from "@system/statistic/index.ts";
import {
getActionGlyph,
htmlClosest,
Expand Down Expand Up @@ -108,6 +111,33 @@ abstract class AbstractNPCSheet extends CreatureSheetPF2e<NPCPF2e> {
protected override activateClickListener(html: HTMLElement): SheetClickActionHandlers {
const handlers = super.activateClickListener(html);

const baseRollCheck = handlers["roll-check"]!;
handlers["roll-check"] = (event, anchor) => {
const variantIdx = "variant" in anchor.dataset ? Number(anchor.dataset.variant) : null;
if (variantIdx === null) return baseRollCheck(event, anchor);

const statisticSlug = htmlClosest(anchor, "[data-statistic]")?.dataset.statistic ?? "";
const skill = this.actor.system.skills[statisticSlug];
const variant = skill?.special?.at(variantIdx);
if (!variant) return baseRollCheck(event, anchor);

const statistic = this.actor.getStatistic(statisticSlug);
const args: StatisticRollParameters = {
...eventToRollParams(event, { type: "check" }),
modifiers: [
new ModifierPF2e({
slug: "variant",
label: variant.label,
modifier: variant.base - skill.base,
}),
],
};
if (anchor.dataset.secret !== undefined) {
args.rollMode = game.user.isGM ? "gmroll" : "blindroll";
}
return statistic?.roll(args);
};

handlers["edit-skills"] = () => {
new NPCSkillsEditor(this.actor).render(true);
};
Expand Down
Loading

0 comments on commit 2b53aa6

Please sign in to comment.