if (typeof MarkStart != 'undefined') {MarkStart('SpellMaster');} // SpellMaster - 5e OGL Sheet Spell Manager // By: Michael G. (Volt Cruelerz) // Github: https://github.com/VoltCruelerz/SpellMaster const SpellDict = {}; const SpellMasterInstall = () => { const defaultSettings = { Sheet: 'OGL', Version: 2.004 }; if(!state.SpellMaster) { state.SpellMaster = defaultSettings; } // Update version let BookDict = state.SpellMaster; for (var bookName in BookDict) { // check if the property/key is defined in the object itself, not in parent if (BookDict.hasOwnProperty(bookName)) { const spellBook = BookDict[bookName]; // Version 1.3 -> 1.4: add Filter.CastTime if (state.SpellMaster.Version < 1.4) { if (spellBook.Filter) { if (!spellBook.Filter.CastTime) { spellBook.Filter.CastTime = 'Any'; log(`Migrating Spellbook ${spellBook.Name} to version 1.4`); } } } // Version 1.5 -> 1.7: add sorcery points if (state.SpellMaster.Version < 1.7) { spellBook.CurSorc = spellBook.CurSorc || 0; spellBook.MaxSorc = spellBook.MaxSorc || 0; log(`Migrating Spellbook ${spellBook.Name} to version 1.7`); } // Version 1.9 -> 1.10: add items if (state.SpellMaster.Version < 2.000) { spellBook.Items = []; log(`Migrating Spellbook ${spellBook.Name} to version 2.000`); } } } // Update Version Number in State if (!state.SpellMaster.Version || state.SpellMaster.Version < defaultSettings.Version) { state.SpellMaster.Version = defaultSettings.Version; } } SpellMasterInstall(); on('ready', () => { let SpellList = []; if (typeof CustomSpellList !== 'undefined') { log('SpellMaster: Loading Custom Spell List'); SpellList = CustomSpellList; } else if (typeof SrdSpellList !== 'undefined') { log('SpellMaster: Loading SRD Spell List'); SpellList = SrdSpellList; } else { const fatalMsg = 'SpellMaster Fatal Error: SRD.js is not installed! Please install SRD.js or a custom spellbook file.'; log(fatalMsg); sendChat('SpellMaster Boot', fatalMsg); throw new Error(fatalMsg); } const chatTrigger = '!SpellMaster';// This is the trigger that makes the script listen const scname = 'SpellMaster';// How this script shows up when it sends chat messages const maxSpellLevel = 10;// My campaign has a few NPCs with 10th-level magic let debugLog = false; // Debug Log const dlog = (str) => { if (debugLog) { log(str); } }; const dStringify = (str, obj) => { if (debugLog) { log(str + JSON.stringify(obj)); } }; // Fatal Log const flog = (str, errorCode) => { log('SPELL MASTER FATAL ERROR CODE ' + errorCode + ' = ' + str); log('DUMPING STATE.SPELLMASTER'); log(JSON.stringify(state.SpellMaster)); }; // Alias state so we don't accidentally break it. let BookDict = state.SpellMaster; let SpellsIndexed = false; // A cache of data so not everything is recreated every time the page is drawn. const Cache = { Books: {}, Exports: {} }; // Creates dictionary of spells const IndexSpellbook = () => { for(let i = 0; i < SpellList.length; i++){ let spell = SpellList[i]; SpellDict[spell.Name] = spell; } SpellsIndexed = true; }; IndexSpellbook(); log("Spell List Indexed with " + SpellList.length + " spells."); if (debugLog) { sendChat(scname, '/w gm Spellbook Indexed with ' + SpellList.length + ' spells.'); } // Retrieves a handout by name const GetHandout = (nameOrId) => { let list = findObjs({ _type: 'handout', name: nameOrId, }); if (list.length === 1) { return list[0]; } list = findObjs({ _type: 'handout', id: nameOrId }); if (list.length === 1) { return list[0]; } }; // Retrieves a character by name or id const GetCharByAny = (nameOrId) => { let character = null; // Try to directly load the character ID character = getObj('character', nameOrId); if (character) { return character; } // Try to load indirectly from the token ID const token = getObj('graphic', nameOrId); if (token) { character = getObj('character', token.get('represents')); if (character) { return character; } } // Try loading through char name const list = findObjs({ _type: 'character', name: nameOrId, }); if (list.length === 1) { return list[0]; } // Default to null return null; }; // Get attribute from char sheet. Should usually use GetCachedAttr() const getattr = (charId, att) => { const attr = findObjs({ type: 'attribute', characterid: charId, name: att, })[0]; if (attr) { return attr.get('current'); } return ''; }; // Retrieves the value stored in the parameter with the provided name const GetParamValue = (argParams, paramName) => { const id = GetParamId(argParams, paramName); if(id === -1){ return null; } return Decaret(argParams[id]); }; // Retrieves the index in the array of params for the specified parameter const GetParamId = (argParams, paramName) => { for(let i = 0; i < argParams.length; i++){ let arg = argParams[i]; const argWords = arg.split(/\s+/); if(argWords[0] == paramName){ return i; } } return -1; }; // Pulls the interior message out of carets (^) const Decaret = (quotedString) => { const startQuote = quotedString.indexOf('^'); const endQuote = quotedString.lastIndexOf('^'); if (startQuote >= endQuote) { sendToGmAndPlayer(`**ERROR:** You must have a string within carets in the phrase ${string}`); return null; } return quotedString.substring(startQuote + 1, endQuote); }; // Calculates Damerau-Levenshtein distance. See: https://stackoverflow.com/a/11958496 const levDist = (s, t) => { var d = []; //2d matrix // Step 1 var n = s.length; var m = t.length; if (n == 0) return m; if (m == 0) return n; //Create an array of arrays in javascript (a descending loop is quicker) for (var i = n; i >= 0; i--) d[i] = []; // Step 2 for (var i = n; i >= 0; i--) d[i][0] = i; for (var j = m; j >= 0; j--) d[0][j] = j; // Step 3 for (var i = 1; i <= n; i++) { var s_i = s.charAt(i - 1); // Step 4 for (var j = 1; j <= m; j++) { //Check the jagged ld total so far if (i == j && d[i][j] > 4) return n; var t_j = t.charAt(j - 1); var cost = (s_i == t_j) ? 0 : 1; // Step 5 //Calculate the minimum var mi = d[i - 1][j] + 1; var b = d[i][j - 1] + 1; var c = d[i - 1][j - 1] + cost; if (b < mi) mi = b; if (c < mi) mi = c; d[i][j] = mi; // Step 6 //Damerau transposition if (i > 1 && j > 1 && s_i == t.charAt(j - 2) && s.charAt(i - 2) == t_j) { d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + cost); } } } // Step 7 return d[n][m]; } /** * Calculate the Damerau-Levenshtein distance to get a list of spell suggestions, which are then printed. * @param input string - the failed spell name to calculate suggestions for * @param inputMessage Object - the source message */ const printSpellSuggestions = (input, inputMessage) => { const suggestions = SpellList.map((spell) => { return { Name: spell.Name, Distance: levDist(input, spell.Name) }; }); suggestions.sort(Sorters.DistanceMin); const topSuggestions = []; // Print the top suggestions. const maxSuggestions = 5; for(let i = 0; i < maxSuggestions; i++) { topSuggestions.push(suggestions[i].Name); } // Also add any substrings (Magic Aura vs Nystul's Magic Aura) SpellList.forEach((spell) => { if ((input.includes(spell.Name) || spell.Name.includes(input)) && !topSuggestions.includes(spell.Name)) { topSuggestions.push(spell.Name); } }); let message = `The spell ${input} does not exist in our records. Did you mean one of these?`; topSuggestions.forEach((suggestion) => { message += `
- ${suggestion}`; }); sendToGmAndPlayer(inputMessage, message); } // Enum of caster types const CasterMode = { Invalid: 0, Full: 1, Half: 2, Third: 3, Pact: 4, None: 5, HalfPlus: 6 }; // The multipliers for caster modes const CasterModeMultiplier = {}; // A dictionary of caster types const CasterTypeMap = {}; // Map full text names to abbreviations, so Intelligence => INT. const StatMap = {}; // Map class name to stat ie Druid => WIS const ClassToStatMap = {}; // The cost of composing sorcery points into spell slots const SlotComposeCost = {} // Perform initial configuration for caster type mappings const BuildMaps = () => { CasterModeMultiplier[CasterMode.Full] = 1; CasterModeMultiplier[CasterMode.HalfPlus] = 1/2; CasterModeMultiplier[CasterMode.Half] = 1/2; CasterModeMultiplier[CasterMode.Third] = 1/3; CasterModeMultiplier[CasterMode.None] = 0; CasterModeMultiplier[CasterMode.Invalid] = 0; CasterModeMultiplier[CasterMode.Pact] = 1; CasterTypeMap['Artificer'] = CasterMode.HalfPlus; CasterTypeMap['Artificer [TK]'] = CasterMode.Full; CasterTypeMap['Barbarian'] = CasterMode.None; CasterTypeMap['Bard'] = CasterMode.Full; CasterTypeMap['Cleric'] = CasterMode.Full; CasterTypeMap['Druid'] = CasterMode.Full; CasterTypeMap['Fighter:Eldritch Knight'] = CasterMode.Third; CasterTypeMap['Monk'] = CasterMode.None; CasterTypeMap['Paladin'] = CasterMode.Half; CasterTypeMap['Ranger'] = CasterMode.Half; CasterTypeMap['Rogue:Arcane Trickster'] = CasterMode.Third; CasterTypeMap['Shaman'] = CasterMode.Full; CasterTypeMap['Sorcerer'] = CasterMode.Full; CasterTypeMap['Warlock'] = CasterMode.Pact; CasterTypeMap['Wizard'] = CasterMode.Full; CasterTypeMap[null] = CasterMode.None; CasterTypeMap['undefined'] = CasterMode.None; CasterTypeMap['SRD'] = CasterMode.Full; StatMap['Strength'] = 'STR'; StatMap['Dexterity'] = 'DEX'; StatMap['Constitution'] = 'CON'; StatMap['Intelligence'] = 'INT'; StatMap['Wisdom'] = 'WIS'; StatMap['Charisma'] = 'CHA'; ClassToStatMap['Artificer'] = 'Intelligence'; ClassToStatMap['Artificer [TK]'] = 'Intelligence'; ClassToStatMap['Bard'] = 'Charisma'; ClassToStatMap['Cleric'] = 'Wisdom'; ClassToStatMap['Druid'] = 'Wisdom'; ClassToStatMap['Fighter'] = 'Intelligence'; ClassToStatMap['Paladin'] = 'Charisma'; ClassToStatMap['Ranger'] = 'Wisdom'; ClassToStatMap['Rogue'] = 'Intelligence'; ClassToStatMap['Shaman'] = 'Wisdom'; ClassToStatMap['Sorcerer'] = 'Charisma'; ClassToStatMap['Warlock'] = 'Charisma'; ClassToStatMap['Wizard'] = 'Intelligence'; SlotComposeCost[1] = 2; SlotComposeCost[2] = 3; SlotComposeCost[3] = 5; SlotComposeCost[4] = 6; SlotComposeCost[5] = 7; }; BuildMaps(); // Get a caster type from the map (CasterMode). const GetCasterTypeFromClass = (className, subName) => { const baseVal = CasterTypeMap[className]; if (baseVal) { return baseVal; } const auxVal = CasterTypeMap[className + ':' + subName]; return auxVal; }; // Get default spell slot array for a caster type and level const GetBaseSpellSlots = (casterMode, level, className = '', subclassName = '') => { const empty = [0,0,0,0,0,0,0,0,0]; dlog(`Getting Slots for mode=${casterMode} lvl=${level}, class=${className}, sub=${subclassName}`); if (casterMode === CasterMode.Full){// 17 tiers const fullCasterSlots = [ [0,0,0,0,0,0,0,0,0],// 0 [2,0,0,0,0,0,0,0,0],// 1 [3,0,0,0,0,0,0,0,0],// 2 [4,2,0,0,0,0,0,0,0],// 3 [4,3,0,0,0,0,0,0,0],// 4 [4,3,2,0,0,0,0,0,0],// 5 [4,3,3,0,0,0,0,0,0],// 6 [4,3,3,1,0,0,0,0,0],// 7 [4,3,3,2,0,0,0,0,0],// 8 [4,3,3,3,1,0,0,0,0],// 9 [4,3,3,3,2,0,0,0,0],// 10 [4,3,3,3,2,1,0,0,0],// 11 [4,3,3,3,2,1,0,0,0],// 12 [4,3,3,3,2,1,1,0,0],// 13 [4,3,3,3,2,1,1,0,0],// 14 [4,3,3,3,2,1,1,1,0],// 15 [4,3,3,3,2,1,1,1,0],// 16 [4,3,3,3,2,1,1,1,1],// 17 [4,3,3,3,3,1,1,1,1],// 18 [4,3,3,3,3,2,1,1,1],// 19 [4,3,3,3,3,2,2,1,1],// 20 ]; return fullCasterSlots[level]; } else if(casterMode === CasterMode.HalfPlus) {// 10 tiers, but immediate caster slots. const halfPlusCasterSlots = [ [0,0,0,0,0,0,0,0,0],// 0 [2,0,0,0,0,0,0,0,0],// 1 [2,0,0,0,0,0,0,0,0],// 2 [3,0,0,0,0,0,0,0,0],// 3 [3,0,0,0,0,0,0,0,0],// 4 [4,2,0,0,0,0,0,0,0],// 5 [4,2,0,0,0,0,0,0,0],// 6 [4,3,0,0,0,0,0,0,0],// 7 [4,3,0,0,0,0,0,0,0],// 8 [4,3,2,0,0,0,0,0,0],// 9 [4,3,2,0,0,0,0,0,0],// 10 [4,3,3,0,0,0,0,0,0],// 11 [4,3,3,0,0,0,0,0,0],// 12 [4,3,3,1,0,0,0,0,0],// 13 [4,3,3,1,0,0,0,0,0],// 14 [4,3,3,2,0,0,0,0,0],// 15 [4,3,3,2,0,0,0,0,0],// 16 [4,3,3,2,1,0,0,0,0],// 17 [4,3,3,2,1,0,0,0,0],// 18 [4,3,3,2,2,0,0,0,0],// 19 [4,3,3,2,2,0,0,0,0],// 20 ]; return halfPlusCasterSlots[level]; } else if(casterMode === CasterMode.Half) {// 10 tiers const validClasses = [ 'Paladin', 'Ranger', ]; if (!validClasses.includes(className)) { return empty; } const halfCasterSlots = [ [0,0,0,0,0,0,0,0,0],// 0 [0,0,0,0,0,0,0,0,0],// 1 [2,0,0,0,0,0,0,0,0],// 2 [3,0,0,0,0,0,0,0,0],// 3 [3,0,0,0,0,0,0,0,0],// 4 [4,2,0,0,0,0,0,0,0],// 5 [4,2,0,0,0,0,0,0,0],// 6 [4,3,0,0,0,0,0,0,0],// 7 [4,3,0,0,0,0,0,0,0],// 8 [4,3,2,0,0,0,0,0,0],// 9 [4,3,2,0,0,0,0,0,0],// 10 [4,3,3,0,0,0,0,0,0],// 11 [4,3,3,0,0,0,0,0,0],// 12 [4,3,3,1,0,0,0,0,0],// 13 [4,3,3,1,0,0,0,0,0],// 14 [4,3,3,2,0,0,0,0,0],// 15 [4,3,3,2,0,0,0,0,0],// 16 [4,3,3,2,1,0,0,0,0],// 17 [4,3,3,2,1,0,0,0,0],// 18 [4,3,3,2,2,0,0,0,0],// 19 [4,3,3,2,2,0,0,0,0],// 20 ]; return halfCasterSlots[level]; } else if (casterMode === CasterMode.Third) {// 7 tiers const validSubs = [ 'Arcane Trickster', 'Eldritch Knight' ]; if (!validSubs.includes(subclassName)) { return empty; } const thirdCasterSlots = [ [0,0,0,0,0,0,0,0,0],// 0 [0,0,0,0,0,0,0,0,0],// 1 [0,0,0,0,0,0,0,0,0],// 2 [2,0,0,0,0,0,0,0,0],// 3 [3,0,0,0,0,0,0,0,0],// 4 [3,0,0,0,0,0,0,0,0],// 5 [3,0,0,0,0,0,0,0,0],// 6 [4,2,0,0,0,0,0,0,0],// 7 [4,2,0,0,0,0,0,0,0],// 8 [4,2,0,0,0,0,0,0,0],// 9 [4,3,0,0,0,0,0,0,0],// 10 [4,3,0,0,0,0,0,0,0],// 11 [4,3,0,0,0,0,0,0,0],// 12 [4,3,2,0,0,0,0,0,0],// 13 [4,3,2,0,0,0,0,0,0],// 14 [4,3,2,0,0,0,0,0,0],// 15 [4,3,3,0,0,0,0,0,0],// 16 [4,3,3,0,0,0,0,0,0],// 17 [4,3,3,0,0,0,0,0,0],// 18 [4,3,3,1,0,0,0,0,0],// 19 [4,3,3,1,0,0,0,0,0],// 20 ]; return thirdCasterSlots[level]; } else if (casterMode === CasterMode.Pact) { const pactCasterSlots = [ [0,0,0,0,0,0,0,0,0],// 0 [1,0,0,0,0,0,0,0,0],// 1 [2,0,0,0,0,0,0,0,0],// 2 [0,2,0,0,0,0,0,0,0],// 3 [0,2,0,0,0,0,0,0,0],// 4 [0,0,2,0,0,0,0,0,0],// 5 [0,0,2,0,0,0,0,0,0],// 6 [0,0,0,2,0,0,0,0,0],// 7 [0,0,0,2,0,0,0,0,0],// 8 [0,0,0,0,2,0,0,0,0],// 9 [0,0,0,0,2,0,0,0,0],// 10 [0,0,0,0,3,1,0,0,0],// 11 [0,0,0,0,3,1,0,0,0],// 12 [0,0,0,0,3,1,1,0,0],// 13 [0,0,0,0,3,1,1,0,0],// 14 [0,0,0,0,3,1,1,1,0],// 15 [0,0,0,0,3,1,1,1,0],// 16 [0,0,0,0,3,1,1,1,1],// 17 [0,0,0,0,3,1,1,1,1],// 18 [0,0,0,0,3,1,1,1,1],// 19 [0,0,0,0,3,1,1,1,1],// 20 ]; return pactCasterSlots[level]; } // Default to empty return empty; }; // A and B are arrays of slots const AddSlots = (a, b) => { let c = [0,0,0,0,0,0,0,0,0]; if (a && !b) { return a; } else if (!a && b) { return b; } else if (!a && !b) { return c; } for (let i = 0; i < 10; i++) { const aVal = a[i] || 0 const bVal = b[i] || 0 c[i] = aVal + bVal; } return c; }; // For those classes that don't have a dedicated spell list, get the class they rely on. const GetSpellListClassFromClass = (leveledClass) => { // EKs and ATs use the Wizard spell list if ((leveledClass.Name === 'Fighter' && leveledClass.Subclass === 'Eldritch Knight') || (leveledClass.Name === 'Rogue' && leveledClass.Subclass === 'Arcane Trickster')) { return 'Wizard'; } return leveledClass.Name; } // Get the full max slot array for a list of leveled classes {Name, Level}. const GetCharSlots = (leveledClasses) => { // Iterate through classes and get total levels per type let fullLevel = 0; let halfPlusLevel = 0; let halfLevel = 0; let thirdLevel = 0; let pactLevel = 0; let casterClasses = 0; for (let i = 0; i < leveledClasses.length; i++) { const leveledClass = leveledClasses[i]; const mode = GetCasterTypeFromClass(leveledClass.Name, leveledClass.Subclass); dlog(`${leveledClass.Name}[${leveledClass.Level}]: ${GetBaseSpellSlots(mode, leveledClass.Level, leveledClass.Name, leveledClass.Subclass)}`); if (mode === CasterMode.Full) fullLevel += leveledClass.Level || 0; if (mode === CasterMode.HalfPlus) halfPlusLevel += leveledClass.Level || 0; if (mode === CasterMode.Half) halfLevel += leveledClass.Level || 0; if (mode === CasterMode.Third) thirdLevel += leveledClass.Level || 0; if (mode === CasterMode.Pact) pactLevel += leveledClass.Level || 0; // Track multiclassing if (mode !== CasterMode.None && mode !== CasterMode.Invalid) casterClasses++; } // Add the fractional levels for non-pact magic const fullFraction = fullLevel*CasterModeMultiplier[CasterMode.Full]; const halfFraction = halfLevel >= 2 ? halfLevel*CasterModeMultiplier[CasterMode.Half] : 0; const thirdFraction = thirdLevel >= 3 ? thirdLevel*CasterModeMultiplier[CasterMode.Third] : 0; const halfPlusFraction = halfPlusLevel >= 1 ? halfPlusLevel*CasterModeMultiplier[CasterMode.HalfPlus] : 0; const normLevel = casterClasses > 1 ? fullFraction + Math.floor(halfFraction) + Math.floor(thirdFraction) + Math.floor(halfPlusFraction) : Math.ceil(fullFraction + halfFraction + thirdFraction + halfPlusFraction); // Add the slots of all the types together const normSlots = GetBaseSpellSlots(CasterMode.Full, normLevel, 'NORMAL'); dlog('Normal Slots[' + normLevel + ']: ' + normSlots); const pactSlots = GetBaseSpellSlots(CasterMode.Pact, pactLevel, 'PACT'); dlog('Pact Slots[' + pactLevel + ']: ' + pactSlots); // Add the normal and pact slots together return AddSlots(normSlots, pactSlots); }; // Creates a link const CreateLink = (text, linkTo) => { return `${text}`; }; // All sorters available to SpellMaster const Sorters = { NameAlpha: (a, b) => { const nameA=a.Name.toLowerCase(); const nameB=b.Name.toLowerCase(); if (nameA < nameB) // sort string ascending { return -1; } if (nameA > nameB) { return 1; } return 0 // default return value (no sorting) }, LevelName: (a, b) => { // Sort by level first const levelA = a.Level; const levelB = b.Level; if (levelA < levelB) { return -1; } else if (levelA > levelB) { return 1; } // Then sort by alpha const nameA=a.Name.toLowerCase(); const nameB=b.Name.toLowerCase(); if (nameA < nameB) // sort string ascending { return -1; } if (nameA > nameB) { return 1; } return 0 // default return value (no sorting) }, DistanceMin: (a, b) => { // Sort by level first const distanceA = a.Distance; const distanceB = b.Distance; if (distanceA < distanceB) { return -1; } else if (distanceA > distanceB) { return 1; } return 0 // default return value (no sorting) }, CostName: (a, b) => { // Sort by cost first const costA = a.ChargeCost; const costB = b.ChargeCost; if (costA < costB) { return -1; } else if (costA > costB) { return 1; } // Then sort by alpha let nameA = a.SpellName || a.CustomName; let nameB = b.SpellName || b.CustomName; nameA = nameA.toLowerCase(); nameB = nameB.toLowerCase(); if (nameA < nameB) // sort string ascending { return -1; } if (nameA > nameB) { return 1; } return 0 // default return value (no sorting) }, }; // Filtration options const Filters = { WithFlag: 0, WithoutFlag: 1, NotApplicable: 2 }; const FilterSymbols = ['X', '!', '_']; /** * Sends a message to a gm and a player. If the player is a gm, don't double-send * @param incomingMsg Object - The message that came in and contains the user info. * @param outgoingMsg string - The message to publish. */ const sendToGmAndPlayer = (incomingMsg, outgoingMsg) => { let sendSuccess = false; try { sendChat(scname, `/w "${incomingMsg.who.replace(' (GM)', '')}" ${outgoingMsg}`); sendSuccess = true; } catch(e) { log('Error sending spellcast: ' + e.Message); log('Spell Text: ' + outgoingMsg); } if(!sendSuccess || !playerIsGM(incomingMsg.playerid)) { sendChat(scname, `/w gm ${outgoingMsg}`); } }; // Retrieves a list of leveled classes for a given spellbook {Name, Level} const GetLeveledClasses = (char, spellbook) => { let cachedBook = Cache.Books[spellbook.Name]; if (!cachedBook) { cachedBook = RefreshCachedBook(spellbook); } const classDisplay = GetCachedAttr(char, cachedBook, 'class_display'); const leveledClassesString = classDisplay.split(','); let leveledClasses = []; for (let i = 0; i < leveledClassesString.length; i++) { const trimmedClass = leveledClassesString[i].trim(); const classDetails = trimmedClass.split(' '); const className = classDetails[classDetails.length-2]; const classLevel = parseInt(classDetails[classDetails.length-1]); const subclassName = trimmedClass.substr(0, trimmedClass.indexOf(className)).trim(); leveledClasses.push({ Name: className, Subclass: subclassName, Level: classLevel }); } return leveledClasses; }; // Gets the stat mod for the given spellcasting class const GetStatModForClass = (char, spellbook, className) => { let cachedBook = Cache.Books[spellbook.Name]; const stat = ClassToStatMap[className]; if (!stat) { return -5; } return GetCachedAttr(char, cachedBook, stat.toLowerCase() + '_mod') || 0; }; const GetMaxPreparationString = (char, spellbook) => { let cachedBook = Cache.Books[spellbook.Name]; const leveledClasses = GetLeveledClasses(char, spellbook); let prepString = ''; for(let i = 0; i < leveledClasses.length; i++) { const className = leveledClasses[i].Name; const classLevel = leveledClasses[i].Level; if (className === 'Cleric' || className === 'Druid' || className === 'Shaman') { const statMod = GetCachedAttr(char, cachedBook, 'wisdom_mod') || 0; prepString += `/ ${classLevel + statMod} (${className}) `; } else if (className === 'Wizard' || className === 'Artificer' || className === 'Artificer [TK]') { const statMod = GetCachedAttr(char, cachedBook, 'intelligence_mod') || 0; prepString += `/ ${classLevel + statMod} (${className}) `; } else if (className === 'Warlock' || className === 'Witch') { const spellsKnownAtLevel = [2,3,4,5,6,7,8,9,10,10,11,11,12,12,13,13,14,14,15,15]; let maxKnown = spellsKnownAtLevel[classLevel-1]; maxKnown += classLevel >= 11 ? 1 : 0;// Mystic Arcanum L6 maxKnown += classLevel >= 13 ? 1 : 0;// Mystic Arcanum L7 maxKnown += classLevel >= 15 ? 1 : 0;// Mystic Arcanum L8 maxKnown += classLevel >= 17 ? 1 : 0;// Mystic Arcanum L9 const statMod = GetCachedAttr(char, cachedBook, 'charisma_mod') || 0; prepString += `/ ${maxKnown} (${className}) `; } else if (className === 'Sorcerer') { const spellsKnownAtLevel = [2,3,4,5,6,7,8,9,10,11,12,12,13,13,14,14,15,15,15,15]; let maxKnown = spellsKnownAtLevel[classLevel-1]; const statMod = GetCachedAttr(char, cachedBook, 'charisma_mod') || 0; prepString += `/ ${maxKnown} (${className}) `; } else if (className === 'Paladin') { const statMod = GetCachedAttr(char, cachedBook, 'charisma_mod') || 0; prepString += `/ ${Math.floor(classLevel/2) + statMod} (${className}) `; } else if (className === 'Bard') { const spellsKnownAtLevel = [4,5,6,7,8,9,10,11,12,14,15,15,16,18,19,19,20,22,22,22]; let maxKnown = spellsKnownAtLevel[classLevel-1]; const statMod = GetCachedAttr(char, cachedBook, 'charisma_mod') || 0; prepString += `/ ${maxKnown} (${className}) `; } } return prepString; }; // Returns true if the stat of this instance is a manual DC, false if it's anything else. const StatIsManualDC = (instance) => { return instance.Stat === 'Manual DC'; }; // Gets the description for the spell const GetSpellDescription = (spell) => { return spell.Desc .replace(/\|/g, "
")// In event user is re-importing homebrew .replace("Higher Level:", "HLCODE")// This order matters to prevent double-hits .replace("Higher Levels:", "HLCODE") .replace("At Higher Level:", "HLCODE") .replace("Higher level:", "HLCODE") .replace("At higher level:", "HLCODE") .replace(/HLCODE/g, '- Higher Levels:'); }; // Returns a string that contains the details of a spell (used by expansion and casting) const GetSpellDetails = (book, stat, notes, instanceDC, spell, createLinks) => { text = ""; text += `- School: ${spell.School}
`; text += `- Cast Time: ${spell.CastTime}
`; text += `- Range: ${spell.Range}
`; let componentStr = ""; componentStr += spell.Components.V ? "V" : ""; componentStr += spell.Components.S ? "S" : ""; componentStr += spell.Components.M ? "M" : ""; componentStr += spell.Components.MDetails ? ` (${spell.Components.MDetails})` : ""; text += `- Components: ${componentStr}
`; text += `- Duration: ${spell.Duration}
`; let descStr = GetSpellDescription(spell); text += `- Description: ${descStr}
`; if(createLinks) { const abilityLink = CreateLink('Ability:', `!SpellMaster --UpdateBook ^${book.Name}^ --UpdateSpell ^${spell.Name}^ --ParamName ^Ability^ --ParamValue ^?{Please select the ability to use when casting this spell.|Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma|Manual DC}^`); text += `- ${abilityLink} ${stat}
`; if (stat === 'Manual DC') { // 1.0 -> 1.1 version handling if (!instanceDC) { instanceDC = 8; } const dcLink = CreateLink('DC:', `!SpellMaster --UpdateBook ^${book.Name}^ --UpdateSpell ^${spell.Name}^ --ParamName ^DC^ --ParamValue ^?{Please enter the manually-configured DC}^`); text += `- ${dcLink} ${instanceDC}
`; } const notesLink = CreateLink('Notes:',`!SpellMaster --UpdateBook ^${book.Name}^ --UpdateSpell ^${spell.Name}^ --ParamName ^Notes^ --ParamValue ^?{Please type the new notes section. You may want to type outside this window and paste for longer messages. Use html br tag for line breaks.}^`); text += `- ${notesLink} ${notes}
`; } else { text += `- Ability: ${stat}
`; if (notes) { text += `- Notes: ${notes}
`; } } text += `- Classes: ${spell.Classes}
`; return text; }; // Gets caster info const GetCasterInfo = (char, book, cachedBook, stat, customDC, isManualDC) => { let dc; let attackRollStr; let casterLevel = 0; if (isManualDC) { dc = parseInt(customDC); attackRollStr = `[[d20cs>20 + ${dc-8}[ATKMOD]]]`; } else { const pb = GetCachedAttr(char, cachedBook, 'pb') || 0; const statMod = GetCachedAttr(char, cachedBook, stat.toLowerCase() + '_mod') || 0; const attackMod = GetCachedAttr(char, cachedBook, 'globalmagicmod') || 0; const dcMod = GetCachedAttr(char, cachedBook, 'spell_dc_mod') || 0; casterLevel = GetCachedAttr(char, cachedBook, 'level') || 0; dc = 8 + pb + statMod + dcMod; const statString = statMod !== 0 ? ` + ${statMod}[${StatMap[stat]}]` : ''; const atkString = attackMod !== 0 ? ` + ${attackMod}[ATKMOD]` : ''; attackRollStr = `[[@{${book.Owner}|d20}cs>20${statString} + ${pb}[PROF]${atkString}]]`; } return { DC: dc, AttackRollStr: attackRollStr, CasterLevel: casterLevel }; }; // Generates the upcast string for a spell const GetUpcastString = (spell, castLevel, spellDetails, casterLevel) => { const upcastIndex = spellDetails.indexOf('Higher Levels:'); spellDetails = spellDetails.replace(/(\d+)d(\d+)/gmi, (match, p1, p2, offset, string) => { // Allow upcasting in higher-level casting section let levelScalar = 1; let autoEval = false; let prefix = ''; let suffix = ''; if (castLevel > 0) { if (offset > upcastIndex && upcastIndex !== -1) { levelScalar = castLevel - spell.Level; prefix = match + ' (for a total of '; suffix = ')'; } autoEval = true; } else if (castLevel === 0 && offset <= upcastIndex) { levelScalar = Math.floor((casterLevel + 1) / 6) + 1; autoEval = true; } const retVal = autoEval ? `${prefix}[[${levelScalar*p1}d${p2}]]${suffix}` : match; return retVal; }); return spellDetails; }; // Identifies saves, if any exist. Parameter MUST be lowercased const IdentifySaves = (details) => { let saves = []; if (details.includes('strength saving throw') || details.includes('strength save')) { saves.push("Strength Save"); } if (details.includes('dexterity saving throw') || details.includes('dexterity save')) { saves.push("Dexterity Save"); } if (details.includes('constitution saving throw') || details.includes('constitution save')) { saves.push("Constitution Save"); } if (details.includes('intelligence saving throw') || details.includes('intelligence save')) { saves.push("Intelligence Save"); } if (details.includes('wisdom saving throw') || details.includes('wisdom save')) { saves.push("Wisdom Save"); } if (details.includes('charisma saving throw') || details.includes('charisma save')) { saves.push("Charisma Save"); } return saves.join(','); }; // Pushes a given magical effect to the chat const PublishMagicEffect = (casterName, spell, isSpellAttack, attackRollStr, description, chatMessage, whisperToggle) => { const spellContents = `&{template:npcatk} ` +`{{attack=1}} ` +`{{name=${casterName}}} ` +`{{rname=${spell.Name}}} ` +`{{rnamec=${spell.Name}}} ` +`{{r1=${isSpellAttack ? attackRollStr : '[[0d1]]'}}} ` +`{{always=0}} ` +`{{r2=${isSpellAttack ? attackRollStr : '[[0d1]]'}}} ` +`{{description=${description}}}`; dlog("Spell Contents: " + spellContents); if (whisperToggle) { sendToGmAndPlayer(chatMessage, spellContents); } else { sendChat(scname, spellContents); } return spellContents; } // Prints the spell to the chat when casting a spell const PrintSpell = (book, instance, spell, castLevel, chatMessage) => { const char = GetCharByAny(book.Owner); let cachedBook = Cache.Books[book.Name]; const casterInfo = GetCasterInfo(char, book, cachedBook, instance.Stat, instance.DC, StatIsManualDC(instance)); const attackRollStr = casterInfo.AttackRollStr; const dc = casterInfo.DC; const casterLevel = casterInfo.CasterLevel; let spellDetails = GetSpellDetails(book, instance.Stat, instance.Notes, instance.DC, spell, false); spellDetails = GetUpcastString(spell, castLevel, spellDetails, casterLevel); let parseableDetails = spellDetails.toLowerCase(); let isSpellAttack = parseableDetails.includes('spell attack'); const saveString = IdentifySaves(parseableDetails); const descriptionFull = `- DC: ${dc} ${saveString}
${spellDetails}`; const whisperToggle = GetCachedAttr(char, cachedBook, 'whispertoggle') === '/w gm '; return PublishMagicEffect(book.Owner, spell, isSpellAttack, attackRollStr, descriptionFull, chatMessage, whisperToggle); }; // Prints all knowable spells for a spellbook and given level const PrintKnowables = (msg, book, level) => { const char = GetCharByAny(book.Owner); const leveledClasses = GetLeveledClasses(char, book); let knowables = `/w "${msg.who.replace(' (GM)', '')}" &{template:desc} {{desc=

${book.Owner} - ${level}


`; knowables += `
`; // Iterate over all table spells for(let i = 0; i < SpellList.length; i++) { const spell = SpellList[i]; if (spell.Level === level && !spell.Name.endsWith('- SRD')) { // Determine if the spell is knowable (on any of the book's classes' lists) for (let j = 0; j < leveledClasses.length; j++) { const leveledClass = leveledClasses[j]; let className = GetSpellListClassFromClass(leveledClass); const classes = spell.Classes.split(',').map((className) => className.trim()); if (classes.includes(className)) { // Determine if spell is currently known or not let spellKnown = false; for (let j = 0; j < book.KnownSpells.length; j++) { const instance = book.KnownSpells[j]; if (instance.Name === spell.Name) { spellKnown = true; } } // Build the entry for it const prepButton = spellKnown ? `[-](!SpellMaster --UpdateBook ^${book.Name}^ --RemoveSpell ^${spell.Name}^ --Confirm ^Yes^ --RefreshKnowables ^Yes^)` : `[+](!SpellMaster --UpdateBook ^${book.Name}^ --ImportSpell ^${spell.Name}^ --RefreshKnowables ^Yes^ --FlushCache ^Yes^)` const printLink = CreateLink(spell.Name, `!SpellMaster --PrintDummySpell ^${spell.Name}^ --HypotheticalBook ^${book.Name}^`); const spellLine = `${prepButton} ${printLink}
`; knowables += spellLine; break; } } } } knowables += `
}}`; dlog(knowables); sendChat(scname, knowables, null, null, { noarchive: true }); }; // Gets the link to cast the enchantment const getEnchantmentCastLink = (spellbook, item, enchantment) => { const name = enchantment.SpellName || enchantment.CustomName; const hypotheticalRemainder = item.CurCharges - enchantment.ChargeCost; if (hypotheticalRemainder < 0 || (item.Attunement && !item.Attuned)) { return name; } const maxUpcasts = enchantment.Upcast && enchantment.UpcastCost > 0 ? Math.floor(hypotheticalRemainder / enchantment.UpcastCost) : 0; if (enchantment.SpellName) { const spell = SpellDict[name]; const castLevels = []; for(let i = 0; i <= maxUpcasts; i++) castLevels.push(spell.Level + i); let levelQuery = ''; if (maxUpcasts > 0) { const castLevelString = castLevels.join('|'); levelQuery = `What level would you like to activate the ${name} at|${castLevelString}`; } else { levelQuery = `Would you like to cast ${name} at level ${spell.Level}|Yes,${spell.Level}|No`; } return castLink = CreateLink(name, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --Activate ^${name}^ --Tier ^?{${levelQuery}}^`); } else { flog('CUSTOM ENCHANTMENTS NOT IMPLEMENTED'); } }; /** * Prints an enchantment * @param {Object} book * @param {Object} item * @param {Object} enchantment * @param {number} tier * @param {Object} chatMessage * @returns The string printed to chat log. */ const PrintEnchantment = (book, item, enchantment, tier, chatMessage) => { const name = enchantment.SpellName || enchantment.CustomName; sendChat(scname, 'Print Enchantment: ' + name + ' at ' + tier); const char = GetCharByAny(book.Owner); let cachedBook = Cache.Books[book.Name]; const dcValue = parseInt(enchantment.DC) || 0; const manualDC = dcValue !== 0; const stat = manualDC ? 'Manual DC' : enchantment.DC; const casterInfo = GetCasterInfo(char, book, cachedBook, stat, dcValue, manualDC); const attackRollStr = casterInfo.AttackRollStr; const dc = casterInfo.DC; let casterLevel = casterInfo.CasterLevel; if (enchantment.SpellName) { const spell = SpellDict[enchantment.SpellName]; let spellDetails = GetSpellDetails(book, stat, '', dcValue, spell, false); // Synthetically re-level the caster for cantrip enchantments if (spell.Level === 0) { const grade = tier + 1; tier = 0; casterLevel = Math.max(6 * (grade - 1) - 1, 1); } spellDetails = GetUpcastString(spell, tier, spellDetails, casterLevel); let parseableDetails = spellDetails.toLowerCase(); let isSpellAttack = parseableDetails.includes('spell attack'); const saveString = IdentifySaves(parseableDetails); const descriptionFull = `- DC: ${dc} ${saveString}
${spellDetails}`; const whisperToggle = GetCachedAttr(char, cachedBook, 'whispertoggle') === '/w gm '; return PublishMagicEffect(item.Name, spell, isSpellAttack, attackRollStr, descriptionFull, chatMessage, whisperToggle); } else { flog('Printing Custom Enchantments is not yet supported.'); } }; /** * Print a dummy version of the spell. * @param book {Object} the spellbook object to consider adding to. * @param spell {Object} the spell to print * @param chatMessage {Object} the source chat message to reply to */ const PrintDummySpell = (book, spell, chatMessage) => { const char = GetCharByAny(book.Owner); let cachedBook = RefreshCachedBook(book); const casterInfo = GetCasterInfo(char, book, cachedBook, book.Stat, 0, false); const attackRollStr = casterInfo.AttackRollStr; const dc = casterInfo.DC; const casterLevel = casterInfo.CasterLevel; let spellDetails = GetSpellDetails(book, book.Stat, '', 0, spell, false); spellDetails = GetUpcastString(spell, spell.Level, spellDetails, casterLevel); let parseableDetails = spellDetails.toLowerCase(); let isSpellAttack = parseableDetails.includes('spell attack'); const saveString = IdentifySaves(parseableDetails); const descriptionFull = `- DC: ${dc} ${saveString}
${spellDetails}`; const whisperToggle = GetCachedAttr(char, cachedBook, 'whispertoggle') === '/w gm '; return PublishMagicEffect(book.Owner, spell, isSpellAttack, attackRollStr, descriptionFull, chatMessage, whisperToggle); }; // The various subsections of a spellbook that can be cached const CacheOptions = { All: 0, Owner: 1, Filtering: 2, Tools: 3, Prepared: 4, SorcPoints: 5, Spells: 6, PrepLists: 7, Items: 8, AllSpellLevels: [0,1,2,3,4,5,6,7,8,9,10] }; // Checks specified level and all levels above to see if a spell is castable const HasSlotsOfAtLeastLevel = (spellbook, level) => { if (level === 0) { return true; } for (let i = level-1; i < spellbook.CurSlots.length; i++) { const slotsAtLevel = spellbook.CurSlots[i]; if (slotsAtLevel > 0) { return true; } } return false; }; // Create a new cache object const RefreshCachedBook = (spellbook) => { const char = GetCharByAny(spellbook.Owner); const startCache = new Date(); let cachedBook = { OwnerStr: '', FilteringStr: '', ToolsStr: '', PreparedStr: '', SpellsStr: '', SpellLevels: ['','','','','','','','','','',''], PrepListsStr: '', Sheet: {} }; // Build cached attributes from sheet (this is expensive) cachedBook.Sheet['_id'] = GetCachedAttr(char, cachedBook, '_id', true); cachedBook.Sheet['level'] = GetCachedAttr(char, cachedBook, 'level', true); cachedBook.Sheet['class_display'] = GetCachedAttr(char, cachedBook, 'class_display', true); cachedBook.Sheet['whispertoggle'] = GetCachedAttr(char, cachedBook, 'whispertoggle', true); cachedBook.Sheet['pb'] = GetCachedAttr(char, cachedBook, 'pb', true); cachedBook.Sheet['globalmagicmod'] = GetCachedAttr(char, cachedBook, 'globalmagicmod', true); cachedBook.Sheet['spell_dc_mod'] = GetCachedAttr(char, cachedBook, 'spell_dc_mod', true); cachedBook.Sheet['strength_mod'] = GetCachedAttr(char, cachedBook, 'strength_mod', true); cachedBook.Sheet['dexterity_mod'] = GetCachedAttr(char, cachedBook, 'dexterity_mod', true); cachedBook.Sheet['constitution_mod'] = GetCachedAttr(char, cachedBook, 'constitution_mod', true); cachedBook.Sheet['intelligence_mod'] = GetCachedAttr(char, cachedBook, 'intelligence_mod', true); cachedBook.Sheet['wisdom_mod'] = GetCachedAttr(char, cachedBook, 'wisdom_mod', true); cachedBook.Sheet['charisma_mod'] = GetCachedAttr(char, cachedBook, 'charisma_mod', true); const stopCache = new Date(); const diffCache = stopCache - startCache; dlog('Initial Sheet Caching: ' + diffCache); Cache.Books[spellbook.Name] = cachedBook; return cachedBook; }; // Loads from the cache if possible, loads from char if not const GetCachedAttr = (char, cachedBook, attrName, forceReload = false) => { if (forceReload) { let attrVal = getattr(char.id, attrName); // Try to parse as int if possible let numVal = parseInt(attrVal); if (!isNaN(numVal)) { attrVal = numVal; } cachedBook.Sheet[attrName] = attrVal; return attrVal; } else { return cachedBook.Sheet[attrName]; } }; // Prints a spellbook out to its handout. // spellbook: the spellbook object // dirtyCaches: an array of CacheOptions that must be rebuilt. // dirtyLevels: an array of spell levels to be rebuilt. const PrintSpellbook = (spellbook, dirtyCaches, dirtyLevels) => { const startTime = new Date(); const activePrepList = spellbook.PreparationLists[spellbook.ActivePrepList]; const char = GetCharByAny(spellbook.Owner); let text = ""; let br = "
"; let hr = "
"; let cachedBook = Cache.Books[spellbook.Name]; // ================================================================================= // Owner let ownerStr = ''; if (dirtyCaches.includes(CacheOptions.All) || dirtyCaches.includes(CacheOptions.Owner)) { dlog('Rebuilding Owner'); let uri = `http://journal.roll20.net/character/${char.id}`; ownerStr += `A spellbook for ${CreateLink(spellbook.Owner, uri)}`; ownerStr += '
'; cachedBook.OwnerStr = ownerStr; } else { dlog('Using Cached Owner'); ownerStr = cachedBook.OwnerStr; } text += ownerStr; // ================================================================================= // Filter bar let filterStr = ''; if (dirtyCaches.includes(CacheOptions.All) || dirtyCaches.includes(CacheOptions.Filtering)) { dlog('Rebuilding Filtering'); const vFilter = CreateLink(`[${FilterSymbols[spellbook.Filter.V]}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --ParamName ^V^ --ParamValue ^?{Please enter the new filter option|V,${Filters.WithFlag}|No-V,${Filters.WithoutFlag}|No Filter,${Filters.NotApplicable}}^`); const sFilter = CreateLink(`[${FilterSymbols[spellbook.Filter.S]}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --ParamName ^S^ --ParamValue ^?{Please enter the new filter option|S,${Filters.WithFlag}|No-S,${Filters.WithoutFlag}|No Filter,${Filters.NotApplicable}}^`); const mFilter = CreateLink(`[${FilterSymbols[spellbook.Filter.M]}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --ParamName ^M^ --ParamValue ^?{Please enter the new filter option|M,${Filters.WithFlag}|No-M,${Filters.WithoutFlag}|No Filter,${Filters.NotApplicable}}^`); const castFilter = CreateLink(`[${spellbook.Filter.CastTime}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --ParamName ^CastTime^ --ParamValue ^?{Please enter the new filter option|1 Action|1 Bonus Action|Special|Combat|Noncombat|Any}^`); const concFilter = CreateLink(`[${FilterSymbols[spellbook.Filter.Concentration]}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --ParamName ^Concentration^ --ParamValue ^?{Please enter the new filter option|Concentration,${Filters.WithFlag}|No-Concentration,${Filters.WithoutFlag}|No Filter,${Filters.NotApplicable}}^`); const rituFilter = CreateLink(`[${FilterSymbols[spellbook.Filter.Ritual]}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --ParamName ^Ritual^ --ParamValue ^?{Please enter the new filter option|Ritual,${Filters.WithFlag}|No-Ritual,${Filters.WithoutFlag}|No Filter,${Filters.NotApplicable}}^`); const prepFilter = CreateLink(`[${FilterSymbols[spellbook.Filter.Prepared]}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --ParamName ^Prepared^ --ParamValue ^?{Please enter the new filter option|Prepared,${Filters.WithFlag}|No-Prepared,${Filters.WithoutFlag}|No Filter,${Filters.NotApplicable}}^`); const slotsFilter = CreateLink(`[${FilterSymbols[spellbook.Filter.Slots]}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --ParamName ^Slots^ --ParamValue ^?{Please enter the new filter option|Slots Remaining,${Filters.WithFlag}|Slots Empty,${Filters.WithoutFlag}|No Filter,${Filters.NotApplicable}}^`); const searchFilter = CreateLink(`["${spellbook.Filter.Search}"]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --ParamName ^Search^ --ParamValue ^?{Please enter the new search string}^`); filterStr += `Filtering: ${vFilter} V ${sFilter} S ${mFilter} M - ${castFilter} Casting Time - ${concFilter} Concentration - ${rituFilter} Ritual - ${prepFilter} Prepared - ${slotsFilter} Slots Remaining - ${searchFilter} Search
`; cachedBook.FilteringStr = filterStr; } else { dlog('Using Cached Filtering'); filterStr = cachedBook.FilteringStr; } text += filterStr; // ================================================================================= // Tools let toolsStr = ''; if (dirtyCaches.includes(CacheOptions.All) || dirtyCaches.includes(CacheOptions.Tools)) { dlog('Rebuilding Tools'); const fillSlotsLink = CreateLink(`[Long Rest]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --SetSlots ^Full^`); const levelUpLink = CreateLink(`[Level Up]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --LevelUp ^?{Are you sure you want to resync the cache for Level Up? This will reset any custom spell level slots. (Slots reserved for individual spells are unaffected.) Type 'Yes' to confirm}^`); const flushCacheLink = CreateLink(`[Refresh Cache]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --FlushCache ^Yes^`); toolsStr += `Tools: ${fillSlotsLink} - ${levelUpLink} - ${flushCacheLink}
`; cachedBook.ToolsStr = toolsStr; } else { dlog('Using Cached Tools'); toolsStr = cachedBook.ToolsStr; } text += toolsStr; // ================================================================================= // Sorcery Points let sorcStr = ''; if (dirtyCaches.includes(CacheOptions.All) || dirtyCaches.includes(CacheOptions.SorcPoints)) { dlog('Rebuilding Sorcery Points'); const curSorcPoints = CreateLink(`[${spellbook.CurSorc}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --SetCurSorc ^?{Set current sorcery points}^`); const maxSorcPoints = CreateLink(`[${spellbook.MaxSorc}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --SetMaxSorc ^?{Set maximum sorcery points}^`); const composeSorcPoints = CreateLink(`[Compose Slot]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --ComposeSlot ^?{What level spell slot would you like to compose from sorcery points as a bonus action|L1=2p,1|L2=3p,2|L3=5p,3|L4=6p,4|L5=7p,5}^`); sorcStr += `Sorcery Points: ${curSorcPoints} / ${maxSorcPoints} - ${composeSorcPoints}
`; cachedBook.sorcStr = sorcStr; } else { dlog('Using Cached Sorc Points'); sorcStr = cachedBook.sorcStr; } text += sorcStr; // ================================================================================= // Prepared Spell Count let preparedStr = ''; if (dirtyCaches.includes(CacheOptions.All) || dirtyCaches.includes(CacheOptions.Prepared)) { dlog('Rebuilding Prepared'); const prepString = `${activePrepList.PreparedSpells.length} ${GetMaxPreparationString(char, spellbook)}`; preparedStr += `Prepared: ${prepString}
`; preparedStr += '
'; cachedBook.PreparedStr = preparedStr; } else { dlog('Using Cached Prepared'); preparedStr = cachedBook.PreparedStr; } text += preparedStr; // ================================================================================= // Items let inventoryStr = ''; if (dirtyCaches.includes(CacheOptions.All) || dirtyCaches.includes(CacheOptions.Items)) { dlog('Rebuilding Items'); inventoryStr += '

Items

'; inventoryStr += '
'; spellbook.Items.sort(Sorters.NameAlpha); spellbook.Items.forEach((item) => { let itemStr = ''; const isAttunedLink = CreateLink(`[${item.Attuned ? 'X' : '_'}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --ParamName ^Attuned^ --ParamValue ^${!item.Attuned}^`); const curChargesLink = CreateLink(`[${item.CurCharges}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --ParamName ^Cur^ --ParamValue ^?{Please enter the new current current value for ${item.Name}}^`); const maxChargesLink = CreateLink(`[${item.MaxCharges}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --ParamName ^Max^ --ParamValue ^?{Please enter the new maximum charges value for ${item.Name}}^`); const isExpandedLink = CreateLink(`[${item.Expanded ? '-' : '+'}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --ParamName ^Expanded^ --ParamValue ^${!item.Expanded}^`); const renameLink = CreateLink(item.Name, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --ParamName ^Name^ --ParamValue ^?{Please enter the new name for ${item.Name}}^`); itemStr += `

${item.Attunement ? isAttunedLink : ''} ${renameLink} - ${curChargesLink} / ${maxChargesLink} - ${isExpandedLink}

`; if (item.Expanded) { itemStr += hr; const attunementStr = item.Attunement ? `Requires Attunement${br}` : `Free Access${br}`; itemStr += CreateLink(attunementStr, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --ParamName ^Attunement^ --ParamValue ^${!item.Attunement}^`); const appearanceLink = CreateLink('Appearance', `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --ParamName ^Appearance^ --ParamValue ^?{Please enter the new appearance for ${item.Name}}^`); const effectLink = CreateLink('Effect', `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --ParamName ^Effect^ --ParamValue ^?{Please enter the new effect for ${item.Name}}^`); const regenLink = CreateLink('Recharge Rate', `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --ParamName ^RegenRate^ --ParamValue ^?{Please enter the new regeneration rate for ${item.Name}}^`); const addSpellLink = CreateLink('Add Spell Enchantment', `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --AddSpell ^?{Please enter the name of the spell.}^ --ChargeCost ^?{Charge Cost}^ --Upcast ^?{Can it be upcast|Yes|No}^ --UpcastCost ^?{Cost per upcast level. If no upcast, put 0}^ --DC ^?{Please choose the DC. Spell attack will be calculated|Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma|0|1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|18|19|20|21|22|23|24|25|26|27|28|29|30}^`); itemStr += appearanceLink + `: ${item.Appearance}${br}`; itemStr += effectLink + `: ${item.Effect}${br}`; itemStr += `

Enchantments

`; item.Enchantments.sort(Sorters.CostName); item.Enchantments.forEach((enchantment) => { let enchantmentStr = ''; const enchantmentName = enchantment.SpellName || enchantment.CustomName; const chargeCostLink = CreateLink(`[${enchantment.ChargeCost}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --UpdateEnchantment ^${enchantmentName}^ --ParamName ^ChargeCost^ --ParamValue ^?{Please input the new charge cost}^`); const expandEnchantmentLink = CreateLink(`[${enchantment.Expanded ? '-' : '+'}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --UpdateEnchantment ^${enchantmentName}^ --ParamName ^Expanded^ --ParamValue ^${!enchantment.Expanded}^`); const upcastCostLink = CreateLink(enchantment.UpcastCost, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --UpdateEnchantment ^${enchantmentName}^ --ParamName ^UpcastCost^ --ParamValue ^?{Please input the new upcast cost per level}^`); const upcastLink = CreateLink(`${enchantment.Upcast ? 'Upcast Cost: ' : 'No Upcast'}`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --UpdateEnchantment ^${enchantmentName}^ --ParamName ^Upcast^ --ParamValue ^?{Can you upcast this spell|Yes|No}^`); const upcastStr = `- ${upcastLink} ${enchantment.Upcast ? upcastCostLink : ''}
`; const deleteEnchantmentLink = CreateLink(`[Delete]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --DeleteEnchantment ^${enchantmentName}^ --Confirm ^?{Type Yes to confirm deletion}^`); const dcLink = CreateLink(enchantment.DC, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateItem ^${item.Name}^ --UpdateEnchantment ^${enchantmentName}^ --ParamName ^DC^ --ParamValue ^?{Please choose the DC. Spell attack will be calculated|Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma|0|1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|18|19|20|21|22|23|24|25|26|27|28|29|30}^`); enchantmentStr += `${chargeCostLink} ${getEnchantmentCastLink(spellbook, item, enchantment)} ${expandEnchantmentLink}
`; if (enchantment.SpellName) { if (enchantment.Expanded) { const spell = SpellDict[enchantment.SpellName]; enchantmentStr += `- Cast Time: ${spell.CastTime}
`; enchantmentStr += `- Range: ${spell.Range}
`; enchantmentStr += `- Duration: ${spell.Duration}
`; enchantmentStr += `- Description: ${GetSpellDescription(spell)}
`; enchantmentStr += `- DC: ${dcLink}
` enchantmentStr += upcastStr; enchantmentStr += `- ${deleteEnchantmentLink}

`; } } itemStr += enchantmentStr; }); itemStr += addSpellLink + br; itemStr += regenLink + `: ${item.RegenRate}`; itemStr += br + br; itemStr += CreateLink('[Delete]', `!SpellMaster --UpdateBook ^${spellbook.Name}^ --RemoveItem ^${item.Name}^ --Confirm ^?{Type Yes to delete ${item.Name}}^`); itemStr += hr; } inventoryStr += itemStr; }); inventoryStr += CreateLink('[+]', `!SpellMaster` + ` --UpdateBook ^${spellbook.Name}^` + ` --CreateItem ^?{Please enter the name of the item you would like to create.}^` + ` --Attunement ^?{Does the item require attunement?|Yes|No}^` + ` --MaxCharges ^?{Please enter the maximum charges. 0 is infinite.}^` + ` --RegenRate ^?{Please enter the recharge rate per day. 0 is no recharge or infinite.}^` + ` --Appearance ^?{Please describe the appearance of the item if you wish.}^` + ` --Effect ^?{Please describe the effect of the item if you wish.}^`); inventoryStr += '
'; cachedBook.InventoryStr = inventoryStr; } else { dlog('Using Cached Items'); inventoryStr = cachedBook.InventoryStr; } text += inventoryStr; // ================================================================================= // Spells let spellStr = ''; if (dirtyCaches.includes(CacheOptions.All) || dirtyCaches.includes(CacheOptions.Spells)) { dlog('Rebuilding Spells'); // Perform alpha sort on known spells (in case one got added) spellbook.KnownSpells.sort(Sorters.NameAlpha); spellStr += '

Spells

'; spellStr += '
'; dlog('Dirty Levels: ' + dirtyLevels); for (let i = 0; i < maxSpellLevel; i++) { const hasSlotsOfAtLeastLevel = HasSlotsOfAtLeastLevel(spellbook, i); if (i > 0 && ((spellbook.Filter.Slots === Filters.WithFlag && !hasSlotsOfAtLeastLevel) || (spellbook.Filter.Slots === Filters.WithoutFlag && hasSlotsOfAtLeastLevel))) { continue; } if (dirtyLevels !== CacheOptions.AllSpellLevels && !dirtyLevels.includes(i) && cachedBook.SpellLevels[i].length > 0) { spellStr += cachedBook.SpellLevels[i]; dlog(` Using Cached Level: ${i}`); continue; } dlog(` Rebuilding Level: ${i}`); let levelStr = ''; const curSlotLink = CreateLink(`[${spellbook.CurSlots[i-1]}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateSlot ^${i}^ --ParamName ^Cur^ --ParamValue ^?{Please enter the new current value for Slot Level ${i}}^`); const maxSlotLink = CreateLink(`[${spellbook.MaxSlots[i-1]}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateSlot ^${i}^ --ParamName ^Max^ --ParamValue ^?{Please enter the new maximum value for Slot Level ${i}}^`); const decomposeLink = spellbook.MaxSorc > 0 && spellbook.CurSlots[i-1] > 0 ? ' - ' + CreateLink(`[Decompose]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --DecomposeSlot ^${i}^`) : ''; const fullListLink = CreateLink(`[...]`, `!SpellMaster --PrintKnowables ^${spellbook.Name}^ --Level ^${i}^`); levelStr += i > 0 ? `

Level ${i} Spells - ${curSlotLink} / ${maxSlotLink}${decomposeLink} - ${fullListLink}

` : `

Cantrips - ${fullListLink}

`; // Print all spells at current level spellbook.KnownSpells.forEach((spellInstance) => { const spell = SpellDict[spellInstance.Name]; if (!spell) { sendChat(scname, `ERROR: No such spell ${spellInstance.Name} exists! This is likely due to a spell rename. Please use '!SpellMaster --Menu' to manually delete the offending spell. In the event this does not resolve it, please contact the script author. state.SpellMaster is being dumped to the logs.`); flog(`Spell ${spellInstance.Name} does not exist.`, 0); return; } if (spell.Level !== i) { return; } const spellIsPrepared = activePrepList.PreparedSpells.map((item) => {return item.Name;}).indexOf(spellInstance.Name) > -1 || spell.Level === 0 || spellInstance.Lock; // Check filtering if (spellbook.Filter) { if (spell.Level !== 0 && ((spellbook.Filter.Prepared === Filters.WithFlag && !spellIsPrepared) || (spellbook.Filter.Prepared === Filters.WithoutFlag && spellIsPrepared))) { return; } if ((spellbook.Filter.V === Filters.WithFlag && !spell.Components.V) || (spellbook.Filter.V === Filters.WithoutFlag && spell.Components.V)) { return; } if ((spellbook.Filter.S === Filters.WithFlag && !spell.Components.S) || (spellbook.Filter.S === Filters.WithoutFlag && spell.Components.S)) { return; } if ((spellbook.Filter.M === Filters.WithFlag && !spell.Components.M) || (spellbook.Filter.M === Filters.WithoutFlag && spell.Components.M)) { return; } if ((spellbook.Filter.Concentration === Filters.WithFlag && spell.Duration.toLowerCase().indexOf('concentration') === -1) || (spellbook.Filter.Concentration === Filters.WithoutFlag && spell.Duration.toLowerCase().indexOf('concentration') > -1)) { return; } if ((spellbook.Filter.Ritual === Filters.WithFlag && !spell.IsRitual) || (spellbook.Filter.Ritual === Filters.WithoutFlag && spell.IsRitual)) { return; } if (spellbook.Filter.Search.length > 0) { if (!(spell.Name.includes(spellbook.Filter.Search) || spell.Components.MDetails.includes(spellbook.Filter.Search) || spell.Desc.includes(spellbook.Filter.Search) || spell.Duration.includes(spellbook.Filter.Search) || spellInstance.Notes.includes(spellbook.Filter.Search) || (spell.Ability && spell.Ability.includes(spellbook.Filter.Search)) || spell.Range.includes(spellbook.Filter.Search) || spell.Classes.includes(spellbook.Filter.Search))) { return; } } if (spellbook.Filter.CastTime !== 'Any') { const filterCast = spellbook.Filter.CastTime; const spellCast = spell.CastTime; if (filterCast !== spellCast) { const spellCastCombat = spellCast === '1 Action' || spellCast === '1 Bonus Action'; if (filterCast === 'Combat' && !spellCastCombat) { return; } if (filterCast === 'Noncombat' && spellCastCombat && !spell.IsRitual) { return; } const filterIsSpecific = filterCast === '1 Action' || filterCast === '1 Bonus Action' || filterCast === 'Special'; if (filterIsSpecific) { return; } } } } // Create the preparation button. let prepButton = ''; if (spellInstance.Lock) { prepButton = '[O]'; } else { prepButton = spellIsPrepared ? CreateLink('[X]', `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateSpell ^${spellInstance.Name}^ --ParamName ^Prepared^ --ParamValue ^False^`) : CreateLink('[_]', `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateSpell ^${spellInstance.Name}^ --ParamName ^Prepared^ --ParamValue ^True^`); } // Cantrips are always prepared prepButton = spell.Level === 0 ? '[X]' : prepButton; let tagStr = ""; tagStr += spell.IsRitual ? " (R)" : ""; tagStr += spell.Duration.toLowerCase().includes('concentration') ? " (C)" : ""; // Expansion button const expandedText = spellInstance.IsExpanded ? CreateLink('[-]', `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateSpell ^${spellInstance.Name}^ --ParamName ^Expanded^ --ParamValue ^False^`) : CreateLink('[+]', `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateSpell ^${spellInstance.Name}^ --ParamName ^Expanded^ --ParamValue ^True^`); // Generate upcast string let canUpcast = false; let levelString = spell.Level; if (spell.Level > 0) {// Ignore cantrips let upcastOptions = ""; // Start at current level and scale up, recording all that have valid options for(let j = spell.Level-1; j < maxSpellLevel; j++) { // If the spell level has slots or the individual spell instance has slots, mark it if (spellbook.CurSlots[j] > 0 || (spell.Level-1 === j && spellInstance.CurSlots > 0)) { upcastOptions += `|${j+1}`; if (j >= spell.Level) { canUpcast = true; } } } if (canUpcast) { levelString = `?{Select the level at which to cast ${spell.Name}${upcastOptions}}`; } } const castLink = CreateLink(spell.Name, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --CastSpell ^${spellInstance.Name}^ --ParamName ^Level^ --ParamValue ^${levelString}^`); const indiCur = CreateLink(`[${spellInstance.CurSlots}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateSpell ^${spellInstance.Name}^ --ParamName ^CurSlots^ --ParamValue ^?{Please type the new current slots for ${spell.Name}}^`); const indiMax = CreateLink(`[${spellInstance.MaxSlots}]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateSpell ^${spellInstance.Name}^ --ParamName ^MaxSlots^ --ParamValue ^?{Please type the new maximum slots for ${spell.Name}}^`); let titleSlotDisplayStr = ''; let innerSlotDisplayStr = ''; if (spellInstance.CurSlots > 0 || spellInstance.MaxSlots > 0) { titleSlotDisplayStr = ` - ${indiCur} / ${indiMax}`; } else { innerSlotDisplayStr = `${indiCur} / ${indiMax}`; } levelStr += `

${prepButton} ${castLink}${tagStr}${titleSlotDisplayStr} - ${expandedText}

`; if (spellInstance.IsExpanded) { levelStr += innerSlotDisplayStr; if (spell.Level > 0) { levelStr += ' - '; levelStr += CreateLink(`${spellInstance.Lock ? 'Always Prepared' : 'Manually Prepared'}`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --UpdateSpell ^${spellInstance.Name}^ --ParamName ^Lock^ --ParamValue ^${spellInstance.Lock ? 'False' : 'True'}^`); } levelStr += hr; levelStr += GetSpellDetails(spellbook, spellInstance.Stat, spellInstance.Notes, spellInstance.DC, spell, true); levelStr += br; levelStr += CreateLink('[Delete]', `!SpellMaster --UpdateBook ^${spellbook.Name}^ --RemoveSpell ^${spell.Name}^ --Confirm ^?{Type Yes to delete ${spell.Name}}^`); levelStr += hr; } }); levelStr += br; levelStr += CreateLink('[+]', `!SpellMaster --UpdateBook ^${spellbook.Name}^ --ImportSpell ^?{Please enter the name of the spell you would like to import.}^`); levelStr += hr; cachedBook.SpellLevels[i] = levelStr; spellStr += levelStr; } cachedBook.SpellsStr = spellStr; } else { dlog('Using Cached Spells'); spellStr = cachedBook.SpellsStr; } text += spellStr; // ================================================================================= // Preparation Tabs let prepListStr = ''; if (dirtyCaches.includes(CacheOptions.All) || dirtyCaches.includes(CacheOptions.PrepLists)) { dlog('Rebuilding Prep Lists'); prepListStr += '

Preparation Lists

'; for (let i = 0; i < spellbook.PreparationLists.length; i++) { const curList = spellbook.PreparationLists[i]; const isActive = spellbook.ActivePrepList === i; const radioButtonActive = isActive ? '[X]' : '[_]'; const radioButtonLink = CreateLink(radioButtonActive, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --SetActive ^${i}^`); const deletePrepLink = CreateLink('[-]', `!SpellMaster --UpdateBook ^${spellbook.Name}^ --RemovePrepList ^${i}^ --Confirm ^?{Type Yes to delete ${curList.Name}}^`); const nameLink = CreateLink(curList.Name, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --RenamePrepList ^${i}^ --ParamValue ^?{Please enter the new name for ${curList.Name}}^`); prepListStr += `

${radioButtonLink} ${nameLink} (${curList.PreparedSpells.length}) ${deletePrepLink}

`; } prepListStr += br; prepListStr += CreateLink(`[+]`, `!SpellMaster --UpdateBook ^${spellbook.Name}^ --AddPrepList ^?{Please enter the new preparation list name below.}^`); cachedBook.PrepListsStr = prepListStr; } else { dlog('Using Cached Prep List'); prepListStr = cachedBook.PrepListsStr; } text += prepListStr; // ================================================================================= // Print //log(text); GetHandout(spellbook.Handout).set('notes', text); const stopTime = new Date(); const diff = stopTime - startTime; dlog(`Print Time with Dirty Cache ${dirtyCaches} and Dirty Levels ${dirtyLevels}: ${diff}`); }; // Capitalizes the first letter of each word. Numerals are left where they are. const ToTitleCase = (str) => { return str.replace(/\w\S*/g, function(txt){ return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); }); }; // Object containing the tools to parse OGL Sheet Spells const OGLSpell = { // Prefix RepeatingPrefix: 'repeating_spell-', LevelTag: ['cantrip',1,2,3,4,5,6,7,8,9], // Suffixes Name: '_spellname', School: '_spellschool', Ritual: '_spellritual', CastTime: '_spellcastingtime', Range: '_spellrange', ComponentV: '_spellcomp_v', ComponentS: '_spellcomp_s', ComponentM: '_spellcomp_m', ComponentMDetails: '_spellcomp_materials', Concentration: '_spellconcentration', Duration: '_spellduration', CastAbility: '_spell_ability', Description: '_spelldescription', HigherLevels: '_spellathigherlevels', Class: '_spellclass', Type: '_spellsource', // Builds the prefix string for a given level GetPrefix: (level) => { return OGLSpell.RepeatingPrefix + OGLSpell.LevelTag[level] + '_'; }, // Gets the spell attributes for a given character id and level GetSpellAttrs: (characterId, level) => { const prefix = `repeating_spell-${OGLSpell.LevelTag[level]}`; return _.filter(findObjs({ type: "attribute", characterid: characterId }), attr => attr.get("name").startsWith(prefix)); }, // Retrieves only the name attributes for a given char id and level (should be faster) GetSpellNameAttrs: (characterId, level) => { const prefix = `repeating_spell-${OGLSpell.LevelTag[level]}`; const suffix = OGLSpell.Name; const objs = findObjs({ type: "attribute", characterid: characterId }); let nameAttrs = []; for(let i = 0; i < objs.length; i++) { const attr = objs[i]; const attrName = attr.get("name"); if (attrName.endsWith(suffix) && attrName.startsWith(prefix)) { nameAttrs.push(attr); } } return nameAttrs; }, // Gets a dictionary of spells by lowercased name to object ids GetSpellIds: (characterId, level) => { const re = new RegExp(`repeating_spell-${OGLSpell.LevelTag[level]}_([^_]+)${OGLSpell.Name}$`); const spellNameAttrs = OGLSpell.GetSpellNameAttrs(characterId, level); return _.reduce(spellNameAttrs, (lookup, attr) => { const match = attr.get("name").match(re); match && (lookup[attr.get("current").toLowerCase()] = match[1]); return lookup; }, {}); }, // Gets a spell attribute with the provided spellId and suffix GetSpellAttr: (charId, level, spellId, suffix) => { return getattr(charId, OGLSpell.GetPrefix(level) + spellId + suffix); }, // Loads all the details into a spell object. If a checkbox is undefined, assume default value. GetSpellDetails: (charId, level, spellId) => { let higherLevels = OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.HigherLevels); higherLevels = higherLevels ? '|' + higherLevels : ''; // Ritual defaults false let isRitual = OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.Ritual) === '{{ritual=1}}'; // VSM defaults true let compV = OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.ComponentV); compV = (compV === '' || compV === '{{v=1}}'); let compS = OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.ComponentS); compS = (compS === '' || compS === '{{s=1}}'); let compM = OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.ComponentM); compM = (compM === '' || compM === '{{m=1}}'); // Conc defaults false const conc = OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.Concentration) === '{{concentration=1}}' ? "Concentration, " : ""; const spellObj = { Name: ToTitleCase(OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.Name)), Level: level, School: ToTitleCase(OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.School)), IsRitual: isRitual, CastTime: ToTitleCase(OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.CastTime)), Range: OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.Range), Components: { V: compV, S: compS, M: compM, MDetails: OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.ComponentMDetails) }, Duration: conc + OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.Duration), Desc: OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.Description) + higherLevels, Classes: ToTitleCase(OGLSpell.GetSpellAttr(charId, level, spellId, OGLSpell.Class)) } return spellObj; }, // Stringifies a standalone spell obj. No comma at the very end. StringifySpellObj: (spellObj) => { const desc = spellObj.Desc .replace(/\n||
|

/g,'|')// swap breaks for something we can safely print and copy .replace(/<\/p>/g,'')// remove paragraphing .replace(/\"/g,'\\"'); const str = '' + ` {
` + ` Name: "${spellObj.Name}",
` + ` Level: ${spellObj.Level},
` + ` School: "${spellObj.School}",
` + ` IsRitual: ${spellObj.IsRitual},
` + ` CastTime: "${spellObj.CastTime}",
` + ` Range: "${spellObj.Range}",
` + ` Components: {
` + ` V: ${spellObj.Components.V},
` + ` S: ${spellObj.Components.S},
` + ` M: ${spellObj.Components.M},
` + ` MDetails: "${spellObj.Components.MDetails}"
` + ` },
` + ` Duration: "${spellObj.Duration}",
` + ` Desc: "${desc}",
` + ` Classes: "${spellObj.Classes}"
` + ` }` return str; } }; // Loads a single spell level const LoadHomebrewLevel = async (char, charName, level) => { const charId = char.id; const exportCache = Cache.Exports[charName]; let homebrewObjs = exportCache.HomebrewObjs; let newSpellObjs = exportCache.NewSpellObjs; let deleteDict = exportCache.DeleteDict; let renameDict = exportCache.RenameDict; // Load homebrew spells sendChat(scname, `[${level+1}/13] Loading Level ${level} homebrew spells from ${charName}...`); _.defer(() => { dlog(`Attempting to gather Spell[${level}] for ${charName}`); const spellsAtLevel = OGLSpell.GetSpellIds(charId, level); dlog(`Gathered ${spellsAtLevel.length} spells.`); for (let spellName in spellsAtLevel) { if (spellsAtLevel.hasOwnProperty(spellName)) { const spellId = spellsAtLevel[spellName]; dlog(` Discovered ${spellName}: ${spellId}`); const spellObj = OGLSpell.GetSpellDetails(charId, level, spellId); // Check for renames and deletes const nameParams = spellObj.Name.split('|'); if (nameParams.length > 1) { if (nameParams[0].toUpperCase() === 'RENAME') { if (nameParams.length !== 3) { sendChat(scname, 'ERROR: Unable to parse rename of ' + spellObj.Name); } const oldName = nameParams[1]; const newName = nameParams[2]; spellObj.Name = newName; renameDict[newName] = oldName; dlog(` RENAME: ${oldName} to ${newName}`); } else if (nameParams[0].toUpperCase() === 'DELETE') { if (nameParams.length !== 2) { sendChat(scname, 'ERROR: Unable to parse delete of ' + spellObj.Name); } const deleteableName = nameParams[1]; spellObj.Name = deleteableName; deleteDict[deleteableName.toUpperCase()] = true; dlog(` DELETE: ${deleteableName}`); continue; } } homebrewObjs.push(spellObj); newSpellObjs.push(spellObj); } } sendChat(scname, `[${level+1}/13] Finished Level ${level}.`); // Update counter exportCache.CompleteLevels++; dlog(`Finished Level ${level}. Current status: ${exportCache.CompleteLevels}/10`); // Spin off assembly if all levels complete if (exportCache.CompleteLevels === 10) { dlog('Scheduling Assembly.'); _.defer(AssembleHomebrew, char, charName); } else { _.defer(LoadHomebrewLevel, char, charName, level+1); } }); }; // Assemble homebrew spells with the stock list const AssembleHomebrew = async (char, charName) => { dlog('Beginning Homebrew Assembly...'); const exportCache = Cache.Exports[charName]; let homebrewObjs = exportCache.HomebrewObjs; let newSpellObjs = exportCache.NewSpellObjs; let deleteDict = exportCache.DeleteDict; let renameDict = exportCache.RenameDict; // Import Existing List sendChat(scname, '[11/13] Importing existing spells...'); _.defer(() => { dlog('Importing existing spells...'); for (let i = 0; i < SpellList.length; i++) { const existingSpell = SpellList[i]; // Check for spell deletion if(deleteDict[existingSpell.Name.toUpperCase()]) { dlog(` SKIP DELETED: ${existingSpell.Name}`); continue; } // Search homebrew for overrides let overrideExists = false; for (let j = 0; j < homebrewObjs.length; j++) { const homebrewSpell = homebrewObjs[j]; // Basic comparison if (existingSpell.Name.toUpperCase() === homebrewSpell.Name.toUpperCase()) { overrideExists = true; break; } // Check for renames const oldName = renameDict[homebrewSpell.Name]; if (oldName && existingSpell.Name.toUpperCase() === oldName.toUpperCase()) { dlog(` RENAME OVERRIDE: ${oldName} = ${homebrewSpell.Name}`); overrideExists = true; break; } } // If we haven't inserted an overriding homebrew spell, add the default if (!overrideExists) { newSpellObjs.push(existingSpell); } } newSpellObjs.sort(Sorters.LevelName); // Stringify the new list sendChat(scname, '[12/13] Stringifying new spell list...'); _.defer(() => { dlog('Stringifying new spell list...'); let isFirst = true; let spellListString = '

';
                spellListString += `if (typeof MarkStart != 'undefined') {MarkStart('CustomSpellList');}
`; spellListString += `var CustomSpellList = [
`; for (let i = 0; i < newSpellObjs.length; i++) { const spellObj = newSpellObjs[i]; const spellString = OGLSpell.StringifySpellObj(spellObj); if (isFirst) { isFirst = false; spellListString += spellString; } else { spellListString += ',
' + spellString; } } spellListString += '];
'; spellListString += `if (typeof MarkStop != 'undefined') {MarkStop('CustomSpellList');}
`; spellListString += '
'; // Print to notes section sendChat(scname, '[13/13] Exporting...'); _.defer(() => { dlog('Exporting spell list...'); char.set('bio', spellListString); sendChat(scname, 'EXPORT COMPLETE'); dlog('Finished Export after ' + (new Date() - exportCache.Timer)); }); }); }); }; // Asynchronously exports homebrew material const ExportHomebrew = (charName) => { const char = GetCharByAny(charName); if (!char) { sendChat(scname, `Character ${charName} does not exist!`); return; } // Prevent doing this twice const oldCache = Cache.Exports[charName]; if (oldCache && oldCache.CompleteLevels < 10) { sendChat(scname, `WARNING: Another export operation for this character is already in progress and has completed ${oldCache.CompleteLevels} levels.`); return; } // Begin making new cache const exportCache = { CompleteLevels: 0, HomebrewObjs: [], NewSpellObjs: [], DeleteDict: {}, RenameDict: {}, Timer: new Date() } Cache.Exports[charName] = exportCache; // Begin operation sendChat(scname, 'STARTING ASYNC EXPORT'); _.defer(LoadHomebrewLevel, char, charName, 0); }; // Process chat messages on('chat:message', (msg) => { if (msg.type !== 'api') return; if (!msg.content.startsWith(chatTrigger)) return; if (!SpellsIndexed) { sendChat(scname, "SpellMaster is still indexing spells. Please wait a few seconds and try again."); return; } const startParse = new Date(); dlog('Received: ' + msg.content); const argWords = msg.content.split(/\s+/); const argParams = msg.content.split('--'); // Prints the chat UI menu // !SpellMaster --Menu const printMenu = '--Menu'; if(argWords.includes(printMenu) || msg.content === '!SpellMaster') { let menu = `/w gm &{template:desc} {{desc=

Spell Master


` + `[Create Spellbook](!SpellMaster ` + `--CreateBook ^?{Please type the name of your previously-created handout to be used for this spellbook.}^ ` + `--Owner ^?{Please enter the name of the character that will use this.}^)
` + `[Delete Spellbook](!SpellMaster --DeleteBook ^?{Please type the name of the spellbook to delete.}^ --Confirm ^?{Please type Yes to confirm}^)
` + `[Delete Spell](!SpellMaster --UpdateBook ^?{Spellbook Name}^ --RemoveSpell ^?{Spell Name}^ --Confirm ^?{Type Yes to confirm deletion of this spell from this spell list.}^)
` + `[Flush Cache](!SpellMaster --UpdateBook ^?{Spellbook Name}^ --FlushCache ^Yes^)
` + `[Export Homebrew](!SpellMaster --ExportHomebrew ^?{Please type the name of the character sheet to export house ruled spells from. Be warned that SpellMaster will overwrite its Bio and Info tab}^)
` + `[Set Debug Mode](!SpellMaster --SetDebug ^?{Set the logging level|Debug|Normal}^)
` + `}}`; log('Menu: ' + menu); sendChat(scname, menu); return; } // Create new spell book handout const createBookTag = '--CreateBook'; if(argWords.includes(createBookTag)) { const bookName = GetParamValue(argParams, 'CreateBook'); const owner = GetParamValue(argParams, 'Owner'); log("To Configure Handout \"" + bookName + "\" as a spellbook"); const handout = GetHandout(bookName); if (!handout) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No such handout as ${bookName} exists!`); return; } const char = GetCharByAny(owner); if (!char) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No such character ${owner} exists!`); return; } const spellbook = { IsSpellbook: true, Name: bookName, Handout: handout.id, Owner: owner, Stat: 'Wisdom', PreparationLists: [// An array of objects with arrays of spell names { Name: 'General', PreparedSpells: []// Will be formatted as an array of spell names that are prepared when a certain list is active } ], Filter: { V: Filters.NotApplicable, S: Filters.NotApplicable, M: Filters.NotApplicable, CastTime: "Any", Concentration: Filters.NotApplicable, Ritual: Filters.NotApplicable, Slots: Filters.WithFlag, Prepared: Filters.NotApplicable, Search: "", }, ActivePrepList: 0, // The below fields are set to default values and will be populated later KnownSpells: [], CurSorc: 0, MaxSorc: 0, CurSlots: [-1,-1,-1,-1,-1,-1,-1,-1,-1], MaxSlots: [-1,-1,-1,-1,-1,-1,-1,-1,-1], Items: [] }; RefreshCachedBook(spellbook); const leveledClasses = GetLeveledClasses(char, spellbook); // Import known spells let knownSpells = []; for (let i = 0; i < SpellList.length; i++) { const curSpell = SpellList[i]; // Do not autopopulate cantrips if (curSpell.Level === 0) { continue; } // Keep only the best let bestClass = false; let bestMod = -5; // Iterate over the classes for this character, looking for any with this spell for (let i = 0; i < leveledClasses.length; i++) { const leveledClass = leveledClasses[i]; let className = leveledClass.Name; const statMod = GetStatModForClass(char, spellbook, className); const classes = curSpell.Classes.split(',').map((className) => className.trim()); if (classes.includes(className) && statMod > bestMod) { bestClass = className; bestMod = statMod; spellbook.Stat = ClassToStatMap[className]; } } // Add it to the list under the best option if (bestClass === 'Cleric' || bestClass === 'Druid' || bestClass === 'Shaman' || bestClass === 'Paladin' || bestClass === 'Artificer') { knownSpells.push({ Name: curSpell.Name, IsExpanded: false, Stat: ClassToStatMap[bestClass], DC: 8, Lock: false, Notes: 'From ' + bestClass, CurSlots: 0, MaxSlots: 0 }); } } spellbook.KnownSpells = knownSpells; // Get slots for classes. Do it twice for unique array objects spellbook.CurSlots = GetCharSlots(leveledClasses); for (let i = 0; i < spellbook.CurSlots.length; i++) { spellbook.MaxSlots[i] = spellbook.CurSlots[i]; } // Create state entry BookDict[bookName] = spellbook; log("Successfully created a new spell list!"); PrintSpellbook(BookDict[bookName], [CacheOptions.All], CacheOptions.AllSpellLevels); sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" Spellbook created.`); const stopParse = new Date(); const diffParse = stopParse - startParse; dlog('Creation Time: ' + diffParse); return; } // Update existing book const updateBookTag = '--UpdateBook'; if (argWords.includes(updateBookTag)) { // Book to use const bookName = GetParamValue(argParams, 'UpdateBook'); const spellbook = BookDict[bookName]; if (!spellbook) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" No such book as ${bookName}`); return; } // Other operation codes const importSpell = GetParamValue(argParams, 'ImportSpell'); const removeSpell = GetParamValue(argParams, 'RemoveSpell'); const updateSpell = GetParamValue(argParams, 'UpdateSpell'); const updateSlot = GetParamValue(argParams, 'UpdateSlot'); const addPrepList = GetParamValue(argParams, 'AddPrepList'); const setActive = GetParamValue(argParams, 'SetActive'); const removePrepList = GetParamValue(argParams, 'RemovePrepList'); const renamePrepList = GetParamValue(argParams, 'RenamePrepList'); const castSpell = GetParamValue(argParams, 'CastSpell'); const setSlots = GetParamValue(argParams, 'SetSlots'); const flushCache = GetParamValue(argParams, 'FlushCache'); const levelUp = GetParamValue(argParams, 'LevelUp'); const curSorc = GetParamValue(argParams, 'SetCurSorc'); const maxSorc = GetParamValue(argParams, 'SetMaxSorc'); const composeSlot = GetParamValue(argParams, 'ComposeSlot'); const decomposeSlot = GetParamValue(argParams, 'DecomposeSlot'); const createItem = GetParamValue(argParams, 'CreateItem'); const removeItem = GetParamValue(argParams, 'RemoveItem'); const updateItem = GetParamValue(argParams, 'UpdateItem'); // Parameters const paramName = GetParamValue(argParams, 'ParamName'); const paramValue = GetParamValue(argParams, 'ParamValue'); const confirm = GetParamValue(argParams, 'Confirm'); const refreshKnowables = GetParamValue(argParams, 'RefreshKnowables'); // the list of caches that need to be updated. Duplicates are fine. dirtyCaches = []; dirtyLevels = []; // Build the cache if it doesn't already exist. if (!Cache.Books[spellbook.Name] || flushCache) { dlog(`Refreshing Cache for ${spellbook.Name}`); RefreshCachedBook(spellbook); dirtyCaches.push(CacheOptions.All); dirtyLevels = CacheOptions.AllSpellLevels; if (flushCache) { sendChat(scname, `Cache Flushed for ${spellbook.Name}.`); } } // Interaction buttons if (importSpell) { const spell = SpellDict[importSpell]; if (!spell) { printSpellSuggestions(importSpell, msg); return; } for (let i = 0; i < spellbook.KnownSpells.length; i++) { const knownSpell = spellbook.KnownSpells[i]; if (knownSpell.Name === importSpell) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ${importSpell} cannot be imported as it is already in your list!`); return; } } // Determine which stat this should go under let spellStat = 'Wisdom'; let bestClass = 'From X'; let bestMod = -6; const char = GetCharByAny(spellbook.Owner); const leveledClasses = GetLeveledClasses(char, spellbook); for (let i = 0; i < leveledClasses.length; i++) { const leveledClass = leveledClasses[i]; let className = GetSpellListClassFromClass(leveledClass); // Get the modifier for comparison const statMod = GetStatModForClass(char, spellbook, className); // If it's in the list and the best so far, record that. if (spell.Classes.includes(className) && statMod > bestMod) { spellStat = ClassToStatMap[className]; bestClass = leveledClass.Name; bestMod = statMod; } } // Add the known spell instance spellbook.KnownSpells.push({ Name: spell.Name, IsExpanded: false, Stat: spellStat, Lock: false, Notes: 'From ' + bestClass, CurSlots: 0, MaxSlots: 0 }); dirtyCaches.push(CacheOptions.Spells); dirtyLevels = [spell.Level]; if (refreshKnowables === 'Yes') { PrintKnowables(msg, spellbook, spell.Level); } } else if (removeSpell) { if (confirm !== 'Yes') { return; } const knownSpells = spellbook.KnownSpells; let spellUnprepared = false; let spellDeleted = false; // Remove from any preparation lists for(let i = 0; i < spellbook.KnownSpells.length; i++) { const knownSpell = spellbook.KnownSpells[i]; if (knownSpell.Name === removeSpell) { for(let j = 0; j < spellbook.PreparationLists.length; j++) { const prepList = spellbook.PreparationLists[j].PreparedSpells; const prepIndex = prepList.indexOf(knownSpell); if (prepIndex > -1) { prepList.splice(prepIndex, 1); spellUnprepared = true; } } } } // Remove from known list for (let i = 0; i < knownSpells.length; i++) { const curSpellInstance = knownSpells[i]; if (curSpellInstance.Name === removeSpell) { knownSpells.splice(i,1); spellDeleted = true; break; } } if (!spellDeleted) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" Invalid spell name to delete: ${removeSpell}`); return; } // Set cache options dirtyCaches.push(CacheOptions.Spells); dirtyLevels = [SpellDict[removeSpell].Level]; if (spellUnprepared) { dirtyCaches.push(CacheOptions.Prepared); dirtyCaches.push(CacheOptions.PrepLists); } if (refreshKnowables === 'Yes') { PrintKnowables(msg, spellbook, SpellDict[removeSpell].Level); } } else if (updateSpell) { dirtyCaches.push(CacheOptions.Spells); if (dirtyLevels !== CacheOptions.AllSpellLevels){ dirtyLevels = [SpellDict[updateSpell].Level]; } if (paramName === 'Prepared') { dlog(`${spellbook.Owner} is attempting to toggle the preparation of ${updateSpell} to value ${paramValue}`); const prepList = spellbook.PreparationLists[spellbook.ActivePrepList].PreparedSpells; if (paramValue === 'True') { for(let i = 0; i < spellbook.KnownSpells.length; i++) { const knownSpell = spellbook.KnownSpells[i]; if (knownSpell.Name === updateSpell) { if (!prepList.includes(knownSpell)) { prepList.push(knownSpell); } break; } } } else { for(let i = 0; i < spellbook.KnownSpells.length; i++) { const knownSpell = spellbook.KnownSpells[i]; if (knownSpell.Name === updateSpell) { const prepIndex = prepList.findIndex((element) => {return element.Name === knownSpell.Name}); if (prepIndex > -1) { prepList.splice(prepIndex, 1); } break; } } } dirtyCaches.push(CacheOptions.Prepared); dirtyCaches.push(CacheOptions.PrepLists); } else if (paramName === 'Expanded') { for (let i = 0; i < spellbook.KnownSpells.length; i++) { const knownSpell = spellbook.KnownSpells[i]; if (knownSpell.Name === updateSpell) { knownSpell.IsExpanded = paramValue === 'True'; break; } } } else if (paramName === 'Ability') { for (let i = 0; i < spellbook.KnownSpells.length; i++) { const knownSpell = spellbook.KnownSpells[i]; if (knownSpell.Name === updateSpell) { knownSpell.Stat = paramValue; break; } } } else if (paramName === 'DC') { for (let i = 0; i < spellbook.KnownSpells.length; i++) { const knownSpell = spellbook.KnownSpells[i]; if (knownSpell.Name === updateSpell) { knownSpell.DC = parseInt(paramValue); break; } } } else if (paramName === 'Notes') { for (let i = 0; i < spellbook.KnownSpells.length; i++) { const knownSpell = spellbook.KnownSpells[i]; if (knownSpell.Name === updateSpell) { knownSpell.Notes = paramValue; break; } } } else if (paramName === 'Lock') { dlog(`${spellbook.Owner} is attempting to toggle the lock of ${updateSpell}`); for (let i = 0; i < spellbook.KnownSpells.length; i++) { const knownSpell = spellbook.KnownSpells[i]; if (knownSpell.Name === updateSpell) { knownSpell.Lock = paramValue === 'True'; break; } } if (paramValue === 'True') { // Remove from any preparation lists (since it's now in all and none of them) for(let i = 0; i < spellbook.KnownSpells.length; i++) { const knownSpell = spellbook.KnownSpells[i]; if (knownSpell.Name === updateSpell) { for(let j = 0; j < spellbook.PreparationLists.length; j++) { const prepList = spellbook.PreparationLists[j].PreparedSpells; const prepIndex = prepList.findIndex((element) => {return element.Name === knownSpell.Name}); if (prepIndex > -1) { prepList.splice(prepIndex, 1); } } } } } dirtyCaches.push(CacheOptions.Prepared); dirtyCaches.push(CacheOptions.PrepLists); } else if (paramName === 'CurSlots') { const newVal = parseInt(paramValue) || 0; for (let i = 0; i < spellbook.KnownSpells.length; i++) { const knownSpell = spellbook.KnownSpells[i]; if (knownSpell.Name === updateSpell) { knownSpell.CurSlots = newVal; break; } } } else if (paramName === 'MaxSlots') { const newVal = parseInt(paramValue) || 0; for (let i = 0; i < spellbook.KnownSpells.length; i++) { const knownSpell = spellbook.KnownSpells[i]; if (knownSpell.Name === updateSpell) { knownSpell.MaxSlots = newVal; break; } } } } else if (updateSlot) { const slotIndex = (parseInt(updateSlot) || 0) - 1; const newVal = parseInt(paramValue); if (paramName === 'Max') { spellbook.MaxSlots[slotIndex] = newVal; } else if (paramName === 'Cur') { spellbook.CurSlots[slotIndex] = newVal; } dirtyCaches.push(CacheOptions.Spells); // This could alter the spell availability of this spell and all below it (aside from cantrips) for (let i = slotIndex+1; i > 0; i--) { dirtyLevels.push(i); } } else if (addPrepList) { for(let i = 0; i < spellbook.PreparationLists.length; i++) { const existingPrepList = spellbook.PreparationLists[i]; if (existingPrepList.Name === addPrepList) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" Invalid preparation list name ${addPrepList} as it already exists in this spellbook.`); return; } } spellbook.PreparationLists.push({ Name: addPrepList, PreparedSpells: []// Will be formatted as an array of spell names that are prepared when a certain list is active }); dirtyCaches.push(CacheOptions.PrepLists); } else if (setActive) { const activeIndex = parseInt(setActive) || 0; spellbook.ActivePrepList = activeIndex; dirtyCaches.push(CacheOptions.Prepared); dirtyCaches.push(CacheOptions.Spells); dirtyCaches.push(CacheOptions.PrepLists); dirtyLevels = CacheOptions.AllSpellLevels; } else if (removePrepList) { if (confirm !== 'Yes') { return; } const prepIdToRemove = parseInt(removePrepList); if (spellbook.ActivePrepList == prepIdToRemove) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" Cannot remove currently-active preparation list.`); return; } spellbook.PreparationLists.splice(parseInt(removePrepList), 1); dirtyCaches.push(CacheOptions.PrepLists); } else if (renamePrepList) { const prepIdToRename = parseInt(renamePrepList); // Make sure not empty string if (paramValue.length === 0) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" Cannot set name to empty string.`); return; } // Names must be exclusive for (let i = 0; i < spellbook.PreparationLists.length; i++) { if (spellbook.PreparationLists[i].Name === paramValue && i !== prepIdToRename) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" Name already exists.`); return; } } spellbook.PreparationLists[prepIdToRename].Name = paramValue; dirtyCaches.push(CacheOptions.PrepLists); } else if (castSpell) { const spell = SpellDict[castSpell]; dlog(spellbook.Owner + ' is casting ' + spell.Name + ' with level ' + paramValue + ' when base spell level is ' + spell.Level); const level = parseInt(paramValue) || 0; if (level < 0 || level < spell.Level) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" Invalid cast level ${paramValue}.`); return; } if (spellbook.CurSlots[level-1] === 0 && level === 0) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" Unable to cast spell from expended slot level.`); return; } let instance = false; for (let i in spellbook.KnownSpells) { let curInstance = spellbook.KnownSpells[i]; if (curInstance.Name === castSpell) { instance = curInstance; break; } } if (!instance) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" Instance does not exist.`); return; } dlog(`Available slots: Individual=${instance.CurSlots} - Global=${spellbook.CurSlots[level-1]}`); if(level > 0 && instance.CurSlots < 1 && spellbook.CurSlots[level-1] < 1) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" Spell slots of that level are exhausted.`); return; } PrintSpell(spellbook, instance, spell, level, msg); if(level > 0) { // Attempt to cast from per-spell slots first since they sometimes recharge faster, but only if casting at base level if (instance.CurSlots > 0 && spell.Level === level) { instance.CurSlots--; } else { spellbook.CurSlots[level-1]--; dirtyLevels = [level];// Yes, these two arrays are not indexed the same :( } } dirtyCaches.push(CacheOptions.Spells); } else if (setSlots) { if (setSlots === 'Full') { // Refill spell level slots for (let i = 0; i < spellbook.CurSlots.length; i++) { spellbook.CurSlots[i] = spellbook.MaxSlots[i]; } // Refill class/other feature slots for (let i = 0; i < spellbook.KnownSpells.length; i++) { const knownSpell = spellbook.KnownSpells[i]; knownSpell.CurSlots = knownSpell.MaxSlots; } dirtyCaches.push(CacheOptions.Spells); dirtyLevels = CacheOptions.AllSpellLevels; // Refill Sorcery Points spellbook.CurSorc = spellbook.MaxSorc; dirtyCaches.push(CacheOptions.SorcPoints); // Refil Items for (let i = 0; i < spellbook.Items.length; i++) { const item = spellbook.Items[i]; const simpleRefillCount = parseInt(item.RegenRate); if (isNaN(simpleRefillCount) || item.RegenRate.includes('d') || item.RegenRate.includes('+') || item.RegenRate.includes('-')) { try { sendChat('', `[[${item.RegenRate}]]`, (rollResult) => { const intResult = rollResult[0].inlinerolls[0].results.total; item.CurCharges = Math.min(item.CurCharges + intResult, item.MaxCharges); dirtyCaches.push(CacheOptions.Items); sendChat(scname, `${spellbook.Owner}, ${item.Name} has recovered ${intResult} for a total of ${item.CurCharges}/${item.MaxCharges}.`); PrintSpellbook(BookDict[bookName], dirtyCaches, dirtyLevels); }); } catch (e) { sendChat(scname, `ERROR: ${spellbook.Owner}, ${item.Name}'s recharge rate could not be parsed: ${item.RegenRate}.`); } } else { item.CurCharges = Math.min(item.CurCharges + simpleRefillCount, item.MaxCharges); dirtyCaches.push(CacheOptions.Items); sendChat(scname, `${spellbook.Owner}, ${item.Name} has recovered ${item.RegenRate} for a total of ${item.CurCharges}/${item.MaxCharges}.`); } } sendChat(scname, `${spellbook.Owner} has finished a long rest to restore ${spellbook.Name}`); } } else if (curSorc) { spellbook.CurSorc = parseInt(curSorc); dirtyCaches.push(CacheOptions.SorcPoints); } else if (maxSorc) { spellbook.MaxSorc = parseInt(maxSorc); spellbook.CurSorc = Math.min(spellbook.CurSorc, spellbook.MaxSorc); dirtyCaches.push(CacheOptions.SorcPoints); dirtyCaches.push(CacheOptions.Spells); dirtyLevels = CacheOptions.AllSpellLevels; } else if (composeSlot) { const slotInt = parseInt(composeSlot); const remainder = spellbook.CurSorc - SlotComposeCost[slotInt]; const slotIndex = slotInt-1; if (remainder >= 0) { if (spellbook.CurSlots[slotIndex] === spellbook.MaxSlots[slotIndex]) { sendChat(scname, `Warning: ${spellbook.Owner} has already filled all of their level ${slotInt} spell slots. Another cannot be composed at this time.`); } else { spellbook.CurSorc = remainder; spellbook.CurSlots[slotIndex]++; dirtyLevels.push(slotInt); dirtyCaches.push(CacheOptions.Spells); dirtyCaches.push(CacheOptions.SorcPoints); sendChat(scname, `${spellbook.Owner} has used a bonus action to compose a new level ${slotInt} spell slot from sorcery points.`); } } else { sendChat(scname, `Warning: ${spellbook.Owner} does not have enough sorcery points to create a slot of level ${slotInt}. (${spellbook.CurSorc}/${SlotComposeCost[slotInt]}).`); } } else if (decomposeSlot) { if (spellbook.CurSorc === spellbook.MaxSorc) { sendChat(scname, `Warning: ${spellbook.Owner} is already at maximum sorcery points.`); } else { const slotInt = parseInt(decomposeSlot); spellbook.CurSorc = Math.min(spellbook.CurSorc+slotInt, spellbook.MaxSorc); const slotIndex = slotInt-1; spellbook.CurSlots[slotIndex]--; dirtyLevels.push(slotInt); dirtyCaches.push(CacheOptions.Spells); dirtyCaches.push(CacheOptions.SorcPoints); sendChat(scname, `${spellbook.Owner} has used a bonus action to decompose a level ${slotInt} spell slot into sorcery points.`); } } else if (levelUp) { if (levelUp !== 'Yes') { return; } RefreshCachedBook(spellbook); const char = GetCharByAny(spellbook.Owner); const leveledClasses = GetLeveledClasses(char, spellbook); spellbook.MaxSlots = GetCharSlots(leveledClasses); dirtyCaches.push(CacheOptions.All); dirtyLevels = CacheOptions.AllSpellLevels; sendChat(scname, `Leveled Up for ${spellbook.Name}.`); } else if (createItem) { const itemName = createItem; if (!itemName) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No item name provided.`); return; } const maxChargesStr = GetParamValue(argParams, 'MaxCharges'); if (!maxChargesStr) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No charges provided. If the item is chargeless, use 0.`); return; } const maxCharges = parseInt(maxChargesStr) || 0; const regenRate = GetParamValue(argParams, 'RegenRate'); if (!regenRate) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No regen rate provided. If the item has infinite charges or does not recharge, use 0.`); return; } const attunement = GetParamValue(argParams, 'Attunement') === 'Yes'; const appearance = GetParamValue(argParams, 'Appearance'); const effect = GetParamValue(argParams, 'Effect'); const item = { Name: itemName, Attunement: attunement, CurCharges: maxCharges, MaxCharges: maxCharges, RegenRate: regenRate, Enchantments: [], Appearance: appearance, Effect: effect, Expanded: true, Attuned: false }; spellbook.Items.push(item); dirtyCaches.push(CacheOptions.Items); sendChat(scname, `Added the ${itemName} for ${spellbook.Name}.`); } else if (removeItem) { if (!removeItem) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No item to delete provided.`); return; } spellbook.Items = spellbook.Items.filter((value) => { return value.Name !== removeItem; }); dirtyCaches.push(CacheOptions.Items); sendChat(scname, `Deleted the ${removeItem} from ${spellbook.Name}.`); } else if (updateItem) { if (!updateItem) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No item to update provided.`); return; } let item = spellbook.Items.find((item) => item.Name === updateItem); if (!item) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No such item exists.`); return; } const addSpell = GetParamValue(argParams, 'AddSpell'); const updateEnchantment = GetParamValue(argParams, 'UpdateEnchantment'); const deleteEnchantment = GetParamValue(argParams, 'DeleteEnchantment'); const activate = GetParamValue(argParams, 'Activate'); if (addSpell) { if (!SpellDict[addSpell]) { printSpellSuggestions(addSpell, msg); return; } const chargeCost = parseInt(GetParamValue(argParams, 'ChargeCost')) || 0; const upcast = GetParamValue(argParams, 'Upcast') === 'Yes'; const upcastCost = parseInt(GetParamValue(argParams, 'UpcastCost')) || 0; const dc = GetParamValue(argParams, 'DC') || '0';// Deliberately a string item.Enchantments.push({ SpellName: addSpell, ChargeCost: chargeCost, Upcast: upcast, UpcastCost: upcastCost, DC: dc, Expanded: true }); } else if (updateEnchantment) { let enchantment = item.Enchantments.find((enchantment) => enchantment.SpellName === updateEnchantment || enchantment.CustomName === updateEnchantment); if (!enchantment) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No such enchantment exists.`); return; } if (!paramName) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No parameter name provided.`); return; } if (!paramValue) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No parameter value provided.`); return; } if (paramName === 'Name') { if (enchantment.SpellName) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: Spell enchantments cannot be renamed.`); return; } enchantment.CustomName = paramValue; } else if (paramName === 'Expanded') { enchantment.Expanded = paramValue === 'true'; } else if (paramName === 'ChargeCost') { enchantment.ChargeCost = parseInt(paramValue) || 0; } else if (paramName === 'UpcastCost') { enchantment.UpcastCost = parseInt(paramValue) || 0; } else if (paramName === 'Upcast') { enchantment.Upcast = paramValue === 'Yes'; } else if (paramName === 'DC') { enchantment.DC = paramValue || 0; } } else if (deleteEnchantment) { if (confirm !== 'Yes') { return; } item.Enchantments = item.Enchantments.filter((enchantment) => enchantment.SpellName !== deleteEnchantment && enchantment.CustomName !== deleteEnchantment); } else if (activate) { let enchantment = item.Enchantments.find((enchantment) => enchantment.SpellName === activate || enchantment.CustomName === activate); if (!enchantment) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No such enchantment exists.`); return; } const tier = parseInt(GetParamValue(argParams, 'Tier')); if (isNaN(tier) || tier < 0) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: Invalid activation tier: ${GetParamValue(argParams, 'Tier')}.`); return; } if (enchantment.SpellName) { const spell = SpellDict[enchantment.SpellName]; const cost = enchantment.ChargeCost + (enchantment.Upcast && enchantment.UpcastCost > 0 ? enchantment.UpcastCost * (tier - spell.Level) : 0); const remainder = item.CurCharges - cost; if (remainder < 0) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: Insufficient charges. Attempted to consume ${cost}, but only ${item.CurCharges} remain.`); return; } item.CurCharges = remainder; } else { flog('Activating custom enchantments not possible yet.'); } PrintEnchantment(spellbook, item, enchantment, tier, msg); } else {// Minor config updates if (!paramName) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No parameter name provided.`); return; } if (!paramValue) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" ERROR: No parameter value provided.`); return; } if (paramName === 'Name') { item.Name = paramValue; } else if (paramName === 'Attuned') { item.Attuned = paramValue === 'true'; } else if (paramName === 'Expanded') { item.Expanded = paramValue === 'true'; } else if (paramName === 'Cur') { item.CurCharges = parseInt(paramValue) || 0; } else if (paramName === 'Max') { item.MaxCharges = parseInt(paramValue) || 0; } else if (paramName === 'Attunement') { item.Attunement = paramValue === 'true'; } else if (paramName === 'Appearance') { item.Appearance = paramValue; } else if (paramName === 'Effect') { item.Effect = paramValue; } else if (paramName === 'RegenRate') { item.RegenRate = paramValue; } } dirtyCaches.push(CacheOptions.Items); } // Filtration else if (paramName === 'V') { spellbook.Filter.V = parseInt(paramValue); dirtyCaches.push(CacheOptions.Spells); dirtyCaches.push(CacheOptions.Filtering); dirtyLevels = CacheOptions.AllSpellLevels; } else if (paramName === 'S') { spellbook.Filter.S = parseInt(paramValue); dirtyCaches.push(CacheOptions.Spells); dirtyCaches.push(CacheOptions.Filtering); dirtyLevels = CacheOptions.AllSpellLevels; } else if (paramName === 'M') { spellbook.Filter.M = parseInt(paramValue); dirtyCaches.push(CacheOptions.Spells); dirtyCaches.push(CacheOptions.Filtering); dirtyLevels = CacheOptions.AllSpellLevels; } else if (paramName === 'Ritual') { spellbook.Filter.Ritual = parseInt(paramValue); dirtyCaches.push(CacheOptions.Spells); dirtyCaches.push(CacheOptions.Filtering); dirtyLevels = CacheOptions.AllSpellLevels; } else if (paramName === 'Slots') { spellbook.Filter.Slots = parseInt(paramValue); dirtyCaches.push(CacheOptions.Spells); dirtyCaches.push(CacheOptions.Filtering); dirtyLevels = CacheOptions.AllSpellLevels; } else if (paramName === 'Concentration') { spellbook.Filter.Concentration = parseInt(paramValue); dirtyCaches.push(CacheOptions.Spells); dirtyCaches.push(CacheOptions.Filtering); dirtyLevels = CacheOptions.AllSpellLevels; } else if (paramName === 'Prepared') { spellbook.Filter.Prepared = parseInt(paramValue); dirtyCaches.push(CacheOptions.Spells); dirtyCaches.push(CacheOptions.Filtering); dirtyLevels = CacheOptions.AllSpellLevels; } else if (paramName === 'Search') { spellbook.Filter.Search = paramValue; dirtyCaches.push(CacheOptions.Spells); dirtyCaches.push(CacheOptions.Filtering); dirtyLevels = CacheOptions.AllSpellLevels; } else if (paramName === 'CastTime') { spellbook.Filter.CastTime = paramValue; dirtyCaches.push(CacheOptions.Spells); dirtyCaches.push(CacheOptions.Filtering); dirtyLevels = CacheOptions.AllSpellLevels; } PrintSpellbook(BookDict[bookName], dirtyCaches, dirtyLevels); const stopParse = new Date(); const diffParse = stopParse - startParse; dlog('Update Time: ' + diffParse); return; } // Remove existing book const deleteBookTag = '--DeleteBook'; if (argWords.includes(deleteBookTag)) { const bookToDelete = GetParamValue(argParams, 'DeleteBook'); const confirm = GetParamValue(argParams, 'Confirm'); const spellbook = BookDict[bookToDelete]; if (!spellbook) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" No such spellbook as ${bookToDelete} exists`); return; } if (confirm === 'Yes') { delete BookDict[bookToDelete]; GetHandout(spellbook.Handout).set('notes', ''); sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" Spellbook deleted.`); } const stopParse = new Date(); const diffParse = stopParse - startParse; dlog('Deletion Time: ' + diffParse); return; } // Exports homebrew spells from a char sheet to js const exportHomebrewTag = '--ExportHomebrew'; if (argWords.includes(exportHomebrewTag)) { const charName = GetParamValue(argParams, 'ExportHomebrew'); ExportHomebrew(charName); return; } // Prints all potentially knowable spells given a creature's classes const printKnowablesTag = '--PrintKnowables'; if (argWords.includes(printKnowablesTag)) { const bookName = GetParamValue(argParams, 'PrintKnowables'); const spellbook = BookDict[bookName]; if (!spellbook) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" No such book as ${bookName}`); return; } const level = parseInt(GetParamValue(argParams, 'Level')); if(isNaN(level)) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" Invalid level ${GetParamValue(argParams, 'Level')}`); return; } PrintKnowables(msg, spellbook, level); return; } const printSpellTag = '--PrintDummySpell'; if (argWords.includes(printSpellTag)) { const spellName = GetParamValue(argParams, 'PrintDummySpell'); const spell = SpellDict[spellName]; if(!spell) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" Invalid spell ${spellName}`, null, { noarchive: true }); return; } const bookName = GetParamValue(argParams, 'HypotheticalBook'); const spellbook = BookDict[bookName]; if (!spellbook) { sendChat(scname, `/w "${msg.who.replace(' (GM)', '')}" No such book as ${bookName}`); return; } PrintDummySpell(spellbook, spell, msg); return; } // Sets or unsets the debugLog flag const debugTag = '--SetDebug'; if (argWords.includes(debugTag)) { debugLog = GetParamValue(argParams, 'SetDebug') === 'Debug'; sendChat(scname, 'Debug Mode: ' + debugLog); return; } }); // Perform garbage collection on orphaned spellbooks const PurgeOldSpellbooks = () => { // This won't clean up *instantly* but this runs every time, so this will anneal state over time for (let bookName in BookDict) { const book = BookDict[bookName]; // Don't accidentally delete any non-spellbook properties if (book.IsSpellbook) { // Attempt to get the handout. const handout = GetHandout(book); // If it's dead, delete it because the user destroyed it. if (handout === null) { log("ALERT: SpellMaster detected an orphaned book. It will be deleted: " + bookName); delete BookDict[bookName]; break; } } } } PurgeOldSpellbooks(); }); if (typeof MarkStop != 'undefined') {MarkStop('SpellMaster');}