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. 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 += `` for (const spell of preppedSpells) { options += `` } 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'); 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']; 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) } } } 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); } } function getHighestSpellslot(actor) { let charges = 0; actor.spellcasting.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 += ``; for (let i = parseInt(options.level); i < 12; i++) { const currentSlotLevel = Object.values(entry.system.slots)[i]; const { value, max } = currentSlotLevel; if (value) select.innerHTML += ``; } 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); } }