diff --git a/build/lib/compendium-pack.ts b/build/lib/compendium-pack.ts index 2051d1bec98..cdb2fc044dd 100644 --- a/build/lib/compendium-pack.ts +++ b/build/lib/compendium-pack.ts @@ -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)) { @@ -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")) { diff --git a/src/module/item/base/sheet/sheet.ts b/src/module/item/base/sheet/sheet.ts index 4373c16f1e5..5554bc6fd51 100644 --- a/src/module/item/base/sheet/sheet.ts +++ b/src/module/item/base/sheet/sheet.ts @@ -483,7 +483,10 @@ class ItemSheetPF2e extends ItemSheet(html, 'tagify-tags[name="system.traits.otherTags"]'), { maxTags: 6 }); // Handle select and input elements that show modified prepared values until focused - const modifiedPropertyFields = htmlQueryAll(html, "[data-property]"); + const modifiedPropertyFields = htmlQueryAll( + html, + "input[data-property], select[data-property]", + ); for (const input of modifiedPropertyFields) { const propertyPath = input.dataset.property ?? ""; const baseValue = @@ -503,6 +506,14 @@ class ItemSheetPF2e extends ItemSheet(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 && diff --git a/src/module/item/helpers.ts b/src/module/item/helpers.ts index 9ad90804320..adc861d4445 100644 --- a/src/module/item/helpers.ts +++ b/src/module/item/helpers.ts @@ -93,7 +93,7 @@ class ItemChatData { } async #prepareDescription(): Promise> { - 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(); @@ -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 diff --git a/src/module/item/spellcasting-entry/collection.ts b/src/module/item/spellcasting-entry/collection.ts index 5574395db7a..aefddb299c0 100644 --- a/src/module/item/spellcasting-entry/collection.ts +++ b/src/module/item/spellcasting-entry/collection.ts @@ -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 extends Collection> { @@ -367,8 +367,8 @@ class SpellCollection extends Collection { /** 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; @@ -103,6 +110,15 @@ interface WeaponSystemSource extends Investable { 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 { otherTags: OtherWeaponTag[]; toggles?: { @@ -196,6 +212,7 @@ interface ComboWeaponMeleeUsage { export type { ComboWeaponMeleeUsage, SpecificWeaponData, + StaffSpellData, WeaponDamage, WeaponFlags, WeaponMaterialData, diff --git a/src/module/item/weapon/document.ts b/src/module/item/weapon/document.ts index fa86c41d0fd..294a2196302 100644 --- a/src/module/item/weapon/document.ts +++ b/src/module/item/weapon/document.ts @@ -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"; @@ -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"; @@ -366,6 +368,13 @@ class WeaponPF2e 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")); @@ -434,6 +443,34 @@ class WeaponPF2e extends Ph processTwoHandTrait(this); } + /** Get description with staff spells possibly included */ + override async getDescription(): Promise { + 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, htmlOptions: EnrichmentOptions = {}, @@ -745,6 +782,12 @@ class WeaponPF2e 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); } diff --git a/src/module/item/weapon/sheet.ts b/src/module/item/weapon/sheet.ts index a72e1229078..3bdc7fe01b0 100644 --- a/src/module/item/weapon/sheet.ts +++ b/src/module/item/weapon/sheet.ts @@ -1,4 +1,5 @@ import { AutomaticBonusProgression as ABP } from "@actor/character/automatic-bonus-progression.ts"; +import { ItemPF2e } from "@item"; import { ItemSheetOptions } from "@item/base/sheet/sheet.ts"; import { MATERIAL_DATA, @@ -8,14 +9,32 @@ import { RUNE_DATA, getPropertyRuneSlots, } from "@item/physical/index.ts"; +import { SpellSlotGroupId } from "@item/spellcasting-entry/collection.ts"; +import { coerceToSpellGroupId, getSpellRankLabel } from "@item/spellcasting-entry/helpers.ts"; import { SheetOptions, createSheetTags } from "@module/sheet/helpers.ts"; -import { ErrorPF2e, htmlQueryAll, objectHasKey, setHasElement, sortStringRecord, tupleHasValue } from "@util"; +import { + ErrorPF2e, + htmlClosest, + htmlQueryAll, + objectHasKey, + setHasElement, + sortStringRecord, + tupleHasValue, +} from "@util"; +import { UUIDUtils } from "@util/uuid.ts"; import * as R from "remeda"; -import { ComboWeaponMeleeUsage, SpecificWeaponData, WeaponPersistentDamage } from "./data.ts"; +import type { ComboWeaponMeleeUsage, SpecificWeaponData, StaffSpellData, WeaponPersistentDamage } from "./data.ts"; import type { WeaponPF2e } from "./document.ts"; import { MANDATORY_RANGED_GROUPS, WEAPON_RANGES } from "./values.ts"; export class WeaponSheetPF2e extends PhysicalItemSheetPF2e { + static override get defaultOptions(): ItemSheetOptions { + return { + ...super.defaultOptions, + dragDrop: [{ dropSelector: ".staff-spells" }], + }; + } + protected override get validTraits(): Record { return CONFIG.PF2E.weaponTraits; } @@ -117,6 +136,41 @@ export class WeaponSheetPF2e extends PhysicalItemSheetPF2e { weaponMAP: CONFIG.PF2E.weaponMAP, weaponRanges, weaponReload: CONFIG.PF2E.weaponReload, + staff: await this.#prepareStaffSpells(), + }; + } + + async #prepareStaffSpells(): Promise { + const item = this.item; + const staff = item.system.staff; + if (!staff) return null; + + const items = await UUIDUtils.fromUUIDs(R.unique(staff.spells.map((s) => s.uuid))); + const itemsByUuid = R.mapToObj(items, (i) => [i.uuid, i]); + + const allSpellGroups: SpellSlotGroupId[] = ["cantrips", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + return { + defaultEffect: game.i18n.localize("PF2E.Item.Weapon.Staff.DefaultEffect"), + effect: staff.effect, + spells: allSpellGroups.map((rank) => ({ + rank, + label: getSpellRankLabel(rank), + spells: + staff.spells + .filter((s) => s.rank === rank) + .map((data) => { + const spell = itemsByUuid[data.uuid]; + return { + img: spell?.img ?? data.img, + name: spell?.name ?? data.name, + uuid: data.uuid, + rank, + fromWorld: data.uuid.startsWith("Item."), + linked: !!spell, + }; + }) ?? [], + })), }; } @@ -157,6 +211,18 @@ export class WeaponSheetPF2e extends PhysicalItemSheetPF2e { } }); } + + const staffSpellRemoves = htmlQueryAll(html, "[data-action=remove-staff-spell]"); + for (const element of staffSpellRemoves) { + const uuid = htmlClosest(element, "[data-uuid]")?.dataset.uuid; + const rank = coerceToSpellGroupId(htmlClosest(element, "[data-rank]")?.dataset.rank); + element.addEventListener("click", () => { + const spells = this.item.system.staff?.spells.filter((s) => !(s.uuid === uuid && s.rank === rank)); + if (spells) { + this.item.update({ system: { staff: { spells } } }); + } + }); + } } protected override async _updateObject(event: Event, formData: Record): Promise { @@ -183,6 +249,61 @@ export class WeaponSheetPF2e extends PhysicalItemSheetPF2e { return super._updateObject(event, formData); } + + protected override async _onDrop(event: DragEvent): Promise { + if (!this.isEditable) return; + + const item = await (async (): Promise => { + try { + const dataString = event.dataTransfer?.getData("text/plain"); + const dropData = JSON.parse(dataString ?? ""); + return (await ItemPF2e.fromDropData(dropData)) ?? null; + } catch { + return null; + } + })(); + + // Handle dragging an item to a staff + const staffSpellsElement = htmlClosest(event.target, ".staff-spells"); + if (staffSpellsElement && item?.isOfType("spell")) { + const savedSpells = this.item._source.system.staff?.spells ?? []; + const rank = item.isCantrip ? "cantrips" : coerceToSpellGroupId(staffSpellsElement.dataset.rank); + if (savedSpells.some((s) => s.uuid === item.uuid && s.rank === rank)) { + ui.notifications.warn("Spell already exists at this rank"); + return; + } else if (typeof rank === "number" && rank < item.baseRank) { + ui.notifications.warn( + game.i18n.format("PF2E.Item.Spell.Warning.InvalidRank", { + spell: item.name, + spellRank: getSpellRankLabel(item.baseRank), + targetRank: getSpellRankLabel(rank), + }), + ); + return; + } + + if (rank !== null) { + const spells: StaffSpellData[] = R.sortBy( + [...savedSpells, { img: item.img, name: item.name, rank, uuid: item.uuid }], + (s) => (s.rank === "cantrips" ? 0 : s.rank), + ); + await this.item.update({ system: { staff: { spells } } }); + } + + return; + } + + super._onDrop(event); + } + + /** Clean up staff data if this sheet was closed. Doing it here allows a window of recovery of accidental deletion */ + override close(options?: { force?: boolean | undefined }): Promise { + if (!this.item.system.traits.value.includes("staff") && this.item._source.system.staff) { + this.item.update({ "system.staff": null }); + } + + return super.close(options); + } } interface PropertyRuneSheetSlot { @@ -221,4 +342,26 @@ interface WeaponSheetData extends PhysicalItemSheetData { weaponMAP: typeof CONFIG.PF2E.weaponMAP; weaponRanges: Record; weaponReload: typeof CONFIG.PF2E.weaponReload; + staff: StaffSheetData | null; +} + +interface StaffSheetData { + defaultEffect: string; + effect: string; + spells: StaffSpellRankSheetData[]; +} + +interface StaffSpellRankSheetData { + rank: SpellSlotGroupId; + label: string; + spells: SpellBrief[]; +} + +interface SpellBrief { + uuid: ItemUUID; + rank: SpellSlotGroupId; + name: string; + img: ImageFilePath; + fromWorld: boolean; + linked: boolean; } diff --git a/src/styles/item/_abc-sheet.scss b/src/styles/item/_abc-sheet.scss index 6abf42dfec9..e69f89ff56b 100644 --- a/src/styles/item/_abc-sheet.scss +++ b/src/styles/item/_abc-sheet.scss @@ -1,72 +1,3 @@ -.item-ref-group { - ul.item-refs { - border: 1px solid var(--color-border-light-2); - border-radius: 3px; - padding: 0; - margin: 0; - - &.empty { - height: 1.75rem; - - > li { - font-style: italic; - font-weight: 500; - opacity: 0.75; - - .image-placeholder { - background: rgba(black, 0.1); - border: 1px solid var(--color-disabled); - border-radius: 2px; - box-sizing: border-box; - height: 1.625rem; - } - } - } - - > li { - align-items: center; - display: grid; - grid-template-columns: 1.625rem auto 2em 1em; - padding: var(--space-1); - - &:nth-of-type(even) { - background-color: rgba(120, 100, 82, 0.1); - } - - .name { - display: block; - height: 1em; - line-height: 1em; - margin-left: 0.25em; - - i.fa-globe { - padding: 0 var(--space-3); - } - } - - .level { - font-weight: 500; - height: 1.25em; - text-align: center; - } - - a.remove { - padding: 0 var(--space-2); - } - } - - ul { - grid-column: 1 / 5; - margin-top: 0; - padding-left: 0.5em; - - &:empty { - display: none; - } - } - } -} - .form-group > label a.small-button { padding-left: var(--space-4); font-size: 0.9em; diff --git a/src/styles/item/_index.scss b/src/styles/item/_index.scss index ef11af76a43..16cffaf7715 100644 --- a/src/styles/item/_index.scss +++ b/src/styles/item/_index.scss @@ -136,6 +136,17 @@ scrollbar-gutter: stable; } + .contenteditable-input { + background-color: rgba(255, 255, 255, 0.5); + border: 1px solid rgb(118, 118, 118); + color: var(--color-text-dark-input); + padding: 1px 2px; + &[placeholder]:empty::before { + content: attr(placeholder); + color: #555; + } + } + @import "sidebar"; } @@ -304,7 +315,7 @@ font-weight: 600; } - .form-group > label:first-of-type { + .form-group > label:first-of-type:not(.short) { flex-basis: 11em; } @@ -520,6 +531,79 @@ } } + .item-ref-group { + ul.item-refs { + border: 1px solid var(--color-border-light-2); + border-radius: 3px; + padding: 0; + margin: 0; + + &.empty { + height: 1.75rem; + + > li { + font-style: italic; + font-weight: 500; + opacity: 0.75; + + .image-placeholder { + background: rgba(black, 0.1); + border: 1px solid var(--color-disabled); + border-radius: 2px; + box-sizing: border-box; + height: 1.625rem; + } + } + } + + > li { + align-items: center; + display: grid; + grid-template: "img name level controls" / 1.625rem auto 2em 1em; + padding: var(--space-1); + + &:nth-of-type(even) { + background-color: rgba(120, 100, 82, 0.1); + } + + .name { + display: block; + height: 1em; + line-height: 1em; + margin-left: 0.25em; + + i.fa-solid { + padding: 0 var(--space-3); + + i.fa-solid { + padding-left: 0; + } + } + } + + .level { + font-weight: 500; + height: 1.25em; + text-align: center; + } + + a.remove { + grid-area: controls; + padding: 0 var(--space-2); + } + } + + ul { + grid-column: 1 / 5; + margin-top: 0; + padding-left: 0.5em; + + &:empty { + display: none; + } + } + } + } + @import "abc-sheet"; @import "activations"; @import "mystification"; diff --git a/src/styles/item/_weapon-sheet.scss b/src/styles/item/_weapon-sheet.scss index 6c5cf6165b4..88910a17167 100644 --- a/src/styles/item/_weapon-sheet.scss +++ b/src/styles/item/_weapon-sheet.scss @@ -1,3 +1,15 @@ .precious-material select { width: 18em; } + +.staff { + .item-ref-group { + margin-top: 0; + } + + .staff-spells { + h3 { + padding-top: var(--space-4); + } + } +} diff --git a/static/lang/en.json b/static/lang/en.json index 40df865afb9..79fd0a73e45 100644 --- a/static/lang/en.json +++ b/static/lang/en.json @@ -1765,9 +1765,11 @@ }, "Activation": { "Add": "Add Activation", + "Activate": "Activate", "Cast": "Cast a Spell", "Command": "command", "CommandSheetLabel": "Command", + "Effect": "Effect", "Envision": "envision", "EnvisionSheetLabel": "Envision", "Interact": "Interact", @@ -2724,6 +2726,10 @@ "Hint": "Marking this weapon as a specific magic weapon indicates that it does more than what its material composition and runes allow. The name, level, rarity, and price will no longer be overridden from precious material or runes at the time of marking. Those material and runes will, however, serve as a baseline for level, rarity, and price adjustments from later upgrades.", "Label": "Specific Magic Weapon" }, + "Staff": { + "Label": "Staff Spells", + "DefaultEffect": "You expend a number of charges from the item to cast a spell from its list." + }, "ThrownUsage": { "Label": "Thrown Usage" }, diff --git a/static/templates/items/partials/staff-description-append.hbs b/static/templates/items/partials/staff-description-append.hbs new file mode 100644 index 00000000000..cfb1175eb5c --- /dev/null +++ b/static/templates/items/partials/staff-description-append.hbs @@ -0,0 +1,13 @@ +
+

{{localize "PF2E.Item.Activation.Activate"}} {{localize "PF2E.Item.Activation.Cast"}}

+

{{localize "PF2E.Item.Activation.Effect"}} {{effect}}

+
    + {{#each spells as |section|}} +
  • + {{section.label}} + {{#each section.spells as |spell|}} + {{{spell.link}}} + {{/each}} +
  • + {{/each}} +
diff --git a/static/templates/items/weapon-details.hbs b/static/templates/items/weapon-details.hbs index 65f88ebbe17..1019f64ce13 100644 --- a/static/templates/items/weapon-details.hbs +++ b/static/templates/items/weapon-details.hbs @@ -293,6 +293,50 @@ {{/if}} +{{#if staff}} +
+ {{localize "PF2E.Item.Weapon.Staff.Label"}} +
+ + {{localize "PF2E.Item.Activation.Cast"}} +
+
+ + {{staff.effect}} +
+
+ {{#each staff.spells as |section|}} +
+

{{localize section.label}}

+ +
+ {{/each}} +
+
+{{/if}} +