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 18, 2025
1 parent 3fcb8d2 commit a870ec0
Show file tree
Hide file tree
Showing 14 changed files with 407 additions and 81 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
13 changes: 12 additions & 1 deletion src/module/item/base/sheet/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,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 @@ -503,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
4 changes: 2 additions & 2 deletions src/module/item/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class ItemChatData {
}

async #prepareDescription(): Promise<Pick<ItemDescriptionData, "value" | "gm">> {
const { data, item } = this;
const item = this.item;
const actor = item.actor;
const rollOptions = new Set([actor?.getRollOptions(), item.getRollOptions("item")].flat().filter(R.isTruthy));
const description = await this.item.getDescription();
Expand Down Expand Up @@ -129,7 +129,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 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 @@ -367,8 +367,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 { performLatePreparation } from "@item/helpers.ts";
import type { NPCAttackDamage } from "@item/melee/data.ts";
import type { NPCAttackTrait } from "@item/melee/types.ts";
Expand All @@ -17,7 +18,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 @@ -366,6 +368,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 @@ -434,6 +443,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 @@ -745,6 +782,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 a870ec0

Please sign in to comment.