Roll20 uses cookies to improve your experience on our site. Cookies enable you to enjoy certain features, social sharing functionality, and tailor message and display ads to your interests on our site and others. They also help us understand how our site is being used. By continuing to use our site, you consent to our use of cookies. Update your cookie preferences .
×
Create a free account

[Script] Difficulty Rating - 5e Encounter Calculator

1560899411

Edited 1562112552
GM Michael
API Scripter
Per request here , I've made an encounter calculator for 5th edition D&D.  Operation is simple, but straightforward.  Below you'll find a pair of screenshots that demonstrate the bulk of its features (click to zoom). Operations Upon first (and continuing until the user does does it) API startup, until mode is configured, the script will prompt the GM to select a mode (OGL vs 5e-Shaped). After that, really, the only operation you need is below. !dr From there, use the buttons in the main menu panel.  All messages from Difficulty Rating are whispered to the gm. Set Mode: This allows you to configure OGL or 5e-Shaped modes.  OGL is the primary development environment, so 5e-Shaped should be considered experimental and less frequently maintained. Add Player: It adds the player to a state field for tracking.  Once added, a player's level can be updated or the player can be deleted. Calculate Selected: Calculates the difficulty of an encounter of the saved player list versus the selected NPCs. Versioning Available through Roll20 API Scripting Library. 1.00: Initial Release 1.03: Improve 5e-shaped compatibility, add leveling of existing player entries.
1560934004

