Skip to content

Instantly share code, notes, and snippets.

@ianjsikes
Last active September 21, 2020 15:49
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ianjsikes/f6c077101e9cd51057daf638069dd724 to your computer and use it in GitHub Desktop.
Save ianjsikes/f6c077101e9cd51057daf638069dd724 to your computer and use it in GitHub Desktop.
Unofficial Roll20 import script for GiffyGlyph's Monster Maker

GGMM

Unofficial Roll20 import script for GiffyGlyph's Monster Maker https://giffyglyph.com/monstermaker/

Installation

Roll20 API scripts require a Roll20 Pro subscription. Go to the landing page of your campaign and click Settings > API Scripts. Click "New Script", paste in GGMM.js and then click Save Script. The script is now available from inside your Roll20 game using the !ggmm chat command.

Features

Import

Import any monster exported from the Monster Maker app. From the app, click the gear in the bottom right and choose "Save to .json". Open the JSON file and copy the contents. Inside Roll20, enter the chat command !ggmm followed by the pasted JSON. Example:

!ggmm {"blueprint":{"vid":1,"method":"quickstart","description":{"name":...

The imported monster should appear in your journal tab.

Monster Maker Macros

Imported monsters will have three Token Actions set on them to facilitate the "Freeform attack" style of play described in Giffyglyph's Monster Maker.

AttackRoll will roll 1d20+attack and give you quick options to make a full attack or multiattack (if the monster's damage is greater than 10).

DamageRoll will let you choose a damage modifier (x1, x2, x0.5, etc.) and a die type (flat, d4, d8, d12, etc.) and output the damage.

SaveAction will display one of the monster's save DCs, and give you quick options for rolling full or AoE damage.


Known Issues

There are a few known bugs and/or discrepencies between the script and the Monster Maker web app:

  • Sometimes the body text of Reactions are not imported correctly
  • Shortcode commands with multiple attributes (eg: [attack + damage, d4]) are not able to be parsed
  • The web app does not show stealth/perception as trained skills even when the monster's role indicates they should be trained. The importer script shows these correctly.
  • The default token of an imported monster does not have its size, hp, or ac set correctly
class _Slo {
version = "0.2.1";
randomId = () =>
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
who = (name) => name.replace(/\(GM\)/, "").trim();
whisper = (from, to, msg) => sendChat(from, `/w "${this.who(to)}" ${msg}`);
sendChatAsync = (from, msg, opts) =>
new Promise((resolve, reject) => {
try {
sendChat(from, msg, (results) => resolve(results), opts);
} catch (err) {
reject(err);
}
});
roll = async (rollFormula) => {
const [rollResultMsg] = await this.sendChatAsync("", `/r ${rollFormula}`);
if (rollResultMsg.type !== "rollresult") {
throw new Error(`Roll failed: ${rollFormula}`);
}
const rollResult = JSON.parse(rollResultMsg.content);
return rollResult.total;
};
rollData = async (rollFormula) => {
const [rollResultMsg] = await this.sendChatAsync("", `/r ${rollFormula}`);
if (rollResultMsg.type !== "rollresult") {
throw new Error(`Roll failed: ${rollFormula}`);
}
const rollResult = JSON.parse(rollResultMsg.content);
return rollResult;
};
getTokens = (ids) =>
(ids || []).map(({ _type, _id }) => getObj(_type, _id)).filter((o) => !!o);
generateUUID = (function () {
"use strict";
var a = 0,
b = [];
return function () {
var c = new Date().getTime() + 0,
d = c === a;
a = c;
for (var e = new Array(8), f = 7; 0 <= f; f--) {
e[
f
] = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(
c % 64
);
c = Math.floor(c / 64);
}
c = e.join("");
if (d) {
for (f = 11; 0 <= f && 63 === b[f]; f--) {
b[f] = 0;
}
b[f]++;
} else {
for (f = 0; 12 > f; f++) {
b[f] = Math.floor(64 * Math.random());
}
}
for (f = 0; 12 > f; f++) {
c += "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(
b[f]
);
}
return c;
};
})();
generateRowID = () => {
"use strict";
return this.generateUUID().replace(/_/g, "Z");
};
}
const Slo = new _Slo();
const _tcache = {};
const TC = new Proxy(_tcache, {
get: (obj, tokenId) => {
if (tokenId in obj) return obj[tokenId];
let token = getObj("graphic", tokenId);
if (token) {
obj[tokenId] = token;
}
return token;
},
});
const _ccache = {};
const CC = new Proxy(_ccache, {
get: (obj, characterId) => {
if (characterId in obj) return obj[characterId];
let character = getObj("character", characterId);
if (character) {
obj[characterId] = character;
}
return character;
},
});
class CommandParser {
constructor(trigger, ...aliases) {
this.trigger = trigger;
this.aliases = aliases || [];
this.defaultCmd;
this.subCmds = {};
this.buttonActions = {
__ungrouped: {},
};
}
default(action) {
this.defaultCmd = { action };
return this;
}
command(name, action) {
this.subCmds[name] = { action };
return this;
}
button({ action, group = "__ungrouped" }) {
if (!this.buttonActions[group]) {
this.buttonActions[group] = {};
}
let id = Slo.randomId();
this.buttonActions[group][id] = action;
return `${this.trigger} _btn_${id}`;
}
async handleMessage(msg) {
if (msg.type !== "api") return;
let content = msg.content.trim();
let [trigger, subCommand, ...args] = content.split(" ");
if (trigger !== this.trigger) {
if (!this.aliases.length || !this.aliases.includes(trigger)) {
return;
}
}
if (subCommand && subCommand.startsWith("_btn_")) {
let btnId = subCommand.replace("_btn_", "");
for (const group in this.buttonActions) {
if (btnId in this.buttonActions[group]) {
let action = this.buttonActions[group][btnId];
if (group !== "__ungrouped") {
delete this.buttonActions[group];
} else {
delete group[btnId];
}
await action();
return;
}
}
throw new Error(`Hey, that button is expired!`);
}
if (
!subCommand ||
subCommand.startsWith("--") ||
!(subCommand in this.subCmds)
) {
if (this.defaultCmd) {
const opts = this.parseArgs([subCommand, ...args]);
await this.defaultCmd.action(opts, msg);
} else {
this.showHelp();
}
} else {
if (subCommand in this.subCmds) {
const opts = this.parseArgs(args);
await this.subCmds[subCommand].action(opts, msg);
return;
}
this.showHelp();
}
}
parseArgs(args) {
let options = args.reduce(
(opts, arg) => {
if (!arg) return opts;
if (arg.startsWith("--")) {
let [key, val] = arg.slice(2).split("=");
return { ...opts, [key]: val === undefined ? true : val };
}
return {
...opts,
_: [...opts._, arg],
};
},
{ _: [] }
);
return options;
}
showHelp() {}
}
const ScriptBase = ({ name, version, stateKey = name, initialState = {} }) =>
class {
version = version;
name = name;
get state() {
if (!state[stateKey]) {
state[stateKey] = initialState;
}
return state[stateKey];
}
onMessage = async (msg) => {
if (!this.parser) return;
try {
await this.parser.handleMessage(msg);
} catch (err) {
log(`${name} ERROR: ${err.message}`);
sendChat(
`${name} ERROR:`,
`/w ${msg.who.replace(/\(GM\)/, "").trim()} ${err.message}`
);
}
};
constructor() {
on("chat:message", this.onMessage);
on("ready", () => {
log(`\n[====== ${name} v${version} ======]`);
});
}
resetState(newState = initialState) {
state[stateKey] = newState;
}
};
//@ts-check
const GGMM_VERSION_ID = 1;
/**
* Unofficial Roll20 import script for GiffyGlyph's Monster Maker
* https://giffyglyph.com/monstermaker/
*
* Script by @ianjsikes
*/
const GGMM = {
/**
* @param {string} str
* @returns {string}
*/
caps: (str) => str.slice(0, 1).toUpperCase() + str.slice(1),
/**
* @param {{
* rating: string;
* custom: {
* rating: string | null;
* proficiency: number | null;
* xp: number | null;
* }
* }} challenge
* @returns {number}
*/
challengeToProficiency: (challenge) => {
if (challenge.rating === "custom") {
return challenge.custom.proficiency;
}
if (challenge.rating.includes("/")) return 2;
let cr = parseInt(challenge.rating);
if (cr === 0) return 2;
return Math.floor((cr - 1) / 4) + 2;
},
/**
* @param {string} expr
* @returns {number}
*/
evalMath: (expr) => {
return Math.floor(Function(`"use strict";return (${expr})`)());
},
/**
* @param {number} num
* @param {number} die
* @returns {string}
*/
getDice: (num, die) => {
let avg = die / 2 + 0.5;
let numDice = Math.floor(num / avg);
if (numDice * avg === num) {
return `${numDice}d${die}`;
}
return `${numDice}d${die} + ${Math.round(num - numDice * avg)}`;
},
/**
* @param {GGMMMonster} monster
* @param {string} shortcode
* @returns {string}
*/
parseShortcode: (monster, shortcode) => {
let expr = shortcode;
let die = null;
// Check for the random die syntax [hp, d4]
let matches = shortcode.match(/(.*)\, ?d(\d+)$/);
if (matches) {
expr = matches[1];
die = parseInt(matches[2]);
}
let [_, code] = expr.match(/[^a-z\-]*([a-z\-]+)[^a-z\-]*/) || [];
if (!code) {
throw new Error(`Unable to parse ${shortcode}`);
}
let getVal = (code) => {
if (code === "level") return monster.level;
if (code.startsWith("attack")) return monster.attack;
if (code === "damage") return monster.damage;
if (code === "ac") return monster.ac;
if (code === "hp") return monster.hp;
if (code.startsWith("dc-primary")) return monster.dcs[0];
if (code.startsWith("dc-secondary")) return monster.dcs[0];
if (code === "xp") return monster.xp;
if (code === "proficiency") return monster.proficiencyBonus;
if (code === "cr") return monster.challengeRating;
if (code.endsWith("-mod")) {
let stat = code.slice(0, 3);
return Math.floor((monster.abilityScores[stat] - 10) / 2);
}
if (code.endsWith("-score")) {
let stat = code.slice(0, 3);
return monster.abilityScores[stat];
}
if (code.endsWith("-save")) {
let stat = code.slice(0, 3);
return monster.savingThrows[stat];
}
};
let val = getVal(code);
if (val === undefined) {
throw new Error(`Unable to parse ${shortcode}`);
}
let computedExpr = GGMM.evalMath(expr.replace(code, val));
if (
computedExpr === undefined ||
computedExpr === null ||
isNaN(computedExpr)
) {
throw new Error(`Unable to parse ${shortcode}`);
}
let str = `${computedExpr}`;
if (code === "attack") {
str = `${computedExpr < 0 ? "-" : "+"}${str}`;
}
if (code === "dc-primary" || code === "dc-secondary") {
str = `DC ${str}`;
}
if (die !== null) {
str = `${str} (${GGMM.getDice(computedExpr, die)})`;
}
return str;
},
/**
* @param {GGMMMonster} monster
* @param {string} text
* @returns {string}
*/
parseText: (monster, text) => {
if (!text) throw new Error("UNDEF PARSE TEXT");
return text.replace(/\[[^\[\]]+\]/g, (substr) => {
try {
return GGMM.parseShortcode(monster, substr.slice(1, -1));
} catch (error) {
return substr;
}
});
},
};
class GGMMMonster {
/**
* @param {object} data
*/
constructor(data) {
if (data.vid !== GGMM_VERSION_ID) {
throw new Error(
`Giffyglyph Monster Maker schema version mismatch. Expected ${GGMM_VERSION_ID}, found ${data.vid}`
);
}
this.data = data;
}
get quickstart() {
return this.data.method === "quickstart";
}
/** @returns {string} */
get name() {
let d = this.data.description;
if (d.phases > 1) {
return `${d.name} (${d.phase}/${d.phases})`;
}
return d.name;
}
/** @returns {string} */
get size() {
return this.data.description.size;
}
/** @returns {string} */
get type() {
return this.data.description.type;
}
/** @returns {string} */
get alignment() {
return this.data.description.alignment;
}
/** @returns {string} */
get level() {
return this.data.description.level;
}
/** @returns {string} */
get role() {
return this.data.description.role;
}
/** @returns {string} */
get rank() {
return this.data.description.rank;
}
/** @returns {string | null} */
get image() {
return this.data.display.image.url;
}
/** @returns {[number, number]} */
get dcs() {
const bonus = GGMM_DATA.ranks[this.rank].spellDCs;
return GGMM_DATA.statsByLevel[this.level].spellDCs.map((dc) => dc + bonus);
}
/** @returns {number | null} */
get attack() {
if (!this.quickstart) return null;
return (
GGMM_DATA.statsByLevel[this.level].attack +
GGMM_DATA.roles[this.role].attack +
GGMM_DATA.ranks[this.rank].attack
);
}
/** @returns {number | null} */
get damage() {
if (!this.quickstart) return null;
return Math.round(
GGMM_DATA.statsByLevel[this.level].damage *
GGMM_DATA.roles[this.role].damage *
GGMM_DATA.ranks[this.rank].damage
);
}
/** @returns {number} */
get ac() {
if (this.quickstart) {
return (
GGMM_DATA.statsByLevel[this.level].ac +
GGMM_DATA.ranks[this.rank].ac +
GGMM_DATA.roles[this.role].ac +
(this.data.ac.modifier || 0)
);
}
return this.data.ac.base || 0;
}
/** @returns {string | null} */
get acType() {
return this.data.ac.type;
}
/** @returns {number} */
get hp() {
if (this.quickstart) {
let hitpoints =
GGMM_DATA.statsByLevel[this.level].hp * GGMM_DATA.roles[this.role].hp;
if (this.rank === "solo") {
hitpoints *= this.data.description.players || 4;
hitpoints /= this.data.description.phases || 1;
} else {
hitpoints *= GGMM_DATA.ranks[this.rank].hp;
}
hitpoints += this.data.hp.modifier;
hitpoints = Math.floor(hitpoints);
return hitpoints;
}
return this.data.hp.average || 0;
}
/** @returns {string | null} */
get hpFormula() {
if (this.rank === "solo") {
let hp = Math.floor(
(GGMM_DATA.statsByLevel[this.level].hp *
GGMM_DATA.roles[this.role].hp) /
(this.data.description.phases || 1)
);
return `${hp} per player`;
}
return this.data.hp.roll;
}
/** @returns {string} */
get speed() {
let s = this.data.speed;
let str = s.normal;
if (s.burrow) str += ", burrow " + s.burrow;
if (s.climb) str += ", climb " + s.climb;
if (s.fly) str += ", fly " + s.fly;
if (s.swim) str += ", swim " + s.swim;
if (s.other) str += ", " + s.other;
return str;
}
/** @returns {{ str: number; dex: number; con: number; int: number; wis: number; cha: number; }} */
get abilityScores() {
if (this.quickstart) {
let scores = GGMM_DATA.statsByLevel[this.level].abilityModifiers.reduce(
(obj, mod, idx) => {
let score = mod * 2 + 10;
let abilityName = this.data.abilities.quickstart[idx].ability;
return { ...obj, [abilityName]: score };
},
{}
);
return scores;
}
return {
str: this.data.abilities.str,
dex: this.data.abilities.dex,
con: this.data.abilities.con,
int: this.data.abilities.int,
wis: this.data.abilities.wis,
cha: this.data.abilities.cha,
};
}
/** @returns {{ str?: number; dex?: number; con?: number; int?: number; wis?: number; cha?: number; }} */
get savingThrows() {
let scores = this.abilityScores;
if (this.quickstart) {
let savesByLevel = GGMM_DATA.statsByLevel[this.level].savingThrows.map(
(num) =>
num +
GGMM_DATA.roles[this.role].savingThrows +
GGMM_DATA.ranks[this.rank].savingThrows
);
let saves = {};
saves[this.data.savingThrows.quickstart[0].ability] = savesByLevel[0];
saves[this.data.savingThrows.quickstart[1].ability] = savesByLevel[1];
saves[this.data.savingThrows.quickstart[2].ability] = savesByLevel[1];
saves[this.data.savingThrows.quickstart[3].ability] = savesByLevel[2];
saves[this.data.savingThrows.quickstart[4].ability] = savesByLevel[2];
saves[this.data.savingThrows.quickstart[5].ability] = savesByLevel[2];
return saves;
}
return this.data.savingThrows.manual.reduce((obj, { ability }, idx) => {
let mod = Math.floor((scores[ability] - 10) / 2);
return { ...obj, [ability]: mod + 2 };
}, {});
}
/** @returns {number} */
get proficiencyBonus() {
if (this.quickstart)
return GGMM_DATA.statsByLevel[this.level].proficiencyBonus;
return GGMM.challengeToProficiency(this.data.challenge);
}
/** @returns {number} */
get xp() {
if (this.quickstart) return GGMM_DATA.statsByLevel[this.level].xp;
}
/** @returns {{ [key: string]: number }} */
get skills() {
let scores = this.abilityScores;
if (this.quickstart) {
let data = {};
const prof = GGMM_DATA.statsByLevel[this.level].proficiencyBonus;
for (const skill of this.data.skills) {
let name;
let score;
if (skill.custom.name) {
name = skill.custom.name;
score = scores[skill.custom.ability];
} else {
name = skill.name;
score = scores[GGMM_DATA.skillToStat[name]];
}
let mod = Math.floor((score - 10) / 2);
data[name] =
mod + (skill.proficiency === "expertise" ? 2 * prof : prof);
if (name === "stealth") {
data[name] += GGMM_DATA.ranks[this.rank].stealth;
} else if (name === "perception") {
data[name] += GGMM_DATA.ranks[this.rank].perception;
}
}
const role = GGMM_DATA.roles[this.role];
if (data["stealth"] === undefined) {
data["stealth"] =
GGMM_DATA.statsByLevel[this.level].initiative +
GGMM_DATA.ranks[this.rank].stealth +
(role.stealth ? prof : 0);
}
if (data["perception"] === undefined) {
data["perception"] =
GGMM_DATA.statsByLevel[this.level].initiative +
GGMM_DATA.ranks[this.rank].perception +
(role.perception ? prof : 0);
}
return data;
}
let data = {};
for (const skill of this.data.skills) {
let name;
let score;
if (skill.custom.name) {
name = skill.custom.name;
score = scores[skill.custom.ability];
} else {
name = skill.name;
score = scores[GGMM_DATA.skillToStat[name]];
}
let mod = Math.floor((score - 10) / 2);
data[name] = mod * (skill.proficiency === "expertise" ? 2 : 1);
}
return data;
}
/** @returns {number} */
get initiative() {
if (this.quickstart) {
const { initiative, proficiencyBonus } = GGMM_DATA.statsByLevel[
this.level
];
return (
initiative +
GGMM_DATA.ranks[this.rank].initiative +
(GGMM_DATA.roles[this.role].initiative ? proficiencyBonus : 0)
);
}
return this.abilityScores.dex;
}
/** @returns {string} */
get damageVulnerabilities() {
return this.data.vulnerabilities
.map((v) => (v.type === "custom" ? v.custom : v.type))
.join(", ");
}
/** @returns {string} */
get damageResistances() {
return this.data.resistances
.map((r) => (r.type === "custom" ? r.custom : r.type))
.join(", ");
}
/** @returns {string} */
get damageImmunities() {
return this.data.immunities
.map((i) => (i.type === "custom" ? i.custom : i.type))
.join(", ");
}
/** @returns {string} */
get conditionImmunities() {
return this.data.conditions
.map((c) => (c.type === "custom" ? c.custom : c.type))
.join(", ");
}
/** @returns {string} */
get senses() {
const skills = this.skills;
let arr = Object.entries(this.data.senses)
.map(([sense, range]) => {
if (range === null) return null;
if (sense === "other") return range;
return `${sense} ${range}`;
})
.filter((s) => s !== null);
let perception = skills.perception + 10;
arr.push(`passive Perception ${perception}`);
return arr.join(", ");
}
/** @returns {string} */
get languages() {
return this.data.languages
.map((l) => (l.name === "custom" ? l.custom : l.name))
.join(", ");
}
/** @returns {string} */
get challengeRating() {
if (this.quickstart) {
return GGMM_DATA.mlToCr[this.rank][this.level];
}
if (this.data.challenge.rating === "custom") {
return this.data.challenge.custom.rating;
}
return this.data.challenge.rating;
}
/** @returns {{ name: string; detail: string }[]} */
get traits() {
let traits = [...this.data.traits];
if (this.rank === "solo") {
if (this.data.description.phases > 1) {
traits.unshift({
name: `Phase Transition (Transformation)`,
detail: `When reduced to 0 hit points, remove all on-going effects on yourself as you transform and start a new phase transition event.`,
});
} else {
traits.unshift({
name: `Phase Transition`,
detail: `At 66% and 33% hit points, you may remove all on-going effects on yourself and start a new phase transition event.`,
});
}
}
let paragonActions = this.paragonActions;
if (paragonActions !== 0) {
traits.unshift({
name: `Paragon Actions`,
detail: `You may take ${
paragonActions === "players"
? "one Paragon Action per player (minus 1)"
: `${paragonActions} Paragon Action`
} per round to either move or act.`,
});
}
traits.unshift({
name: `Level ${this.level} ${GGMM.caps(this.rank)} ${GGMM.caps(
this.role
)}`,
detail: `Attack: [attack], Damage: [damage]
Attack DCs: Primary [dc-primary], Secondary [dc-secondary]`,
});
return traits.map((trait) => {
return {
name: trait.name,
detail: GGMM.parseText(this, trait.detail),
};
});
}
/** @returns {{ name: string; detail: string }[]} */
get actions() {
return this.data.actions.map((action) => {
return {
name: action.name,
detail: GGMM.parseText(this, action.detail),
};
});
}
/** @returns {number | "players"} */
get paragonActions() {
if (!this.quickstart) return 0;
if (this.data.paragonActions.type === "custom") {
return this.data.paragonActions.amount;
}
if (this.rank === "elite") {
return 1;
}
if (this.rank === "solo") {
return "players";
}
return 0;
}
/** @returns {{ name: string; detail: string }[]} */
get reactions() {
return this.data.reactions.map((reaction) => {
return {
name: reaction.name,
detail: GGMM.parseText(this, reaction.detail),
};
});
}
/** @returns {number | null} */
get legendaryActionsPerRound() {
return this.data.legendaryActionsPerRound;
}
/** @returns {{ name: string; detail: string }[]} */
get legendaryActions() {
return this.data.legendaryActions.map((action) => {
return {
name: action.name,
detail: GGMM.parseText(this, action.detail),
};
});
}
/** @returns {number | null} */
get lairActionsInitiative() {
return this.data.lairActionsInitiative;
}
/** @returns {{ name: string; detail: string }[]} */
get lairActions() {
return this.data.lairActions.map((action) => {
return {
name: action.name,
detail: GGMM.parseText(this, action.detail),
};
});
}
/** @returns {string | null} */
get note() {
return (this.data.notes[0] || {}).detail;
}
}
class _GGMMImporter extends ScriptBase({
name: "GGMMImporter",
version: "0.1.0",
stateKey: "GGMM_IMPORTER",
initialState: {},
}) {
constructor() {
super();
this.parser = new CommandParser("!ggmm")
.default((_, msg) => {
let data = JSON.parse(msg.content.replace("!ggmm", ""));
let monsterDataArr = [];
if (!data.blueprint && !data.monster && !data.vault) {
throw new Error("Invalid JSON structure");
}
if (data.blueprint) {
monsterDataArr = [data.blueprint];
} else if (data.monster) {
monsterDataArr = [data.monster.blueprint];
} else if (data.vault) {
monsterDataArr = data.vault.map((m) => m.blueprint);
}
let errors = [];
for (const monsterData of monsterDataArr) {
try {
this.import(monsterData);
} catch (error) {
errors.push(error);
}
}
if (errors.length) {
errors.map((error) => log(error.message));
throw errors[0];
}
})
.command("damage", (opts) => {
if (!opts.id) {
throw new Error("You must specify a character with the --id option");
}
if (opts.mod && !isNaN(parseFloat(opts.mod))) {
if (opts.die && !isNaN(parseInt(opts.die))) {
this.dealDamage(
opts.id,
parseFloat(opts.mod),
parseInt(opts.die),
opts.save
);
} else {
this.damageDieMenu(opts.id, parseFloat(opts.mod), opts.save);
}
} else {
this.damageModMenu(opts.id);
}
})
.command("attack", (opts) => {
if (!opts.id) {
throw new Error("You must specify a character with the --id option");
}
this.rollAttack(opts.id);
})
.command("save", (opts) => {
if (!opts.id) {
throw new Error("You must specify a character with the --id option");
}
if (opts.type) {
this.showSave(opts.id, opts.type);
} else {
this.saveMenu(opts.id);
}
})
.command("reset", () => {
log("resetting state");
this.resetState();
});
}
/** @param {object} data */
import = (data) => {
let char = new GGMMMonster(data);
const obj = createObj("character", {
name: char.name,
});
/** @param {string} name @param {string | number | boolean} val @param {string | number | boolean} max */
let set = (name, val, max = "") => {
if (val === null) return;
if (val === undefined) {
// throw new Error(
// `Attempt to set undefined value for ${name} on ${char.name}`
// );
log(`Attempt to set undefined value for ${name} on ${char.name}`);
return;
}
return createObj("attribute", {
characterid: obj.id,
name,
current: val,
max,
});
};
if (char.quickstart) {
createObj("ability", {
characterid: obj.id,
name: "AttackRoll",
action: `!ggmm attack --id=${obj.id}`,
istokenaction: true,
});
createObj("ability", {
characterid: obj.id,
name: "DamageRoll",
action: `!ggmm damage --id=${obj.id}`,
istokenaction: true,
});
createObj("ability", {
characterid: obj.id,
name: "SaveAction",
action: `!ggmm save --id=${obj.id}`,
istokenaction: true,
});
}
set("npc_options-flag", "0");
set("npc", 1);
set("l1mancer_status", "completed");
set("rtype", "{{always=1}} {{r2=[[1d20");
set("wtype", "@{whispertoggle}");
set("dtype", "full");
set("init_tiebreaker", "@{dexterity}/100");
set("global_save_mod_flag", 1);
set("global_skill_mod_flag", 1);
set("global_attack_mod_flag", 1);
set("global_damage_mod_flag", 1);
set("charname_output", "{{charname=@{character_name}}}");
set("npc_name_flag", 0);
set("showleveler", 0);
set("invalidXP", 0);
set("reaction_flag", 0);
if (char.quickstart) {
set("ggmm_level", char.level);
set("ggmm_rank", char.rank);
set("ggmm_role", char.role);
set("ggmm_attack", char.attack);
set("ggmm_damage", char.damage);
set("ggmm_dc_primary", char.dcs[0]);
set("ggmm_dc_secondary", char.dcs[1]);
}
set("cd_bar1_m", char.hp);
set("cd_bar1_v", char.hp);
set("cd_bar2_v", char.ac);
let size = char.size;
let sizeToTokenSize = {
tiny: 0.5,
small: 1,
medium: 1,
large: 2,
huge: 3,
gargantuan: 4,
};
set("token_size", sizeToTokenSize[size] || 1);
set("npc_name", char.name);
set("npc_sizebase", char.size);
set("npc_typebase", char.type);
set("npc_alignmentbase", char.alignment);
set("npc_type", `${char.size} ${char.type}, ${char.alignment}`);
set("npc_ac", char.ac);
if (char.acType) set("npc_actype", char.acType);
let hp = char.hp;
let hpFormula = char.hpFormula;
set("hp", "", hp);
set("npc_hpbase", `${hp}${hpFormula ? ` (${hpFormula})` : ""}`);
set("npcd_hp", hp);
if (hpFormula) {
set("npc_hpformula", hpFormula);
set("npcd_hpformula", `(${hpFormula})`);
}
set("npc_speed", char.speed);
set("initiative_bonus", char.initiative);
let scores = char.abilityScores;
let saves = char.savingThrows;
for (let stat of [
"strength",
"dexterity",
"constitution",
"intelligence",
"wisdom",
"charisma",
]) {
let s = stat.slice(0, 3);
let score = scores[s];
set(`${stat}_base`, score);
set(`${stat}`, score);
set(`${stat}_mod`, Math.floor((score - 10) / 2));
let save = saves[s];
if (save !== undefined) {
set(`npc_${s}_save`, save);
set(`npcd_${s}_save`, save);
set(`npc_${s}_save_flag`, 1);
set(`npc_${s}_save_base`, save);
}
}
set("npc_saving_flag", 1);
let skills = char.skills;
for (let skill in skills) {
let val = skills[skill];
if (!skill) throw new Error("NO SKILLLLLL");
let _skill = skill.replace(" ", "_");
set(`${_skill}_bonus`, val);
set(`npc_${_skill}`, val);
set(`npc_${_skill}_base`, val);
set(`npc_${_skill}_flag`, 1);
}
set("npc_skills_flag", 1);
set("npc_vulnerabilities", char.damageVulnerabilities);
set("npc_resistances", char.damageResistances);
set("npc_immunities", char.damageImmunities);
set("npc_condition_immunities", char.conditionImmunities);
set("npc_senses", char.senses);
set("npc_languages", char.languages);
set("npc_challenge", char.challengeRating);
set("npc_xp", char.xp);
set("npcspellcastingflag", 0);
set("npc_spelldc", char.dcs[0]);
set("npc_spellattackmod", char.attack);
set("npcreactionsflag", char.reactions.length ? 1 : 0);
set("npc_legendary_actions", char.legendaryActionsPerRound);
for (const trait of char.traits) {
let id = Slo.generateRowID();
set(`repeating_npctrait_${id}_name`, trait.name);
set(`repeating_npctrait_${id}_desc`, trait.detail);
}
for (const action of char.actions) {
let id = Slo.generateRowID();
set(`repeating_npcaction_${id}_name`, action.name);
set(`repeating_npcaction_${id}_description`, action.detail);
}
for (const reaction of char.reactions) {
let id = Slo.generateRowID();
set(`repeating_npcreaction_${id}_name`, reaction.name);
// TODO: Why don't reaction descriptions show?
set(`repeating_npcreaction_${id}_description`, reaction.detail);
}
for (const action of char.legendaryActions) {
let id = Slo.generateRowID();
set(`repeating_npcaction-l_${id}_name`, action.name);
set(`repeating_npcaction-l_${id}_description`, action.detail);
}
obj.set({
_defaulttoken: JSON.stringify({
width: (sizeToTokenSize[size] || 1) * 70,
height: (sizeToTokenSize[size] || 1) * 70,
bar1_value: char.hp,
bar1_max: char.hp,
bar2_value: char.ac,
represents: obj.id,
name: char.name,
}),
});
sendChat(this.name, `/w gm Successfully imported ${char.name}`);
};
/** @param {string} charId */
damageModMenu = (charId) => {
let damage = parseInt(getAttrByName(charId, "ggmm_damage"));
if (!damage || isNaN(damage)) {
throw new Error(
"This command can only be used on characters with the 'ggmm_damage' attribute!"
);
}
let name = getObj("character", charId).get("name");
sendChat(
name,
`/w gm ${this.makeOptionsButtons(`${damage} damage. Choose mod:`, [
[{ text: "x1", link: `!ggmm damage --id=${charId} --mod=1` }],
[
{ text: "x0.25", link: `!ggmm damage --id=${charId} --mod=0.25` },
{ text: "x0.5", link: `!ggmm damage --id=${charId} --mod=0.5` },
{ text: "x0.75", link: `!ggmm damage --id=${charId} --mod=0.75` },
],
[
{ text: "x2", link: `!ggmm damage --id=${charId} --mod=2` },
{ text: "x3", link: `!ggmm damage --id=${charId} --mod=3` },
{ text: "x4", link: `!ggmm damage --id=${charId} --mod=4` },
],
])}`
);
};
/**
* @param {string} charId
* @param {number} modifier
* @param {string | undefined} save
*/
damageDieMenu = (charId, modifier, save) => {
let damage = parseInt(getAttrByName(charId, "ggmm_damage"));
if (!damage || isNaN(damage)) {
throw new Error(
"This command can only be used on characters with the 'ggmm_damage' attribute!"
);
}
let saveFlag = "";
if (save === "half") saveFlag = " --save=half";
if (save === "none") saveFlag = " --save=none";
damage = Math.floor(damage * modifier);
// If damage is too small, just use flat damage
if (damage <= 2) return this.dealDamage(charId, modifier, 0, save);
let name = getObj("character", charId).get("name");
sendChat(
name,
`/w gm ${this.makeOptionsButtons(`${damage} damage. Choose dice:`, [
[
{
text: "Flat",
link: `!ggmm damage --id=${charId} --mod=${modifier} --die=0${saveFlag}`,
},
],
[
{
val: 4,
text: "d4",
link: `!ggmm damage --id=${charId} --mod=${modifier} --die=4${saveFlag}`,
},
{
val: 6,
text: "d6",
link: `!ggmm damage --id=${charId} --mod=${modifier} --die=6${saveFlag}`,
},
{
val: 8,
text: "d8",
link: `!ggmm damage --id=${charId} --mod=${modifier} --die=8${saveFlag}`,
},
].filter(({ val }) => damage > val / 2),
[
{
val: 10,
text: "d10",
link: `!ggmm damage --id=${charId} --mod=${modifier} --die=10${saveFlag}`,
},
{
val: 12,
text: "d12",
link: `!ggmm damage --id=${charId} --mod=${modifier} --die=12${saveFlag}`,
},
{
val: 20,
text: "d20",
link: `!ggmm damage --id=${charId} --mod=${modifier} --die=20${saveFlag}`,
},
].filter(({ val }) => damage > val / 2),
])}`
);
};
/**
* @param {string} charId
* @param {number} modifier
* @param {number} die
* @param {string | undefined} save
*/
dealDamage = (charId, modifier, die, save) => {
let damage = parseInt(getAttrByName(charId, "ggmm_damage"));
if (!damage || isNaN(damage)) {
throw new Error(
"This command can only be used on characters with the 'ggmm_damage' attribute!"
);
}
damage = Math.floor(damage * modifier);
let finalDamage = die === 0 ? damage : GGMM.getDice(damage, die);
let name = getObj("character", charId).get("name");
sendChat(name, `Damage: [[${finalDamage}]]`);
if (save === "half") {
sendChat(name, `Half damage on save`);
} else if (save === "none") {
sendChat(name, `No damage on save`);
}
};
/**
* @param {string} charId
*/
saveMenu = (charId) => {
let primary = getAttrByName(charId, "ggmm_dc_primary");
let secondary = getAttrByName(charId, "ggmm_dc_secondary");
if (!primary || !secondary) {
throw new Error(
"This command can only be used on characters with the 'ggmm_dc_primary' and 'ggmm_dc_secondary' attributes!"
);
}
let name = getObj("character", charId).get("name");
sendChat(
name,
`/w gm ${this.makeOptionsButtons(`Save Difficulty`, [
[
{
text: `Easy (${secondary})`,
link: `!ggmm save --id=${charId} --type=easy`,
},
{
text: `Hard (${primary})`,
link: `!ggmm save --id=${charId} --type=hard`,
},
],
])}`
);
};
/**
* @param {string} charId
* @param {"easy" | "hard"} type
*/
showSave = (charId, type) => {
if (type !== "easy" && type !== "hard") {
throw new Error(
"Argument --type must be either 'easy' or 'hard'! Received: " + type
);
}
let primary = getAttrByName(charId, "ggmm_dc_primary");
let secondary = getAttrByName(charId, "ggmm_dc_secondary");
if (!primary || !secondary) {
throw new Error(
"This command can only be used on characters with the 'ggmm_dc_primary' and 'ggmm_dc_secondary' attributes!"
);
}
let dc = type === "hard" ? primary : secondary;
let name = getObj("character", charId).get("name");
sendChat(name, `Saving Throw DC ${dc}`);
sendChat(
name,
`/w gm ${this.makeOptionsButtons(`Save Damage`, [
[{ text: `Full Damage`, link: `!ggmm damage --id=${charId} --mod=1` }],
[
{
text: `AoE (half)`,
link: `!ggmm damage --id=${charId} --mod=0.5 --save=half`,
},
],
[
{
text: `AoE (none)`,
link: `!ggmm damage --id=${charId} --mod=0.75 --save=none`,
},
],
])}`
);
};
/**
* @param {string} charId
*/
rollAttack = (charId) => {
let attack = parseInt(getAttrByName(charId, "ggmm_attack"));
let damage = parseInt(getAttrByName(charId, "ggmm_damage"));
if (!attack || isNaN(attack) || !damage || isNaN(damage)) {
throw new Error(
"This command can only be used on characters with the 'ggmm_attack' and 'ggmm_damage' attributes!"
);
}
let multi = damage < 10 ? 1 : damage < 30 ? 2 : damage < 60 ? 3 : 4;
let name = getObj("character", charId).get("name");
sendChat(name, `Attack roll: [[1d20+${attack}]] ||| [[1d20+${attack}]]`);
sendChat(
name,
`/w gm ${this.makeOptionsButtons(`Attack Damage`, [
[
{
text: `Full Attack`,
link: `!ggmm damage --id=${charId} --mod=1`,
},
],
multi > 1
? [
{
text: `Multi Attack (${multi})`,
link: `!ggmm damage --id=${charId} --mod=${1 / multi}`,
},
]
: [],
])}`
);
};
/**
*
* @param {string} header
* @param {{ text: string, link: string }[][]} buttonRows
*/
makeOptionsButtons = (header, buttonRows) => {
let menuStyle = `background: #fff; border: solid 1px #000; border-radius: 5px; margin-bottom: 1em; overflow: hidden;`;
let headerStyle = `background: #000; color: #fff; font-weight: bold; font-size: 18px; padding-top: 8px; padding-bottom: 8px; text-align: center;`;
let rowStyle = `display: table; text-align: center; width: 100%`;
let btnStyle = `display: table-cell;`;
let aStyle = `width: 100%;`;
let str = `<div style="${menuStyle}"><div style="${headerStyle}">${header}</div>${buttonRows
.map(
(row) =>
`<div style="${rowStyle}">${row
.map(
(button) =>
`<div style="${btnStyle} width: ${
100 / row.length
}%;"><a style="${aStyle}" href="${button.link}">${
button.text
}</a></div>`
)
.join("")}</div>`
)
.join("")}</div>`.replace("\n", "");
return str;
};
MENU_CSS = {
menu: {
background: "#fff",
border: "solid 1px #000",
"border-radius": "5px",
"margin-bottom": "1em",
overflow: "hidden",
},
menuHeader: {
background: "#000",
color: "#fff",
"font-weight": "bold",
"font-size": "24px",
"padding-top": "8px",
"padding-bottom": "8px",
"text-align": "center",
},
row: {
display: "flex",
"justify-content": "space-evenly",
},
btn: {},
rowContainer: {},
};
}
const GGMMImporter = new _GGMMImporter();
const GGMM_DATA = {
skillToStat: {
acrobatics: "dex",
"animal handling": "wis",
arcana: "int",
athletics: "str",
deception: "cha",
history: "int",
insight: "wis",
intimidation: "cha",
investigation: "int",
medicine: "wis",
nature: "int",
perception: "wis",
performance: "cha",
persuasion: "cha",
religion: "int",
"sleight of hand": "dex",
stealth: "dex",
survival: "wis",
},
ranks: {
minion: {
ac: -1,
attack: -2,
hp: 0.2,
damage: 0.75,
savingThrows: -2,
spellDCs: -2,
initiative: -2,
perception: -2,
xp: 0.25,
stealth: -2,
},
standard: {
ac: 0,
attack: 0,
hp: 1,
damage: 1,
savingThrows: 0,
spellDCs: 0,
initiative: 0,
perception: 0,
xp: 1,
stealth: 0,
},
elite: {
ac: 2,
attack: 2,
hp: 2,
damage: 1.1,
savingThrows: 2,
spellDCs: 2,
initiative: 2,
perception: 2,
xp: 2,
stealth: 2,
},
solo: {
ac: 2,
attack: 2,
hp: "players",
damage: 1.2,
savingThrows: 2,
spellDCs: 2,
initiative: 4,
perception: 4,
xp: "players",
stealth: 2,
},
},
roles: {
controller: {
ac: -2,
savingThrows: -1,
hp: 1,
attack: 0,
damage: 1,
speed: 0,
perception: false,
stealth: false,
initiative: true,
},
defender: {
ac: 2,
savingThrows: 1,
hp: 1,
attack: 0,
damage: 1,
speed: -10,
perception: true,
stealth: false,
initiative: false,
},
lurker: {
ac: -4,
savingThrows: -2,
hp: 0.5,
attack: 2,
damage: 1.5,
speed: 0,
perception: true,
stealth: true,
initiative: false,
},
scout: {
ac: -2,
savingThrows: -1,
hp: 1,
attack: 0,
damage: 0.75,
speed: 10,
perception: true,
stealth: true,
initiative: true,
},
sniper: {
ac: 0,
savingThrows: 0,
hp: 0.75,
attack: 0,
damage: 1.25,
speed: 0,
perception: false,
stealth: true,
initiative: false,
},
striker: {
ac: -4,
savingThrows: -2,
hp: 1.25,
attack: 2,
damage: 1.25,
speed: 0,
perception: false,
stealth: false,
initiative: false,
},
supporter: {
ac: -2,
savingThrows: -1,
hp: 0.75,
attack: 0,
damage: 0.75,
speed: 0,
perception: false,
stealth: false,
initiative: true,
},
},
statsByLevel: {
"−5": {
ac: 11,
hp: 1,
attack: -1,
damage: 1,
spellDCs: [8, 5],
initiative: 0,
proficiencyBonus: 0,
savingThrows: [1, 0, -1],
abilityModifiers: [1, 0, 0, 0, 0, -1],
xp: 0,
},
"−4": {
ac: 12,
hp: 1,
attack: 0,
damage: 1,
spellDCs: [9, 6],
initiative: 1,
proficiencyBonus: 0,
savingThrows: [2, 1, -1],
abilityModifiers: [2, 1, 1, 0, 0, -1],
xp: 0,
},
"−3": {
ac: 13,
hp: 4,
attack: 1,
damage: 1,
spellDCs: [10, 7],
initiative: 1,
proficiencyBonus: 1,
savingThrows: [3, 1, 0],
abilityModifiers: [2, 1, 1, 0, 0, -1],
xp: 2,
},
"−2": {
ac: 13,
hp: 8,
attack: 1,
damage: 1,
spellDCs: [10, 7],
initiative: 1,
proficiencyBonus: 1,
savingThrows: [3, 1, 0],
abilityModifiers: [2, 1, 1, 0, 0, -1],
xp: 6,
},
"−1": {
ac: 13,
hp: 12,
attack: 1,
damage: 1,
spellDCs: [10, 7],
initiative: 1,
proficiencyBonus: 1,
savingThrows: [3, 1, 0],
abilityModifiers: [2, 1, 1, 0, 0, -1],
xp: 12,
},
0: {
ac: 14,
hp: 16,
attack: 2,
damage: 1,
spellDCs: [10, 7],
initiative: 1,
proficiencyBonus: 1,
savingThrows: [4, 2, 0],
abilityModifiers: [3, 2, 1, 1, 0, -1],
xp: 25,
},
1: {
ac: 14,
hp: 26,
attack: 3,
damage: 2,
spellDCs: [11, 8],
initiative: 1,
proficiencyBonus: 2,
savingThrows: [5, 3, 0],
abilityModifiers: [3, 2, 1, 1, 0, -1],
xp: 50,
},
2: {
ac: 14,
hp: 30,
attack: 3,
damage: 4,
spellDCs: [11, 8],
initiative: 1,
proficiencyBonus: 2,
savingThrows: [5, 3, 0],
abilityModifiers: [3, 2, 1, 1, 0, -1],
xp: 112,
},
3: {
ac: 14,
hp: 33,
attack: 3,
damage: 5,
spellDCs: [11, 8],
initiative: 1,
proficiencyBonus: 2,
savingThrows: [5, 3, 0],
abilityModifiers: [3, 2, 1, 1, 0, -1],
xp: 175,
},
4: {
ac: 15,
hp: 36,
attack: 4,
damage: 8,
spellDCs: [12, 9],
initiative: 2,
proficiencyBonus: 2,
savingThrows: [6, 3, 1],
abilityModifiers: [4, 3, 2, 1, 1, 0],
xp: 275,
},
5: {
ac: 16,
hp: 60,
attack: 5,
damage: 10,
spellDCs: [13, 10],
initiative: 2,
proficiencyBonus: 3,
savingThrows: [7, 4, 1],
abilityModifiers: [4, 3, 2, 1, 1, 0],
xp: 450,
},
6: {
ac: 16,
hp: 64,
attack: 5,
damage: 11,
spellDCs: [13, 10],
initiative: 2,
proficiencyBonus: 3,
savingThrows: [7, 4, 1],
abilityModifiers: [4, 3, 2, 1, 1, 0],
xp: 575,
},
7: {
ac: 16,
hp: 68,
attack: 5,
damage: 13,
spellDCs: [13, 10],
initiative: 2,
proficiencyBonus: 3,
savingThrows: [7, 4, 1],
abilityModifiers: [4, 3, 2, 1, 1, 0],
xp: 725,
},
8: {
ac: 17,
hp: 72,
attack: 6,
damage: 17,
spellDCs: [14, 11],
initiative: 3,
proficiencyBonus: 3,
savingThrows: [8, 5, 1],
abilityModifiers: [5, 3, 2, 2, 1, 0],
xp: 975,
},
9: {
ac: 18,
hp: 102,
attack: 7,
damage: 19,
spellDCs: [15, 12],
initiative: 3,
proficiencyBonus: 4,
savingThrows: [9, 5, 2],
abilityModifiers: [5, 3, 2, 2, 1, 0],
xp: 1250,
},
10: {
ac: 18,
hp: 107,
attack: 7,
damage: 21,
spellDCs: [15, 12],
initiative: 3,
proficiencyBonus: 4,
savingThrows: [9, 5, 2],
abilityModifiers: [5, 3, 2, 2, 1, 0],
xp: 1475,
},
11: {
ac: 18,
hp: 111,
attack: 7,
damage: 23,
spellDCs: [15, 12],
initiative: 3,
proficiencyBonus: 4,
savingThrows: [9, 5, 2],
abilityModifiers: [5, 3, 2, 2, 1, 0],
xp: 1800,
},
12: {
ac: 18,
hp: 115,
attack: 8,
damage: 28,
spellDCs: [15, 12],
initiative: 3,
proficiencyBonus: 4,
savingThrows: [10, 6, 2],
abilityModifiers: [6, 4, 3, 2, 1, 0],
xp: 2100,
},
13: {
ac: 19,
hp: 152,
attack: 9,
damage: 30,
spellDCs: [16, 13],
initiative: 3,
proficiencyBonus: 5,
savingThrows: [11, 7, 2],
abilityModifiers: [6, 4, 3, 2, 1, 0],
xp: 2500,
},
14: {
ac: 19,
hp: 157,
attack: 9,
damage: 32,
spellDCs: [16, 13],
initiative: 3,
proficiencyBonus: 5,
savingThrows: [11, 7, 2],
abilityModifiers: [6, 4, 3, 2, 1, 0],
xp: 2875,
},
15: {
ac: 19,
hp: 162,
attack: 9,
damage: 35,
spellDCs: [16, 13],
initiative: 3,
proficiencyBonus: 5,
savingThrows: [11, 7, 2],
abilityModifiers: [6, 4, 3, 2, 1, 0],
xp: 3250,
},
16: {
ac: 20,
hp: 167,
attack: 10,
damage: 41,
spellDCs: [17, 14],
initiative: 4,
proficiencyBonus: 5,
savingThrows: [12, 7, 3],
abilityModifiers: [7, 5, 3, 2, 2, 1],
xp: 3750,
},
17: {
ac: 21,
hp: 210,
attack: 11,
damage: 43,
spellDCs: [18, 15],
initiative: 4,
proficiencyBonus: 6,
savingThrows: [13, 8, 3],
abilityModifiers: [7, 5, 3, 2, 2, 1],
xp: 4500,
},
18: {
ac: 21,
hp: 216,
attack: 11,
damage: 46,
spellDCs: [18, 15],
initiative: 4,
proficiencyBonus: 6,
savingThrows: [13, 8, 3],
abilityModifiers: [7, 5, 3, 2, 2, 1],
xp: 5000,
},
19: {
ac: 21,
hp: 221,
attack: 11,
damage: 48,
spellDCs: [18, 15],
initiative: 4,
proficiencyBonus: 6,
savingThrows: [13, 8, 3],
abilityModifiers: [7, 5, 3, 2, 2, 1],
xp: 5500,
},
20: {
ac: 22,
hp: 226,
attack: 12,
damage: 51,
spellDCs: [19, 16],
initiative: 5,
proficiencyBonus: 6,
savingThrows: [14, 9, 3],
abilityModifiers: [8, 6, 4, 3, 2, 1],
xp: 6250,
},
21: {
ac: 22,
hp: 276,
attack: 13,
damage: 53,
spellDCs: [20, 17],
initiative: 5,
proficiencyBonus: 7,
savingThrows: [15, 9, 4],
abilityModifiers: [8, 6, 4, 3, 2, 1],
xp: 8250,
},
22: {
ac: 22,
hp: 282,
attack: 13,
damage: 56,
spellDCs: [20, 17],
initiative: 5,
proficiencyBonus: 7,
savingThrows: [15, 9, 4],
abilityModifiers: [8, 6, 4, 3, 2, 1],
xp: 10250,
},
23: {
ac: 22,
hp: 288,
attack: 13,
damage: 58,
spellDCs: [20, 17],
initiative: 5,
proficiencyBonus: 7,
savingThrows: [15, 9, 4],
abilityModifiers: [8, 6, 4, 3, 2, 1],
xp: 12500,
},
24: {
ac: 23,
hp: 294,
attack: 14,
damage: 61,
spellDCs: [20, 17],
initiative: 5,
proficiencyBonus: 7,
savingThrows: [16, 10, 4],
abilityModifiers: [9, 6, 4, 3, 2, 1],
xp: 15500,
},
25: {
ac: 24,
hp: 350,
attack: 15,
damage: 63,
spellDCs: [21, 18],
initiative: 5,
proficiencyBonus: 8,
savingThrows: [17, 11, 4],
abilityModifiers: [9, 6, 4, 3, 2, 1],
xp: 18750,
},
26: {
ac: 24,
hp: 357,
attack: 15,
damage: 66,
spellDCs: [21, 18],
initiative: 5,
proficiencyBonus: 8,
savingThrows: [17, 11, 4],
abilityModifiers: [9, 6, 4, 3, 2, 1],
xp: 22500,
},
27: {
ac: 24,
hp: 363,
attack: 15,
damage: 68,
spellDCs: [21, 18],
initiative: 5,
proficiencyBonus: 8,
savingThrows: [17, 11, 4],
abilityModifiers: [9, 6, 4, 3, 2, 1],
xp: 26250,
},
28: {
ac: 25,
hp: 369,
attack: 16,
damage: 71,
spellDCs: [22, 19],
initiative: 6,
proficiencyBonus: 8,
savingThrows: [18, 11, 5],
abilityModifiers: [10, 7, 5, 4, 3, 2],
xp: 30000,
},
29: {
ac: 26,
hp: 432,
attack: 17,
damage: 73,
spellDCs: [23, 20],
initiative: 6,
proficiencyBonus: 9,
savingThrows: [19, 12, 5],
abilityModifiers: [10, 7, 5, 4, 3, 2],
xp: 33750,
},
30: {
ac: 26,
hp: 439,
attack: 17,
damage: 76,
spellDCs: [23, 20],
initiative: 6,
proficiencyBonus: 9,
savingThrows: [19, 12, 5],
abilityModifiers: [10, 7, 5, 4, 3, 2],
xp: 38750,
},
31: {
ac: 26,
hp: 446,
attack: 17,
damage: 78,
spellDCs: [23, 20],
initiative: 6,
proficiencyBonus: 9,
savingThrows: [19, 12, 5],
abilityModifiers: [10, 7, 5, 4, 3, 2],
xp: 44500,
},
32: {
ac: 26,
hp: 453,
attack: 18,
damage: 81,
spellDCs: [24, 21],
initiative: 7,
proficiencyBonus: 9,
savingThrows: [20, 13, 5],
abilityModifiers: [11, 8, 5, 4, 3, 2],
xp: 51000,
},
33: {
ac: 27,
hp: 522,
attack: 19,
damage: 83,
spellDCs: [25, 22],
initiative: 7,
proficiencyBonus: 10,
savingThrows: [21, 13, 6],
abilityModifiers: [11, 8, 5, 4, 3, 2],
xp: 58750,
},
34: {
ac: 27,
hp: 530,
attack: 19,
damage: 86,
spellDCs: [25, 22],
initiative: 7,
proficiencyBonus: 10,
savingThrows: [21, 13, 6],
abilityModifiers: [11, 8, 5, 4, 3, 2],
xp: 67750,
},
35: {
ac: 27,
hp: 537,
attack: 19,
damage: 88,
spellDCs: [25, 22],
initiative: 7,
proficiencyBonus: 10,
savingThrows: [21, 13, 6],
abilityModifiers: [11, 8, 5, 4, 3, 2],
xp: 77750,
},
},
mlToCr: {
minion: {
"-5": "0",
"-4": "0",
"-3": "0",
"-2": "0",
"-1": "0",
0: "0",
1: "1/8",
2: "1/4",
3: "1/2",
4: "1/2",
5: "1/2",
6: "1/2",
7: "1",
8: "1",
9: "1",
10: "1",
11: "2",
12: "2",
13: "2",
14: "3",
15: "3",
16: "3",
17: "4",
18: "4",
19: "4",
20: "4",
21: "5",
22: "6",
23: "7",
24: "8",
25: "9",
26: "10",
27: "10",
28: "11",
29: "12",
30: "12",
},
standard: {
"-5": "0",
"-4": "0",
"-3": "0",
"-2": "0",
"-1": "0",
0: "1/8",
1: "1/4",
2: "1/2",
3: "1",
4: "1",
5: "2",
6: "2",
7: "3",
8: "4",
9: "4",
10: "4",
11: "5",
12: "5",
13: "6",
14: "7",
15: "7",
16: "8",
17: "8",
18: "9",
19: "10",
20: "11",
21: "12",
22: "13",
23: "14",
24: "15",
25: "16",
26: "17",
27: "18",
28: "19",
29: "20",
30: "21",
},
elite: {
"-5": "0",
"-4": "0",
"-3": "0",
"-2": "0",
"-1": "1/8",
0: "1/4",
1: "1/2",
2: "1",
3: "2",
4: "3",
5: "3",
6: "4",
7: "4",
8: "5",
9: "6",
10: "7",
11: "7",
12: "8",
13: "9",
14: "10",
15: "10",
16: "11",
17: "12",
18: "13",
19: "14",
20: "15",
21: "16",
22: "17",
23: "18",
24: "19",
25: "20",
26: "21",
27: "22",
28: "23",
29: "24",
30: "25",
},
solo: {
"-5": "0",
"-4": "0",
"-3": "0",
"-2": "1/8",
"-1": "1/4",
0: "1/2",
1: "1",
2: "2",
3: "3",
4: "4",
5: "5",
6: "6",
7: "7",
8: "8",
9: "9",
10: "10",
11: "11",
12: "12",
13: "13",
14: "14",
15: "15",
16: "16",
17: "17",
18: "18",
19: "19",
20: "20",
21: "21",
22: "22",
23: "23",
24: "24",
25: "25",
26: "26",
27: "27",
28: "28",
29: "29",
30: "30",
},
},
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment