// ==UserScript== // @name DnDBeyond Spell Points (v2) // @description Spell point tracker // @version 2.3.0 // @author Mwr247 // @namespace Mwr247 // @homepageURL https://github.com/Mwr247/DnDBeyondSpellPointsV2 // @downloadURL https://raw.githubusercontent.com/Mwr247/DnDBeyondSpellPointsV2/main/DnDBeyondSpellPointsV2.js // @updateURL https://raw.githubusercontent.com/Mwr247/DnDBeyondSpellPointsV2/main/DnDBeyondSpellPointsV2.js // @match https://www.dndbeyond.com/*characters/* // @match https://www.dndbeyond.com/characters // @run-at document-idle // @grant none // ==/UserScript== (() => { 'use strict'; const sp = [ // Class Level, Sorc Points, Spell Points, Max Slot Level [1,0,4,1], [2,2,6,1], [3,3,14,2], [4,4,17,2], [5,5,27,3], [6,6,32,3], [7,7,38,4], [8,8,44,4], [9,9,57,5], [10,10,64,5], [11,11,73,6], [12,12,73,6], [13,13,83,7], [14,14,83,7], [15,15,94,8], [16,16,94,8], [17,17,107,9], [18,18,114,9], [19,19,123,9], [20,20,133,9] ]; const sc = [ // Spell Level, Point Cost, Limit of 1 [1,2,false], [2,3,false], [3,5,false], [4,6,false], [5,7,false], [6,8,true], [7,10,true], [8,11,true], [9,13,true] ]; const player = { id: null, level: null, points: 0, maxPoints: 0, maxSpellLevel: 0, data: null }; let loaded = 10; let spSystem = null; let useSpellPoints = null; let mergeSorcPoints = null; let token = null; let tokenExpires = 0; const getToken = cb => { console.log('refreshing token'); fetch('https://auth-service.dndbeyond.com/v1/cobalt-token', { method: 'POST', credentials: 'include' }).then(resp => resp.json()).then(data => { console.log('token updated'); token = data.token; tokenExpires = Date.now() + data.ttl * 1000 - 10000; cb(); }).catch(error => console.error(error)); }; const getData = (path='', obj={}, cb=()=>{}) => { console.log('loading data'); const dataCall = (path, obj, cb) => () => { console.log('data call to', path); obj.headers = Object.assign(obj.headers || {}, {'Content-type': 'application/json;charset=utf-8', 'Authorization': 'Bearer ' + token}); if (obj.body) {obj.body = JSON.stringify(obj.body);} fetch('https://character-service.dndbeyond.com/character/v5/' + path, obj).then(resp => resp.json()).then(data => { cb(data.data); }).catch(error => console.error(error)); }; if (token == null || tokenExpires <= Date.now()) { getToken(dataCall(path, obj, cb)); } else { dataCall(path, obj, cb)(); } }; const init = () => { player.id = location.pathname.split('/characters/')[1].split('/')[0]; if (player.id == null) { return; } getData('character/' + player.id, {}, data => { player.data = data; spSystem = (player.data?.customActions || []).find(act => act.name === 'Spell Points'); useSpellPoints = spSystem?.isProficient === true; mergeSorcPoints = spSystem?.isMartialArts === true; const classes = data.classes.map(cl => { const isCaster = (cl.definition.canCastSpells == true || cl.subclassDefinition?.canCastSpells == true) && cl.definition.id !== 7; const level = cl.level || 1; const divisor = cl.definition.spellRules?.multiClassSpellSlotDivisor || cl.subclassDefinition?.spellRules?.multiClassSpellSlotDivisor || 1; const rounder = cl.definition.spellRules?.multiClassSpellSlotRounding || cl.subclassDefinition?.spellRules?.multiClassSpellSlotRounding || 1; return isCaster * Math[rounder === 1 ? 'floor' : 'ceil'](level / divisor); }); player.level = classes.reduce((a, b) => a + b, 0) || 1; const sorcPoints = (((player.data?.actions?.class || []).find(act => act?.id === '1031') || {}).limitedUse?.maxUses + (player.data?.feats || []).some(feat => feat?.definition?.id === 452833) * 2) || 0; player.maxPoints = sorcPoints * mergeSorcPoints + sp[player.level - 1][2]; const content = document.getElementById('character-tools-target'); if (!content) {return;} const sheet = [...content.getElementsByClassName('ct-character-header-desktop')].length; if (sheet) { if (!useSpellPoints) {return;} console.log('Spell point tracker active'); player.points = Math.max(player.maxPoints - (spSystem?.fixedValue || 0) * 1, 0); player.maxSpellLevel = sp[player.level - 1][3]; const setPoints = val => { val = Math.max(Math.min(val, player.maxPoints), 0); player.points = val; const tmp = Object.assign(spSystem, {characterId: +player.id, fixedValue: (player.maxPoints - val) || null}); getData('custom/action', {method: 'PUT', body: tmp}, data => { console.log('updated spell point action'); spSystem.fixedValue = tmp.fixedValue; }); (document.getElementById('pointsDisplay') || {}).innerText = player.points + ' / ' + player.maxPoints; }; const cast = level => { const cost = sc[level - 1][1]; return evt => { if (player.points >= cost){ setPoints(player.points - cost); console.log('cast level', level, 'spell with', cost, 'points'); } else { alert('Insufficient spell points'); } if (!sc[level - 1][2]) {evt.stopPropagation();} }; }; const castClick = evt => { console.log('checking levels'); setTimeout(() => { [...content.getElementsByClassName('ct-content-group')].forEach(el => { if (!/^CANTRIP/.test(el.innerText)) { const level = +el.innerText[0]; console.log('level', level); const lvl = el.querySelector('.ct-content-group__header-content'); if (!lvl.spFlag){ lvl.spFlag = true; lvl.innerText += ' (Cost ' + sc[level - 1][1] + ')'; } [...el.querySelectorAll('.ddbc-spell-damage-effect__damages > .integrated-dice__container')].filter(ele => !ele.evtFlag).forEach(ele => { ele.evtFlag = true; ele.addEventListener('click', cast(level)); }); [...el.getElementsByClassName('ct-spells-spell')].filter(ele => !ele.evtFlag).forEach(ele => { ele.evtFlag = true; ele.addEventListener('click', panelOpenClick); }); } }); }, 10); }; const actionCastClick = evt => { console.log('checking actions'); setTimeout(() => { [...content.getElementsByClassName('ct-feature-snippet__spell-summary')].filter(ele => !ele.evtFlag).forEach(ele => { ele.evtFlag = true; ele.addEventListener('click', panelOpenClick); }); }, 10); }; const panelOpenClick = evt => { console.log('opened panel'); setTimeout(() => { const spDetail = document.getElementsByClassName('ct-spell-detail')[0]; if (spDetail != null) { const spCast = spDetail.querySelector('.ct-spell-caster__casting-action > button'); spCast.innerHTML = spCast.innerHTML.replace('Spell Slot', 'Spell Points'); const spLvl = spDetail.getElementsByClassName('ct-spell-caster__casting-level-current')[0]; const spCost = spDetail.getElementsByClassName('ct-spell-caster__casting-action-count--spellcasting')[0]; console.log('spell level:', spLvl.innerText[0]); spCast.spLvl = spLvl.innerText[0]; spCost.innerText = sc[+spCast.spLvl - 1][1]; spCast.addEventListener('click', evt => cast(+spCast.spLvl)(evt)); [...spDetail.getElementsByClassName('ct-spell-caster__casting-level-action')].forEach(ele => { ele.addEventListener('click', evt => { setTimeout(() => { console.log('spell level:', spLvl.innerText[0]); spCast.spLvl = spLvl.innerText[0]; spCost.innerText = sc[+spCast.spLvl - 1][1]; }, 10); }); }); } }, 50); }; const actionClick = evt => { console.log('clicked actions'); setTimeout(() => { [...content.querySelectorAll('.ct-actions__content .ddbc-tab-options__header')].forEach(ele => ele.addEventListener('click', actionCastClick)); actionCastClick(evt); }, 50); }; const spellClick = evt => { console.log('clicked spells'); setTimeout(() => { let tmp = content.getElementsByClassName('ct-spells-level-casting__info-group')[2]; let pdc = tmp.cloneNode(true); pdc.childNodes[1].innerText = 'Spell Points'; pdc.childNodes[0].childNodes[0].innerText = ''; let pdSub = document.createElement('span'); pdSub.innerText = '–'; pdSub.style.color = '#BB0000'; pdSub.style.userSelect = 'none'; pdSub.style.cursor = 'pointer'; pdSub.addEventListener('click', evt => { setPoints(player.points - 1); }); pdc.childNodes[0].childNodes[0].appendChild(pdSub); let pd = document.createElement('span'); pd.innerText = player.points + ' / ' + player.maxPoints; pd.id = 'pointsDisplay'; pd.style.margin = '0 4px'; pd.style.cursor = 'pointer'; pd.addEventListener('click', evt => { let val = prompt('Override Spell Points', player.points); if (val == null) {return;} else {val = +val;} if (val >= 0) {setPoints(val);} else {alert('Invalid point value');} }); pdc.childNodes[0].childNodes[0].appendChild(pd); let pdAdd = document.createElement('span'); pdAdd.innerText = '+'; pdAdd.style.color = '#00BB00'; pdAdd.style.userSelect = 'none'; pdAdd.style.cursor = 'pointer'; pdAdd.addEventListener('click', evt => { setPoints(player.points + 1); }); pdc.childNodes[0].childNodes[0].appendChild(pdAdd); tmp.parentNode.appendChild(pdc); [...content.querySelectorAll('.ct-spells__content .ddbc-tab-options__header')].forEach(ele => ele.addEventListener('click', castClick)); content.getElementsByClassName('ct-spells-filter__input')[0].addEventListener('input', castClick); castClick(evt); }, 50); }; const rest = evt => { setPoints(player.maxPoints); }; const restClick = evt => { setTimeout(() => { document.querySelector('.ct-reset-pane__action .ct-button--confirm').addEventListener('click', rest); }, 50); }; [...content.querySelectorAll('.ct-primary-box button')].filter(a => a.innerText.toLowerCase() === 'spells')[0].addEventListener('click', spellClick); [...content.querySelectorAll('.ct-primary-box button')].filter(a => a.innerText.toLowerCase() === 'actions')[0].addEventListener('click', actionClick); actionClick(); content.querySelector('.ct-character-header-desktop__group--long-rest .ct-character-header-desktop__button').addEventListener('click', restClick); loaded = 0; } else if (/\/builder/.test(window.location.pathname)) { const hashChange = evt => { if (/\/home\/basic/.test(window.location.pathname)) { setTimeout(() => { useSpellPoints = spSystem?.isProficient === true; mergeSorcPoints = spSystem?.isMartialArts === true; const opt = [...content.querySelectorAll('[class^=styles_checkboxesContainer__]')].find(ele => /Optional Features/.test(ele.innerText)); if (opt == null) { return; } const tmp = opt.querySelectorAll('[class^=styles_checkboxWrapper__]')[0]; const useSp = tmp.cloneNode(true); useSp.childNodes[0].childNodes[1].innerText = 'Use Spell Points (Variant Rule)'; useSp.childNodes[0].childNodes[0].childNodes[0].childNodes[0].childNodes[0].checked = useSpellPoints; useSp.childNodes[0].addEventListener('click', evt => { if (spSystem != null) { const tmp = Object.assign(spSystem, {characterId: +player.id, isProficient: !spSystem.isProficient}); getData('custom/action', {method: 'PUT', body: tmp}, data => { console.log('updated spell point action'); spSystem.isProficient = tmp.isProficient; }); } else { getData('custom/action', {method: 'POST', body: {'characterId': +player.id, 'name': 'Spell Points', 'actionType': '3'}}, data => { console.log('created spell point action'); (player.data?.customActions || []).push(data); spSystem = (player.data?.customActions || []).find(act => act?.name === 'Spell Points'); const tmp = Object.assign(spSystem, {characterId: +player.id, isProficient: !spSystem.isProficient}); getData('custom/action', {method: 'PUT', body: tmp}, data => { console.log('updated spell point action'); spSystem.isProficient = tmp.isProficient; }); }); } useSpellPoints = !useSpellPoints; useSp.childNodes[0].childNodes[0].childNodes[0].childNodes[0].childNodes[0].checked = useSpellPoints; }); tmp.parentNode.appendChild(useSp); const mergeSp = tmp.cloneNode(true); mergeSp.childNodes[0].childNodes[1].innerText = 'Combine Spell Points with Sorcery Points'; mergeSp.childNodes[0].childNodes[0].childNodes[0].childNodes[0].childNodes[0].checked = mergeSorcPoints; mergeSp.childNodes[0].addEventListener('click', evt => { if (spSystem != null) { const tmp = Object.assign(spSystem, {characterId: +player.id, isMartialArts: !spSystem.isMartialArts}); getData('custom/action', {method: 'PUT', body: tmp}, data => { console.log('updated spell point action'); spSystem.isMartialArts = tmp.isMartialArts; }); } else { getData('custom/action', {method: 'POST', body: {'characterId': +player.id, 'name': 'Spell Points', 'actionType': '3'}}, data => { console.log('created spell point action'); (player.data?.customActions || []).push(data); spSystem = (player.data?.customActions || []).find(act => act?.name === 'Spell Points'); const tmp = Object.assign(spSystem, {characterId: +player.id, isMartialArts: !spSystem.isMartialArts}); getData('custom/action', {method: 'PUT', body: tmp}, data => { console.log('updated spell point action'); spSystem.isMartialArts = tmp.isMartialArts; }); }); } mergeSorcPoints = !mergeSorcPoints; mergeSp.childNodes[0].childNodes[0].childNodes[0].childNodes[0].childNodes[0].checked = mergeSorcPoints; }); tmp.parentNode.appendChild(mergeSp); }, 50); } }; hashChange(); loaded = 0; } else { if (loaded-- > 0) { console.log('attempting to load point tracker...'); setTimeout(init, 1000); }else { console.log('point tracker failed to load'); } return; } }); // console.log('player', player); }; let initializer = null; let prevUrl = ''; const obs = new MutationObserver(mut => { if (location.href !== prevUrl) { prevUrl = location.href; let delay = 1000; if (/\/builder/.test(window.location.pathname) && loaded === 0) { delay = 0; } clearTimeout(initializer); initializer = setTimeout(init, delay); } }); obs.observe(document, {subtree: true, childList: true}); })();