Edited 1560936303
Ok - checked this out. First ... The Shaped 5e player will think the API is broken because they will only get the message that the API outputted nothing to the chat window.  This is because the "desc" template is not available to 5e-shaped.  Robin Kuiper uses a sheet agnostic method of avoiding that tangle for his Death Tracker, Concentration, and StatusInfo scripts (as well as others) that formats everything outside of templates in css format.  I wouldn't feel right trying to explain his work, but suggest checking out the way he does it at his GitHub .  This is the menu design I had in mind when I had the idea, but one that is really important when I saw the Shaped sheet didn't get love (appearing invisible/ broken to those of us who use it, though its just a issue with templates).  If you don't want to go that route - you might be able to get away with a first run setup that doesn't format anything in the chat query, but merely offers a choice of which mode to use when running !dr through a "/w gm".  Change the template from "desc" to "5e-shaped", and "{{desc=" to "{{content=" if the 5e-shaped sheet is chosen, which resolves any 'appears invisible' issue for shaped users. Next review ... Looking for the API to dynamically update the levels instead of asking on run of Add Player.  After all, during the course of the game, the players levels will change (or at least we hope so ... lol).  So instead of the query prompt, it should be able to import the level from the Add Player, and each time it's run, be updating the level stored.  I believe that both of the popular character sheets use @{level} as a total level attribute, it should be simple enough to grab that when !dr is loaded for anything stored or from the selected token when added. Similarly, I believe both sheets use @{character_name} for the Character's name associated with the selected token, it shouldn't need to query when adding players if the player is selected when Add Player is clicked.  Given these two concepts, it would make sense to change "Add Player" button/ link to "Add Selected Player". I tried to adjust these myself, but my knowledge of how API's absorb variables from the chat window or sheet is not up to par to make it work - as noted in the comment of the rewrite's I tried to do.  (everything I wanted to change was bold type ) Lastly - Even after running everything, encountered the following difficulty when I ran the Calculate Selected on one NPC: Your scripts are currently disabled due to an error that was detected. Please make appropriate changes to your scripts and click the "Save Script" button and we'll attempt to start running them again. More info... For reference, the error message generated was: TypeError: Cannot read property 'Mult' of undefined TypeError: Cannot read property 'Mult' of undefined at GetCountMultiplier (apiscript.js:21484:47) at on (apiscript.js:21655:26) at eval (eval at <anonymous> (/home/node/d20-api-server/api.js:151:1), <anonymous>:65:16) at Object.publish (eval at <anonymous> (/home/node/d20-api-server/api.js:151:1), <anonymous>:70:8) at /home/node/d20-api-server/api.js:1634:12 at /home/node/d20-api-server/node_modules/firebase/lib/firebase-node.js:93:560 at hc (/home/node/d20-api-server/node_modules/firebase/lib/firebase-node.js:39:147) at Kd (/home/node/d20-api-server/node_modules/firebase/lib/firebase-node.js:93:546) at Id.Mb (/home/node/d20-api-server/node_modules/firebase/lib/firebase-node.js:93:489) at Zd.Ld.Mb (/home/node/d20-api-server/node_modules/firebase/lib/firebase-node.js:94:425) at /home/node/d20-api-server/node_modules/firebase/lib/firebase-node.js:111:400 And this in the disabled sandbox (black output window): "No Attr: [object Object]: npc" right before a copy of the pink output above. Not sure how to change the code bits to resolve that one, as I was able to with the easier for me to understand template/ html areas.  Observe the changes I made, maybe the resolutions I had actually affect things elsewhere that caused an error - not sure: //if (MarkStart) {MarkStart('DifficultyRating');} /* * Difficulty Rating - 5e Encounter Calculator * by Michael Greene (Volt Cruelerz) * */ on('ready', () => { const drname = 'Difficulty Rating'; // Initialize the state const ConfigureState = () => { if (!state.DifficultyRating) { state.DifficultyRating = { Party: [], NPCField: 'npc_challenge' }; } }; ConfigureState(); 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; }; const getAttrs = (char, attrName) => { const attr = filterObjs((obj) => { if (obj.get('type') === 'attribute' && obj.get('characterid') === char.id && obj.get('name') == attrName) { return obj; } }); if (!attr || attr.length === 0) { log('No Attr: ' + char + ': ' + attrName); return null; } return attr; }; const getAttr = (char, attrName) => { let attr = getAttrs(char, attrName); if (!attr) { return null; } return attr[0]; }; const getAttrsFromSub = (char, substringName) => { const attr = filterObjs((obj) => { if (obj.get('type') === 'attribute' && obj.get('characterid') === char.id && obj.get('name').indexOf(substringName) !== -1) { return obj; } }); if (!attr || attr.length === 0) { log('No Substr Attr: ' + char + ': ' + attrName); return null; } return attr; }; const getAttrFromSub = (char, substringName) => { return getAttrsFromSub(char, substringName)[0]; }; // Pulls the interior message out of carets (^) const Decaret = (quotedString) => { const startQuote = quotedString.indexOf('^'); const endQuote = quotedString.lastIndexOf('^'); if (startQuote >= endQuote) { if (!quietMode) { sendChat(scname, `**ERROR:** You must have a string within carets in the phrase ${string}`); } return null; } return quotedString.substring(startQuote + 1, endQuote); }; class MonsterType { constructor(name, cr) { this.Name = name; this.CR = cr; this.Count = 1; } } const Ratings = { Trivial: `This encounter should pose no threat to the party, but may delay them if they opt to not expend resources.`, Easy: `An easy encounter doesn't tax the characters' resources or put them in serious peril. They might lose a few hit points, but victory is pretty much guaranteed.`, Medium: `A medium encounter usually has one or two scary moments for the players, but the characters should emerge victorius with no casualties. One or more of them might need to use healing resources.`, Hard: `A hard encounter could go badly for the adventurers. Weaker characters might get taken out of the fight, and there's a slim chance that one or more characters might die.`, Deadly: `A deadly encounter could be lethal for one or more player characters. Survival often requires good tactics and quick thinking, and the party risks defeat.` }; // Converts level-1 to [easy, medium, hard, deadly] exp thresholds const XPThresholds = [ [25, 50, 75, 100], [50, 100, 150, 200], [75, 150, 225, 400], [125, 250, 375, 500], [250, 500, 750, 1100], [300, 600, 900, 1400], [350, 750, 1100, 1700], [450, 900, 1400, 2100], [550, 1100, 1600, 2400], [600, 1200, 1900, 2800], [800, 1600, 2400, 3600], [1000, 2000, 3000, 4500], [1100, 2200, 3400, 5100], [1250, 2500, 3800, 5700], [1400, 2800, 4300, 6400], [1600, 3200, 4800, 7200], [2000, 3900, 5900, 8800], [2100, 4200, 6300, 9500], [2400, 4900, 7300, 10900], [2800, 5700, 8500, 12700] ]; const CRToExp = {}; const BuildCRToExp = () => { CRToExp[0] = 10; CRToExp[1/8] = 25; CRToExp[1/4] = 50; CRToExp[1/2] = 100; CRToExp[1] = 200; CRToExp[2] = 450; CRToExp[3] = 700; CRToExp[4] = 1100; CRToExp[5] = 1800; CRToExp[6] = 2300; CRToExp[7] = 2900; CRToExp[8] = 3900; CRToExp[9] = 5000; CRToExp[10] = 5900; CRToExp[11] = 7200; CRToExp[12] = 8400; CRToExp[13] = 10000; CRToExp[14] = 11500; CRToExp[15] = 13000; CRToExp[16] = 15000; CRToExp[17] = 18000; CRToExp[18] = 20000; CRToExp[19] = 22000; CRToExp[20] = 25000; CRToExp[21] = 33000; CRToExp[22] = 41000; CRToExp[23] = 50000; CRToExp[24] = 62000; CRToExp[25] = 75000; CRToExp[26] = 90000; CRToExp[27] = 105000; CRToExp[28] = 115000; CRToExp[29] = 135000; CRToExp[30] = 155000; }; BuildCRToExp(); class CountMultiplier { constructor(min, max, mult) { this.Min = min; this.Max = max; this.Mult = mult; } } const CountMultipliers = [ new CountMultiplier(0, 0, 0.5),// This is just for shifting due to party size. new CountMultiplier(1, 1, 1), new CountMultiplier(2, 2, 1.5), new CountMultiplier(3, 6, 2), new CountMultiplier(7, 10, 2.5), new CountMultiplier(11, 14, 3), new CountMultiplier(15, 999999999, 4), new CountMultiplier(999999999, 999999999, 4),// This is not in the DMG and is just for shifting due to party size ]; const GetCountMultiplier = (heroCount, monsterCount) => { let multIndex = -1; // Start at 1 because 0 is only shifted to due to party size. for (let i = 1; i < CountMultipliers.length; i++) { let mult = CountMultipliers[i]; if (mult.Min <= monsterCount && mult.Max >= monsterCount) { multIndex = i; break; } } if (heroCount < 3) { return CountMultipliers[multIndex+1].Mult; } else if (heroCount > 5) { return CountMultipliers[multIndex-1].Mult; } else { return CountMultipliers[multIndex].Mult; } }; const DailyExp = [ 300, 600, 1200, 1700, 3500, 4000, 5000, 6000, 7500, 9000, 10500, 11500, 13500, 15000, 18000, 20000, 25000, 27000, 30000, 40000 ]; class Player { constructor(name, level) { this.Name = name; this.Level = level; } } const GetPartyThresholds = () => { let easy = 0; let medium = 0; let hard = 0; let deadly = 0; state.DifficultyRating.Party.forEach((player) => { const level = player.Level; const expTier = XPThresholds[level-1]; easy += expTier[0]; medium += expTier[1]; hard += expTier[2]; deadly += expTier[3]; }); return [easy, medium, hard, deadly]; } const GetDailyExp = () => { let total = 0; state.DifficultyRating.Party.forEach((player) => { const level = player.Level; total += DailyExp[level-1]; }); return total; } const PrintStatus = () => { let status = `/w gm &{template: 5e-shaped } {{ content =<h3>Difficulty Rating</h3><hr>`; status += `<div align="left" style="margin-left: 7px;margin-right: 7px">`; // Add player list if (state.DifficultyRating.Party.length > 0) { status += `<h4>Party (${state.DifficultyRating.Party.length})</h4>`; status += '<ul>'; state.DifficultyRating.Party.forEach((player) => { status += `<li>${player.Name} - ${player.Level}<br/>[Remove](!dr --removePlayer ^${player.Name}^)</li>`; }); status += '</ul>'; } status += `<h4>Tools</h4>`; status += `[Set Mode](!dr --setMode ?{Select a mode|OGL|5e-Shaped})<br/>`; status += '[Add Selected Player](!dr --addPlayer @{selected|level} ^ @{selected|character_name} ^)<br/>'; //The two bold entries did NOT work. level returned 0 for whatever reason and character name didn't do anything, sadly. - Wolf status += `[Calculate Selected](!dr --calculate)<br/>`; status += `</div>}}`; sendChat(drname, status); }; on('chat:message', (msg) => { if (msg.type !== 'api') return; if (!msg.content.startsWith('!encounters5e') && !msg.content.startsWith('!dr')) return; if (msg.content === '!dr' || msg.content === '!dr --help') { PrintStatus(); return; } let strTokens = msg.content.split(' '); if (strTokens.length < 2) return; // Process command const command = strTokens[1]; if (command === '--setMode') { if (strTokens.length < 3) return; const mode = strTokens[2]; if (mode === 'OGL') { state.DifficultyRating.NPCField = 'npc_challenge'; sendChat(drname, 'OGL Mode Activated.'); } else if (mode === '5e-Shaped') { state.DifficultyRating.NPCField = 'challenge'; sendChat(drname, '5e-Shaped Mode Activated.'); } } // Add a new player else if (command === '--addPlayer') { if (strTokens.length < 4) return; const level = parseInt(strTokens[2]) || 0; const name = Decaret(msg.content); if (!name || name.length === 0) { sendChat(drname, 'Name was empty.'); return; } state.DifficultyRating.Party.push(new Player(name, level)); PrintStatus(); } // Remove existing player else if (command === '--removePlayer') { const charName = Decaret(msg.content); for (let i = 0; i < state.DifficultyRating.Party.length; i++) { const player = state.DifficultyRating.Party[i]; if (player.Name === charName) { state.DifficultyRating.Party.splice(i, 1); } } PrintStatus(); } // Remove all players (debug tool) else if (command === '--purgeParty') { state.DifficultyRating.Party = []; PrintStatus(); } // Calculate difficulty rating of selected monsters else if (command === '--calculate') { const monsters = {}; let monsterCount = 0; if (!msg.selected) { sendChat(drname, '/w gm No tokens were selected.'); return; } msg.selected.forEach((selection) => { let token = getObj('graphic', selection._id); // Attempt to load from cache let type = token.get('represents'); let existingEntry = monsters[type]; // If it already exists, just increment the counter if (existingEntry) { existingEntry.Count++; monsterCount++; } else { // Generate a new cache entry let name = token.get('name'); let char = getCharByAny(type); const npcAttr = getAttr(char, 'npc'); // Make sure it's actually an NPC if (npcAttr && npcAttr.get('current')) { let cr = parseInt(getAttr(char, state.DifficultyRating.NPCField).get('current')) || 0; existingEntry = new MonsterType(name, cr); monsters[type] = existingEntry; monsterCount++; } else { log('Warning! Non-npc selected for encounter difficulty: ' + name); } } }); // Get the multiplier based on numbers of participants const mult = GetCountMultiplier(state.DifficultyRating.Party.length, monsterCount); const thresholds = GetPartyThresholds(); let expTotal = 0; for (let type in monsters) { // check if the property/key is defined in the object itself, not in parent if (monsters.hasOwnProperty(type)) { const monster = monsters[type]; expTotal += CRToExp[monster.CR] * monster.Count; } } const adjustedExp = mult * expTotal; let highestDifficulty = 'Trivial'; let difficultyDesc = Ratings.Trivial; for (let i = 0; i < thresholds.length; i++) { if (adjustedExp > thresholds[i]) { if (i === 0) { difficultyDesc = Ratings.Easy; highestDifficulty = 'Easy'; } else if (i === 1) { difficultyDesc = Ratings.Medium; highestDifficulty = 'Medium'; } else if (i === 2) { difficultyDesc = Ratings.Hard; highestDifficulty = 'Hard'; } else if (i === 3) { difficultyDesc = Ratings.Deadly; highestDifficulty = 'Deadly'; } } } let status = `/w gm &{template: 5e-shaped } {{ content =<h3>Difficulty Rating</h3><hr>` + `<div align="left" style="margin-left: 7px;margin-right: 7px">` + `<h4>${highestDifficulty}</h4>` + `${difficultyDesc}<br/><br/>` + `<h4>Experience</h4>` + `<b>Raw Exp</b>: ${expTotal}<br/>` + `<b>Adjusted Exp</b>: ${adjustedExp}<br/>` + `<b>Per Player Exp</b>: ${(expTotal/state.DifficultyRating.Party.length).toFixed(0)}<br/>` + `<b>Daily Budget</b>: ${(100*adjustedExp/GetDailyExp()).toFixed(2)}%<br/>` + `<b>Other Thresholds</b>` + `<ul>` + `<li>Easy: ${thresholds[0]}</li>` + `<li>Medium: ${thresholds[1]}</li>` + `<li>Hard: ${thresholds[2]}</li>` + `<li>Deadly: ${thresholds[3]}</li>` + `</ul><br/>`; // Add player level list if (state.DifficultyRating.Party.length > 0) { status += `<h4>Party (${state.DifficultyRating.Party.length})</h4>`; status += '<ul>'; state.DifficultyRating.Party.forEach((player) => { status += `<li>${player.Name} - ${player.Level}</li>`; }); status += '</ul><br/>'; } if (monsterCount > 0) { status += `<h4>Monsters (${monsterCount})</h4>`; status += '<ul>'; for (let type in monsters) { // check if the property/key is defined in the object itself, not in parent if (monsters.hasOwnProperty(type)) { const monster = monsters[type]; status += `<li>${monster.Count}x ${monster.Name}: CR${monster.CR}</li>`; } } status += '</ul>'; } status += `</div>}}`; sendChat(drname, status); } }); log(`-=> Difficulty Rating online. <=-`); }); //if (MarkStop) {MarkStop('DifficultyRating');}
I asked Robin to join in on this project to see what he could add, and we'll see what input he can provide.  Additionally, he might be able to help you with publishing it in Roll20's script library :-)  First script cred!
1560941005

