Skip to content

Commit

Permalink
Add support for registering spells to staves
Browse files Browse the repository at this point in the history
  • Loading branch information
CarlosFdez committed Jan 15, 2025
1 parent b4ac1f9 commit 570d711
Show file tree
Hide file tree
Showing 17 changed files with 430 additions and 101 deletions.
14 changes: 14 additions & 0 deletions build/lib/compendium-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,14 @@ class CompendiumPack {

if (itemIsOfType(docSource, "physical")) {
docSource.system.equipped = { carryType: "worn" };

// Staff spell and name is only used to correct from broken links, but our uuid system handles that
if (itemIsOfType(docSource, "weapon") && docSource.system.staff?.spells) {
for (const spell of docSource.system.staff.spells) {
delete spell.name;
delete spell.img;
}
}
} else if (docSource.type === "feat") {
const featCategory = docSource.system.category;
if (!setHasElement(FEAT_OR_FEATURE_CATEGORIES, featCategory)) {
Expand Down Expand Up @@ -337,6 +345,12 @@ class CompendiumPack {
);
}

if (itemIsOfType(source, "weapon") && source.system.staff?.spells) {
for (const spell of source.system.staff.spells) {
spell.uuid = CompendiumPack.convertUUID(spell.uuid, convertOptions);
}
}

if (itemIsOfType(source, "feat", "action") && source.system.selfEffect) {
source.system.selfEffect.uuid = CompendiumPack.convertUUID(source.system.selfEffect.uuid, convertOptions);
} else if (itemIsOfType(source, "ancestry", "background", "class", "kit")) {
Expand Down
7 changes: 6 additions & 1 deletion src/module/item/base/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import type {
RawItemChatData,
TraitChatData,
} from "./data/index.ts";
import type { ItemTrait } from "./data/system.ts";
import type { ItemDescriptionData, ItemTrait } from "./data/system.ts";
import type { ItemSheetPF2e } from "./sheet/sheet.ts";

/** The basic `Item` subclass for the system */
Expand Down Expand Up @@ -430,6 +430,11 @@ class ItemPF2e<TParent extends ActorPF2e | null = ActorPF2e | null> extends Item
/* Chat Card Data */
/* -------------------------------------------- */

/** Retrieves base description data before enriching. May be overriden to prepend or append additional data */
async getDescription(): Promise<ItemDescriptionData> {
return { ...this.system.description };
}

/**
* Internal method that transforms data into something that can be used for chat.
* Currently renders description text using enrichHTML.
Expand Down
18 changes: 15 additions & 3 deletions src/module/item/base/sheet/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,12 @@ class ItemSheetPF2e<TItem extends ItemPF2e> extends ItemSheet<TItem, ItemSheetOp
const rollData = { ...this.item.getRollData(), ...this.actor?.getRollData() };

// Get the source description in case this is an unidentified physical item
enrichedContent.description = await TextEditor.enrichHTML(item._source.system.description.value, {
const description = await item.getDescription();
enrichedContent.description = await TextEditor.enrichHTML(description.value, {
rollData,
secrets: item.isOwner,
});
enrichedContent.gmNotes = await TextEditor.enrichHTML(item.system.description.gm.trim(), { rollData });
enrichedContent.gmNotes = await TextEditor.enrichHTML(description.gm.trim(), { rollData });

const validTraits = this.validTraits;
const hasRarity = !item.isOfType("action", "condition", "deity", "effect", "lore", "melee");
Expand Down Expand Up @@ -482,7 +483,10 @@ class ItemSheetPF2e<TItem extends ItemPF2e> extends ItemSheet<TItem, ItemSheetOp
tagify(htmlQuery<HTMLTagifyTagsElement>(html, 'tagify-tags[name="system.traits.otherTags"]'), { maxTags: 6 });

// Handle select and input elements that show modified prepared values until focused
const modifiedPropertyFields = htmlQueryAll<HTMLSelectElement | HTMLInputElement>(html, "[data-property]");
const modifiedPropertyFields = htmlQueryAll<HTMLSelectElement | HTMLInputElement>(
html,
"input[data-property], select[data-property]",
);
for (const input of modifiedPropertyFields) {
const propertyPath = input.dataset.property ?? "";
const baseValue =
Expand All @@ -502,6 +506,14 @@ class ItemSheetPF2e<TItem extends ItemPF2e> extends ItemSheet<TItem, ItemSheetOp
});
}

// Handle contenteditable fields
for (const input of htmlQueryAll<HTMLSpanElement>(html, "span[contenteditable][data-property]")) {
const propertyPath = input.dataset.property ?? "";
input.addEventListener("blur", () => {
this.item.update({ [propertyPath]: input.textContent });
});
}

// Add a link to add GM notes
if (
this.isEditable &&
Expand Down
14 changes: 7 additions & 7 deletions src/module/item/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,16 @@ class ItemChatData {
}

async #prepareDescription(): Promise<Pick<ItemDescriptionData, "value" | "gm">> {
const { data, item } = this;
const { item } = this;
const rollOptions = new Set(
[item.actor?.getRollOptions(), item.getRollOptions("item")].flat().filter(R.isTruthy),
);

const description = await this.item.getDescription();

const baseText = await (async (): Promise<string> => {
const override = data.description?.override;
if (!override) return data.description.value;
const override = description?.override;
if (!override) return description.value;
return override
.flatMap((line) => {
if (!line.predicate.test(rollOptions)) return [];
Expand Down Expand Up @@ -115,7 +117,7 @@ class ItemChatData {

const templatePath = "systems/pf2e/templates/items/partials/addendum.hbs";
return Promise.all(
data.description.addenda.flatMap((unfiltered) => {
description.addenda.flatMap((unfiltered) => {
const addendum = {
label: game.i18n.localize(unfiltered.label),
contents: unfiltered.contents
Expand All @@ -138,9 +140,7 @@ class ItemChatData {

return {
value: await TextEditor.enrichHTML(assembled, { ...this.htmlOptions, rollData }),
gm: game.user.isGM
? await TextEditor.enrichHTML(data.description.gm, { ...this.htmlOptions, rollData })
: "",
gm: game.user.isGM ? await TextEditor.enrichHTML(description.gm, { ...this.htmlOptions, rollData }) : "",
};
}
}
Expand Down
16 changes: 8 additions & 8 deletions src/module/item/spell/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ConsumablePF2e } from "@item";
import { ItemPF2e } from "@item";
import { processSanctification } from "@item/ability/helpers.ts";
import { ItemSourcePF2e, RawItemChatData } from "@item/base/data/index.ts";
import { ItemDescriptionData } from "@item/base/data/system.ts";
import { SpellSlotGroupId } from "@item/spellcasting-entry/collection.ts";
import { spellSlotGroupIdToNumber } from "@item/spellcasting-entry/helpers.ts";
import { BaseSpellcastingEntry } from "@item/spellcasting-entry/types.ts";
Expand Down Expand Up @@ -835,6 +836,13 @@ class SpellPF2e<TParent extends ActorPF2e | null = ActorPF2e | null> extends Ite
return ChatMessagePF2e.create(messageSource, { renderSheet: false });
}

override async getDescription(): Promise<ItemDescriptionData> {
const description = await super.getDescription();
const prepend = await createDescriptionPrepend(this, { includeTraditions: false });
description.value = `${prepend}\n${description.value}`;
return description;
}

override async getChatData(
this: SpellPF2e<ActorPF2e>,
htmlOptions: EnrichmentOptionsPF2e = {},
Expand Down Expand Up @@ -864,13 +872,6 @@ class SpellPF2e<TParent extends ActorPF2e | null = ActorPF2e | null> extends Ite

const systemData: SpellSystemData = this.system;

const description = await (async () => {
const options = { ...htmlOptions, rollData };
const prepend = await createDescriptionPrepend(this, { includeTraditions: false });
const description = await TextEditor.enrichHTML(this.description, options);
return `${prepend}\n${description}`;
})();

const spellcasting = this.spellcasting;
if (!spellcasting) {
console.warn(
Expand Down Expand Up @@ -945,7 +946,6 @@ class SpellPF2e<TParent extends ActorPF2e | null = ActorPF2e | null> extends Ite

return this.processChatData(htmlOptions, {
...systemData,
description: { ...this.system.description, value: description },
isAttack: this.isAttack,
isSave,
check: this.isAttack && statisticChatData ? statisticChatData.check : undefined,
Expand Down
5 changes: 1 addition & 4 deletions src/module/item/spell/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
} from "@util";
import { tagify } from "@util/tags.ts";
import * as R from "remeda";
import { createDescriptionPrepend, createSpellRankLabel } from "./helpers.ts";
import { createSpellRankLabel } from "./helpers.ts";
import type {
EffectAreaShape,
SpellDamageSource,
Expand Down Expand Up @@ -65,9 +65,6 @@ export class SpellSheetPF2e extends ItemSheetPF2e<SpellPF2e> {
const sheetData = await super.getData(options);
const spell = this.item;

const descriptionPrepend = await createDescriptionPrepend(spell, { includeTraditions: true });
sheetData.enrichedContent.description = `${descriptionPrepend}${sheetData.enrichedContent.description}`;

const variants = spell.overlays.overrideVariants
.map((variant) => ({
name: variant.name,
Expand Down
6 changes: 3 additions & 3 deletions src/module/item/spellcasting-entry/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ItemPF2e, SpellPF2e, SpellcastingEntryPF2e } from "@item";
import { OneToTen, ValueAndMax, ZeroToTen } from "@module/data.ts";
import { ErrorPF2e, groupBy, localizer, ordinalString } from "@util";
import * as R from "remeda";
import { spellSlotGroupIdToNumber } from "./helpers.ts";
import { getSpellRankLabel, spellSlotGroupIdToNumber } from "./helpers.ts";
import { BaseSpellcastingEntry, SpellPrepEntry, SpellcastingSlotGroup } from "./types.ts";

class SpellCollection<TActor extends ActorPF2e> extends Collection<SpellPF2e<TActor>> {
Expand Down Expand Up @@ -366,8 +366,8 @@ class SpellCollection<TActor extends ActorPF2e> extends Collection<SpellPF2e<TAc
#warnInvalidDrop(warning: DropWarningType, { spell, groupId }: WarnInvalidDropParams): void {
const localize = localizer("PF2E.Item.Spell.Warning");
if (warning === "invalid-rank" && typeof groupId === "number") {
const spellRank = game.i18n.format("PF2E.Item.Spell.Rank.Ordinal", { rank: ordinalString(spell.baseRank) });
const targetRank = game.i18n.format("PF2E.Item.Spell.Rank.Ordinal", { rank: ordinalString(groupId) });
const spellRank = getSpellRankLabel(spell.baseRank);
const targetRank = getSpellRankLabel(groupId);
ui.notifications.warn(localize("InvalidRank", { spell: spell.name, spellRank, targetRank }));
} else if (warning === "cantrip-mismatch") {
const locKey = spell.isCantrip ? "CantripToRankedSlots" : "NonCantripToCantrips";
Expand Down
10 changes: 9 additions & 1 deletion src/module/item/spellcasting-entry/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ActorPF2e } from "@actor";
import type { OneToTen, ZeroToTen } from "@module/data.ts";
import { Statistic } from "@system/statistic/statistic.ts";
import { ordinalString } from "@util/misc.ts";
import * as R from "remeda";
import type { SpellSlotGroupId } from "./collection.ts";
import type { SpellcastingEntry } from "./types.ts";
Expand Down Expand Up @@ -38,4 +39,11 @@ function coerceToSpellGroupId(value: unknown): SpellSlotGroupId | null {
return numericValue.between(1, 10) ? (numericValue as OneToTen) : null;
}

export { coerceToSpellGroupId, createCounteractStatistic, spellSlotGroupIdToNumber };
/** Returns the label for a rank header, such as "1st Rank" */
function getSpellRankLabel(group: "cantrips" | number): string {
return group === 0 || group === "cantrips"
? game.i18n.localize("PF2E.Actor.Creature.Spellcasting.Cantrips")
: game.i18n.format("PF2E.Item.Spell.Rank.Ordinal", { rank: ordinalString(group) });
}

export { coerceToSpellGroupId, createCounteractStatistic, getSpellRankLabel, spellSlotGroupIdToNumber };
19 changes: 18 additions & 1 deletion src/module/item/weapon/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import type {
PhysicalSystemSource,
UsageDetails,
} from "@item/physical/index.ts";
import { ZeroToFour } from "@module/data.ts";
import type { SpellSlotGroupId } from "@item/spellcasting-entry/collection.ts";
import type { ZeroToFour } from "@module/data.ts";
import { DamageDieSize, DamageType } from "@system/damage/index.ts";
import type { WeaponTraitToggles } from "./trait-toggles.ts";
import type {
Expand Down Expand Up @@ -89,6 +90,12 @@ interface WeaponSystemSource extends Investable<PhysicalSystemSource> {
/** Doubly-embedded adjustments, attachments, talismans etc. */
subitems: PhysicalItemSource[];

/** If this is a staff, the number of charges and what spells is available on this staff */
staff: {
effect: string;
spells: StaffSpellData[];
} | null;

// Refers to custom damage, *not* property runes
property1: {
value: string;
Expand All @@ -103,6 +110,15 @@ interface WeaponSystemSource extends Investable<PhysicalSystemSource> {
selectedAmmoId: string | null;
}

interface StaffSpellData {
uuid: ItemUUID;
rank: SpellSlotGroupId;
/** The spell's name, used if the lookup fails */
name?: string;
/** The spell's image, used if the lookup fails */
img?: ImageFilePath;
}

interface WeaponTraitsSource extends PhysicalItemTraits<WeaponTrait> {
otherTags: OtherWeaponTag[];
toggles?: {
Expand Down Expand Up @@ -196,6 +212,7 @@ interface ComboWeaponMeleeUsage {
export type {
ComboWeaponMeleeUsage,
SpecificWeaponData,
StaffSpellData,
WeaponDamage,
WeaponFlags,
WeaponMaterialData,
Expand Down
45 changes: 44 additions & 1 deletion src/module/item/weapon/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ConsumablePF2e, MeleePF2e, ShieldPF2e } from "@item";
import { ItemProxyPF2e, PhysicalItemPF2e } from "@item";
import { createActionRangeLabel } from "@item/ability/helpers.ts";
import type { ItemSourcePF2e, MeleeSource, RawItemChatData } from "@item/base/data/index.ts";
import type { ItemDescriptionData } from "@item/base/data/system.ts";
import type { NPCAttackDamage } from "@item/melee/data.ts";
import type { NPCAttackTrait } from "@item/melee/types.ts";
import type { PhysicalItemConstructionContext } from "@item/physical/document.ts";
Expand All @@ -16,7 +17,8 @@ import type { RangeData } from "@item/types.ts";
import type { StrikeRuleElement } from "@module/rules/rule-element/strike.ts";
import type { UserPF2e } from "@module/user/document.ts";
import { DamageCategorization } from "@system/damage/helpers.ts";
import { ErrorPF2e, objectHasKey, setHasElement, sluggify, tupleHasValue } from "@util";
import { ErrorPF2e, objectHasKey, ordinalString, setHasElement, sluggify, tupleHasValue } from "@util";
import { UUIDUtils } from "@util/uuid.ts";
import * as R from "remeda";
import type { WeaponDamage, WeaponFlags, WeaponSource, WeaponSystemData } from "./data.ts";
import { processTwoHandTrait } from "./helpers.ts";
Expand Down Expand Up @@ -365,6 +367,13 @@ class WeaponPF2e<TParent extends ActorPF2e | null = ActorPF2e | null> extends Ph
const mandatoryMelee = !mandatoryRanged && traits.value.some((t) => /^thrown-{1,3}$/.test(t));
if (mandatoryMelee) this.system.range = null;

// Initialize staff spells if this weapon is a staff
if (traits.value.includes("staff")) {
this.system.staff = fu.mergeObject({ effect: "", spells: [] }, this.system.staff ?? {});
} else {
this.system.staff = null;
}

// Final sweep: remove any non-sensical trait that may throw off later automation
if (this.isMelee) {
traits.value = traits.value.filter((t) => !RANGED_ONLY_TRAITS.has(t) && !t.startsWith("volley"));
Expand Down Expand Up @@ -433,6 +442,34 @@ class WeaponPF2e<TParent extends ActorPF2e | null = ActorPF2e | null> extends Ph
processTwoHandTrait(this);
}

/** Get description with staff spells possibly included */
override async getDescription(): Promise<ItemDescriptionData> {
const description = await super.getDescription();
if (this.system.staff?.spells.length) {
const uuids = R.unique(this.system.staff.spells.map((s) => s.uuid));
const entriesByLevel = R.groupBy(
R.sortBy(this.system.staff.spells, (s) => s.rank),
(s) => (s.rank === "cantrips" ? 0 : s.rank),
);
const spellsByUUID = R.mapToObj(await UUIDUtils.fromUUIDs(uuids), (s) => [s.uuid, s]);
const append = await renderTemplate("systems/pf2e/templates/items/partials/staff-description-append.hbs", {
effect: this.system.staff.effect || game.i18n.localize("PF2E.Item.Weapon.Staff.DefaultEffect"),
spells: Object.values(entriesByLevel)
.map((group) => {
const rank = group[0].rank;
const spells = group.map((e) => spellsByUUID[e.uuid]).filter((s) => !!s);
const label =
rank === "cantrips" ? game.i18n.localize("PF2E.TraitCantrip") : ordinalString(rank);
return { label, spells: spells.map((s) => ({ link: s.link })) };
})
.filter((g) => g.spells.length),
});
description.value += `\n${append}`;
}

return description;
}

override async getChatData(
this: WeaponPF2e<ActorPF2e>,
htmlOptions: EnrichmentOptions = {},
Expand Down Expand Up @@ -746,6 +783,12 @@ class WeaponPF2e<TParent extends ActorPF2e | null = ActorPF2e | null> extends Ph

const traits = changed.system.traits ?? {};
if ("value" in traits && Array.isArray(traits.value)) {
// Clean up staff spells automatically if the staff trait is removed and the list is empty
const spells = changed.system.staff?.spells ?? this._source.system.staff?.spells;
if (!traits.value.includes("staff") && !spells?.length) {
changed.system.staff = null;
}

traits.value = traits.value.filter((t) => t in CONFIG.PF2E.weaponTraits);
}

Expand Down
Loading

0 comments on commit 570d711

Please sign in to comment.