All user data for FoundryVTT. Includes worlds, systems, modules, and any asset in the "foundryuserdata" directory. Does NOT include the FoundryVTT installation itself.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

366 lines
16 KiB

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 = `
<h4 class='skill-name spellcasting'>Charges</h4>
<input class="${moduleID}-charges" type="number" value="${spellcastingEntry.getFlag(moduleID, 'charges')}" placeholder="0">
<a class="${moduleID}-charge"><i class="fas fa-redo"></i></a>
`;
} else {
chargeEl = document.createElement('div');
chargeEl.classList.add('inline-field');
chargeEl.innerHTML = `
<label>Charges</label>
<input class="dc-input modifier adjustable" type="number" value="${spellcastingEntry.getFlag(moduleID, 'charges')}" placeholder="0">
<a class="${moduleID}-charge"><i class="fas fa-redo"></i></a>
`;
}
// 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 += `<optgroup label="${spellcastingEntry.name}">`
for (const spell of preppedSpells) {
options += `<option data-entry-id="${spell.entryId}" data-slot-level="${spell.slotLevel}" data-slot-id="${spell.slotId}">${spell.name} (+${spell.slotLevel})</option>`
}
options += `</optgroup>`;
}
}
if (options) options = `<option></option>` + options;
const content = options
? `
<div style="font-size: var(--font-size-13);">Expend spell slot for extra charges?</div>
<select style="width: 100%; margin-bottom: 5px;">
${options}
</select>
`
: 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 = `<i class="fas fa-list-alt"></i>`;
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 += `<optgroup label="${entry.name}">`;
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 += `<option value="${entry.id}-${i}">Level ${i} Slot (${value}/${max})</option>`;
}
select.innerHTML += `</optgroup>`;
}
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);
}
}