Edited 1560946581
GM Michael
API Scripter
Blek. I'd forgotten about desc not working right in shaped.  I know how to fix it. I'd considered auto-importing player characters, which is something I support in things like SpellMaster, but this is a much simpler tool and it won't change frequently, plus, if you ever have some non-pc members of the party for whatever exotic reason, I wanted to support that.  Animal companions and mounts are the first to come to mind.  I'm not opposed to adding a level up button though. And for what it's worth, I have worked on scripts that are in the one click menu, but I was never the one that actually did the versioning/publishing with it.  I imagine it's mainly just a matter of pulling down the repo, adding my own, doing some boilerplate, and then making a pull request.
1560946366

Edited 1560946557
GM Michael
API Scripter
1.03 Released 1.03 has been released, which fixes compatibility with 5e-shaped and allows the user to edit the level of existing player entries.  Includes a few other bug fixes as well.  Upon API startup, the user will be prompted to set the mode.  Until they do, the API will prompt them each time on startup.
1561091970

Edited 1561092792
GM Michael
API Scripter
Well, we'll see how this goes!  With any luck, I didn't screw up the process of submitting my first script to the repo, and this pull request will be accepted.  The wiki says pulls are done once a week, and given the history of merges, it looks like it happens on Tuesdays.  Presuming it goes through, I'll then start the process of migrating several of my other independently-maintained scripts to the shared repo! Mass Combat and SpellMaster are next on the docket, though I have no idea how I'm going to get a one-click installation for SpellMaster running, given its relationship with SRD.js.  Perhaps it'll be a dependency or something.  We'll see.
1561092634
GiGs
Pro
Sheet Author
API Scripter
I think the weekly review is done on Monday, with changes going live Monday or Tuesday, so you wont have too long to wait.
1561501430

