Browse Source

23-10-08 session

master
Austin Decker 1 year ago
parent
commit
f7e3f522ad
20 changed files with 507 additions and 24 deletions
  1. +1
    -1
      Config/options.json
  2. BIN
      Data/assets/akihiro_token.png
  3. BIN
      Data/assets/aysun_adara.jpg
  4. BIN
      Data/assets/aysun_adara_token.png
  5. +21
    -0
      Data/modules/pf2e-staves/LICENSE
  6. +30
    -0
      Data/modules/pf2e-staves/README.md
  7. +48
    -0
      Data/modules/pf2e-staves/module.json
  8. +359
    -0
      Data/modules/pf2e-staves/scripts/main.js
  9. +19
    -0
      Data/modules/pf2e-staves/styles/style.css
  10. BIN
      Data/worlds/the-fall-of-plaguestone/data/actors.db
  11. BIN
      Data/worlds/the-fall-of-plaguestone/data/combats.db
  12. BIN
      Data/worlds/the-fall-of-plaguestone/data/fog.db
  13. BIN
      Data/worlds/the-fall-of-plaguestone/data/folders.db
  14. BIN
      Data/worlds/the-fall-of-plaguestone/data/items.db
  15. BIN
      Data/worlds/the-fall-of-plaguestone/data/macros.db
  16. BIN
      Data/worlds/the-fall-of-plaguestone/data/messages.db
  17. BIN
      Data/worlds/the-fall-of-plaguestone/data/scenes.db
  18. BIN
      Data/worlds/the-fall-of-plaguestone/data/settings.db
  19. BIN
      Data/worlds/the-fall-of-plaguestone/data/tables.db
  20. BIN
      Data/worlds/the-fall-of-plaguestone/data/users.db

+ 1
- 1
Config/options.json View File

@ -10,7 +10,7 @@
"proxyPort": 443, "proxyPort": 443,
"proxySSL": true, "proxySSL": true,
"routePrefix": null, "routePrefix": null,
"updateChannel": "testing",
"updateChannel": "stable",
"upnp": true, "upnp": true,
"upnpLeaseDuration": null, "upnpLeaseDuration": null,
"awsConfig": null, "awsConfig": null,

BIN
Data/assets/akihiro_token.png (Stored with Git LFS) View File

size 94168

BIN
Data/assets/aysun_adara.jpg (Stored with Git LFS) View File

size 5083

BIN
Data/assets/aysun_adara_token.png (Stored with Git LFS) View File

size 78210

+ 21
- 0
Data/modules/pf2e-staves/LICENSE View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 jessev14
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

+ 30
- 0
Data/modules/pf2e-staves/README.md View File

@ -0,0 +1,30 @@
![All Downloads](https://img.shields.io/github/downloads/jessev14/pf2e-staves/total?style=for-the-badge)
![Latest Release Download Count](https://img.shields.io/github/downloads/jessev14/pf2e-staves/latest/module.zip)
[![Forge Installs](https://img.shields.io/badge/dynamic/json?label=Forge%20Installs&query=package.installs&suffix=%25&url=https%3A%2F%2Fforge-vtt.com%2Fapi%2Fbazaar%2Fpackage%2Fpf2e-staves&colorB=4aa94a)](https://forge-vtt.com/bazaar#package=pf2e-staves)
This module was funded by a commission. Donations help fund updates and new modules!
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/jessev14)
# PF2e Staves
This FoundryVTT module implements automations for Staves in the PF2e game system.
## Usage
When an item with the "magical" and "staff" traits are added to a character sheet, a corresponding spellcasting entry will be created.
![](/images/pf2e-staves-1.png)
Any spells linked in the text description will be automatically added to the spellcasting entry. This works for custom spells, as long as the spell exists in the item directory.
![](/images/pf2e-staves-2.png)
This spellcasting entry uses charges. The number of charges defaults to the highest level spell slot the character has. Casting spells in this spellcasting entry requires charges equal the level of the spell. If the character has Spontaneous spellcasting, the charge cost can be reduced by right clicking the Cast button. A spell slot of equal or higher level than the spell can be expended to reduce the charge cost to one charge.
![](/images/pf2e-staves-3.png)
When the character is ready to regain charges, the reset button next to the charge value will open a prompt to charge the stave. This will replenish the number of charges back to the default (equal to highest spell slot available). If the character has Prepared spellcasting, the player may choose a spell slot to expend to gain extra charges equal to the level of the spell slot.
![](/images/pf2e-staves-4.png)

+ 48
- 0
Data/modules/pf2e-staves/module.json View File

@ -0,0 +1,48 @@
{
"id": "pf2e-staves",
"title": "PF2e Staves",
"description": "",
"authors": [
{
"name": "enso",
"discord": "enso#0361"
}
],
"version": "1.3.1",
"compatibility": {
"minimum": "10",
"verified": "10"
},
"relationships": {
"systems": [
{
"id": "pf2e",
"type": "system",
"compatibility": {
"minimum": "4.3.0"
}
}
],
"requires": [
{
"id": "lib-wrapper",
"type": "module",
"compatibility": {
"minimum": "1",
"verified": "1.12.7.1"
}
}
]
},
"esmodules": [
"./scripts/main.js"
],
"styles": [
"./styles/style.css"
],
"languages": [],
"flags": {},
"url": "https://github.com/jessev14/pf2e-staves",
"manifest": "https://github.com/jessev14/pf2e-staves/releases/latest/download/module.json",
"download": "https://github.com/jessev14/pf2e-staves/releases/download/1.3.1/module.zip"
}

+ 359
- 0
Data/modules/pf2e-staves/scripts/main.js View File

@ -0,0 +1,359 @@
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 = `
<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');
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'];
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 += `<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);
}
}

+ 19
- 0
Data/modules/pf2e-staves/styles/style.css View File

@ -0,0 +1,19 @@
input.pf2e-staves-charges {
background: var(--alt);
border: 1px solid var(--sub);
border-radius: 3px;
color: #fff;
font-size: var(--font-size-11);
height: 14px;
text-align: center;
width: 22px;
padding: 0;
margin-left: 5px;
}
a.pf2e-staves-charge {
font-size: var(--font-size-10);
padding-left: 5px;
line-height: 1px;
}

BIN
Data/worlds/the-fall-of-plaguestone/data/actors.db (Stored with Git LFS) View File

size 1338587

BIN
Data/worlds/the-fall-of-plaguestone/data/combats.db (Stored with Git LFS) View File

size 295

BIN
Data/worlds/the-fall-of-plaguestone/data/fog.db (Stored with Git LFS) View File

size 5862251

BIN
Data/worlds/the-fall-of-plaguestone/data/folders.db (Stored with Git LFS) View File

size 5023

BIN
Data/worlds/the-fall-of-plaguestone/data/items.db (Stored with Git LFS) View File

size 44516

BIN
Data/worlds/the-fall-of-plaguestone/data/macros.db (Stored with Git LFS) View File

size 26260

BIN
Data/worlds/the-fall-of-plaguestone/data/messages.db (Stored with Git LFS) View File

size 9564078

BIN
Data/worlds/the-fall-of-plaguestone/data/scenes.db (Stored with Git LFS) View File

size 472802

BIN
Data/worlds/the-fall-of-plaguestone/data/settings.db (Stored with Git LFS) View File

size 36739

BIN
Data/worlds/the-fall-of-plaguestone/data/tables.db (Stored with Git LFS) View File

size 23654

BIN
Data/worlds/the-fall-of-plaguestone/data/users.db (Stored with Git LFS) View File

size 9357

Loading…
Cancel
Save