export const moduleID = 'pf2e-staves';
export const lg = x => console.log(x);
const mostCommonInList = (arr) => {
return arr.sort((a,b) =>
arr.filter(v => v===a).length
- arr.filter(v => v===b).length
).pop();
}
Hooks.once('init', () => {
// Add Charge spell type.
if(CONFIG.PF2E.spellCategories) CONFIG.PF2E.spellCategories.charge = 'Charge';
CONFIG.PF2E.preparationType.charge = 'Charge';
// Patch spellcastingEntry#cast to use charges instead of spell slots for staves.
libWrapper.register(moduleID, 'CONFIG.PF2E.Item.documentClasses.spellcastingEntry.prototype.cast', spellcastingEntry_cast, 'MIXED');
});
// When stave added to a character, also create corresponding spellcasting entry.
Hooks.on('createItem', async (weapon, options, userID) => {
if (!weapon.actor) return;
if (userID !== game.user.id) return;
const traits = weapon.system.traits?.value;
const isStave = traits?.includes('magical') && traits?.includes('staff');
const isCoda = traits?.includes('coda') && traits?.includes('staff');
if (!isStave && !isCoda) return;
return createStaveSpellcastingEntry(weapon, weapon.actor);
});
// When stave updated on a character, create spellcasting entry if none found. Update existing entry if found.
Hooks.on('updateItem', async (weapon, update, options, userID) => {
if (!weapon.actor) return;
if (userID !== game.user.id) return;
const traits = weapon.system.traits?.value;
const isStave = traits?.includes('magical') && traits?.includes('staff');
const isCoda = traits?.includes('coda') && traits?.includes('staff');
if (!isStave && !isCoda) return;
const { actor } = weapon;
const existingStaveEntry = actor.spellcasting.find(s => s.flags && s.flags[moduleID]?.staveID === weapon?.id);
return createStaveSpellcastingEntry(weapon, actor, existingStaveEntry);
});
// Delete spellcastingEntry associated with stave.
Hooks.on('preDeleteItem', (weapon, options, userID) => {
const traits = weapon.system.traits?.value;
const isStave = traits?.includes('magical') && traits?.includes('staff');
const isCoda = traits?.includes('coda') && traits?.includes('staff');
if (!isStave && !isCoda) return;
const { actor } = weapon;
const spellcastingEntries = actor.items.filter(i => i.type === 'spellcastingEntry');
const spellcastingEntry = spellcastingEntries.find(i => i.getFlag(moduleID, 'staveID') === weapon.id);
if (spellcastingEntry) spellcastingEntry.delete();
});
// Implement charge spellcasting rules on character sheet.
// Hooks.on('renderCharacterSheetPF2e', (sheet, [html], sheetData) => {
Hooks.on('renderCreatureSheetPF2e', (sheet, [html], sheetData) => {
const actor = sheet.object;
const isPC = actor.type === 'character';
const spellcastingLis = html.querySelectorAll('li.spellcasting-entry');
for (const li of spellcastingLis) {
const spellcastingEntry = actor.spellcasting.get(li.dataset.containerId);
if (spellcastingEntry?.system?.prepared?.value !== 'charge') continue;
let chargeEl;
if (isPC) {
chargeEl = document.createElement('section');
chargeEl.innerHTML = `
Charges
`;
} else {
chargeEl = document.createElement('div');
chargeEl.classList.add('inline-field');
chargeEl.innerHTML = `
`;
}
// Charge input.
chargeEl.querySelector('input').addEventListener('focus', ev => {
ev.currentTarget.select();
});
chargeEl.querySelector('input').addEventListener('change', async ev => {
const { target } = ev;
const charges = target.value;
const clampedCharges = Math.max(0, charges);
target.value = clampedCharges;
await spellcastingEntry.setFlag(moduleID, 'charges', clampedCharges);
});
// Charge stave prompt.
chargeEl.querySelector('a').addEventListener('click', async ev => {
let options = ``;
for (const li of spellcastingLis) {
const spellcastingEntry = actor.spellcasting.get(li.dataset.containerId);
if (spellcastingEntry?.system?.prepared?.value !== 'prepared') continue;
const preppedSpells = [];
for (const spellLi of li.querySelectorAll('li.item.spell')) {
if (spellLi.dataset.expendedState === 'true' || !parseInt(spellLi.dataset.slotLevel)) continue;
const spell = actor.items.get(spellLi.dataset.itemId);
const { entryId, slotLevel, slotId } = spellLi.dataset;
preppedSpells.push({
name: spell.name,
entryId,
slotLevel,
slotId
});
}
if (preppedSpells.length) {
options += ``;
}
}
if (options) options = `` + options;
const content = options
? `
Expend spell slot for extra charges?
`
: null;
await Dialog.prompt({
title: 'Charge Stave?',
content,
label: 'Charge',
callback: async ([dialogHtml]) => {
const charges = getHighestSpellslot(actor);
const select = dialogHtml.querySelector('select');
if (!select || !select?.selectedIndex) return spellcastingEntry.setFlag(moduleID, 'charges', charges);
const selectedSpellOption = select.options[select.selectedIndex];
const { entryId, slotLevel, slotId } = selectedSpellOption.dataset;
const entry = actor.items.get(entryId);
entry.setSlotExpendedState(slotLevel, slotId, true);
return spellcastingEntry.setFlag(moduleID, 'charges', charges + parseInt(slotLevel));
},
rejectClose: false,
options: { width: 250 }
});
});
const characterHeader = li.querySelector('div.statistic-values');
const npcHeader = li.querySelector('h4.name');
if (isPC) characterHeader.appendChild(chargeEl);
else npcHeader.after(chargeEl);
// Add spontaneous spellcasting rules to Cast button right click.
const castButtons = li.querySelectorAll('button.cast-spell,button[data-action="cast-spell"]');
castButtons.forEach(button => {
button.addEventListener('contextmenu', () => {
const spellLi = button.closest('li.item.spell');
const { itemId, slotLevel, slotId, entryId } = spellLi.dataset;
const collection = actor.spellcasting.collections.get(entryId, { strict: true });
const spell = collection.get(itemId, { strict: true });
collection.entry.cast(spell, { level: slotLevel, [`${moduleID}Spontaneous`]: true });
});
});
// Add .slotless-level-toggle button.
const slotToggleButton = document.createElement('a');
slotToggleButton.title = 'Toggle visibility of spell levels without slots';
slotToggleButton.classList.add('skill-name', 'slotless-level-toggle');
slotToggleButton.innerHTML = ``;
slotToggleButton.addEventListener('click', async ev => {
ev.preventDefault();
const spellcastingID = $(ev.currentTarget).parents(".item-container").attr("data-container-id") ?? "";
if (!spellcastingID) return;
const spellcastingEntry = actor.items.get(spellcastingID);
const bool = !(spellcastingEntry?.system?.showSlotlessLevels || {}).value;
await spellcastingEntry.update({
"system.showSlotlessLevels.value": bool,
});
});
const itemControls = li.querySelector('div.item-controls');
itemControls.prepend(slotToggleButton);
}
});
async function createStaveSpellcastingEntry(stave, actor, existingEntry = null) {
const spells = [];
const description = stave.system.description.value;
const slotLevels = ['Cantrips?', '1st', '2nd', '3rd', '4th', '5th', '6th', '7th', '8th', '9th', '10th', '11th'];
// If the previous and current descriptions match, assume no changes are needed and skip this
const prevDesc = existingEntry?.getFlag(moduleID, "prevDescription");
if (prevDesc === description) return;
for (let i = 0; i < slotLevels.length; i++) {
const regex = new RegExp(`${slotLevels[i]}.*@(UUID|Compendium).*\n`);
const match = description.match(regex);
if (!match) continue;
const strs = match[0].match(/(@UUID\[Compendium\.|@Compendium\[)(.*?)]({.*?})?/g);
for (const str of strs) {
const UUID = str.split('[')[1].split(']')[0].replace('Compendium.', '');
const spell = await fromUuid("Compendium." + UUID);
if (!spell || spell?.type !== 'spell') continue;
let spellClone;
if (spell.id) spellClone = spell.clone({ 'system.location.heightenedLevel': i });
else {
const { pack, _id } = spell;
const spellFromPack = await game.packs.get(pack)?.getDocuments().find(s => s.id === _id);
spellClone = spellFromPack.clone({ 'system.location.heightenedLevel': i });
}
spells.push(spellClone);
}
}
if (!spells.length) { // fallback
const UUIDs = description.match(/(@UUID\[Compendium\.|@Compendium\[)(.*?)].*?}/g);
if (!UUIDs) return;
for (const str of UUIDs) {
const UUID = str.split('[')[1].split(']')[0].replace('Compendium.', '');
const spell = await fromUuid("Compendium." + UUID);
if (!spell || spell?.type !== 'spell') continue;
if (spell.id) spells.push(spell);
else {
const { pack, _id } = spell;
const spellFromPack = await game.packs.get(pack)?.getDocuments().find(s => s.id === _id);
if (spellFromPack) spells.push(spellFromPack);
}
}
}
if (!spells.length) return;
if (!existingEntry) {
const highestMentalAbilityValue = Math.max(...Object.keys(actor.abilities).filter(abi => ['cha', 'int', 'wis'].includes(abi)).map(abi => actor.abilities[abi].value));
// picking best mental ability; not always correct, but it's a good rule of thumb
const bestMentalAbility = Object.keys(actor.abilities).find(abi => actor.abilities[abi].value === highestMentalAbilityValue);
// rule of thumb for tradition is to pick whatever exists in other spellcasting entries
const mostCommonTradition = mostCommonInList(actor.spellcasting.map(se => se?.system?.tradition.value).filter(se => !!se));
const createData = {
type: 'spellcastingEntry',
name: stave.name,
system: {
prepared: {
value: 'charge'
},
ability: {
value: bestMentalAbility
},
tradition: {
value: mostCommonTradition
},
showSlotlessLevels: {
value: false
}
},
flags: {
[moduleID]: {
staveID: stave.id,
charges: getHighestSpellslot(actor),
prevDescription: description
}
}
}
const [spellcastingEntry] = await actor.createEmbeddedDocuments('Item', [createData]);
for (const spell of spells) await spellcastingEntry.addSpell(spell);
} else {
for (const spell of existingEntry.spells) await spell.delete();
for (const spell of spells) await existingEntry.addSpell(spell);
await existingEntry.setFlag(moduleID, "prevDescription", description);
}
}
function getHighestSpellslot(actor) {
let charges = 0;
actor.spellcasting.contents.forEach(entry => {
if (!entry?.flags || entry.flags[moduleID]) return;
let i = 0;
Object.values(entry.system.slots).forEach(slot => {
if (slot.max && charges < i) charges = i;
i++;
});
});
return charges;
}
async function spellcastingEntry_cast(wrapped, spell, options) {
if (!spell.spellcasting.flags[moduleID] || spell.isCantrip) return wrapped(spell, options);
options.consume = false;
if (options[`${moduleID}Override`]) return wrapped(spell, options);
const { actor } = spell;
const charges = spell.spellcasting.getFlag(moduleID, 'charges');
if (options[`${moduleID}Spontaneous`]) {
if (!charges) return ui.notifications.warn('You do not have enough stave charges to cast this spell.');
const select = document.createElement('select');
select.style.width = '100%';
select.style['margin-bottom'] = '5px';
for (const entry of actor.spellcasting.filter(e => e.system)) {
if (entry.system.prepared.value !== 'spontaneous') continue;
select.innerHTML += ``;
}
if (!select.length) return ui.notifications.warn('You do not have any Spontaneous spell slots available to cast this spell.');
await Dialog.prompt({
title: 'Use Spell Slot?',
content: select.outerHTML,
label: 'Consume Spell Slot',
callback: async ([html]) => {
const select = html.querySelector('select');
const [entryID, selectedLevel] = select.value.split('-');
const entry = actor.spellcasting.get(entryID);
const currentSlots = entry.system.slots[`slot${selectedLevel}`].value;
await actor.spellcasting.get(entryID).update({ [`system.slots.slot${selectedLevel}.value`]: currentSlots - 1 });
await spell.spellcasting.setFlag(moduleID, 'charges', charges - 1);
options[`${moduleID}Override`] = true;
return spell.spellcasting.cast(spell, options);
},
rejectClose: false,
options: { width: 250 }
});
} else {
if (spell.level > charges) return ui.notifications.warn('You do not have enough stave charges to cast this spell.');
await spell.spellcasting.setFlag(moduleID, 'charges', charges - spell.level);
return wrapped(spell, options);
}
}