Edited 1561505383
GM Michael
API Scripter
I am pleased to announce that Difficulty Rating is now in the API Script Library! Due to my ignorance of how the process worked, I sorta messed up the description field, but I submitted another pull request to fix it so it'll have the readme text in it.
the scrip cant seem to work out the cr of monsters lower than 1 like goblins. it just sets them to 1
FracturedSolace said: the scrip cant seem to work out the cr of monsters lower than 1 like goblins. it just sets them to 1 +1. It does not appear to parse the CR properly, ignoring the "/" of a fractional CR and everything after it.
1567981007

Edited 1567981026
GM Michael
API Scripter
I've confirmed the issue on my end.  Unfortunately, using decimals doesn't work well with Roll20's sheets, which has downstream effects. I'll have to upgrade the parser sometime when I get the chance.  I've been pretty busy lately, unfortunately.
Hi Michael, is it normal that an encounter between six level-1 goblins and three level-3 characters plus a level-one character has been classified as "deadly" by the API? Also, the API doesn't seem to work in 5e-shaped mode, but only as OGL (the Sandbox immediately crashes with the 5e-shaped mode). Anyway, I think this API is really helpful and has got great potential, once fixed. Thanks for all of your hard work on this! 
1570315134
keithcurtis
Forum Champion
Marketplace Creator
API Scripter
That's what the official system calculates to. However, note that goblins are CR1/4, not CR1. You would need to up that to 11 goblins to make it a deadly encounter for that party.
1570315420

Edited 1570315483
Aaahhh, I see, sorry, I didn't read the previous posts properly!!! Now everything makes sense. I guess I will have to wait for a fix. Best wishes.
It defaults all tokens to 1 HD
1572112496
keithcurtis
Forum Champion
Marketplace Creator
API Scripter
Do you mean 1 CR? If so, read the posts above. If not, could you explain a bit more what you mean?
1572138309

Edited 1572178531
GM Michael
API Scripter
1.04 Released I finally got around to fixing fractional CRs.&nbsp; I issued a pull request.&nbsp; In the meantime, you can always pull it from my fork: <a href="https://github.com/VoltCruelerz/roll20-api-scripts/tree/master/DifficultyRating" rel="nofollow">https://github.com/VoltCruelerz/roll20-api-scripts/tree/master/DifficultyRating</a>