var ShapedScripts = /******/ (function(modules) { // webpackBootstrap /******/ // The module cache /******/ var installedModules = {}; /******/ // The require function /******/ function __webpack_require__(moduleId) { /******/ // Check if module is in cache /******/ if(installedModules[moduleId]) /******/ return installedModules[moduleId].exports; /******/ // Create a new module (and put it into the cache) /******/ var module = installedModules[moduleId] = { /******/ exports: {}, /******/ id: moduleId, /******/ loaded: false /******/ }; /******/ // Execute the module function /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); /******/ // Flag the module as loaded /******/ module.loaded = true; /******/ // Return the exports of the module /******/ return module.exports; /******/ } /******/ // expose the modules object (__webpack_modules__) /******/ __webpack_require__.m = modules; /******/ // expose the module cache /******/ __webpack_require__.c = installedModules; /******/ // __webpack_public_path__ /******/ __webpack_require__.p = ""; /******/ // Load entry module and return exports /******/ return __webpack_require__(0); /******/ }) /************************************************************************/ /******/ ([ /* 0 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const Roll20 = __webpack_require__(1); const roll20 = new Roll20(); const parseModule = __webpack_require__(3); const mmFormat = __webpack_require__(4); const myState = roll20.getState('ShapedScripts'); const Logger = __webpack_require__(5); const logger = new Logger('5eShapedCompanion', roll20); const EntityLookup = __webpack_require__(6); const JSONValidator = __webpack_require__(8); const el = new EntityLookup(); const Reporter = __webpack_require__(9); const reporter = new Reporter(roll20, 'Shaped Scripts'); const ShapedScripts = __webpack_require__(10); const srdConverter = __webpack_require__(22); const sanitise = __webpack_require__(23); const mpp = __webpack_require__(24); const shaped = new ShapedScripts(logger, myState, roll20, parseModule.getParser(mmFormat, logger), el, reporter, srdConverter, sanitise, mpp); const _ = __webpack_require__(2); roll20.logWrap = 'roll20'; logger.wrapModule(el); logger.wrapModule(roll20); logger.wrapModule(srdConverter); const jsonValidator = new JSONValidator(__webpack_require__(4)); el.configureEntity('monsters', [ EntityLookup.jsonValidatorAsEntityProcessor(jsonValidator), el.getSpellHydrator(), ], EntityLookup.jsonValidatorAsVersionChecker(jsonValidator)); el.configureEntity('spells', [el.getMonsterSpellUpdater()], EntityLookup.getVersionChecker('0.2.1')); roll20.on('ready', () => { shaped.checkInstall(); shaped.registerEventHandlers(); }); module.exports = { addEntities(entities) { try { if (typeof entities === 'string') { entities = JSON.parse(entities); } // Suppress excessive logging when adding big lists of entities const prevLogLevel = myState.config.logLevel; myState.config.logLevel = Logger.INFO; const result = el.addEntities(entities); myState.config.logLevel = prevLogLevel; const summary = _.mapObject(result, (resultObject, type) => { if (type === 'errors') { return resultObject.length; } return _.mapObject(resultObject, operationResultArray => operationResultArray.length); }); logger.info('Summary of adding entities to the lookup: $$$', summary); logger.info('Details: $$$', result); if (!_.isEmpty(result.errors)) { const message = _.chain(result.errors) .groupBy('entity') .mapObject(entityErrors => _.chain(entityErrors) .pluck('errors') .flatten() .value() ) .map((errors, entityName) => `
  • ${entityName}:
  • `) .value(); reporter.reportError(`JSON import error:`); } } catch (e) { reporter.reportError('JSON parse error, please see log for more information'); logger.error(e.toString()); logger.error(e.stack); } }, }; /***/ }, /* 1 */ /***/ function(module, exports, __webpack_require__) { /* globals state, createObj, findObjs, getObj, getAttrByName, sendChat, on, log, Campaign, playerIsGM, spawnFx, spawnFxBetweenPoints, filterObjs */ 'use strict'; const _ = __webpack_require__(2); // noinspection JSUnusedLocalSymbols module.exports = class Roll20Wrapper { getState(module) { if (!state[module]) { state[module] = {}; } return state[module]; } createObj(type, attributes) { return createObj(type, attributes); } findObjs(attributes) { return findObjs(attributes); } filterObjs(attributes) { return filterObjs(attributes); } getObj(type, id) { return getObj(type, id); } getOrCreateObj(type, attributes) { const newAttributes = _.extend(_.clone(attributes), { type }); const existing = this.findObjs(newAttributes); switch (existing.length) { case 0: return this.createObj(type, newAttributes); case 1: return existing[0]; default: throw new Error(`Asked for a single ${type} but more than 1 was found matching attributes: ` + `${JSON.stringify(attributes)}`); } } getAttrByName(character, attrName, valueType) { return getAttrByName(character, attrName, valueType); } checkCharacterFlag(character, flag) { const value = this.getAttrByName(character, flag); switch (typeof value) { case 'boolean': return value; case 'number': return !!value; default: return value === '1' || value === 'on'; } } getAttrObjectByName(character, attrName) { const attr = this.findObjs({ type: 'attribute', characterid: character, name: attrName }); return attr && attr.length > 0 ? attr[0] : null; } getOrCreateAttr(characterId, attrName) { return this.getOrCreateObj('attribute', { characterid: characterId, name: attrName }); } setAttrByName(characterId, attrName, value) { this.getOrCreateAttr(characterId, attrName).set('current', value); } processAttrValue(characterId, attrName, cb) { const attribute = this.getOrCreateAttr(characterId, attrName); attribute.set('current', cb(attribute.get('current'))); } getRepeatingSectionAttrs(characterId, sectionName) { const prefix = `repeating_${sectionName}`; return _.filter(this.findObjs({ type: 'attribute', characterid: characterId }), attr => attr.get('name').indexOf(prefix) === 0); } getRepeatingSectionItemIdsByName(characterId, sectionName) { const re = new RegExp(`repeating_${sectionName}_([^_]+)_name$`); return _.reduce(this.getRepeatingSectionAttrs(characterId, sectionName), (lookup, attr) => { const match = attr.get('name').match(re); if (match) { lookup[attr.get('current').toLowerCase()] = match[1]; } return lookup; }, {}); } getCurrentPage(playerId) { let pageId; if (this.playerIsGM(playerId)) { pageId = this.getObj('player', playerId).get('lastpage'); } else { pageId = this.getCampaign().get('playerspecificpages')[playerId] || this.getCampaign().get('playerpageid'); } return pageId ? this.getObj('page', pageId) : null; } spawnFx(pointsArray, fxType, pageId) { switch (pointsArray.length) { case 1: spawnFx(pointsArray[0].x, pointsArray[0].y, fxType, pageId); break; case 2: spawnFxBetweenPoints(pointsArray[0], pointsArray[1], fxType, pageId); break; default: throw new Error('Wrong number of points supplied to spawnFx: $$$', pointsArray); } } playerIsGM(playerId) { return playerIsGM(playerId); } getCampaign() { return Campaign(); // eslint-disable-line } sendChat(sendAs, message, callback, options) { return sendChat(sendAs, message, callback, options); } on(event, callback) { return on(event, callback); } log(msg) { return log(msg); } }; /***/ }, /* 2 */ /***/ function(module, exports) { module.exports = _; /***/ }, /* 3 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); /** * A specification of a field that can appear * in the format that this parser processes * @typedef {Object} FieldSpec * @property {FieldSpec[]} [contentModel] - list of child fieldSpecs for complex content * @property {boolean} [bare] - if true this field appears as a bare value with no parseToken in front of it * @property {string} [parseToken] - the token that defines the beginning of this field (usually case insensitive). Not * used for bare tokens. * @property {string} [pattern] - a pattern that defines the value of this field. For bare properties this will * determine if the field matches at all, whereas for normal ones this will just be used to validate them * @property {number} [forNextMatchGroup] - the 1-based index of the match group from the supplied pattern that will * contain text that should be handed to the next parser rather than used as part of this field. * @property {number} [forPreviousMatchGroup] - the 1-based index of the match group from the supplied pattern that * will contain text that should be handed to the previous parser to complete its value rather than used as part of * this field. Only applicable to bare properties, since ones with a token have a clearly defined start based on the * parseToken * @property {number} [matchGroup=0] - the index of the capturing group in the supplied pattern to use as the value for * this field. If left at the default of 0, the whole match will be used. * @property {boolean} [caseSensitive=false] - If true, the pattern used for the value of this field will be made case * sensitive. Note that parseToken matching is always case insensitive. * @property {string} type - the type of this field. Currently valid values are [orderedContent, unorderedContent, * string, enumType, integer, abililty] * @property {string[]} enumValues - an array of valid values for this field if the type is enumType * @property {number} [minOccurs=1] - the minimum number of times this field should occur in the parent content model. * @property {number} [maxOccurs=1] - the maximum number of times this field should occur in the parent content model. */ /** * * @param {FieldSpec} formatSpec - Root format specification for this parser * @param logger - Logger object to use for reporting errors etc. * @returns {{parse: ((*))}} - A parser that will process text in the format specified by the supplied formatSpec into * JSON objects */ function getParser(formatSpec, logger) { // noinspection JSUnusedGlobalSymbols const parserModule = { makeContentModelParser(fieldSpec, ordered) { const module = this; return { parse(stateManager, textLines, resume) { const parseState = stateManager.enterChildParser(this, resume); let someMatch = false; let canContinue; let stopParser = null; parseState.subParsers = parseState.subParsers || module.makeParserList(fieldSpec.contentModel); if (parseState.resumeParser) { if (!parseState.resumeParser.resumeParse(stateManager, textLines)) { stateManager.leaveChildParser(this); return false; } someMatch = true; } function parseRunner(parser, index, parsers) { if (!parser.parse(stateManager, textLines)) { if (parser.required === 0 || !ordered) { // No match but it's ok to keep looking // through the rest of the content model for one return false; } // No match but one was required here by the content model } else { parser.justMatched = true; if (parser.required > 0) { parser.required--; } parser.allowed--; if (ordered) { // Set all the previous parsers to be exhausted since we've matched // this one and we're in a strictly ordered content model. _.each(parsers.slice(0, index), _.partial(_.extend, _, { allowed: 0 })); } } return true; } do { stopParser = _.find(parseState.subParsers, parseRunner); logger.debug('Stopped at parser $$$', stopParser); canContinue = stopParser && stopParser.justMatched; if (stopParser) { someMatch = someMatch || stopParser.justMatched; stopParser.justMatched = false; } // Lose any parsers that have used up all their cardinality already parseState.subParsers = _.reject(parseState.subParsers, { allowed: 0 }); } while (!_.isEmpty(parseState.subParsers) && !_.isEmpty(textLines) && canContinue); stateManager.leaveChildParser(this, someMatch ? parseState : undefined); return someMatch; }, resumeParse(stateManager, textLines) { return this.parse(stateManager, textLines, true); }, complete(parseState/* , finalText*/) { const missingContent = _.filter(parseState.subParsers, 'required'); if (!_.isEmpty(missingContent)) { throw new MissingContentError(missingContent); } }, }; }, matchParseToken(myParseState, textLines) { if (_.isEmpty(textLines) || this.bare) { return !_.isEmpty(textLines); } const re = new RegExp(`^(.*?)(${this.parseToken})(?:[\\s.]+|$)`, 'i'); const match = textLines[0].match(re); if (match) { logger.debug('Found match $$$', match[0]); myParseState.forPrevious = match[1]; myParseState.text = ''; textLines[0] = textLines[0].slice(match[0].length).trim(); if (!textLines[0]) { textLines.shift(); } } return !!match; }, matchValue(myParseState, textLines) { if (this.pattern && this.bare) { // If this is not a bare value then we can take all the text up to next // token and just validate it at the end. If it is, then the pattern itself // defines whether this field matches and we must run it immediately. if (_.isEmpty(textLines)) { return false; } textLines[0] = textLines[0].trim(); const matchGroup = this.matchGroup || 0; const re = new RegExp(this.pattern, this.caseSensitive ? '' : 'i'); logger.debug('$$$ attempting to match value [$$$] against regexp $$$', this.name, textLines[0], re.toString()); const match = textLines[0].match(re); if (match) { logger.debug('Successful match! $$$', match); myParseState.text = match[matchGroup]; if (!myParseState.forPrevious && this.forPreviousMatchGroup) { logger.debug('Setting forPrevious to $$$', match[this.forPreviousMatchGroup]); myParseState.forPrevious = match[this.forPreviousMatchGroup]; } textLines[0] = textLines[0].slice(match.index + match[0].length); if (this.forNextMatchGroup && match[this.forNextMatchGroup]) { textLines[0] = match[this.forNextMatchGroup] + textLines[0]; } if (!textLines[0]) { myParseState.text += '\n'; textLines.shift(); } return true; } logger.debug('Match failed'); return false; } logger.debug('$$$ standard string match, not using pattern', this.name); myParseState.text = ''; return true; }, orderedContent(fieldSpec) { return this.makeContentModelParser(fieldSpec, true); }, unorderedContent(fieldSpec) { return this.makeContentModelParser(fieldSpec, false); }, string(/* fieldSpec */) { return this.makeSimpleValueParser(); }, enumType(fieldSpec) { const parser = this.makeSimpleValueParser(); if (fieldSpec.bare) { parser.matchValue = function matchValue(myParseState, textLines) { const firstMatch = _.chain(fieldSpec.enumValues) .map(enumValue => { logger.debug('Attempting to parse as enum property $$$', enumValue); const pattern = `^(.*?)(${enumValue})(?:[\\s.]+|$)`; const re = new RegExp(pattern, this.caseSensitive ? '' : 'i'); return textLines[0].match(re); }) .compact() .sortBy(match => match[1].length) .first() .value(); if (firstMatch) { logger.debug('Finished trying to parse as enum property, match: $$$', firstMatch); myParseState.text = firstMatch[2]; myParseState.forPrevious = firstMatch[1]; textLines[0] = textLines[0].slice(firstMatch.index + firstMatch[0].length); if (!textLines[0]) { textLines.shift(); } return true; } return false; }; } return parser; }, number(fieldSpec) { const parser = this.makeSimpleValueParser(); parser.typeConvert = function typeConvert(textValue) { const parts = textValue.split('/'); let intVal; if (parts.length > 1) { intVal = parts[0] / parts[1]; } else { intVal = parseInt(textValue, 10); } if (_.isNaN(intVal)) { throw new BadValueError(fieldSpec.name, textValue, '[Integer]'); } return intVal; }; return parser; }, ability(/* fieldSpec */) { const parser = this.number(); parser.matchValue = function matchValue(parseState, textLines) { if (_.isEmpty(textLines)) { return false; } textLines[0] = textLines[0].trim(); const re = new RegExp('^([\\sa-z\\(\\)]*)(\\d+(?:\\s?\\([\\-+\\d]+\\))?)', 'i'); logger.debug('Attempting to match value [$$$] against regexp $$$', textLines[0].trim(), re.toString()); let match = textLines[0].match(re); if (match) { logger.debug('Successful match $$$', match); parseState.text = match[2]; textLines[0] = match[1] + textLines[0].slice(match.index + match[0].length); if (!textLines[0]) { textLines.shift(); } return true; } else if (textLines[1]) { // Try and match against the next line in case we have a two line format textLines[1] = textLines[1].trim(); match = textLines[1].match(/^(\d+)(?:\s?\([\-+\d]+\))?/); if (match) { logger.debug('Successful ability match $$$ on next line - looks like two line format', match); parseState.text = match[1]; textLines[1] = textLines[1].slice(match[0].length); if (!textLines[0]) { textLines.shift(); } if (!textLines[0]) { textLines.shift(); } return true; } } return false; }; return parser; }, heading(fieldSpec) { fieldSpec.bare = true; const parser = this.makeSimpleValueParser(); parser.skipOutput = true; return parser; }, makeSimpleValueParser() { const module = this; return { parse(stateManager, textLines) { const parseState = stateManager.enterChildParser(this); const match = this.matchParseToken(parseState, textLines) && this.matchValue(parseState, textLines); if (match) { stateManager.completeCurrentStack(parseState.forPrevious); delete parseState.forPrevious; stateManager.leaveChildParser(this, parseState); } else { stateManager.leaveChildParser(this); } return match; }, complete(parseState, finalText) { parseState.text += finalText || ''; if (parseState.text) { parseState.value = this.extractValue(parseState.text); parseState.value = this.typeConvert(parseState.value); parseState.setOutputValue(); } }, extractValue(text) { text = text.trim(); if (this.pattern && !this.bare) { const regExp = new RegExp(this.pattern, this.caseSensitive ? '' : 'i'); const match = text.match(regExp); if (match) { const matchGroup = this.matchGroup || 0; return match[matchGroup]; } throw new BadValueError(this.name, text, regExp); } else { return text; } }, typeConvert(textValue) { return textValue; }, resumeParse(stateManager, textLines) { if (_.isEmpty(textLines)) { return false; } const parseState = stateManager.enterChildParser(this, true); parseState.text += `${textLines.shift()}\n`; stateManager.leaveChildParser(this, parseState); return true; }, matchParseToken: module.matchParseToken, matchValue: module.matchValue, }; }, makeBaseParseState(skipOutput, propertyPath, outputObject, completedObjects) { return { text: '', getObjectValue() { let value = outputObject; const segments = _.clone(propertyPath); while (segments.length) { const prop = segments.shift(); if (prop.flatten) { continue; } value = value[prop.name]; if (_.isArray(value)) { value = _.last(value); } } return value; }, setOutputValue() { if (skipOutput) { return; } let outputTo = outputObject; const segments = _.clone(propertyPath); while (segments.length > 0) { const prop = segments.shift(); if (prop.flatten) { continue; } let currentValue = outputTo[prop.name]; let newValue = segments.length === 0 ? this.value : {}; if (_.isUndefined(currentValue) && prop.allowed > 1) { currentValue = []; outputTo[prop.name] = currentValue; } if (_.isArray(currentValue)) { let arrayItem = _.find(currentValue, _.partial(_.negate(_.contains), completedObjects)); if (!arrayItem) { currentValue.push(newValue); arrayItem = _.last(currentValue); } newValue = arrayItem; } else if (_.isUndefined(currentValue)) { outputTo[prop.name] = newValue; } else if (segments.length === 0) { throw new Error('Simple value property somehow already had value when we came to set it'); } else { newValue = currentValue; } outputTo = newValue; } }, logWrap: `parseState[${_.pluck(propertyPath, 'name').join('/')}]`, toJSON() { return _.extend(_.clone(this), { propertyPath }); }, }; }, makeParseStateManager() { const incompleteParserStack = []; const currentPropertyPath = []; const completedObjects = []; const module = this; return { outputObject: {}, leaveChildParser(parser, state) { currentPropertyPath.pop(); if (state) { state.resumeParser = _.isEmpty(incompleteParserStack) ? null : _.last(incompleteParserStack).parser; incompleteParserStack.push({ parser, state }); } }, completeCurrentStack(finalText) { while (!_.isEmpty(incompleteParserStack)) { const incomplete = incompleteParserStack.shift(); incomplete.parser.complete(incomplete.state, finalText); const value = incomplete.state.getObjectValue(); if (_.isObject(value) && !incomplete.parser.flatten) { // Crude but this list is unlikely to get that big completedObjects.push(value); } } }, enterChildParser(parser, resume) { currentPropertyPath.push({ name: parser.name, allowed: parser.allowed, flatten: parser.flatten, }); if (!resume || _.isEmpty(incompleteParserStack) || parser !== _.last(incompleteParserStack).parser) { return module.makeBaseParseState(parser.skipOutput, _.clone(currentPropertyPath), this.outputObject, completedObjects); } return incompleteParserStack.pop().state; }, logWrap: 'parserState', toJSON() { return _.extend(_.clone(this), { incompleteParsers: incompleteParserStack, propertyPath: currentPropertyPath, }); }, }; }, parserId: 0, parserAttributes: [ 'forPreviousMatchGroup', 'forNextMatchGroup', 'parseToken', 'flatten', 'pattern', 'matchGroup', 'bare', 'caseSensitive', 'name', 'skipOutput', ], getParserFor(fieldSpec) { logger.debug('Making parser for field $$$', fieldSpec); const parserBuilder = this[fieldSpec.type]; if (!parserBuilder) { throw new Error(`Can\'t make parser for type ${fieldSpec.type}`); } const parser = parserBuilder.call(this, fieldSpec); parser.required = _.isUndefined(fieldSpec.minOccurs) ? 1 : fieldSpec.minOccurs; parser.allowed = _.isUndefined(fieldSpec.maxOccurs) ? 1 : fieldSpec.maxOccurs; _.extend(parser, _.pick(fieldSpec, this.parserAttributes)); _.defaults(parser, { parseToken: parser.name, }); parser.id = this.parserId++; parser.logWrap = `parser[${parser.name}]`; return parser; }, makeParserList(contentModelArray) { const module = this; return _.chain(contentModelArray) .reject('noParse') .reduce((parsers, fieldSpec) => { parsers.push(module.getParserFor(fieldSpec)); return parsers; }, []) .value(); }, logWrap: 'parseModule', }; logger.wrapModule(parserModule); const parser = parserModule.getParserFor(formatSpec); return { parse(text) { logger.debug('Text: $$$', text); const textLines = _.chain(text.split('\n')) .invoke('trim') .compact() .value(); logger.debug(parser); const stateManager = parserModule.makeParseStateManager(); const success = parser.parse(stateManager, textLines); while (success && !_.isEmpty(textLines)) { parser.resumeParse(stateManager, textLines); } stateManager.completeCurrentStack(textLines.join('\n')); if (success && textLines.length === 0) { stateManager.outputObject.version = formatSpec.formatVersion; logger.info(stateManager.outputObject); return stateManager.outputObject; } return null; }, }; } /** * @constructor */ function ParserError(message) { // noinspection JSUnusedGlobalSymbols this.message = message; } ParserError.prototype = new Error(); /** * @constructor */ function MissingContentError(missingFieldParsers) { this.missingFieldParsers = missingFieldParsers; this.message = ''; } MissingContentError.prototype = new ParserError(); /** * @constructor */ function BadValueError(name, value, pattern) { 'use strict'; this.name = name; this.value = value; this.pattern = pattern; // noinspection JSUnusedGlobalSymbols this.message = `Bad value [${this.value}] for field [${this.name}]. Should have matched pattern: ${this.pattern}`; } BadValueError.prototype = new ParserError(); module.exports = { getParser, MissingContentError, BadValueError, ParserError, }; /***/ }, /* 4 */ /***/ function(module, exports) { module.exports = { "formatVersion": "0.2", "name": "monsters", "maxOccurs": "Infinity", "type": "orderedContent", "bare": true, "contentModel": [ { "name": "coreInfo", "type": "orderedContent", "flatten": true, "contentModel": [ { "name": "name", "type": "string", "bare": "true" }, { "name": "size", "enumValues": [ "Tiny", "Small", "Medium", "Large", "Huge", "Gargantuan" ], "type": "enumType", "bare": "true" }, { "name": "type", "type": "string", "bare": "true", "pattern": "^([\\w\\s\\(\\),-]+),", "matchGroup": 1 }, { "name": "alignment", "type": "enumType", "enumValues": [ "lawful good", "lawful neutral", "lawful evil", "neutral good", "neutral evil", "neutral", "chaotic good", "chaotic neutral", "chaotic evil", "unaligned", "any alignment", "any good alignment", "any non-good alignment", "any evil alignment", "any non-evil alignment", "any lawful alignment", "any non-lawful alignment", "any chaotic alignment", "any non-chaotic alignment", "construct", "(?:lawful|neutral|chaotic) (?:good|neutral|evil) \\(\\d+%\\) or (?:lawful|neutral|chaotic) (?:good|neutral|evil) \\(\\d+%\\)" ], "bare": true } ] }, { "name": "attributes", "type": "unorderedContent", "flatten": true, "contentModel": [ { "name": "AC", "parseToken": "armor class", "pattern": "\\d+\\s*(?:\\([^)]*\\))?", "type": "string" }, { "name": "HP", "parseToken": "hit points", "type": "string", "pattern": "\\d+(?:\\s?\\(\\s?\\d+d\\d+(?:\\s?[-+]\\s?\\d+)?\\s?\\))?" }, { "name": "speed", "minOccurs": 0, "type": "string", "pattern": "^\\d+\\s?ft[\\.]?(,\\s?(fly|swim|burrow|climb)\\s\\d+\\s?ft[\\.]?)*(\\s?\\([^\\)]+\\))?$" }, { "name": "strength", "parseToken": "str", "type": "ability" }, { "name": "dexterity", "parseToken": "dex", "type": "ability" }, { "name": "constitution", "parseToken": "con", "type": "ability" }, { "name": "intelligence", "parseToken": "int", "type": "ability" }, { "name": "wisdom", "parseToken": "wis", "type": "ability" }, { "name": "charisma", "parseToken": "cha", "type": "ability" }, { "name": "savingThrows", "minOccurs": 0, "parseToken": "saving throws", "type": "string", "pattern": "(?:(?:^|,\\s*)(?:Str|Dex|Con|Int|Wis|Cha)\\s+[\\-\\+]\\d+)+" }, { "name": "skills", "minOccurs": 0, "type": "string", "pattern": "(?:(?:^|,\\s*)(?:Acrobatics|Animal Handling|Arcana|Athletics|Deception|History|Insight|Intimidation|Investigation|Medicine|Nature|Perception|Performance|Persuasion|Religion|Sleight of Hand|Stealth|Survival)\\s+[\\-\\+]\\d+)+" }, { "minOccurs": 0, "type": "string", "name": "damageVulnerabilities", "parseToken": "damage vulnerabilities" }, { "minOccurs": 0, "type": "string", "name": "damageResistances", "parseToken": "damage resistances" }, { "minOccurs": 0, "type": "string", "name": "damageImmunities", "parseToken": "damage immunities" }, { "minOccurs": 0, "type": "string", "name": "conditionImmunities", "parseToken": "condition immunities" }, { "name": "senses", "type": "string", "minOccurs": 0, "pattern": "(?:(?:^|,\\s*)(?:blindsight|darkvision|tremorsense|truesight)\\s+\\d+\\s*ft\\.?(?: or \\d+ ft\\. while deafened)?(?:\\s?\\([^\\)]+\\))?)+" }, { "name": "passivePerception", "parseToken": ",?\\s*passive Perception", "minOccurs": 0, "type": "number", "skipOutput": true }, { "name": "spells", "minOccurs": 0, "type": "string" }, { "name": "languages", "minOccurs": 0, "type": "string" } ] }, { "name": "challenge", "type": "string", "pattern": "^\\s*(\\d+(?:\\s*\\/\\s*\\d)?)\\s*(?:\\(\\s*[\\d,]+\\s*XP\\s*\\)\\s*)?$", "matchGroup": 1 }, { "name": "traitSection", "type": "orderedContent", "minOccurs": 0, "maxOccurs": 1, "flatten": true, "contentModel": [ { "name": "traits", "type": "orderedContent", "minOccurs": 1, "maxOccurs": "Infinity", "contentModel": [ { "name": "name", "type": "string", "pattern": "(^|.*?[a-z]\\.\\s?)((?:[A-Z][\\w\\-']+[,:!]?|A)(?:\\s(?:[A-Z][\\w\\-']+[,:!]?|of|to|in|the|with|and|or|a)+)*)(\\s?\\([^\\)]+\\))?\\.(?!$)", "matchGroup": 2, "forPreviousMatchGroup": 1, "forNextMatchGroup": 3, "bare": true, "caseSensitive": true }, { "name": "recharge", "type": "string", "pattern": "^\\(([^\\)]+)\\)", "bare": true, "matchGroup": 1, "minOccurs": 0 }, { "name": "text", "bare": true, "type": "string" } ] } ] }, { "name": "actionSection", "type": "orderedContent", "minOccurs": 0, "maxOccurs": 1, "flatten": true, "contentModel": [ { "name": "actionHeader", "type": "heading", "bare": true, "pattern": "^Actions$" }, { "name": "actions", "type": "orderedContent", "minOccurs": 1, "maxOccurs": "Infinity", "contentModel": [ { "name": "name", "type": "string", "pattern": "(^|.*?[a-z]\\.\\s?)((?:\\d+\\.\\s?)?(?:[A-Z][\\w\\-']+[,:!]?|A)(?:\\s(?:[A-Z][\\w\\-']+[,:!]?|of|in|to|with|the|and|or|a|\\+\\d+)+)*)(\\s?\\([^\\)]+\\))?\\.(?!$)", "matchGroup": 2, "forPreviousMatchGroup": 1, "forNextMatchGroup": 3, "bare": true, "caseSensitive": true }, { "name": "recharge", "type": "string", "bare": true, "pattern": "^\\(([^\\)]+)\\)", "matchGroup": 1, "minOccurs": 0 }, { "name": "text", "bare": true, "type": "string" } ] } ] }, { "name": "reactionSection", "type": "orderedContent", "minOccurs": 0, "maxOccurs": 1, "flatten": true, "contentModel": [ { "name": "reactionHeader", "type": "heading", "bare": true, "pattern": "^Reactions$" }, { "name": "reactions", "type": "orderedContent", "minOccurs": 1, "maxOccurs": "Infinity", "contentModel": [ { "name": "name", "type": "string", "pattern": "(^|.*?[a-z]\\.\\s?)((?:[A-Z][\\w\\-']+[,:!]?|A)(?:\\s(?:[A-Z][\\w\\-']+[,:!]?|of|in|to|with|the|and|or|a)+)*)(\\s?\\([^\\)]+\\))?\\.(?!$)", "matchGroup": 2, "forPreviousMatchGroup": 1, "forNextMatchGroup": 3, "bare": true, "caseSensitive": true }, { "name": "recharge", "type": "string", "bare": true, "pattern": "^\\(([^\\)]+)\\)", "matchGroup": 1, "minOccurs": 0 }, { "name": "text", "bare": true, "type": "string" } ] } ] }, { "name": "legendaryActionSection", "type": "orderedContent", "minOccurs": 0, "maxOccurs": 1, "flatten": true, "contentModel": [ { "name": "actionHeader", "type": "heading", "bare": true, "pattern": "^Legendary Actions$" }, { "name": "legendaryPoints", "type": "number", "bare": true, "pattern": "^The[ \\w]+can take (\\d+) legendary actions.*?start of its turn[.]?", "matchGroup": 1 }, { "name": "legendaryActions", "type": "orderedContent", "minOccurs": 1, "maxOccurs": "Infinity", "contentModel": [ { "name": "name", "type": "string", "bare": true, "pattern": "(^|.*?[a-z]\\.\\s?)((?:[A-Z][\\w\\-']+[,:!]?|A)(?:\\s(?:[A-Z][\\w\\-']+[,:!]?|of|with|to|the|and|or|a)+)*)(\\s?\\([^\\)]+\\))?\\.(?!$)", "matchGroup": 2, "forPreviousMatchGroup": 1, "forNextMatchGroup": 3, "caseSensitive": true }, { "name": "cost", "type": "number", "bare": true, "pattern": "^\\s*\\(\\s*costs (\\d+) actions\\s*\\)", "matchGroup": 1, "minOccurs": 0 }, { "name": "text", "bare": true, "type": "string" } ] } ] }, { "name": "lairActionSection", "type": "orderedContent", "minOccurs": 0, "maxOccurs": 1, "flatten": true, "contentModel": [ { "name": "actionHeader", "type": "heading", "bare": true, "pattern": "^Lair Actions$" }, { "name": "lairActions", "type": "string", "bare": true, "minOccurs": 1, "maxOccurs": "Infinity" } ] }, { "name": "regionalEffectsSection", "type": "orderedContent", "minOccurs": 0, "maxOccurs": 1, "flatten": true, "contentModel": [ { "name": "actionHeader", "type": "heading", "bare": true, "pattern": "^Regional Effects$" }, { "name": "regionalEffects", "type": "string", "minOccurs": 1, "maxOccurs": "Infinity", "bare": true }, { "name": "regionalEffectsFade", "type": "string", "bare": true } ] } ] }; /***/ }, /* 5 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); function stringify(object) { if (object === undefined) { return object; } return typeof object === 'string' ? object : JSON.stringify(object, (key, value) => (key !== 'logWrap' && key !== 'isLogWrapped' ? value : undefined) ); } function wrapFunctions(object, moduleName, makeWrapper) { const funcs = getAllFuncs(object); return _.each(funcs, funcName => (object[funcName] = makeWrapper(funcName, object[funcName], moduleName))); } function getAllFuncs(obj) { let props = []; let current = obj; do { props = props.concat(Object.getOwnPropertyNames(current)); } while ((current = Object.getPrototypeOf(current)) && current !== Object.prototype); return props.sort().filter((e, i, arr) => (e !== arr[i + 1] && typeof obj[e] === 'function')); } module.exports = class Logger { constructor(loggerName, roll20) { this.prefixString = ''; const state = roll20.getState('roll20-logger'); state[loggerName] = state[loggerName] || Logger.levels.INFO; roll20.on('chat:message', (msg) => { if (msg.type === 'api' && msg.content.startsWith('!logLevel')) { const parts = msg.content.split(/\s/); if (parts.length > 2) { if (!state[parts[1]]) { roll20.sendChat('Logger', `Unrecognised logger name ${parts[1]}`); return; } state[parts[1]] = Logger.levels[parts[2].toUpperCase()] || Logger.levels.INFO; } } }); function shouldLog(level) { return level <= state[loggerName]; } function outputLog(level, message) { if (!shouldLog(level)) { return; } const args = arguments.length > 2 ? _.toArray(arguments).slice(2) : []; let processedMessage = stringify(message); if (processedMessage) { processedMessage = processedMessage.replace(/\$\$\$/g, () => stringify(args.shift())); } // noinspection NodeModulesDependencies roll20.log(`${loggerName} ${Date.now()} ` + `${Logger.getLabel(level)} : ${shouldLog(Logger.levels.TRACE) ? this.prefixString : ''}` + `${processedMessage}`); } _.each(Logger.levels, (level, levelName) => { this[levelName.toLowerCase()] = _.partial(outputLog, level); }); this.wrapModule = function wrapModule(modToWrap) { if (shouldLog(Logger.levels.TRACE)) { wrapFunctions(modToWrap, modToWrap.logWrap, this.wrapFunction.bind(this)); modToWrap.isLogWrapped = true; } return modToWrap; }; this.getLogTap = function getLogTap(level, messageString) { return _.partial(outputLog, level, messageString); }; this.wrapFunction = function wrapFunction(name, func, moduleName) { if (shouldLog(Logger.levels.TRACE)) { if (name === 'toJSON' || moduleName === 'roll20' && name === 'log') { return func; } const logger = this; return function functionWrapper() { logger.trace('$$$.$$$ starting with this value: $$$ and args $$$', moduleName, name, this, arguments); logger.prefixString = `${logger.prefixString} `; const retVal = func.apply(this, arguments); logger.prefixString = logger.prefixString.slice(0, -2); logger.trace('$$$.$$$ ending with return value $$$', moduleName, name, retVal); if (retVal && retVal.logWrap && !retVal.isLogWrapped) { logger.wrapModule(retVal); } return retVal; }; } return func; }; } static getLabel(logLevel) { const logPair = _.chain(Logger.levels).pairs().find(pair => pair[1] === logLevel).value(); return logPair ? logPair[0] : 'UNKNOWN'; } static get levels() { return { OFF: 0, ERROR: 1, WARN: 2, INFO: 3, DEBUG: 4, TRACE: 5, }; } }; /***/ }, /* 6 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); const utils = __webpack_require__(7); module.exports = class EntityLookup { constructor() { this.entities = {}; this.noWhiteSpaceEntities = {}; this.entityProcessors = {}; this.versionCheckers = {}; } configureEntity(entityName, processors, versionChecker) { this.entities[entityName] = {}; this.noWhiteSpaceEntities[entityName] = {}; this.entityProcessors[entityName] = processors || []; this.versionCheckers[entityName] = versionChecker || _.constant(true); } addEntities(entitiesObject) { const results = { errors: [], }; _.chain(entitiesObject) .omit('version', 'patch') .each((entityArray, type) => { results[type] = { withErrors: [], skipped: [], deleted: [], patched: [], added: [], }; if (!this.entities[type]) { results.errors.push({ entity: 'general', errors: [`Unrecognised entity type ${type}`] }); return; } if (!this.versionCheckers[type](entitiesObject.version, results.errors)) { return; } _.each(entityArray, (entity) => { let key = entity.name.toLowerCase(); let operation = !!this.entities[type][key] ? (entitiesObject.patch ? 'patched' : 'skipped') : 'added'; if (operation === 'patched') { entity = patchEntity(this.entities[type][key], entity); if (!entity) { operation = 'deleted'; delete this.entities[type][key]; delete this.noWhiteSpaceEntities[type][key.replace(/\s+/g, '')]; } } if (_.contains(['patched', 'added'], operation)) { const processed = _.reduce(this.entityProcessors[type], utils.executor, { entity, type, version: entitiesObject.version, errors: [], }); if (!_.isEmpty(processed.errors)) { processed.entity = processed.entity.name; results.errors.push(processed); operation = 'withErrors'; } else { if (processed.entity.name.toLowerCase() !== key) { results[type].deleted.push(key); delete this.entities[type][key]; delete this.noWhiteSpaceEntities[type][key.replace(/\s+/g, '')]; key = processed.entity.name.toLowerCase(); } this.entities[type][key] = processed.entity; this.noWhiteSpaceEntities[type][key.replace(/\s+/g, '')] = processed.entity; } } results[type][operation].push(key); }); }); return results; } findEntity(type, name, tryWithoutWhitespace) { const key = name.toLowerCase(); if (!this.entities[type]) { throw new Error(`Unrecognised entity type ${type}`); } let found = this.entities[type][key]; if (!found && tryWithoutWhitespace) { found = this.noWhiteSpaceEntities[type][key.replace(/\s+/g, '')]; } return found && utils.deepClone(found); } searchEntities(type, criteria) { function containsSomeIgnoreCase(array, testValues) { testValues = (_.isArray(testValues) ? testValues : [testValues]).map(s => s.toLowerCase()); return !!_.chain(array) .map(s => s.toLowerCase()) .intersection(testValues) .value().length; } return _.reduce(criteria, (results, criterionValue, criterionField) => { const re = new RegExp(criterionValue, 'i'); const matcher = (entity) => { const value = entity[criterionField]; switch (typeof value) { case 'string': return value.match(re); case 'boolean': case 'number': return value === criterionValue; case 'object': return _.isArray(value) && containsSomeIgnoreCase(value, criterionValue); default: return false; } }; return results.filter(matcher); }, this.getAll(type)); } getAll(type) { if (!this.entities[type]) { throw new Error(`Unrecognised entity type: ${type}`); } return utils.deepClone(_.values(this.entities[type])); } /** * Gets all of the keys for the specified entity type * @param {string} type - The entity type to retrieve keys for (either 'monster' or 'spell') * @param {boolean} sort - True if the returned array should be sorted alphabetically; false otherwise * @function * @public * @name EntityLookup#getKeys * @return {Array} An array containing all keys for the specified entity type */ getKeys(type, sort) { if (!this.entities[type]) { throw new Error(`Unrecognised entity type: ${type}`); } const keys = _.keys(this.entities[type]); if (sort) { keys.sort(); } return keys; } /** * Gets spell hydrator * @function * @public * @name EntityLookup#getSpellHydrator * @return {function} */ getSpellHydrator() { const self = this; return function spellHydrator(monsterInfo) { const monster = monsterInfo.entity; if (monster.spells) { const spellArray = monster.spells.split(', '); monster.spells = _.map(spellArray, spellName => self.findEntity('spells', spellName) || spellName); } return monsterInfo; }; } getMonsterSpellUpdater() { const self = this; return function spellUpdater(spellInfo) { const spell = spellInfo.entity; _.chain(self.entities.monsters) .pluck('spells') .compact() .each(spellArray => { const spellIndex = _.findIndex(spellArray, monsterSpell => { if (typeof monsterSpell === 'string') { return monsterSpell.toLowerCase() === spell.name.toLowerCase(); } return monsterSpell !== spell && monsterSpell.name.toLowerCase() === spell.name.toLowerCase(); }); if (spellIndex !== -1) { spellArray[spellIndex] = spell; } }); return spellInfo; }; } toJSON() { return { monsterCount: _.size(this.entities.monsters), spellCount: _.size(this.entities.spells) }; } get logWrap() { return 'entityLookup'; } static jsonValidatorAsEntityProcessor(jsonValidator) { return function jsonValidatorEntityProcessor(entityInfo) { const wrapper = { version: entityInfo.version, }; wrapper[entityInfo.type] = [entityInfo.entity]; const errors = jsonValidator.validate(wrapper); const flattenedErrors = _.chain(errors).values().flatten().value(); entityInfo.errors = entityInfo.errors.concat(flattenedErrors); return entityInfo; }; } static jsonValidatorAsVersionChecker(jsonValidator) { return EntityLookup.getVersionChecker(jsonValidator.getVersionNumber()); } static getVersionChecker(requiredVersion) { function pruneToMinor(versionString) { return versionString.split('.', 2).join('.'); } return function versionChecker(version, errorsArray) { const prunedVersion = pruneToMinor(version); const prunedRequiredVersion = pruneToMinor(requiredVersion); const valid = prunedVersion === prunedRequiredVersion; if (!valid) { errorsArray.push({ entity: 'general', errors: [`Incorrect entity objects version: [${version}]. Required is: ${requiredVersion}`], }); } return valid; }; } }; function patchEntity(original, patch) { if (patch.remove) { return undefined; } return _.mapObject(original, (propVal, propName) => { if (propName === 'name' && patch.newName) { return patch.newName; } return patch[propName] || propVal; }); } /***/ }, /* 7 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); // noinspection JSUnusedGlobalSymbols module.exports = { deepExtend(original, newValues) { if (!original) { original = _.isArray(newValues) ? [] : {}; } _.each(newValues, (value, key) => { if (_.isArray(original[key])) { if (!_.isArray(value)) { original[key].push(value); } else { original[key] = _.map(value, (item, index) => { if (_.isObject(item)) { return this.deepExtend(original[key][index], item); } return item !== undefined ? item : original[key][index]; }); } } else if (_.isObject(original[key])) { original[key] = this.deepExtend(original[key], value); } else { original[key] = value; } }); return original; }, createObjectFromPath(pathString, value) { const newObject = {}; _.reduce(pathString.split(/\./), (object, pathPart, index, pathParts) => { const match = pathPart.match(/([^.\[]*)(?:\[(\d+)\])?/); const newVal = index === pathParts.length - 1 ? value : {}; if (match[2]) { object[match[1]] = []; object[match[1]][match[2]] = newVal; } else { object[match[1]] = newVal; } return newVal; }, newObject); return newObject; }, getObjectFromPath(obj, path) { path = path.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties path = path.replace(/^\./, ''); // strip a leading dot path.split('.').every(segment => (obj = obj[segment])); return obj; }, deepClone(object) { return JSON.parse(JSON.stringify(object)); }, executor() { switch (arguments.length) { case 0: return undefined; case 1: return arguments[0](); default: // Fall through } const args = Array.apply(null, arguments).slice(2); args.unshift(arguments[0]); return arguments[1].apply(null, args); }, /** * Gets a string as 'Title Case' capitalizing the first letter of each word (i.e. 'the grapes of wrath' -> 'The * Grapes Of Wrath') * @param {string} s - The string to be converted * @return {string} the supplied string in title case */ toTitleCase(s) { return s.replace(/\w\S*/g, txt => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()); }, toOptionsString(options) { let res = options.join(' --'); if (res.length > 0) { res = `--${res}`; } return res; }, /** * Calculates a contrasting color using YIQ luma value * @param {string} hexcolor - the color to calculate a contrasting color for * @return {string} either 'white' or 'black' as determined to be the best contrasting text color for the input color */ getContrastYIQ(hexcolor) { hexcolor = hexcolor.replace('#', ''); if (hexcolor.length === 3) { hexcolor += hexcolor; } const r = parseInt(hexcolor.substr(0, 2), 16); const g = parseInt(hexcolor.substr(2, 2), 16); const b = parseInt(hexcolor.substr(4, 2), 16); const yiq = (r * 299 + g * 587 + b * 114) / 1000; return yiq >= 128 ? 'black' : 'white'; }, /** * Builds an html element as a string using the specified options * @param {string} tag - the html tag type * @param innerHtml - can be a string to be used as the element inner html, or a {tag, innerHtml, attrs} object * in order to build a child html element string * @param attrs - a collection of attributes and their values to be applied to the html element * @return {string} the full html element as a string */ buildHTML(tag, innerHtml, attrs) { if (typeof innerHtml === 'object') { innerHtml = _.map(innerHtml, html => html && this.buildHTML(html.tag, html.innerHtml, html.attrs)).join(''); } let h = `<${tag}`; h += _.chain(attrs) .map((attrVal, attrName) => attrVal !== false && ` ${attrName}="${attrVal}"`) .compact() .value() .join(''); if (innerHtml) { h += `>${innerHtml}`; } else { h += '/>'; } return h; }, missingParam(name) { throw new Error(`Parameter ${name} is required`); }, flattenObject(object) { return _.reduce(object, (explodedProps, propVal, propKey) => { if (_.isObject(propVal)) { return _.extend(explodedProps, this.flattenObject(propVal)); } explodedProps[propKey] = propVal; return explodedProps; }, {}); }, }; /***/ }, /* 8 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); // noinspection JSUnusedGlobalSymbols const validatorFactories = { orderedContent(spec) { return makeContentModelValidator(spec); }, unorderedContent(spec) { return makeContentModelValidator(spec); }, string(spec) { if (spec.pattern) { if (spec.matchGroup) { return regExValidator(spec.name, extractRegexPart(spec.pattern, spec.matchGroup), spec.caseSensitive); } return regExValidator(spec.name, spec.pattern, spec.caseSensitive); } return function noop() { }; }, enumType(spec) { return function enumValidator(value, errors) { if (!_.some(spec.enumValues, enumVal => new RegExp(`^${enumVal}$`, 'i').test(value))) { errors.add(`Value "${value}" should have been one of [${spec.enumValues.join(',')}]`); } }; }, ability(spec) { return regExValidator(spec.name, '\\d+'); }, heading() { // Do not replace with _.noop, these functions get annotated return function noop() { }; }, number() { return function numberValidator(value, errors) { if (typeof value !== 'number') { errors.add(`Value "${value}" should have been a number`); } }; }, }; function extractRegexPart(regexp, matchIndex) { let braceCount = 0; let startIndex = _.findIndex(regexp, (character, index) => { if (character === '(' && (index < 2 || regexp[index - 1] !== '\\') && regexp[index + 1] !== '?') { return ++braceCount === matchIndex; } return false; }); if (startIndex === -1) { throw new Error(`Can\'t find matchgroup ${matchIndex} in regular expression ${regexp}`); } // Lose the bracket startIndex++; let openCount = 1; const endIndex = _.findIndex(regexp.slice(startIndex), (character, index, regexpPart) => { if (character === '(' && regexpPart[index - 1] !== '\\') { openCount++; } if (character === ')' && regexpPart[index - 1] !== '\\') { return --openCount === 0; } return false; }); if (endIndex === -1) { throw new Error(`matchgroup ${matchIndex} seems not to have closing brace in regular expression ${regexp}`); } return regexp.slice(startIndex, startIndex + endIndex); } function regExValidator(fieldName, regexp, caseSensitive) { const re = new RegExp(`^${regexp}$`, caseSensitive ? undefined : 'i'); return function regexpValidate(value, errors) { if (!re.test(value)) { errors.add(`Value "${value}" doesn\'t match pattern /${regexp}/`); } }; } function makeValidator(spec) { const validator = validatorFactories[spec.type](spec); validator.max = _.isUndefined(spec.maxOccurs) ? 1 : spec.maxOccurs; validator.min = _.isUndefined(spec.minOccurs) ? 1 : spec.minOccurs; validator.fieldName = spec.name; return validator; } function makeContentModelValidator(spec) { const parts = _.chain(spec.contentModel) .reject({ type: 'heading' }) .partition({ flatten: true }) .value(); const flattened = _.map(parts[0], makeValidator); const subValidators = _.reduce(parts[1], (subVals, field) => { subVals[field.name] = makeValidator(field); return subVals; }, {}); return function contentModelValidator(object, errors, isFlatten) { let completed = _.reduce(object, (completedMemo, fieldValue, fieldName) => { const validator = subValidators[fieldName]; if (validator) { completedMemo.push(fieldName); errors.pushPath(fieldName); if (_.isArray(fieldValue)) { if (fieldValue.length > validator.max) { errors.add(`Number of entries [${fieldValue.length}] exceeds maximum allowed: ${validator.max}`); } else if (fieldValue.length < validator.min) { errors.add(`Number of entries [${fieldValue.length}] is less than minimum allowed: ${validator.min}`); } else { _.each(fieldValue, (arrayItem, index) => { errors.pushIndex(arrayItem.name ? arrayItem.name : index); validator(arrayItem, errors); errors.popIndex(); }); } } else { validator(fieldValue, errors); } errors.popPath(); } return completedMemo; }, []); let toValidate = _.omit(object, completed); _.chain(flattened) .map(validator => { const subCompleted = validator(toValidate, errors, true); if (subCompleted.length === 0) { return validator; } completed = completed.concat(subCompleted); toValidate = _.omit(toValidate, completed); return null; }) .compact() .each(validator => { if (validator.min > 0) { errors.pushPath(validator.fieldName); errors.add('Section is missing'); errors.popPath(); } }); // If we're a flattened validator (our content is injected directly into the parent content model) // Then we should only report missing fields if there was some match in our content model - otherwise // the parent content model will check the cardinality of this model as a whole if (!isFlatten || !_.isEmpty(completed)) { _.chain(subValidators) .omit(completed) .each(validator => { if (validator.min > 0) { errors.pushPath(validator.fieldName); errors.add('Field is missing'); errors.popPath(); } }); } // Flattened content models shouldn't check for unrecognised fields since they're only parsing // part of the current content model. if (!isFlatten) { _.chain(object) .omit(completed) .each((value, key) => { errors.pushPath(key); errors.add('Unrecognised field'); errors.popPath(); }); } return completed; }; } class Errors { constructor() { this.errors = []; this.currentPath = []; } pushPath(path) { this.currentPath.push(path); } popPath() { return this.currentPath.pop(); } pushIndex(index) { this.currentPath[this.currentPath.length - 1] = `${this.currentPath[this.currentPath.length - 1]}[${index}]`; } popIndex() { this.currentPath[this.currentPath.length - 1] = this.currentPath[this.currentPath.length - 1].replace(/\[[^\]]+\]/, ''); } add(msg) { this.errors.push({ msg, path: _.clone(this.currentPath) }); } getErrors() { return _.chain(this.errors) .groupBy(error => error.path[0]) .mapObject(errorList => _.map(errorList, error => `${error.path.slice(1).join('.')}: ${error.msg}`) ) .value(); } } module.exports = class JSONValidator { constructor(spec) { this.versionProp = { type: 'string', name: 'version', }; this.spec = spec; this.contentValidator = makeValidator({ type: 'unorderedContent', contentModel: [spec, this.versionProp] }); } validate(object) { const errors = new Errors(); this.contentValidator(object, errors); return errors.getErrors(); } getVersionNumber() { return this.spec.formatVersion; } }; /***/ }, /* 9 */ /***/ function(module, exports) { 'use strict'; function makeNormalMessage(scriptName, heading, text) { return '
    ' + '
    ' + `${scriptName} ${heading}
    ${text}
    `; } function makeErrorMessage(scriptName, text) { return '
    ' + '
    ' + `${scriptName}
    ${text}
    `; } class Reporter { constructor(roll20, scriptName) { this.roll20 = roll20; this.scriptName = scriptName; } setPlayer(playerId) { this.playerId = playerId; } reportPublic(heading, text) { // Horrible bug with this at the moment - seems to generate spurious chat // messages when noarchive:true is set // sendChat('ShapedScripts', '' + msg, null, {noarchive:true}); this.roll20.sendChat('', `${makeNormalMessage(this.scriptName, heading, text)}`); } reportPlayer(heading, text) { this.roll20.sendChat('', `/w ${this.getPlayerName()} ${makeNormalMessage(this.scriptName, heading, text)}`); } reportError(text) { this.roll20.sendChat('', `/w ${this.getPlayerName()} ${makeErrorMessage(this.scriptName, text)}`); } getPlayerName() { return this.playerId ? this.roll20.getObj('player', this.playerId).get('displayname').split(/ /)[0] : 'gm'; } } module.exports = Reporter; /***/ }, /* 10 */ /***/ function(module, exports, __webpack_require__) { /* globals unescape */ 'use strict'; const _ = __webpack_require__(2); const parseModule = __webpack_require__(3); const makeCommandProc = __webpack_require__(11); const utils = __webpack_require__(7); const UserError = __webpack_require__(12); const Migrator = __webpack_require__(14); const ShapedConfig = __webpack_require__(15); // Modules const AbilityMaker = __webpack_require__(16); const ConfigUI = __webpack_require__(17); const AdvantageTracker = __webpack_require__(18); const RestManager = __webpack_require__(19); const TraitManager = __webpack_require__(20); const AmmoManager = __webpack_require__(21); /** * @typedef {Object} ChatMessage * @property {string} content * @property {string} type * @property {SelectedItem[]} selected * @property {string} rolltemplate */ /** * * @typedef {Object} SelectedItem * @property {string} _id * @property (string _type */ module.exports = ShapedScripts; function ShapedScripts(logger, myState, roll20, parser, entityLookup, reporter, srdConverter, sanitise, mpp) { let addedTokenIds = []; const reportPublic = reporter.reportPublic.bind(reporter); const reportPlayer = reporter.reportPlayer.bind(reporter); const reportError = reporter.reportError.bind(reporter); const commandProc = makeCommandProc('shaped', roll20); const chatWatchers = []; const advantageTracker = new AdvantageTracker(); const traitManager = new TraitManager(); const ammoManager = new AmmoManager(); const abilityMaker = new AbilityMaker(); const modules = [ abilityMaker, new ConfigUI(), advantageTracker, traitManager, new RestManager(), ammoManager, ]; modules.forEach(module => module.configure(roll20, reporter, logger, myState, commandProc)); /** * * @param {ChatMessage} msg */ this.handleInput = function handleInput(msg) { logger.debug(msg); reporter.setPlayer(msg.playerid); if (msg.type !== 'api') { this.triggerChatWatchers(msg); return; } this.oldStyleModulesConfigure(commandProc).processCommand(msg); }; ///////////////////////////////////////// // Command handlers ///////////////////////////////////////// this.applyTokenDefaults = function applyTokenDefaults(options) { _.each(options.selected.graphic, token => { const represents = token.get('represents'); const character = roll20.getObj('character', represents); if (character) { this.applyCharacterDefaults(character); this.getTokenConfigurer(token)(character); } }); }; this.importStatblock = function importStatblock(options) { logger.info('Importing statblocks for tokens $$$', options.selected.graphic); _.each(options.selected.graphic, token => { const error = `Could not find GM notes on either selected token ${token.get('name')} or the character ` + 'it represents. Have you pasted it in correctly?'; const text = token.get('gmnotes'); if (!text) { const char = roll20.getObj('character', token.get('represents')); if (char) { char.get('gmnotes', notes => { if (notes) { return this.processGMNotes(options, token, notes); } return reportError(error); }); } else { return reportError(error); } } return this.processGMNotes(options, token, text); }); }; this.processGMNotes = function processGMNotes(options, token, text) { text = sanitise(unescape(text), logger); const monsters = parser.parse(text).monsters; mpp(monsters, entityLookup); this.importMonsters(monsters, options, token, [ function gmNotesSetter(character) { character.set('gmnotes', text.replace(/\n/g, '
    ')); }, ]); }; this.importMonstersFromJson = function importMonstersFromJson(options) { if (options.all) { options.monsters = entityLookup.getAll('monsters'); delete options.all; } if (_.isEmpty(options.monsters)) { this.showEntityPicker('monster', 'monsters'); } else { this.importMonsters(options.monsters.slice(0, 20), options, options.selected.graphic, []); options.monsters = options.monsters.slice(20); if (!_.isEmpty(options.monsters)) { setTimeout(() => this.importMonstersFromJson(options), 200); } } }; this.importMonsters = function importMonsters(monsters, options, token, characterProcessors) { const characterRetrievalStrategies = []; if (token) { characterProcessors.push(this.getAvatarCopier(token).bind(this)); if (_.size(monsters) === 1) { characterProcessors.push(this.getTokenConfigurer(token).bind(this)); characterProcessors.push(this.getTokenVisionConfigurer(token, monsters[0].senses)); if (options.replace || options.overwrite) { characterRetrievalStrategies.push(this.getTokenRetrievalStrategy(token).bind(this)); } } } if (options.replace) { characterRetrievalStrategies.push(this.nameRetrievalStrategy); } characterRetrievalStrategies.push(this.creationRetrievalStrategy.bind(this)); characterProcessors.push(this.monsterDataPopulator.bind(this)); const errors = []; const importedList = _.chain(monsters) .map(monsterData => { const character = _.reduce(characterRetrievalStrategies, (result, strategy) => result || strategy(monsterData.name, errors), null); if (!character) { logger.error('Failed to find or create character for monster $$$', monsterData.name); return null; } const oldAttrs = roll20.findObjs({ type: 'attribute', characterid: character.id }); _.invoke(oldAttrs, 'remove'); character.set('name', monsterData.name); _.each(characterProcessors, proc => proc(character, monsterData)); return character && character.get('name'); }) .compact() .value(); if (!_.isEmpty(importedList)) { const monsterList = importedList.join('
  • '); reportPlayer('Import Success', `Added the following monsters: `); } if (!_.isEmpty(errors)) { const errorList = errors.join('
  • '); reportError(`The following errors occurred on import: `); } }; this.importSpellsFromJson = function importSpellsFromJson(options) { if (_.isEmpty(options.spells)) { this.showEntityPicker('spell', 'spells'); } else { this.addSpellsToCharacter(options.selected.character, options.spells); } }; this.importSpellListFromJson = function importSpellListFromJson(options) { const spells = entityLookup.searchEntities('spells', _.pick(options, _.keys(ShapedConfig.spellSearchOptions))); const newOpts = _.omit(options, _.keys(ShapedConfig.spellSearchOptions)); newOpts.spells = spells; this.importSpellsFromJson(newOpts); }; this.getEntityCriteriaAdaptor = function getEntityCriteriaAdaptor(entityType) { return function entityCriteriaAdaptor(criterionOption, options) { const result = entityLookup.searchEntities(entityType, criterionOption, options[entityType]); if (result) { // If we get a result, wipe the existing list so that the new one replaces it options[entityType] = []; } return result; }; }; this.showEntityPicker = function showEntityPicker(entityName, entityNamePlural) { const list = entityLookup.getKeys(entityNamePlural, true); if (!_.isEmpty(list)) { // title case the names for better display list.forEach((part, index) => (list[index] = utils.toTitleCase(part))); // create a clickable button with a roll query to select an entity from the loaded json reportPlayer(`${utils.toTitleCase(entityName)} Importer`, `Click to select a ` + `${entityName}`); } else { reportError(`Could not find any ${entityNamePlural}.
    Please ensure you have a properly formatted ` + `${entityNamePlural} json file.`); } }; this.addSpellsToCharacter = function addSpellsToCharacter(character, spells, noreport) { const gender = roll20.getAttrByName(character.id, 'gender'); const defaultIndex = Math.min(myState.config.defaultGenderIndex, myState.config.genderPronouns.length); const defaultPronounInfo = myState.config.genderPronouns[defaultIndex]; const pronounInfo = _.clone(_.find(myState.config.genderPronouns, pronounDetails => new RegExp(pronounDetails.matchPattern, 'i').test(gender)) || defaultPronounInfo); _.defaults(pronounInfo, defaultPronounInfo); const importData = { spells: srdConverter.convertSpells(spells, pronounInfo), }; this.getImportDataWrapper(character).mergeImportData(importData); if (!noreport) { reportPlayer('Import Success', 'Added the following spells: `); } }; this.monsterDataPopulator = function monsterDataPopulator(character, monsterData) { this.applyCharacterDefaults(character); const converted = srdConverter.convertMonster(monsterData); logger.debug('Converted monster data: $$$', converted); const expandedSpells = converted.spells; delete converted.spells; this.getImportDataWrapper(character).setNewImportData({ npc: converted }); if (expandedSpells) { this.addSpellsToCharacter(character, expandedSpells, true); } return character; }; this.getTokenRetrievalStrategy = function getTokenRetrievalStrategy(token) { return function tokenRetrievalStrategy(name, errors) { if (token) { const character = roll20.getObj('character', token.get('represents')); if (character && roll20.getAttrByName(character.id, 'locked')) { errors.push(`Character with name ${character.get('name')} and id ${character.id}` + ' was locked and cannot be overwritten'); return null; } return character; } return null; }; }; this.nameRetrievalStrategy = function nameRetrievalStrategy(name, errors) { const chars = roll20.findObjs({ type: 'character', name }); if (chars.length > 1) { errors.push(`More than one existing character found with name "${name}". Can\'t replace`); return null; } if (chars[0] && roll20.getAttrByName(chars[0].id, 'locked')) { errors.push(`Character with name ${chars[0].get('name')} and id ${chars[0].id}` + ' was locked and cannot be overwritten'); return null; } return chars[0]; }; this.creationRetrievalStrategy = function creationRetrievalStrategy(name, errors) { if (!_.isEmpty(roll20.findObjs({ type: 'character', name }))) { errors.push(`Can\'t create new character with name "${name}` + '" because one already exists with that name. Perhaps you want --replace?'); return null; } return roll20.createObj('character', { name }); }; this.getAvatarCopier = function getAvatarCopier(token) { return function avatarCopier(character) { character.set('avatar', token.get('imgsrc')); }; }; this.applyCharacterDefaults = function applyCharacterDefaults(character) { _.each(utils.flattenObject(_.omit(myState.config.newCharSettings, 'tokenActions')), (value, key) => { const attribute = roll20.getOrCreateAttr(character.id, ShapedConfig.configToAttributeLookup[key] || key); if (value === '***default***' || (_.isBoolean(value) && !value)) { attribute.remove(); } else { attribute.set('current', _.isBoolean(value) ? 'on' : value); } }); const abilityNames = _.chain(myState.config.newCharSettings.tokenActions) .map((action, actionName) => (action === true ? actionName : action)) .compact() .values(); abilityMaker.addAbilitiesByName(abilityNames, character); }; this.getTokenConfigurer = function getTokenConfigurer(token) { return function tokenConfigurer(character) { token.set('represents', character.id); token.set('name', character.get('name')); const settings = myState.config.tokenSettings; if (settings.number && token.get('name').indexOf('%%NUMBERED%%') === -1) { token.set('name', `${token.get('name')} %%NUMBERED%%`); } _.chain(settings) .pick(['bar1', 'bar2', 'bar3']) .each((bar, barName) => { if (!_.isEmpty(bar.attribute)) { const attribute = roll20.getOrCreateAttr(character.id, bar.attribute); if (attribute) { token.set(`${barName}_value`, attribute.get('current')); if (bar.max) { token.set(`${barName}_max`, attribute.get('max')); } token.set(`showplayers_${barName}`, bar.showPlayers); if (bar.link) { token.set(`${barName}_link`, attribute.id); } } } }); // auras _.chain(settings) .pick(['aura1', 'aura2']) .each((aura, auraName) => { token.set(`${auraName}_radius`, aura.radius); token.set(`${auraName}_color`, aura.color); token.set(`${auraName}_square`, aura.square); }); logger.debug('Settings for tokens: $$$', settings); token.set('showname', settings.showName); token.set('showplayers_name', settings.showNameToPlayers); token.set('showplayers_aura1', settings.showAura1ToPlayers); token.set('showplayers_aura2', settings.showAura2ToPlayers); token.set('light_radius', settings.light.radius); token.set('light_dimradius', settings.light.dimRadius); token.set('light_otherplayers', settings.light.otherPlayers); token.set('light_hassight', settings.light.hasSight); token.set('light_angle', settings.light.angle); token.set('light_losangle', settings.light.losAngle); token.set('light_multiplier', settings.light.multiplier); }; }; this.getTokenVisionConfigurer = function getTokenVisionConfigurer(token, sensesString) { if (_.isEmpty(sensesString)) { return _.noop; } function fullRadiusLightConfigurer() { token.set('light_radius', Math.max(token.get('light_radius') || 0, this.lightRadius)); token.set('light_dimradius', Math.max(token.get('light_dimradius') || 0, this.lightRadius)); } function darkvisionLightConfigurer() { token.set('light_radius', Math.max(token.get('light_radius') || 0, this.lightRadius * 1.1666666)); if (!token.get('light_dimradius')) { token.set('light_dimradius', -5); } } const configureFunctions = { blindsight: fullRadiusLightConfigurer, truesight: fullRadiusLightConfigurer, tremorsense: fullRadiusLightConfigurer, darkvision: darkvisionLightConfigurer, }; const re = /(blindsight|darkvision|tremorsense|truesight)\s+(\d+)/; let match; const senses = []; while ((match = sensesString.match(re))) { senses.push({ name: match[1], lightRadius: parseInt(match[2], 10), configureVision: configureFunctions[match[1]], }); sensesString = sensesString.slice(match.index + match[0].length); } return function configureTokenVision() { senses.forEach(sense => sense.configureVision()); }; }; this.getImportDataWrapper = function getImportDataWrapper(character) { return { setNewImportData(importData) { if (_.isEmpty(importData)) { return; } roll20.setAttrByName(character.id, 'import_data', JSON.stringify(importData)); roll20.setAttrByName(character.id, 'import_data_present', 'on'); }, mergeImportData(importData) { if (_.isEmpty(importData)) { return undefined; } const attr = roll20.getOrCreateAttr(character.id, 'import_data'); const dataPresentAttr = roll20.getOrCreateAttr(character.id, 'import_data_present'); let current = {}; try { if (!_.isEmpty(attr.get('current').trim())) { current = JSON.parse(attr.get('current')); } } catch (e) { logger.warn('Existing import_data attribute value was not valid JSON: [$$$]', attr.get('current')); } _.each(importData, (value, key) => { const currentVal = current[key]; if (currentVal) { if (!_.isArray(currentVal)) { current[key] = [currentVal]; } current[key] = current[key].concat(value); } else { current[key] = value; } }); logger.debug('Setting import data to $$$', current); attr.set('current', JSON.stringify(current)); dataPresentAttr.set('current', 'on'); return current; }, logWrap: 'importDataWrapper', }; }; this.handleSlots = function handleSlots(options) { if (options.use) { roll20.processAttrValue(options.character.id, `spell_slots_l${options.use}`, val => Math.max(0, --val)); } if (options.restore) { const attrName = `spell_slots_l${options.restore}`; const max = roll20.getAttrByName(options.character.id, attrName, 'max'); roll20.processAttrValue(options.character.id, `spell_slots_l${options.restore}`, val => { const retVal = Math.min(max, ++val); logger.debug('Setting attribute to $$$', retVal); return retVal; }); } }; ///////////////////////////////////////////////// // Event Handlers ///////////////////////////////////////////////// this.handleAddToken = function handleAddToken(token) { const represents = token.get('represents'); if (_.isEmpty(represents)) { return; } const character = roll20.getObj('character', represents); if (!character) { return; } addedTokenIds.push(token.id); const wrappedChangeToken = this.wrapHandler(this.handleChangeToken); // URGH. Thanks Roll20. setTimeout((function wrapper(id) { return function innerWrapper() { const addedToken = roll20.getObj('graphic', id); if (addedToken) { wrappedChangeToken(addedToken); } }; /* eslint-disable no-spaced-func */ }(token.id)), 100); /* eslint-enable no-spaced-func */ }; this.handleChangeToken = function handleChangeToken(token) { if (_.contains(addedTokenIds, token.id)) { addedTokenIds = _.without(addedTokenIds, token.id); this.setTokenBarsOnDrop(token, true); advantageTracker.handleTokenChange(token); } }; this.handleAddCharacter = function handleAddCharacter(character) { this.applyCharacterDefaults(character); }; this.setTokenBarsOnDrop = function setTokenBarsOnDrop(token, overwrite) { const character = roll20.getObj('character', token.get('represents')); if (!character) { return; } function setBar(barName, bar, value) { if (value) { token.set(`${barName}_value`, value); if (bar.max) { token.set(`${barName}_max`, value); } } } _.chain(myState.config.tokenSettings) .pick('bar1', 'bar2', 'bar3') .each((bar, barName) => { if (bar.attribute && !token.get(`${barName}_link`) && (!token.get(`${barName}_value`) || overwrite)) { if (bar.attribute === 'HP' && myState.config.sheetEnhancements.rollHPOnDrop) { // Guard against characters that aren't properly configured - i.e. ones used for templates and system // things rather than actual characters if (_.isEmpty(roll20.getAttrByName(character.id, 'hp_formula'))) { logger.debug('Ignoring character $$$ for rolling HP - has no hp_formula attribute', character.get('name')); return; } roll20.sendChat('', `%{${character.get('name')}|shaped_npc_hp}`, results => { if (results && results.length === 1) { const message = this.processInlinerolls(results[0]); if (!results[0].inlinerolls || !results[0].inlinerolls[0]) { logger.warn('HP roll didn\'t have the expected structure. This is what we got back: $$$', results[0]); } else { roll20.sendChat('HP Roller', `/w GM &{template:5e-shaped} ${message}`); setBar(barName, bar, results[0].inlinerolls[0].results.total); } } }); } else { setBar(barName, bar, roll20.getAttrByName(character.id, bar.attribute)); } } }); }; this.registerChatWatcher = function registerChatWatcher(handler, triggerFields) { const matchers = []; if (triggerFields && !_.isEmpty(triggerFields)) { matchers.push((msg, options) => { logger.debug('Matching options: $$$ against triggerFields $$$', options, triggerFields); return _.intersection(triggerFields, _.keys(options)).length === triggerFields.length; }); } chatWatchers.push({ matchers, handler: handler.bind(this) }); }; this.triggerChatWatchers = function triggerChatWatchers(msg) { const options = this.getRollTemplateOptions(msg); logger.debug('Roll template options: $$$', options); _.each(chatWatchers, watcher => { if (_.every(watcher.matchers, matcher => matcher(msg, options))) { watcher.handler(options, msg); } }); }; /** * * @param options * @param {ChatMessage} msg */ this.handleAmmo = function handleAmmo(options, msg) { if (!roll20.checkCharacterFlag(options.character.id, 'ammo_auto_use')) { return; } ammoManager.consumeAmmo(options, msg); }; this.handleHD = function handleHD(options, msg) { const match = options.title.match(/(\d+)d(\d+) HIT_DICE/); if (match && myState.config.sheetEnhancements.autoHD) { const hdCount = parseInt(match[1], 10); const hdSize = match[2]; const hdAttr = roll20.getAttrObjectByName(options.character.id, `hd_d${hdSize}`); const hpAttr = roll20.getOrCreateAttr(options.character.id, 'HP'); const newHp = Math.min(parseInt(hpAttr.get('current') || 0, 10) + this.getRollValue(msg, options.roll1), hpAttr.get('max') || Infinity); if (hdAttr) { if (hdCount <= hdAttr.get('current')) { hdAttr.set('current', hdAttr.get('current') - hdCount); hpAttr.set('current', newHp); if (!hpAttr.get('max')) { hpAttr.set('max', newHp); } } else { reportPublic('HD Police', `${options.characterName} can't use ${hdCount}d${hdSize} hit dice because they ` + `only have ${hdAttr.get('current')} left`); } } } }; this.handleD20Roll = function handleD20Roll(options) { if (options.disadvantage || options.advantage) { const autoRevertOptions = roll20.getAttrByName(options.character.id, 'auto_revert_advantage'); if (autoRevertOptions === 'on') { advantageTracker.setRollOption('normal', [options.character]); } } }; this.handleTraitClick = function handleTraitClick(options) { if (myState.config.sheetEnhancements.autoTraits) { traitManager.handleTraitClick(options); } }; this.handleSpellCast = function handleSpellCast(options) { if (options.friendlyLevel === 'Cantrip' || !myState.config.sheetEnhancements.autoSpellSlots) { return; } const spellLevel = parseInt(options.friendlyLevel.slice(0, 1), 10); const level = options.castAsLevel ? parseInt(options.castAsLevel, 10) : spellLevel; const availableSlots = _.chain(_.range(spellLevel, 10)) .map(slotLevel => roll20.getAttrObjectByName(options.character.id, `spell_slots_l${slotLevel}`)) .compact() .filter(attr => attr.get('current') > 0) .value(); const spellId = _.chain(roll20.getRepeatingSectionItemIdsByName(options.character.id, 'spell')) .pick(options.title.toLowerCase()) .values() .first() .value(); logger.debug('Spell id : $$$', spellId); const ritual = spellId ? !!roll20.getAttrByName(options.character.id, `repeating_spell_${spellId}_ritual`) : false; let msg; const bestSlot = availableSlots .find(slot => parseInt(slot.get('name').match(/spell_slots_l(\d)/)[1], 10) === level) || _.first(availableSlots); if (bestSlot) { const slotLevel = parseInt(bestSlot.get('name').match(/spell_slots_l(\d)/)[1], 10); if (slotLevel === level) { bestSlot.set('current', bestSlot.get('current') - 1); if (ritual) { msg = `1 slot of level ${level} used. [Cast as ritual](!shaped-slots --restore ${level} ` + `--character ${options.character.id}) instead?`; } } else if (!options.castAsLevel) { msg = `You have no level ${level} spell slots left. Do you want to ` + `[Cast at level ${slotLevel}](!shaped-slots --use ${slotLevel} --character ${options.character.id}) instead?`; if (ritual) { msg += ' Alternatively, you could cast a ritual without using a slot.'; } } else { msg = `You have no ${level} spell slots left. You can ` + `[Recast at level ${slotLevel}](! %{${options.character.get('name')}` + `|repeating_spell_${spellId}_spell})`; if (ritual) { msg += ' or cast as a ritual instead.'; } } } else { msg = `${options.character.get('name')} has no spell slots of level ${level} to cast ${options.title}`; if (ritual) { msg += ' but could cast as a ritual instead without using a slot.'; } } if (msg) { roll20.sendChat('Spell Slots Police', msg); } }; this.handleDeathSave = function handleDeathSave(options, msg) { // TODO: Do we want to output text on death/recovery? function increment(val) { return ++val; } // TODO: Advantage? if (roll20.getAttrByName(options.character.id, 'roll_setting') !== '{{roll2=[[d20@{d20_mod}') { const result = this.getRollValue(msg, options.roll1); const attributeToIncrement = result >= 10 ? 'death_saving_throw_successes' : 'death_saving_throw_failures'; roll20.processAttrValue(options.character.id, attributeToIncrement, increment); } }; this.handleFX = function handleFX(options, msg) { const parts = options.fx.split(' '); if (parts.length < 2 || _.some(parts.slice(0, 2), _.isEmpty)) { logger.warn('FX roll template variable is not formated correctly: [$$$]', options.fx); return; } const fxType = parts[0]; const pointsOfOrigin = parts[1]; let targetTokenId; const sourceCoords = {}; const targetCoords = {}; let fxCoords = []; let pageId; // noinspection FallThroughInSwitchStatementJS switch (pointsOfOrigin) { case 'sourceToTarget': case 'source': targetTokenId = parts[2]; fxCoords.push(sourceCoords, targetCoords); break; case 'targetToSource': case 'target': targetTokenId = parts[2]; fxCoords.push(targetCoords, sourceCoords); break; default: throw new Error(`Unrecognised pointsOfOrigin type in fx spec: ${pointsOfOrigin}`); } if (targetTokenId) { const targetToken = roll20.getObj('graphic', targetTokenId); pageId = targetToken.get('_pageid'); targetCoords.x = targetToken.get('left'); targetCoords.y = targetToken.get('top'); } else { pageId = roll20.getCurrentPage(msg.playerid).id; } const casterTokens = roll20.findObjs({ type: 'graphic', pageid: pageId, represents: options.character.id }); if (casterTokens.length) { // If there are multiple tokens for the character on this page, then try and find one of them that is selected // This doesn't work without a selected token, and the only way we can get this is to use @{selected} which is a // pain for people who want to launch without a token selected if(casterTokens.length > 1) { const selected = // _.findWhere(casterTokens, {id: sourceTokenId}); if (selected) { casterTokens = [selected]; } } sourceCoords.x = casterTokens[0].get('left'); sourceCoords.y = casterTokens[0].get('top'); } if (!fxCoords[0]) { logger.warn('Couldn\'t find required point for fx for character $$$, casterTokens: $$$, fxSpec: $$$ ', options.character.id, casterTokens, options.fx); return; } else if (!fxCoords[1]) { fxCoords = fxCoords.slice(0, 1); } roll20.spawnFx(fxCoords, fxType, pageId); }; this.getRollValue = function getRollValue(msg, rollOutputExpr) { const rollIndex = rollOutputExpr.match(/\$\[\[(\d+)\]\]/)[1]; return msg.inlinerolls[rollIndex].results.total; }; /** * * @returns {*} */ this.getRollTemplateOptions = function getRollTemplateOptions(msg) { if (msg.rolltemplate === '5e-shaped') { const regex = /\{\{(.*?)\}\}/g; let match; const options = {}; while (!!(match = regex.exec(msg.content))) { if (match[1]) { const splitAttr = match[1].split('='); const propertyName = splitAttr[0].replace(/_([a-z])/g, (m, letter) => letter.toUpperCase()); options[propertyName] = splitAttr.length === 2 ? splitAttr[1].replace(/\^\{/, '') : ''; } } if (options.characterName) { options.character = roll20.findObjs({ _type: 'character', name: options.characterName, })[0]; } return options; } return {}; }; this.processInlinerolls = function processInlinerolls(msg) { if (_.has(msg, 'inlinerolls')) { return _.chain(msg.inlinerolls) .reduce((previous, current, index) => { previous[`$[[${index}]]`] = current.results.total || 0; return previous; }, {}) .reduce((previous, current, index) => previous.replace(index.toString(), current), msg.content) .value(); } return msg.content; }; // This method can go once all functions are moved into modules which handle their own configuration this.commandProcConfigured = false; this.oldStyleModulesConfigure = function oldStyleModulesConfigure(cp) { if (this.commandProcConfigured) { return cp; } this.commandProcConfigured = true; return cp // !shaped-import-statblock .addCommand('import-statblock', this.importStatblock.bind(this), true) .option('overwrite', ShapedConfig.booleanValidator) .option('replace', ShapedConfig.booleanValidator) .withSelection({ graphic: { min: 1, max: Infinity, }, }) // !shaped-import-monster , !shaped-monster .addCommand(['import-monster', 'monster'], this.importMonstersFromJson.bind(this), true) .option('all', ShapedConfig.booleanValidator) .optionLookup('monsters', _.partial(entityLookup.findEntity.bind(entityLookup), 'monsters', _, false)) .option('overwrite', ShapedConfig.booleanValidator) .option('replace', ShapedConfig.booleanValidator) .withSelection({ graphic: { min: 0, max: 1, }, }) // !shaped-import-spell, !shaped-spell .addCommand(['import-spell', 'spell'], this.importSpellsFromJson.bind(this), false) .optionLookup('spells', _.partial(entityLookup.findEntity.bind(entityLookup), 'spells', _, false)) .withSelection({ character: { min: 1, max: 1, }, }) // !shaped-import-spell-list .addCommand('import-spell-list', this.importSpellListFromJson.bind(this), false) .options(ShapedConfig.spellSearchOptions) .withSelection({ character: { min: 1, max: 1, }, }) // !shaped-token-defaults .addCommand(['token-defaults', 'apply-defaults'], this.applyTokenDefaults.bind(this), false) .withSelection({ graphic: { min: 1, max: Infinity, }, }) // !shaped-slots .addCommand('slots', this.handleSlots.bind(this), false) .option('character', ShapedConfig.getCharacterValidator(roll20), true) .option('use', ShapedConfig.integerValidator) .option('restore', ShapedConfig.integerValidator); }; this.checkInstall = function checkInstall() { logger.info('-=> ShapedScripts v4.1.0 <=-'); Migrator.migrateShapedConfig(myState, logger); }; this.wrapHandler = function wrapHandler(handler) { const self = this; return function handlerWrapper() { try { handler.apply(self, arguments); } catch (e) { if (typeof e === 'string' || e instanceof parseModule.ParserError || e instanceof UserError) { reportError(e); logger.error('Error: $$$', e.toString()); } else { logger.error(e.toString()); logger.error(e.stack); reportError('An error occurred. Please see the log for more details.'); } } finally { logger.prefixString = ''; } }; }; this.updateBarsForCharacterTokens = function updateBarsForCharacterTokens(curr) { roll20.findObjs({ type: 'graphic', represents: curr.get('characterid') }) .forEach(token => this.setTokenBarsOnDrop(token, false)); }; this.getAttributeChangeHandler = function getAttributeChangeHandler(attributeName) { const handlers = { roll_setting: advantageTracker.handleRollOptionChange.bind(advantageTracker), }; _.chain(myState.config.tokenSettings) .pick('bar1', 'bar2', 'bar3') .pluck('attribute') .each(attrName => { if (attrName === 'HP') { attrName = 'hp_formula'; } handlers[attrName] = this.updateBarsForCharacterTokens.bind(this); }); return handlers[attributeName]; }; this.registerEventHandlers = function registerEventHandlers() { roll20.on('chat:message', this.wrapHandler(this.handleInput)); roll20.on('add:token', this.wrapHandler(this.handleAddToken)); roll20.on('change:token', this.wrapHandler(this.handleChangeToken)); roll20.on('change:attribute', this.wrapHandler((curr, prev) => { const handler = this.getAttributeChangeHandler(curr.get('name')); if (handler) { handler(curr, prev); } })); roll20.on('add:character', this.wrapHandler(this.handleAddCharacter)); this.registerChatWatcher(this.handleDeathSave, ['deathSavingThrow', 'character', 'roll1']); this.registerChatWatcher(this.handleAmmo, ['ammoName', 'character']); this.registerChatWatcher(this.handleFX, ['fx', 'character']); this.registerChatWatcher(this.handleHD, ['character', 'title']); this.registerChatWatcher(this.handleD20Roll, ['character', 'roll1']); this.registerChatWatcher(this.handleTraitClick, ['character', 'trait']); // this.registerChatWatcher(this.handleSpellCast, ['character', 'spell', 'friendlyLevel']); }; logger.wrapModule(this); } ShapedScripts.prototype.logWrap = 'ShapedScripts'; /***/ }, /* 11 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); const utils = __webpack_require__(7); const UserError = __webpack_require__(12); const ShapedModule = __webpack_require__(13); function getParser(optionString, validator) { return function parseOptions(arg, errors, options) { const argParts = arg.split(/\s+/); if (argParts[0].toLowerCase() === optionString.toLowerCase()) { const value = argParts.length === 1 ? true : argParts.slice(1).join(' '); const result = validator(value); if (result.valid) { options[argParts[0]] = result.converted; } else { errors.push(`Invalid value [${value}] for option [${argParts[0]}]`); } return true; } return false; }; } function getObjectParser(specObject) { return function parseOptions(arg, errors, options) { const argParts = arg.split(/\s+/); const newObject = utils.createObjectFromPath(argParts[0], argParts.slice(1).join(' ')); const comparison = { spec: specObject, actual: newObject }; while (comparison.spec) { const key = _.keys(comparison.actual)[0]; const spec = comparison.spec[key]; if (!spec) { return false; } if (_.isFunction(comparison.spec[key])) { const result = comparison.spec[key](comparison.actual[key]); if (result.valid) { comparison.actual[key] = result.converted; utils.deepExtend(options, newObject); } else { errors.push(`Invalid value [${comparison.actual[key]}] for option [${argParts[0]}]`); } return true; } else if (_.isArray(comparison.actual[key])) { const newVal = []; newVal[comparison.actual[key].length - 1] = comparison.spec[key][0]; comparison.spec = newVal; comparison.actual = comparison.actual[key]; } else { comparison.spec = comparison.spec[key]; comparison.actual = comparison.actual[key]; } } return false; }; } /** * @constructor */ class Command { constructor(root, handler, roll20, name, gmOnly) { this.root = root; this.handler = handler; this.parsers = []; this.roll20 = roll20; this.name = name; this.gmOnly = gmOnly; } option(optionString, validator, required) { let parser; if (_.isFunction(validator)) { parser = getParser(optionString, validator); } else if (_.isObject(validator)) { const dummy = {}; dummy[optionString] = validator; parser = getObjectParser(dummy); } else { throw new Error(`Bad validator [${validator}] specified for option ${optionString}`); } parser.required = required; parser.optName = optionString; this.parsers.push(parser); return this; } options(optsSpec) { _.each(optsSpec, (validator, key) => this.option(key, validator)); return this; } optionLookup(groupName, lookup, required) { if (typeof lookup !== 'function') { lookup = _.propertyOf(lookup); } const parser = (arg, errors, options) => { const singleResolved = lookup(arg, options); if (singleResolved) { options[groupName] = options[groupName] || []; options[groupName].push.apply(options[groupName], _.isArray(singleResolved) ? singleResolved : [singleResolved]); return true; } const results = _.chain(arg.split(',')) .map(_.partial(_.result, _, 'trim')) .uniq() .reduce((memo, name) => { const resolvedPart = lookup(name, options); if (resolvedPart) { memo.resolved.push(resolvedPart); } else { memo.errors.push(`Unrecognised item ${name} for option group ${groupName}`); } return memo; }, { errors: [], resolved: [] }) .value(); if (!_.isEmpty(results.resolved)) { options[groupName] = results.resolved; errors.push.apply(errors, results.errors); return true; } return false; }; parser.optName = groupName; parser.required = required; this.parsers.push(parser); return this; } handle(args, selection, cmdString, playerIsGM, playerId) { if (!playerIsGM && this.gmOnly) { throw new UserError('You must be a GM to run this command'); } const caches = {}; const startOptions = { errors: [], }; Object.defineProperty(startOptions, 'getCache', { enumerable: false, value: function getCache(key) { return (caches[key] = caches[key] || {}); }, }); startOptions.selected = this.selectionSpec && processSelection(selection || [], this.selectionSpec, this.roll20); const finalOptions = _.chain(args) .reduce((options, arg) => { if (!_.some(this.parsers, parser => parser(arg, options.errors, options))) { options.errors.push(`Unrecognised or poorly formed option ${arg}`); } return options; }, startOptions) .each((propVal, propName, obj) => { // NB Cannot use omit or it will remove the getCache property if (_.isUndefined(propVal)) { delete obj[propName]; } }) .value(); if (finalOptions.errors.length > 0) { throw finalOptions.errors.join('\n'); } delete finalOptions.errors; const missingOpts = _.chain(this.parsers) .where({ required: true }) .pluck('optName') .difference(_.keys(finalOptions)) .value(); if (!_.isEmpty(missingOpts)) { throw new UserError(`Command ${cmdString} was missing options: [${missingOpts.join(',')}]`); } if (finalOptions.character && !playerIsGM) { finalOptions.character.forEach(character => { if (!character.controlledby.contains(playerId) && !character.controlledby.contains('all')) { throw new UserError(`You do not have permission to make changes to character ${character.get('name')}`); } }); } this.handler(finalOptions); } withSelection(selectionSpec) { this.selectionSpec = selectionSpec; return this; } addCommand() { return this.root.addCommand.apply(this.root, arguments); } addModule() { return this.root.addModule.apply(this.root, arguments); } processCommand() { return this.root.processCommand.apply(this.root, arguments); } get logWrap() { return `command [${this.name}]`; } } function processSelection(selection, constraints, roll20) { return _.reduce(constraints, (result, constraintDetails, type) => { const objects = _.chain(selection) .where({ _type: type === 'character' ? 'graphic' : type }) .map(selected => roll20.getObj(selected._type, selected._id)) .map(object => { if (type === 'character' && object) { return roll20.getObj('character', object.get('represents')); } return object; }) .compact() .uniq() .value(); if (_.size(objects) < constraintDetails.min || _.size(objects) > constraintDetails.max) { throw new UserError(`Wrong number of objects of type [${type}] selected, should be between ` + `${constraintDetails.min} and ${constraintDetails.max}`); } switch (_.size(objects)) { case 0: break; case 1: if (constraintDetails.max === 1) { result[type] = objects[0]; } else { result[type] = objects; } break; default: result[type] = objects; } return result; }, {}); } module.exports = function commandParser(rootCommand, roll20) { const commands = {}; return { addCommand(cmds, handler, gmOnly) { const command = new Command(this, handler, roll20, _.isArray(cmds) ? cmds.join(',') : cmds, gmOnly); (_.isArray(cmds) ? cmds : [cmds]).forEach(cmdString => (commands[cmdString] = command)); return command; }, addModule(module) { if (!module instanceof ShapedModule) { throw new Error('Can only pass ShapedModules to addModule'); } return module.configure(this); }, processCommand(msg) { const prefix = `!${rootCommand}-`; if (msg.type === 'api' && msg.content.indexOf(prefix) === 0) { const cmdString = msg.content.slice(prefix.length); const parts = cmdString.split(/\s+--/); const cmdName = parts.shift(); const cmd = commands[cmdName]; if (!cmd) { throw new UserError(`Unrecognised command ${prefix}${cmdName}`); } cmd.handle(parts, msg.selected, `${prefix}${cmdName}`, roll20.playerIsGM(msg.playerid), msg.playerid); } }, logWrap: 'commandParser', }; }; /***/ }, /* 12 */ /***/ function(module, exports) { 'use strict'; class UserError extends Error { constructor(message) { super(); this.message = message; } } module.exports = UserError; /***/ }, /* 13 */ /***/ function(module, exports) { 'use strict'; module.exports = class ShapedModule { configure(roll20, reporter, logger, myState, commandProcessor) { this.roll20 = roll20; this.reporter = reporter; this.logger = logger; this.myState = myState; this.addCommands(commandProcessor); logger.wrapModule(this); } addCommands(/* commandProcessor */) { throw new Error('Subclasses must implement addCommands'); } reportPublic(heading, text) { this.reporter.reportPublic(heading, text); } reportPlayer(heading, text) { this.reporter.reportPlayer(heading, text); } reportError(text) { this.reporter.reportError(text); } get logWrap() { return this.constructor.name; } }; /***/ }, /* 14 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); const utils = __webpack_require__(7); const oneSixConfig = { logLevel: 'INFO', tokenSettings: { number: false, bar1: { attribute: 'HP', max: true, link: false, showPlayers: false, }, bar2: { attribute: 'speed', max: false, link: true, showPlayers: false, }, bar3: { attribute: '', max: false, link: false, showPlayers: false, }, aura1: { radius: '', color: '#FFFF99', square: false, }, aura2: { radius: '', color: '#59e594', square: false, }, light: { radius: '', dimRadius: '', otherPlayers: false, hasSight: false, angle: 360, losAngle: 360, multiplier: 1, }, showName: true, showNameToPlayers: false, showAura1ToPlayers: true, showAura2ToPlayers: true, }, newCharSettings: { sheetOutput: '@{output_to_all}', deathSaveOutput: '@{output_to_all}', initiativeOutput: '@{output_to_all}', showNameOnRollTemplate: '@{show_character_name_yes}', rollOptions: '@{normal}', initiativeRoll: '@{normal_initiative}', initiativeToTracker: '@{initiative_to_tracker_yes}', breakInitiativeTies: '@{initiative_tie_breaker_var}', showTargetAC: '@{attacks_vs_target_ac_no}', showTargetName: '@{attacks_vs_target_name_no}', autoAmmo: '@{ammo_auto_use_var}', autoRevertAdvantage: false, houserules: { savingThrowsHalfProf: false, mediumArmorMaxDex: 2, }, }, advTrackerSettings: { showMarkers: false, ignoreNpcs: false, advantageMarker: 'green', disadvantageMarker: 'red', output: 'silent', }, sheetEnhancements: { rollHPOnDrop: true, autoHD: true, autoSpellSlots: true, }, genderPronouns: [ { matchPattern: '^f$|female|girl|woman|feminine', nominative: 'she', accusative: 'her', possessive: 'her', reflexive: 'herself', }, { matchPattern: '^m$|male|boy|man|masculine', nominative: 'he', accusative: 'him', possessive: 'his', reflexive: 'himself', }, { matchPattern: '^n$|neuter|none|construct|thing|object', nominative: 'it', accusative: 'it', possessive: 'its', reflexive: 'itself', }, ], defaultGenderIndex: 2, }; class Migrator { constructor(startVersion) { this._versions = [{ version: startVersion || 0.1, migrations: [] }]; } skipToVersion(version) { this._versions.push({ version, migrations: [] }); return this; } nextVersion() { const currentVersion = this._versions.slice(-1)[0].version; const nextVersion = (currentVersion * 10 + 1) / 10; // Avoid FP errors this._versions.push({ version: nextVersion, migrations: [] }); return this; } addProperty(path, value) { const expandedProperty = utils.createObjectFromPath(path, value); return this.transformConfig((config) => utils.deepExtend(config, expandedProperty), `Adding property ${path} with value ${value}`); } overwriteProperty(path, value) { return this.transformConfig((config) => { const parts = path.split('.'); const obj = parts.length > 1 ? utils.getObjectFromPath(config, parts.slice(0, -1).join('.')) : config; obj[parts.slice(-1)[0]] = value; return config; }, `Overwriting property ${path} with value ${JSON.stringify(value)}`); } copyProperty(oldPath, newPath) { return this.transformConfig(Migrator.propertyCopy.bind(null, oldPath, newPath), `Copying property from ${oldPath} to ${newPath}`); } static propertyCopy(oldPath, newPath, config) { const oldVal = utils.getObjectFromPath(config, oldPath); if (!_.isUndefined(oldVal)) { const expandedProperty = utils.createObjectFromPath(newPath, oldVal); utils.deepExtend(config, expandedProperty); } return config; } static propertyDelete(path, config) { const parts = path.split('.'); const obj = parts.length > 1 ? utils.getObjectFromPath(config, parts.slice(0, -1).join('.')) : config; if (obj && !_.isUndefined(obj[parts.slice(-1)[0]])) { delete obj[parts.slice(-1)[0]]; } return config; } deleteProperty(propertyPath) { return this.transformConfig(Migrator.propertyDelete.bind(null, propertyPath), `Deleting property ${propertyPath} from config`); } moveProperty(oldPath, newPath) { return this.transformConfig((config) => { config = Migrator.propertyCopy(oldPath, newPath, config); return Migrator.propertyDelete(oldPath, config); }, `Moving property from ${oldPath} to ${newPath}`); } transformConfig(transformer, message) { const lastVersion = this._versions.slice(-1)[0]; lastVersion.migrations.push({ transformer, message }); return this; } migrateConfig(state, logger) { logger.info('Checking config for upgrade, starting state: $$$', state); if (_.isEmpty(state)) { // working with a fresh install here state.version = 0; } if (!this._versions.find(version => version.version >= state.version)) { throw new Error(`Unrecognised schema state ${state.version} - cannot upgrade.`); } return this._versions .filter(version => version.version > state.version) .reduce((versionResult, version) => { logger.info('Upgrading schema to version $$$', version.version); versionResult = version.migrations.reduce((result, migration) => { logger.info(migration.message); return migration.transformer(result); }, versionResult); versionResult.version = version.version; logger.info('Post-upgrade state: $$$', versionResult); return versionResult; }, state); } } const migrator = new Migrator() .addProperty('config', {}) .skipToVersion(0.4) .overwriteProperty('config.genderPronouns', utils.deepClone(oneSixConfig).genderPronouns) .skipToVersion(1.2) .moveProperty('config.autoHD', 'config.sheetEnhancements.autoHD') .moveProperty('config.rollHPOnDrop', 'config.sheetEnhancements.rollHPOnDrop') .skipToVersion(1.4) .moveProperty('config.newCharSettings.savingThrowsHalfProf', 'config.newCharSettings.houserules.savingThrowsHalfProf') .moveProperty('config.newCharSettings.mediumArmorMaxDex', 'config.newCharSettings.houserules.mediumArmorMaxDex') .skipToVersion(1.6) .transformConfig(state => { _.defaults(state.config, oneSixConfig); _.defaults(state.config.tokenSettings, oneSixConfig.tokenSettings); _.defaults(state.config.newCharSettings, oneSixConfig.newCharSettings); _.defaults(state.config.advTrackerSettings, oneSixConfig.advTrackerSettings); _.defaults(state.config.sheetEnhancements, oneSixConfig.sheetEnhancements); return state; }, 'Applying defaults as at schema version 1.6') // 1.7 // add base houserules and variants section // add sheetEnhancements.autoTraits .nextVersion() .addProperty('config.variants', { rests: { longNoHpFullHd: false, }, }) .addProperty('config.sheetEnhancements.autoTraits', true) // 1.8 // Set tokens to have vision by default so that people see the auto-generated stuff based on senses .nextVersion() .overwriteProperty('config.tokenSettings.light.hasSight', true) // 1.9 Add default tab setting .nextVersion() .addProperty('config.newCharSettings.tab', 'core') // 2.0 Add default token actions .nextVersion() .addProperty('config.newCharSettings.tokenActions', { initiative: false, abilityChecks: null, advantageTracker: null, savingThrows: null, attacks: null, statblock: false, traits: null, actions: null, reactions: null, legendaryActions: null, lairActions: null, regionalEffects: null, rests: false, }) // 2.1 Add spells token action .nextVersion() .addProperty('config.newCharSettings.tokenActions.spells', false) // 2.2 Changes to support new roll behaviour in sheet 4.2.1 .nextVersion() .overwriteProperty('config.newCharSettings.sheetOutput', '') .overwriteProperty('config.newCharSettings.deathSaveOutput', '') .overwriteProperty('config.newCharSettings.initiativeOutput', '') .overwriteProperty('config.newCharSettings.showNameOnRollTemplate', '') .overwriteProperty('config.newCharSettings.rollOptions', '') .overwriteProperty('config.newCharSettings.initiativeRoll', '') .overwriteProperty('config.newCharSettings.initiativeToTracker', '') .overwriteProperty('config.newCharSettings.breakInitiativeTies', '') .overwriteProperty('config.newCharSettings.showTargetAC', '') .overwriteProperty('config.newCharSettings.showTargetName', '') .overwriteProperty('config.newCharSettings.autoAmmo', '1') // 2.3 Remove "small" macros .nextVersion() .transformConfig(config => { _.each(config.config.newCharSettings.tokenActions, (value, key) => { if (typeof value === 'string' && value.match('.*Small$')) { config.config.newCharSettings.tokenActions[key] = value.replace(/Small$/, ''); } }); return config; }, 'Removing "small" macros') .addProperty('config.newCharSettings.textSizes', { spellsTextSize: 'text', abilityChecksTextSize: 'text', savingThrowsTextSize: 'text', }) // 2.4 Don't set default values for sheet options to save on attribute bloat .nextVersion() .transformConfig(config => { const ncs = config.config.newCharSettings; const defaults = { sheetOutput: '', deathSaveOutput: '', initiativeOutput: '', showNameOnRollTemplate: '', rollOptions: '{{ignore=[[0', initiativeRoll: '@{shaped_d20}', initiativeToTracker: '@{selected|initiative_formula} &{tracker}', breakInitiativeTies: '', showTargetAC: '', showTargetName: '', autoAmmo: '', tab: 'core', }; _.each(defaults, (defaultVal, key) => { if (ncs[key] === defaultVal) { ncs[key] = '***default***'; } }); if (ncs.houserules.mediumArmorMaxDex === '2') { ncs.houserules.mediumArmorMaxDex = '***default***'; } ['spellsTextSize', 'abilityChecksTextSize', 'savingThrowsTextSize'].forEach(prop => { if (ncs.textSizes[prop] === 'text_big') { ncs.textSizes[prop] = '***default***'; } }); return config; }, 'Removing default values') // 2.5 Custom saving throws .nextVersion() .addProperty('config.newCharSettings.houserules.saves', { useCustomSaves: '***default***', useAverageOfAbilities: '***default***', fortitude: { fortitudeStrength: '***default***', fortitudeDexterity: '***default***', fortitudeConstitution: '***default***', fortitudeIntelligence: '***default***', fortitudeWisdom: '***default***', fortitudeCharisma: '***default***', }, reflex: { reflexStrength: '***default***', reflexDexterity: '***default***', reflexConstitution: '***default***', reflexIntelligence: '***default***', reflexWisdom: '***default***', reflexCharisma: '***default***', }, will: { willStrength: '***default***', willDexterity: '***default***', willConstitution: '***default***', willIntelligence: '***default***', willWisdom: '***default***', willCharisma: '***default***', }, }) .moveProperty('config.newCharSettings.houserules.savingThrowsHalfProf', 'config.newCharSettings.houserules.saves.savingThrowsHalfProf') .addProperty('config.newCharSettings.houserules.baseDC', '***default***'); Migrator.migrateShapedConfig = migrator.migrateConfig.bind(migrator); module.exports = Migrator; /***/ }, /* 15 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); module.exports = class ShapedConfig { static get configToAttributeLookup() { const lookup = { sheetOutput: 'output_option', deathSaveOutput: 'death_save_output_option', initiativeOutput: 'initiative_output_option', showNameOnRollTemplate: 'show_character_name', rollOptions: 'roll_setting', initiativeRoll: 'initiative_roll', initiativeToTracker: 'initiative_to_tracker', breakInitiativeTies: 'initiative_tie_breaker', showTargetAC: 'attacks_vs_target_ac', showTargetName: 'attacks_vs_target_name', autoAmmo: 'ammo_auto_use', autoRevertAdvantage: 'auto_revert_advantage', savingThrowsHalfProf: 'saving_throws_half_proficiency', mediumArmorMaxDex: 'medium_armor_max_dex', spellsTextSize: 'spells_text_size', abilityChecksTextSize: 'ability_checks_text_size', savingThrowsTextSize: 'saving_throws_text_size', baseDC: 'base_dc', useCustomSaves: 'use_custom_saving_throws', useAverageOfAbilities: 'average_of_abilities', }; ['fortitude', 'reflex', 'will'].forEach(save => { ['Strength', 'Dexterity', 'Constitution', 'Intelligence', 'Wisdom', 'Charisma'].forEach(ability => { lookup[`${save}${ability}`] = `${save}_${ability.toLowerCase()}`; }); }); return lookup; } static booleanValidator(value) { const converted = value === 'true' || (value === 'false' ? false : value); return { valid: typeof value === 'boolean' || value === 'true' || value === 'false', converted, }; } static stringValidator(value) { return { valid: true, converted: value, }; } static arrayValidator(value) { return { valid: true, converted: value.split(',').map(s => s.trim()), }; } static getOptionList(options) { return function optionList(value) { if (value === undefined) { return options; } return { converted: options[value], valid: options[value] !== undefined, }; }; } static getBooleanOptionList() { return this.getOptionList({ true: 1, false: '***default***', }); } static integerValidator(value) { const parsed = parseInt(value, 10); return { converted: parsed, valid: !isNaN(parsed), }; } static colorValidator(value) { return { converted: value, valid: /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(value), }; } static get sheetOutputValidator() { return this.getOptionList({ public: '***default***', whisper: '/w GM', }); } static get textSizeValidator() { return this.getOptionList({ normal: 'text', big: '***default***', }); } static get commandOutputValidator() { return this.getOptionList({ public: 'public', whisper: 'whisper', silent: 'silent', }); } static get statusMarkerValidator() { return this.getOptionList(ShapedConfig.validStatusMarkers()); } static get barValidator() { return { attribute: this.stringValidator, max: this.booleanValidator, link: this.booleanValidator, showPlayers: this.booleanValidator, }; } static get auraValidator() { return { radius: this.stringValidator, color: this.colorValidator, square: this.booleanValidator, }; } static get lightValidator() { return { radius: this.stringValidator, dimRadius: this.stringValidator, otherPlayers: this.booleanValidator, hasSight: this.booleanValidator, angle: this.integerValidator, losAngle: this.integerValidator, multiplier: this.integerValidator, }; } static getCharacterValidator(roll20) { return function characterValidator(value) { const char = roll20.getObj('character', value); return { converted: char, valid: !!char, }; }; } static get spellSearchOptions() { return { classes: this.arrayValidator, domains: this.arrayValidator, oaths: this.arrayValidator, patrons: this.arrayValidator, school: this.stringValidator, level: this.integerValidator, }; } static regExpValidator(value) { try { new RegExp(value, 'i').test(''); return { converted: value, valid: true, }; } catch (e) { return { converted: null, valid: false, }; } } static get configOptionsSpec() { return { tokenSettings: { number: this.booleanValidator, bar1: this.barValidator, bar2: this.barValidator, bar3: this.barValidator, aura1: this.auraValidator, aura2: this.auraValidator, light: this.lightValidator, showName: this.booleanValidator, showNameToPlayers: this.booleanValidator, showAura1ToPlayers: this.booleanValidator, showAura2ToPlayers: this.booleanValidator, }, newCharSettings: { sheetOutput: this.sheetOutputValidator, deathSaveOutput: this.sheetOutputValidator, initiativeOutput: this.sheetOutputValidator, showNameOnRollTemplate: this.getOptionList({ true: '/w GM', false: '***default***', }), rollOptions: this.getOptionList({ normal: '***default***', advantage: 'adv {{ignore=[[0', disadvantage: 'dis {{ignore=[[0', two: '{{roll2=[[d20@{d20_mod}', }), initiativeRoll: this.getOptionList({ normal: '***default***', advantage: '2d20@{d20_mod}kh1', disadvantage: '2d20@{d20_mod}kl1', }), initiativeToTracker: this.getOptionList({ true: '***default***', false: '@{initiative_formula}', }), breakInitiativeTies: this.getOptionList({ true: '[[@{initiative} / 100]][tie breaker]', false: '***default***', }), showTargetAC: this.getOptionList({ true: '[[@{target|AC}]]', false: '***default***', }), showTargetName: this.getOptionList({ true: '@{target|token_name}', false: '***default***', }), autoAmmo: this.getOptionList({ true: '1', false: '***default***', }), autoRevertAdvantage: this.booleanValidator, houserules: { baseDC: this.getOptionList(_.range(0, 21).reduce((result, val) => { result[val] = val === 8 ? '***default***' : val; return result; }, {})), mediumArmorMaxDex: this.getOptionList(_.range(0, 11).reduce((result, val) => { result[val] = val === 2 ? '***default***' : val; return result; }, {})), saves: { savingThrowsHalfProf: this.booleanValidator, useCustomSaves: this.getBooleanOptionList(), useAverageOfAbilities: this.getBooleanOptionList(), fortitude: { fortitudeStrength: this.getBooleanOptionList(), fortitudeDexterity: this.getBooleanOptionList(), fortitudeConstitution: this.getBooleanOptionList(), fortitudeIntelligence: this.getBooleanOptionList(), fortitudeWisdom: this.getBooleanOptionList(), fortitudeCharisma: this.getBooleanOptionList(), }, reflex: { reflexStrength: this.getBooleanOptionList(), reflexDexterity: this.getBooleanOptionList(), reflexConstitution: this.getBooleanOptionList(), reflexIntelligence: this.getBooleanOptionList(), reflexWisdom: this.getBooleanOptionList(), reflexCharisma: this.getBooleanOptionList(), }, will: { willStrength: this.getBooleanOptionList(), willDexterity: this.getBooleanOptionList(), willConstitution: this.getBooleanOptionList(), willIntelligence: this.getBooleanOptionList(), willWisdom: this.getBooleanOptionList(), willCharisma: this.getBooleanOptionList(), }, }, }, tab: this.getOptionList({ core: '***default***', spells: 'spells', equipment: 'equipment', character: 'character', settings: 'settings', all: 'all', }), tokenActions: { initiative: this.booleanValidator, abilityChecks: this.getOptionList({ none: null, query: 'abilityChecksQuery', chatWindow: 'abilityChecks', queryShort: 'abilChecksQuery', chatWindowShort: 'abilChecks', }), advantageTracker: this.getOptionList({ none: null, normal: 'advantageTracker', query: 'advantageTrackerQuery', }), savingThrows: this.getOptionList({ none: null, query: 'savingThrowsQuery', chatWindow: 'savingThrows', queryShort: 'savesQuery', chatWindowShort: 'saves', }), attacks: this.getOptionList({ none: null, individualActions: 'attacks', chatWindow: 'attacksMacro', }), statblock: this.booleanValidator, traits: this.getOptionList({ none: null, individualActions: 'traits', chatWindow: 'traitsMacro', }), actions: this.getOptionList({ none: null, individualActions: 'actions', chatWindow: 'actionsMacro', }), spells: this.booleanValidator, reactions: this.getOptionList({ none: null, individualActions: 'reactions', chatWindow: 'reactionsMacro', }), legendaryActions: this.getOptionList({ none: null, individualActions: 'legendaryActions', chatWindow: 'legendaryActionsMacro', chatWindowShort: 'legendaryA', }), lairActions: this.getOptionList({ none: null, chatWindow: 'lairActions', chatWindowShort: 'lairA', }), regionalEffects: this.getOptionList({ none: null, chatWindow: 'regionalEffects', chatWindowShort: 'regionalE', }), rests: this.booleanValidator, }, textSizes: { spellsTextSize: this.textSizeValidator, abilityChecksTextSize: this.textSizeValidator, savingThrowsTextSize: this.textSizeValidator, }, }, advTrackerSettings: { showMarkers: this.booleanValidator, ignoreNpcs: this.booleanValidator, advantageMarker: this.statusMarkerValidator, disadvantageMarker: this.statusMarkerValidator, output: this.commandOutputValidator, }, sheetEnhancements: { rollHPOnDrop: this.booleanValidator, autoHD: this.booleanValidator, autoSpellSlots: this.booleanValidator, autoTraits: this.booleanValidator, }, genderPronouns: [ { matchPattern: this.regExpValidator, nominative: this.stringValidator, accusative: this.stringValidator, possessive: this.stringValidator, reflexive: this.stringValidator, }, ], defaultGenderIndex: this.integerValidator, variants: { rests: { longNoHpFullHd: this.booleanValidator, }, }, }; } static validStatusMarkers() { const markers = [ 'red', 'blue', 'green', 'brown', 'purple', 'pink', 'yellow', 'dead', 'skull', 'sleepy', 'half-heart', 'half-haze', 'interdiction', 'snail', 'lightning-helix', 'spanner', 'chained-heart', 'chemical-bolt', 'death-zone', 'drink-me', 'edge-crack', 'ninja-mask', 'stopwatch', 'fishing-net', 'overdrive', 'strong', 'fist', 'padlock', 'three-leaves', 'fluffy-wing', 'pummeled', 'tread', 'arrowed', 'aura', 'back-pain', 'black-flag', 'bleeding-eye', 'bolt-shield', 'broken-heart', 'cobweb', 'broken-shield', 'flying-flag', 'radioactive', 'trophy', 'broken-skull', 'frozen-orb', 'rolling-bomb', 'white-tower', 'grab', 'screaming', 'grenade', 'sentry-gun', 'all-for-one', 'angel-outfit', 'archery-target', ]; const obj = {}; for (let i = 0; i < markers.length; i++) { obj[markers[i]] = markers[i]; } return obj; } }; /***/ }, /* 16 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); const utils = __webpack_require__(7); const ShapedModule = __webpack_require__(13); const ShapedConfig = __webpack_require__(15); class MacroMaker { constructor(roll20) { if (!roll20) { throw new Error('Rol20 parameter is required for MacroMaker constructor'); } this.roll20 = roll20; this.sortKey = 'originalOrder'; } getAbilityMaker(character) { const self = this; return function abilityMaker(abilitySpec) { const ability = self.roll20.getOrCreateObj('ability', { characterid: character.id, name: abilitySpec.name }); ability.set({ action: abilitySpec.action, istokenaction: true }); // TODO configure this return abilitySpec.name; }; } } class AbilityDeleter extends MacroMaker { constructor(roll20) { super(roll20); this.sortKey = ''; } run(character) { const abilities = this.roll20.findObjs({ type: 'ability', characterid: character.id }); const deleted = _.map(abilities, obj => { const name = obj.get('name'); obj.remove(); return name; }); return `Deleted: ${_.isEmpty(deleted) ? 'None' : deleted.join(', ')}`; } } class RepeatingAbilityMaker extends MacroMaker { constructor(repeatingSection, abilityName, label, canMark, roll20) { super(roll20); this.repeatingSection = repeatingSection; this.abilityName = abilityName; this.label = label; this.canMark = canMark; } run(character, options) { const cache = options.getCache(character.id); cache[this.repeatingSection] = cache[this.repeatingSection] || this.roll20.getRepeatingSectionItemIdsByName(character.id, this.repeatingSection); const configured = _.chain(cache[this.repeatingSection]) .map((repeatingId, repeatingName) => { let repeatingAction = `%{${character.get('name')}|repeating_${this.repeatingSection}_${repeatingId}` + `_${this.abilityName}}`; if (this.canMark && options.mark) { repeatingAction += '\n!mark @{target|token_id}'; } return { name: utils.toTitleCase(repeatingName), action: repeatingAction }; }) .map(this.getAbilityMaker(character)) .value(); const addedText = _.isEmpty(configured) ? 'Not present for character' : configured.join(', '); return `${this.label}: ${addedText}`; } } class RollAbilityMaker extends MacroMaker { constructor(abilityName, newName, roll20) { super(roll20); this.abilityName = abilityName; this.newName = newName; } run(character) { return this.getAbilityMaker(character)({ name: this.newName, action: `%{${character.get('name')}|${this.abilityName}}`, }); } } class CommandAbilityMaker extends MacroMaker { constructor(command, options, newName, roll20) { super(roll20); this.command = command; this.options = options; this.newName = newName; } run(character) { return this.getAbilityMaker(character)({ name: this.newName, action: `!${this.command} ${utils.toOptionsString(this.options)}`, }); } } class MultiCommandAbilityMaker extends MacroMaker { constructor(commandSpecs, roll20) { super(roll20); this.commandSpecs = commandSpecs; } run(character) { const abilMaker = this.getAbilityMaker(character); return this.commandSpecs.map(cmdSpec => abilMaker({ name: cmdSpec.abilityName, action: `!${cmdSpec.command} ${utils.toOptionsString(cmdSpec.options)}`, }) ); } } class RepeatingSectionMacroMaker extends MacroMaker { constructor(abilityName, repeatingSection, macroName, roll20) { super(roll20); this.abilityName = abilityName; this.repeatingSection = repeatingSection; this.macroName = macroName; this.sortKey = 'originalOrder'; } run(character) { if (!_.isEmpty(this.roll20.getRepeatingSectionAttrs(character.id, this.repeatingSection))) { return this.getAbilityMaker(character)({ name: this.macroName, action: `%{${character.get('name')}|${this.abilityName}}`, }); } return `${this.macroName}: Not present for character`; } } function getRepeatingSectionAbilityLookup(sectionName, rollName, roll20) { return function repeatingSectionAbilityLookup(optionName, existingOptions) { const characterId = existingOptions.selected.character[0].id; const cache = existingOptions.getCache(characterId); cache[sectionName] = cache[sectionName] || roll20.getRepeatingSectionItemIdsByName(characterId, sectionName); const repeatingId = cache[sectionName][optionName.toLowerCase()]; if (repeatingId) { return new RollAbilityMaker(`repeating_${sectionName}_${repeatingId}_${rollName}`, utils.toTitleCase(optionName), roll20); } return undefined; }; } module.exports = class AbilityMaker extends ShapedModule { addCommands(commandProcessor) { const roll20 = this.roll20; this.staticAbilityOptions = { DELETE: new AbilityDeleter(roll20), initiative: new RollAbilityMaker('shaped_initiative', 'Init', roll20), abilityChecks: new RollAbilityMaker('shaped_ability_checks', 'Ability Checks', roll20), abilityChecksQuery: new RollAbilityMaker('shaped_ability_checks_query', 'Ability Checks', roll20), abilChecks: new RollAbilityMaker('shaped_ability_checks', 'AbilChecks', roll20), abilChecksQuery: new RollAbilityMaker('shaped_ability_checks_query', 'AbilChecks', roll20), advantageTracker: new MultiCommandAbilityMaker([ { command: 'shaped-at', options: ['advantage'], abilityName: 'Advantage' }, { command: 'shaped-at', options: ['disadvantage'], abilityName: 'Disadvantage' }, { command: 'shaped-at', options: ['normal'], abilityName: 'Normal' }, ], roll20), advantageTrackerQuery: new CommandAbilityMaker('shaped-at', ['?{Roll Option|Normal,normal|w/ Advantage,advantage|w/ Disadvantage,disadvantage}'], '(dis)Adv Query', roll20), savingThrows: new RollAbilityMaker('shaped_saving_throw', 'Saving Throws', roll20), savingThrowsQuery: new RollAbilityMaker('shaped_saving_throw_query', 'Saving Throws', roll20), saves: new RollAbilityMaker('shaped_saving_throw', 'Saves', roll20), savesQuery: new RollAbilityMaker('shaped_saving_throw_query', 'Saves', roll20), attacks: new RepeatingAbilityMaker('attack', 'attack', 'Attacks', true, roll20), attacksMacro: new RepeatingSectionMacroMaker('shaped_attacks', 'attack', 'Attacks', roll20), spells: new RepeatingSectionMacroMaker('shaped_spells', 'spell', 'Spells', roll20), statblock: new RollAbilityMaker('shaped_statblock', 'Statblock', roll20), traits: new RepeatingAbilityMaker('trait', 'trait', 'Traits', false, roll20), traitsMacro: new RepeatingSectionMacroMaker('shaped_traits', 'trait', 'Traits', roll20), actions: new RepeatingAbilityMaker('action', 'action', 'Actions', true, roll20), actionsMacro: new RepeatingSectionMacroMaker('shaped_actions', 'action', 'Actions', roll20), reactions: new RepeatingAbilityMaker('reaction', 'action', 'Reactions', false, roll20), reactionsMacro: new RepeatingSectionMacroMaker('shaped_reactions', 'reaction', 'Reactions', roll20), legendaryActions: new RepeatingAbilityMaker('legendaryaction', 'action', 'Legendary Actions', false, roll20), legendaryActionsMacro: new RepeatingSectionMacroMaker('shaped_legendaryactions', 'legendaryaction', 'Legendary Actions', roll20), legendaryA: new RepeatingSectionMacroMaker('shaped_legendaryactions', 'legendaryaction', 'LegendaryA', roll20), lairActions: new RepeatingSectionMacroMaker('shaped_lairactions', 'lairaction', 'Lair Actions', roll20), lairA: new RepeatingSectionMacroMaker('shaped_lairactions', 'lairaction', 'LairA', roll20), regionalEffects: new RepeatingSectionMacroMaker('shaped_regionaleffects', 'regionaleffect', 'Regional Effects', roll20), regionalE: new RepeatingSectionMacroMaker('shaped_regionaleffects', 'regionaleffect', 'RegionalE', roll20), rests: new CommandAbilityMaker('shaped-rest', ['?{Rest type|Short,short|Long,long}'], 'Rests', roll20), }; return commandProcessor.addCommand('abilities', this.addAbility.bind(this), false) .withSelection({ character: { min: 1, max: Infinity, }, }) .optionLookup('abilities', this.staticAbilityOptions) .optionLookup('abilities', getRepeatingSectionAbilityLookup('spell', 'spell', this.roll20)) .optionLookup('abilities', getRepeatingSectionAbilityLookup('trait', 'trait', this.roll20)) .option('mark', ShapedConfig.booleanValidator); } addAbilitiesByName(abilities, character) { const caches = {}; const options = { getCache(key) { return (caches[key] = caches[key] || {}); }, }; abilities.each(ability => this.staticAbilityOptions[ability].run(character, options)); } addAbility(options) { if (_.isEmpty(options.abilities)) { this.reportError('No abilities specified. ' + 'Take a look at the documentation for a list of ability options.'); return; } const messages = _.map(options.selected.character, (character) => { const operationMessages = _.chain(options.abilities) .sortBy('sortKey') .map(maker => maker.run(character, options)) .value(); if (_.isEmpty(operationMessages)) { return `
  • ${character.get('name')}: Nothing to do
  • `; } let message; message = `
  • Configured the following abilities for character ${character.get('name')}:
  • '; return message; }); this.reportPlayer('Ability Creation', ``); } }; /***/ }, /* 17 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); const utils = __webpack_require__(7); const ShapedModule = __webpack_require__(13); const ShapedConfig = __webpack_require__(15); class ConfigUi extends ShapedModule { addCommands(commandProcessor) { const menus = { atMenu: new AdvantageTrackerMenu(this.myState.config, ShapedConfig.configOptionsSpec), tsMenu: new TokensMenu(this.myState.config, ShapedConfig.configOptionsSpec), barMenu: new TokenBarsMenu(this.myState.config, ShapedConfig.configOptionsSpec), auraMenu: new TokenAurasMenu(this.myState.config, ShapedConfig.configOptionsSpec), ncMenu: new NewCharacterMenu(this.myState.config, ShapedConfig.configOptionsSpec), taMenu: new TokenActionsMenu(this.myState.config, ShapedConfig.configOptionsSpec), hrMenu: new NewCharacterHouseruleMenu(this.myState.config, ShapedConfig.configOptionsSpec), savesMenu: new SavesMenu(this.myState.config, ShapedConfig.configOptionsSpec), fortitudeMenu: new CustomSaveTypeMenu(this.myState.config, ShapedConfig.configOptionsSpec, 'fortitude'), reflexMenu: new CustomSaveTypeMenu(this.myState.config, ShapedConfig.configOptionsSpec, 'reflex'), willMenu: new CustomSaveTypeMenu(this.myState.config, ShapedConfig.configOptionsSpec, 'will'), textMenu: new NewCharacterTextSizeMenu(this.myState.config, ShapedConfig.configOptionsSpec), varsMenu: new VariantsMenu(this.myState.config, ShapedConfig.configOptionsSpec), seMenu: new SheetEnhancementsMenu(this.myState.config, ShapedConfig.configOptionsSpec), }; _.each(menus, menu => this.logger.wrapModule(menu)); return commandProcessor.addCommand('config', this.process.bind(this), true) .options(ShapedConfig.configOptionsSpec) .optionLookup('menu', menus); } process(options) { // drop "menu" options utils.deepExtend(this.myState.config, _.omit(options, 'menu')); if (options.menu) { this.reportPlayer('Configuration', options.menu[0].getMenu()); } else { const menu = new MainMenu(this.myState.config, ShapedConfig.configOptionsSpec); this.reportPlayer('Configuration', menu.getMenu()); } } } module.exports = ConfigUi; ///////////////////////////////////////////////// // Menu Base ///////////////////////////////////////////////// class ConfigMenu { constructor(config, specRoot) { this.config = config; this.specRoot = specRoot; } makeToggleSetting(params) { let currentVal = utils.getObjectFromPath(this.config, params.path); if (params.spec) { currentVal = _.invert(params.spec)[currentVal] === 'true'; } params.command = `${!currentVal}${!_.isUndefined(params.menuCmd) ? ` --${params.menuCmd}` : ''}`; params.linkText = this.makeBoolText(currentVal); params.tooltip = 'click to toggle'; params.buttonColor = currentVal ? '#65c4bd' : '#f84545'; return this.makeOptionRow(params); } makeQuerySetting(params) { const currentVal = _.invert(params.spec)[utils.getObjectFromPath(this.config, params.path)]; const cmd = this.getQueryCommand(params.path, params.title, params.spec); params.command = `${cmd}${!_.isUndefined(params.menuCmd) ? ` --${params.menuCmd}` : ''}`; params.linkText = this.makeText(currentVal); params.tooltip = 'click to change'; params.buttonColor = '#02baf2'; return this.makeOptionRow(params); } makeInputSetting(params) { const currentVal = utils.getObjectFromPath(this.config, params.path); params.command = `?{${params.prompt}|${currentVal}}${!_.isUndefined(params.menuCmd) ? ` --${params.menuCmd}` : ''}`; params.linkText = currentVal || '[not set]'; params.tooltip = 'click to edit'; params.buttonColor = params.linkText === '[not set]' ? '#f84545' : '#02baf2'; return this.makeOptionRow(params); } // noinspection JSUnusedGlobalSymbols makeColorSetting(params) { const currentVal = utils.getObjectFromPath(this.config, params.path); params.command = `?{${params.prompt}|${currentVal}}${!_.isUndefined(params.menuCmd) ? ` --${params.menuCmd}` : ''}`; params.linkText = currentVal || '[not set]'; params.tooltip = 'click to edit'; params.buttonColor = params.linkText === '[not set]' ? '#02baf2' : currentVal; params.buttonTextColor = utils.getContrastYIQ(params.buttonColor); return this.makeOptionRow(params); } backToMainMenuButton() { return utils.buildHTML('a', '<-- Main Menu', { href: '!shaped-config', style: 'text-align: center; margin: 5px 0 0 0; padding: 2px 2px ; border-radius: 10px; white-space: nowrap; ' + 'overflow: hidden; text-overflow: ellipsis; background-color: #02baf2; border-color: #c0c0c0;', }); } backToTokenOptions() { return utils.buildHTML('a', '<-- Token Options', { href: '!shaped-config --tsMenu', style: 'text-align: center; margin: 5px 0 0 0; padding: 2px 2px ; border-radius: 10px; white-space: nowrap; ' + 'overflow: hidden; text-overflow: ellipsis; background-color: #02baf2; border-color: #c0c0c0;', }); } backToNewCharOptions() { return utils.buildHTML('a', '<-- New Character Options', { href: '!shaped-config --ncMenu', style: 'text-align: center; margin: 5px 0 0 0; padding: 2px 2px ; border-radius: 10px; white-space: nowrap; ' + 'overflow: hidden; text-overflow: ellipsis; background-color: #02baf2; border-color: #c0c0c0;', }); } backToHouseRuleOptions() { return utils.buildHTML('a', '<-- Houserule Options', { href: '!shaped-config --hrMenu', style: 'text-align: center; margin: 5px 0 0 0; padding: 2px 2px ; border-radius: 10px; white-space: nowrap; ' + 'overflow: hidden; text-overflow: ellipsis; background-color: #02baf2; border-color: #c0c0c0;', }); } backToSavesOptions() { return utils.buildHTML('a', '<-- Saves Options', { href: '!shaped-config --savesMenu', style: 'text-align: center; margin: 5px 0 0 0; padding: 2px 2px ; border-radius: 10px; white-space: nowrap; ' + 'overflow: hidden; text-overflow: ellipsis; background-color: #02baf2; border-color: #c0c0c0;', }); } getQueryCommand(path, title, optionsSpec) { let currentVal = _.invert(optionsSpec)[utils.getObjectFromPath(this.config, path)]; const optionList = _.keys(optionsSpec); // Fix up if we've somehow ended up with an illegal value if (_.isUndefined(currentVal)) { currentVal = _.first(optionList); utils.deepExtend(this.config, utils.createObjectFromPath(path, optionsSpec[currentVal])); } // move the current option to the front of the list optionList.splice(optionList.indexOf(currentVal), 1); optionList.unshift(currentVal); return `?{${title}|${optionList.join('|')}}`; } makeOptionRow(params) { const col1 = utils.buildHTML('td', params.title); const col2 = utils.buildHTML('td', this.makeOptionButton(params), { style: 'text-align:right;' }); return utils.buildHTML('tr', col1 + col2, { style: 'border: 1px solid gray;' }); } makeOptionButton(params) { if (_.isUndefined(params.width)) { params.width = 80; } let css = `text-align: center; width: ${params.width}px; margin: 2px 0 -3px 0; ` + 'padding: 2px 2px ; border-radius: 10px; border-color: #c0c0c0;' + `white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background-color: ${params.buttonColor};`; if (params.buttonTextColor) { css += `color: ${params.buttonTextColor}`; } return utils.buildHTML('a', params.linkText, { style: css, href: `!shaped-config --${params.path} ${params.command}`, }); } makeText(value) { return utils.buildHTML('span', value, { style: 'padding: 0 2px;' }); } makeBoolText(value) { return value === true ? utils.buildHTML('span', 'on', { style: 'padding: 0 2px;' }) : utils.buildHTML('span', 'off', { style: 'padding: 0 2px;' }); } makeThreeColOptionTable(options) { return utils .buildHTML('tr', [ { tag: 'td', innerHtml: [ { tag: 'table', innerHtml: [ { tag: 'tr', innerHtml: [{ tag: 'th', innerHtml: options.tableTitle, attrs: { colspan: 3 } }], }, { tag: 'tr', innerHtml: [ { tag: 'td', innerHtml: options.colTitles[0], }, { tag: 'td', innerHtml: options.colTitles[1], }, { tag: 'td', innerHtml: options.colTitles[2], }, ], attrs: { style: 'line-height: 1;' }, }, { tag: 'tr', innerHtml: [ { tag: 'td', innerHtml: options.buttons[0], }, { tag: 'td', innerHtml: options.buttons[1], }, { tag: 'td', innerHtml: options.buttons[2], }, ], }, ], attrs: { style: 'width: 100%; text-align: center;' }, }, ], attrs: { colspan: '2' }, }, ], { style: 'border: 1px solid gray;' }); } get logWrap() { return this.constructor.name; } toJSON() { return {}; } } ///////////////////////////////////////////////// // Menus ///////////////////////////////////////////////// class MainMenu extends ConfigMenu { getMenu() { const optionRows = this.makeOptionRow({ title: 'Advantage Tracker', path: 'atMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }) + this.makeOptionRow({ title: 'Token Defaults', path: 'tsMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }) + this.makeOptionRow({ title: 'New Characters', path: 'ncMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }) + this.makeOptionRow({ title: 'Char. Sheet Enhancements', path: 'seMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }) + this.makeOptionRow({ title: 'Houserules & Variants', path: 'varsMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }); const th = utils.buildHTML('th', 'Main Menu', { colspan: '2' }); const tr = utils.buildHTML('tr', th, { style: 'margin-top: 5px;' }); return utils.buildHTML('table', tr + optionRows, { style: 'width: 100%; font-size: 0.9em;' }); } } class AdvantageTrackerMenu extends ConfigMenu { getMenu() { const ats = 'advTrackerSettings'; const menu = 'atMenu'; const optionRows = this.makeQuerySetting({ path: `${ats}.output`, title: 'Output', menuCmd: menu, spec: this.specRoot.advTrackerSettings.output(), }) + this.makeToggleSetting({ path: `${ats}.showMarkers`, title: 'Show Markers', menuCmd: menu, }) + this.makeToggleSetting({ path: `${ats}.ignoreNpcs`, title: 'Ignore NPCs', menuCmd: menu, }) + this.makeQuerySetting({ path: `${ats}.advantageMarker`, title: 'Advantage Marker', menuCmd: menu, spec: this.specRoot.advTrackerSettings.advantageMarker(), }) + this.makeQuerySetting({ path: `${ats}.disadvantageMarker`, title: 'Disadvantage Marker', menuCmd: menu, spec: this.specRoot.advTrackerSettings.disadvantageMarker(), }); const th = utils.buildHTML('th', 'Advantage Tracker Options', { colspan: '2' }); const tr = utils.buildHTML('tr', th, { style: 'margin-top: 5px;' }); const table = utils.buildHTML('table', tr + optionRows, { style: 'width: 100%; font-size: 0.9em;' }); return table + this.backToMainMenuButton(); } } class TokensMenu extends ConfigMenu { getMenu() { // config) { // this.config = config; const ts = 'tokenSettings'; const menu = 'tsMenu'; const optionRows = this.makeToggleSetting({ path: `${ts}.number`, title: 'Numbered Tokens', menuCmd: menu, }) + this.makeToggleSetting({ path: `${ts}.showName`, title: 'Show Name Tag', menuCmd: menu, }) + this.makeToggleSetting({ path: `${ts}.showNameToPlayers`, title: 'Show Name to Players', menuCmd: menu, }) + this.makeInputSetting({ path: `${ts}.light.radius`, title: 'Light Radius', prompt: 'Light Radius (empty to unset)', menuCmd: menu, }) + this.makeInputSetting({ path: `${ts}.light.dimRadius`, title: 'Dim Radius', prompt: 'Light Dim Radius (empty to unset)', menuCmd: menu, }) + this.makeToggleSetting({ path: `${ts}.light.otherPlayers`, title: 'Other players see light', menuCmd: menu, }) + this.makeToggleSetting({ path: `${ts}.light.hasSight`, title: 'Has Sight', menuCmd: menu, }) + this.makeInputSetting({ path: `${ts}.light.angle`, title: 'Light Angle', prompt: 'Light Angle', menuCmd: menu, }) + this.makeInputSetting({ path: `${ts}.light.losAngle`, title: 'LOS Angle', prompt: 'LOS Angle', menuCmd: menu, }) + this.makeInputSetting({ path: `${ts}.light.multiplier`, title: 'Light Muliplier', prompt: 'Light Muliplier', menuCmd: menu, }) + this.makeOptionRow({ title: 'Token Bar Options', path: 'barMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }) + this.makeOptionRow({ title: 'Token Aura Options', path: 'auraMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }); const th = utils.buildHTML('th', 'Token Options', { colspan: '2' }); const tr = utils.buildHTML('tr', th, { style: 'margin-top: 5px;' }); const table = utils.buildHTML('table', tr + optionRows, { style: 'width: 100%; font-size: 0.9em;' }); return table + this.backToMainMenuButton(); } } class TokenBarsMenu extends ConfigMenu { getMenu() { const ts = 'tokenSettings'; const menu = 'barMenu'; let optionRows = ''; for (let i = 1; i <= 3; i++) { const currAttr = utils.getObjectFromPath(this.config, `${ts}.bar${i}.attribute`); const currAttrEmptyHint = currAttr || '[not set]'; const currMax = utils.getObjectFromPath(this.config, `${ts}.bar${i}.max`); const currLink = utils.getObjectFromPath(this.config, `${ts}.bar${i}.link`); const attBtn = this.makeOptionButton({ path: `${ts}.bar${i}.attribute`, linkText: this.makeText(currAttrEmptyHint), tooltip: 'click to edit', buttonColor: currAttrEmptyHint === '[not set]' ? '#f84545' : '#02baf2', width: 60, command: `?{Bar ${i} Attribute (empty to unset)|${currAttr}} --${menu}`, }); const maxBtn = this.makeOptionButton({ path: `${ts}.bar${i}.max`, linkText: this.makeBoolText(currMax), tooltip: 'click to toggle', buttonColor: currMax ? '#65c4bd' : '#f84545', width: 60, command: `${!currMax} --${menu}`, }); const linkBtn = this.makeOptionButton({ path: `${ts}.bar${i}.link`, linkText: this.makeBoolText(currLink), tooltip: 'click to togle', buttonColor: currLink ? '#65c4bd' : '#f84545', width: 60, command: `${!currLink} --${menu}`, }); optionRows += this.makeThreeColOptionTable({ tableTitle: `Bar ${i}`, colTitles: ['Attribute', 'Max', 'Link'], buttons: [attBtn, maxBtn, linkBtn], }); } for (let i = 1; i <= 3; i++) { optionRows += this.makeToggleSetting({ path: `${ts}.bar${i}.showPlayers`, title: `Bar ${i} Show Players`, menuCmd: 'barMenu', }); } const th = utils.buildHTML('th', 'Token Bar Options', { colspan: '2' }); const tr = utils.buildHTML('tr', th, { style: 'margin-top: 5px;' }); const table = utils.buildHTML('table', tr + optionRows, { style: 'width: 100%; font-size: 0.9em;' }); return table + this.backToTokenOptions(); } } class TokenAurasMenu extends ConfigMenu { getMenu() { const ts = 'tokenSettings'; const menu = 'auraMenu'; let optionRows = ''; for (let i = 1; i <= 2; i++) { const currRad = utils.getObjectFromPath(this.config, `${ts}.aura${i}.radius`); const currRadEmptyHint = currRad || '[not set]'; const currColor = utils.getObjectFromPath(this.config, `${ts}.aura${i}.color`); const currSquare = utils.getObjectFromPath(this.config, `${ts}.aura${i}.square`); const radBtn = this.makeOptionButton({ path: `${ts}.aura${i}.radius`, linkText: this.makeText(currRadEmptyHint), tooltip: 'click to edit', buttonColor: currRadEmptyHint === '[not set]' ? '#f84545' : '#02baf2', width: 60, command: `?{Aura ${i} Radius (empty to unset)|${currRad}} --${menu}`, }); const colorBtn = this.makeOptionButton({ path: `tokenSettings.aura${i}.color`, linkText: this.makeText(currColor), tooltip: 'click to edit', buttonColor: currColor, buttonTextColor: utils.getContrastYIQ(currColor), width: 60, command: `?{Aura ${i} Color (hex colors)|${currColor}} --${menu}`, }); const squareBtn = this.makeOptionButton({ path: `tokenSettings.aura${i}.square`, linkText: this.makeBoolText(currSquare), tooltip: 'click to toggle', buttonColor: currSquare ? '#65c4bd' : '#f84545', width: 60, command: `${!currSquare} --${menu}`, }); optionRows += this.makeThreeColOptionTable({ tableTitle: `Aura ${i}`, colTitles: ['Range', 'Color', 'Square'], buttons: [radBtn, colorBtn, squareBtn], }); } for (let i = 1; i <= 2; i++) { optionRows += this.makeToggleSetting({ path: `${ts}.showAura${i}ToPlayers`, title: `Aura ${i} Show Players`, menuCmd: 'auraMenu', }); } const th = utils.buildHTML('th', 'Token Aura Options', { colspan: '2' }); const tr = utils.buildHTML('tr', th, { style: 'margin-top: 5px;' }); const table = utils.buildHTML('table', tr + optionRows, { style: 'width: 100%; font-size: 0.9em;' }); return table + this.backToTokenOptions(); } } class NewCharacterMenu extends ConfigMenu { getMenu() { const menu = 'ncMenu'; const ncs = 'newCharSettings'; let optionRows = ''; const spec = this.specRoot.newCharSettings; const currSheetOut = _.invert(spec.sheetOutput())[utils.getObjectFromPath(this.config, `${ncs}.sheetOutput`)]; const currDSaveOut = _.invert(spec.deathSaveOutput())[utils.getObjectFromPath(this.config, `${ncs}.deathSaveOutput`)]; const currInitOut = _.invert(spec.initiativeOutput())[utils.getObjectFromPath(this.config, `${ncs}.initiativeOutput`)]; const sheetBtn = this.makeOptionButton({ path: `${ncs}.sheetOutput`, linkText: this.makeText(currSheetOut), tooltip: 'click to change', buttonColor: '#02baf2', width: 60, command: `${this.getQueryCommand(`${ncs}.sheetOutput`, 'Sheet Output', spec.sheetOutput())}` + ` --${menu}`, }); const dSaveBtn = this.makeOptionButton({ path: `${ncs}.deathSaveOutput`, linkText: this.makeText(currDSaveOut), tooltip: 'click to change', buttonColor: '#02baf2', width: 60, command: `${this.getQueryCommand(`${ncs}.deathSaveOutput`, 'Death Save Output', spec.deathSaveOutput())}` + ` --${menu}`, }); const initBtn = this.makeOptionButton({ path: `${ncs}.initiativeOutput`, linkText: this.makeText(currInitOut), tooltip: 'click to change', buttonColor: '#02baf2', width: 60, command: `${this.getQueryCommand(`${ncs}.initiativeOutput`, 'Initiative Output', spec.initiativeOutput())}` + ` --${menu}`, }); optionRows += this.makeThreeColOptionTable({ tableTitle: 'Output', colTitles: ['Sheet', 'Death Save', 'Initiative'], buttons: [sheetBtn, dSaveBtn, initBtn], }); optionRows += this.makeToggleSetting({ path: `${ncs}.showNameOnRollTemplate`, title: 'Show Name on Roll Template', menuCmd: menu, spec: spec.showNameOnRollTemplate(), }) + this.makeQuerySetting({ path: `${ncs}.rollOptions`, title: 'Roll Options', menuCmd: menu, spec: spec.rollOptions(), }) + this.makeToggleSetting({ path: `${ncs}.autoRevertAdvantage`, title: 'Revert Advantage', menuCmd: menu, }) + this.makeQuerySetting({ path: `${ncs}.initiativeRoll`, title: 'Init Roll', menuCmd: menu, spec: spec.initiativeRoll(), }) + this.makeToggleSetting({ path: `${ncs}.initiativeToTracker`, title: 'Init To Tracker', menuCmd: menu, spec: spec.initiativeToTracker(), }) + this.makeToggleSetting({ path: `${ncs}.breakInitiativeTies`, title: 'Break Init Ties', menuCmd: menu, spec: spec.breakInitiativeTies(), }) + this.makeToggleSetting({ path: `${ncs}.showTargetAC`, title: 'Show Target AC', menuCmd: menu, spec: spec.showTargetAC(), }) + this.makeToggleSetting({ path: `${ncs}.showTargetName`, title: 'Show Target Name', menuCmd: menu, spec: spec.showTargetName(), }) + this.makeToggleSetting({ path: `${ncs}.autoAmmo`, title: 'Auto Use Ammo', menuCmd: menu, spec: spec.autoAmmo(), }) + this.makeQuerySetting({ path: `${ncs}.tab`, title: 'Default tab', menuCmd: menu, spec: spec.tab(), }) + this.makeOptionRow({ title: 'Default Token Actions', path: 'taMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }) + this.makeOptionRow({ title: 'Houserule Settings', path: 'hrMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }) + this.makeOptionRow({ title: 'Text sizes', path: 'textMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }); const th = utils.buildHTML('th', 'New Character Sheets', { colspan: '2' }); const tr = utils.buildHTML('tr', th, { style: 'margin-top: 5px;' }); const table = utils.buildHTML('table', tr + optionRows, { style: 'width: 100%; font-size: 0.9em;' }); return table + this.backToMainMenuButton(); } } class NewCharacterHouseruleMenu extends ConfigMenu { getMenu() { const hr = 'newCharSettings.houserules'; const menu = 'hrMenu'; const optionRows = this.makeQuerySetting({ path: `${hr}.baseDC`, title: 'Base DC', prompt: 'Base DC', menuCmd: menu, spec: this.specRoot.newCharSettings.houserules.baseDC(), }) + this.makeQuerySetting({ path: `${hr}.mediumArmorMaxDex`, title: 'Medium Armor Max Dex', menuCmd: menu, spec: this.specRoot.newCharSettings.houserules.mediumArmorMaxDex(), }) + this.makeOptionRow({ title: 'Saving Throws', path: 'savesMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }); const th = utils.buildHTML('th', 'Houserule Settings', { colspan: '2' }); const tr = utils.buildHTML('tr', th, { style: 'margin-top: 5px;' }); const table = utils.buildHTML('table', tr + optionRows, { style: 'width: 100%; font-size: 0.9em;' }); return table + this.backToNewCharOptions(); } } class SavesMenu extends ConfigMenu { getMenu() { const saves = 'newCharSettings.houserules.saves'; const menu = 'savesMenu'; const optionRows = this.makeToggleSetting({ path: `${saves}.savingThrowsHalfProf`, title: 'Half Proficiency Saves', menuCmd: menu, }) + this.makeToggleSetting({ path: `${saves}.useCustomSaves`, title: 'Use Custom Saves', menuCmd: menu, spec: this.specRoot.newCharSettings.houserules.saves.useCustomSaves(), }) + this.makeToggleSetting({ path: `${saves}.useAverageOfAbilities`, title: 'Use Average of Highest Abils', menuCmd: menu, spec: this.specRoot.newCharSettings.houserules.saves.useAverageOfAbilities(), }) + this.makeOptionRow({ title: 'Fortitude', path: 'fortitudeMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }) + this.makeOptionRow({ title: 'Reflex', path: 'reflexMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }) + this.makeOptionRow({ title: 'Will', path: 'willMenu', command: '', linkText: 'view -->', buttonColor: '#02baf2', }); const th = utils.buildHTML('th', 'Houserule Settings', { colspan: '2' }); const tr = utils.buildHTML('tr', th, { style: 'margin-top: 5px;' }); const table = utils.buildHTML('table', tr + optionRows, { style: 'width: 100%; font-size: 0.9em;' }); return table + this.backToHouseRuleOptions(); } } class CustomSaveTypeMenu extends ConfigMenu { constructor(config, specRoot, saveName) { super(config, specRoot); this.saveName = saveName; } getMenu() { const saves = `newCharSettings.houserules.saves.${this.saveName}`; const menu = `${this.saveName}Menu`; const spec = this.specRoot.newCharSettings.houserules.saves[this.saveName]; const optionRows = ['Strength', 'Dexterity', 'Constitution', 'Intelligence', 'Wisdom', 'Charisma'] .reduce((result, attr) => { const propName = `${this.saveName}${attr}`; result += this.makeToggleSetting({ path: `${saves}.${propName}`, title: attr, menuCmd: menu, spec: spec[propName](), }); return result; }, ''); const th = utils.buildHTML('th', `${this.saveName} Saves`, { colspan: '2' }); const tr = utils.buildHTML('tr', th, { style: 'margin-top: 5px;' }); const table = utils.buildHTML('table', tr + optionRows, { style: 'width: 100%; font-size: 0.9em;' }); return table + this.backToSavesOptions(); } } class NewCharacterTextSizeMenu extends ConfigMenu { getMenu() { const textSizes = 'newCharSettings.textSizes'; const menu = 'textMenu'; const optionRows = this.makeQuerySetting({ path: `${textSizes}.spellsTextSize`, title: 'Spells', menuCmd: menu, spec: this.specRoot.newCharSettings.textSizes.spellsTextSize(), }) + this.makeQuerySetting({ path: `${textSizes}.abilityChecksSize`, title: 'Ability Checks', menuCmd: menu, spec: this.specRoot.newCharSettings.textSizes.abilityChecksTextSize(), }) + this.makeQuerySetting({ path: `${textSizes}.savingThrowsSize`, title: 'Saving Throws', menuCmd: menu, spec: this.specRoot.newCharSettings.textSizes.savingThrowsTextSize(), }); const th = utils.buildHTML('th', 'Text sizes', { colspan: '2' }); const tr = utils.buildHTML('tr', th, { style: 'margin-top: 5px;' }); const table = utils.buildHTML('table', tr + optionRows, { style: 'width: 100%; font-size: 0.9em;' }); return table + this.backToNewCharOptions(); } } class VariantsMenu extends ConfigMenu { getMenu() { const root = 'variants'; const menu = 'varsMenu'; const optionRows = this.makeToggleSetting({ path: `${root}.rests.longNoHpFullHd`, title: 'Long Rest: No HP, full HD', menuCmd: menu, }); const th = utils.buildHTML('th', 'Houserules & Variants', { colspan: '2' }); const tr = utils.buildHTML('tr', th, { style: 'margin-top: 5px;' }); const table = utils.buildHTML('table', tr + optionRows, { style: 'width: 100%; font-size: 0.9em;' }); return table + this.backToMainMenuButton(); } } class SheetEnhancementsMenu extends ConfigMenu { getMenu() { const root = 'sheetEnhancements'; const menu = 'seMenu'; const optionRows = this.makeToggleSetting({ path: `${root}.rollHPOnDrop`, title: 'Roll HP On Drop', menuCmd: menu, }) + this.makeToggleSetting({ path: `${root}.autoHD`, title: 'Process HD Automatically', menuCmd: menu, }) + this.makeToggleSetting({ path: `${root}.autoSpellSlots`, title: 'Process Spell Slots Automatically', menuCmd: menu, }) + this.makeToggleSetting({ path: `${root}.autoTraits`, title: 'Process Traits automatically', menuCmd: menu, }); const th = utils.buildHTML('th', 'Character Sheet Enhancements', { colspan: '2' }); const tr = utils.buildHTML('tr', th, { style: 'margin-top: 5px;' }); const table = utils.buildHTML('table', tr + optionRows, { style: 'width: 100%; font-size: 0.9em;' }); return table + this.backToMainMenuButton(); } } class TokenActionsMenu extends ConfigMenu { getMenu() { const root = 'newCharSettings.tokenActions'; const menu = 'taMenu'; const spec = this.specRoot.newCharSettings.tokenActions; const optionRows = this.makeToggleSetting({ path: `${root}.initiative`, title: 'Initiative', menuCmd: menu, }) + this.makeQuerySetting({ path: `${root}.abilityChecks`, title: 'Ability Checks', menuCmd: menu, spec: spec.abilityChecks(), }) + this.makeQuerySetting({ path: `${root}.advantageTracker`, title: 'Advantage Tracker', menuCmd: menu, spec: spec.advantageTracker(), }) + this.makeQuerySetting({ path: `${root}.savingThrows`, title: 'Saves', menuCmd: menu, spec: spec.savingThrows(), }) + this.makeQuerySetting({ path: `${root}.attacks`, title: 'Attacks', menuCmd: menu, spec: spec.attacks(), }) + this.makeToggleSetting({ path: `${root}.statblock`, title: 'Statblock', menuCmd: menu, }) + this.makeQuerySetting({ path: `${root}.traits`, title: 'Traits', menuCmd: menu, spec: spec.traits(), }) + this.makeQuerySetting({ path: `${root}.actions`, title: 'Actions', menuCmd: menu, spec: spec.actions(), }) + this.makeToggleSetting({ path: `${root}.spells`, title: 'Spells', menuCmd: menu, }) + this.makeQuerySetting({ path: `${root}.reactions`, title: 'Reactions', menuCmd: menu, spec: spec.reactions(), }) + this.makeQuerySetting({ path: `${root}.legendaryActions`, title: 'Legendary Actions', menuCmd: menu, spec: spec.legendaryActions(), }) + this.makeQuerySetting({ path: `${root}.lairActions`, title: 'Lair Actions', menuCmd: menu, spec: spec.lairActions(), }) + this.makeQuerySetting({ path: `${root}.regionalEffects`, title: 'Regional Effects', menuCmd: menu, spec: spec.regionalEffects(), }) + this.makeToggleSetting({ path: `${root}.rests`, title: 'Rests', menuCmd: menu, }); const th = utils.buildHTML('th', 'Default Token Actions', { colspan: '2' }); const tr = utils.buildHTML('tr', th, { style: 'margin-top: 5px;' }); const table = utils.buildHTML('table', tr + optionRows, { style: 'width: 100%; font-size: 0.9em;' }); return table + this.backToMainMenuButton(); } } /***/ }, /* 18 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); const utils = __webpack_require__(7); const ShapedModule = __webpack_require__(13); const ShapedConfig = __webpack_require__(15); const rollOptions = { normal: { rollSetting: '{{ignore=[[0', message: 'normally', rollInfo: '', shaped_d20: '', }, advantage: { rollSetting: 'adv {{ignore=[[0', message: 'with advantage', rollInfo: '{{advantage=1}}', shaped_d20: '2d20@{d20_mod}kh1', }, disadvantage: { rollSetting: 'dis {{ignore=[[0', message: 'with disadvantage', rollInfo: '{{disadvantage=1}}', shaped_d20: '2d20@{d20_mod}kl1', }, roll2: { rollSetting: '{{roll2=[[d20@{d20_mod}', message: 'two dice', rollInfo: '', shaped_d20: '', }, }; class AdvantageTracker extends ShapedModule { addCommands(commandProcessor) { return commandProcessor.addCommand('at', this.process.bind(this), false) .option('advantage', ShapedConfig.booleanValidator) .option('disadvantage', ShapedConfig.booleanValidator) .option('normal', ShapedConfig.booleanValidator) .option('revert', ShapedConfig.booleanValidator) .option('persist', ShapedConfig.booleanValidator) .option('id', ShapedConfig.getCharacterValidator(this.roll20), false) .withSelection({ character: { min: 0, max: Infinity, }, }); } process(options) { let type = undefined; // const at = new AdvantageTracker(this.logger, this.myState, this.roll20); if (!_.isUndefined(options.id)) { // if an ID is passed, overwrite any selection, and only process for the passed charId options.selected.character = [options.id]; } if (_.isEmpty(options.selected.character)) { this.reportError('Advantage Tracker was called, but no token was selected, and no character id was passed.'); } else { if (options.normal) { type = 'normal'; } else if (options.advantage) { type = 'advantage'; } else if (options.disadvantage) { type = 'disadvantage'; } if (!_.isUndefined(type)) { this.setRollOption(type, options.selected.character); } if (options.revert) { this.setAutoRevert(true, options.selected.character); } else if (options.persist) { this.setAutoRevert(false, options.selected.character); } } } handleRollOptionChange(msg) { const char = []; char.push(msg.get('_characterid')); const br = this.buildResources(_.uniq(_.union(char))); if (!_.isEmpty(br)) { this.setStatusMarkers(br[0].tokens, msg.get('current') === rollOptions.advantage.rollSetting, msg.get('current') === rollOptions.disadvantage.rollSetting); switch (msg.get('current')) { case rollOptions.normal.rollSetting: this.sendChatNotification(br[0], 'normal'); break; case rollOptions.advantage.rollSetting: this.sendChatNotification(br[0], 'advantage'); break; case rollOptions.disadvantage.rollSetting: this.sendChatNotification(br[0], 'disadvantage'); break; default: break; } } } handleTokenChange(token) { this.logger.debug('AT: Updating New Token'); if (this.shouldShowMarkers() && token.get('represents') !== '') { const character = this.roll20.getObj('character', token.get('represents')); const setting = this.roll20.getAttrByName(character.id, 'roll_setting'); if (this.shouldIgnoreNpcs()) { if (this.roll20.getAttrByName(character.id, 'is_npc') === '1') { return; } } token.set(`status_${this.disadvantageMarker()}`, setting === rollOptions.disadvantage.rollSetting); token.set(`status_${this.advantageMarker()}`, setting === rollOptions.advantage.rollSetting); } } buildResources(characterIds) { let res = _.chain(characterIds) .map((charId) => this.roll20.getObj('character', charId)) .reject(_.isUndefined) .map((char) => ({ character: char, tokens: this.roll20.filterObjs((obj) => obj.get('_type') === 'graphic' && char.id === obj.get('represents')), })) .value(); if (this.shouldIgnoreNpcs()) { res = _.chain(res) .filter((c) => { const isNpc = this.roll20.getAttrByName(c.character.id, 'is_npc'); return isNpc && parseInt(isNpc, 10) === 0; }) .value(); } return res; } setStatusMarkers(tokens, showAdvantage, showDisadvantage) { if (this.shouldShowMarkers()) { _.each(tokens, (token) => { token.set(`status_${this.advantageMarker()}`, showAdvantage); token.set(`status_${this.disadvantageMarker()}`, showDisadvantage); }); } } setAutoRevert(value, selectedChars) { const resources = this.buildResources(_.chain(selectedChars).map(c => c.get('_id')).value()); _.each(resources, (resource) => { const charId = resource.character.get('_id'); this.roll20.setAttrByName(charId, 'auto_revert_advantage', value ? 'on' : 0); }); } setRollOption(type, selectedChars) { const resources = this.buildResources(_.chain(selectedChars).map(c => c.get('_id')).value()); _.each(resources, (resource) => { const charId = resource.character.get('_id'); this.setStatusMarkers(resource.tokens, type === 'advantage', type === 'disadvantage'); if (this.roll20.getAttrByName(charId, 'roll_setting') === rollOptions[type].rollSetting) { return; } this.roll20.setAttrByName(charId, 'roll_setting', rollOptions[type].rollSetting); this.roll20.setAttrByName(charId, 'roll_info', rollOptions[type].rollInfo); this.roll20.setAttrByName(charId, 'shaped_d20', rollOptions[type].shaped_d20); this.sendChatNotification(resource, type); }); } sendChatNotification(resource, type) { if (this.outputOption() !== 'silent') { let msg = ` &{template:5e-shaped} {{character_name=${resource.character.get('name')}}} ` + `@{${resource.character.get('name')}|show_character_name} {{title=${utils.toTitleCase(type)}}} ` + `{{text_top=${resource.character.get('name')} is rolling ${rollOptions[type].message}!}}`; if (this.outputOption() === 'whisper') { msg = `/w gm ${msg}`; } this.roll20.sendChat('Shaped AdvantageTracker', msg); } } outputOption() { return this.myState.config.advTrackerSettings.output; } shouldShowMarkers() { return this.myState.config.advTrackerSettings.showMarkers; } shouldIgnoreNpcs() { return this.myState.config.advTrackerSettings.ignoreNpcs; } advantageMarker() { return this.myState.config.advTrackerSettings.advantageMarker; } disadvantageMarker() { return this.myState.config.advTrackerSettings.disadvantageMarker; } } module.exports = AdvantageTracker; /***/ }, /* 19 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); const ShapedModule = __webpack_require__(13); const ShapedConfig = __webpack_require__(15); class RestManager extends ShapedModule { addCommands(commandProcessor) { return commandProcessor.addCommand('rest', this.handleRest.bind(this), false) .option('long', ShapedConfig.booleanValidator) .option('short', ShapedConfig.booleanValidator) .option('id', ShapedConfig.getCharacterValidator(this.roll20), false) .withSelection({ character: { min: 0, max: Infinity, }, }); } handleRest(options) { let chars = options.selected.character; if (!_.isUndefined(options.id)) { // if an ID is passed, overwrite any selection, and only process for the passed charId // NB this id will have been hydrated into a character object by the option processing so it's // already an object. The name should really change but it's baked into the sheet now so it's // too much hassle to change. chars = [options.id]; } if (options.long) { // handle long rest this.doLongRest(chars); } else if (options.short) { // handle short rest this.doShortRest(chars); } else { this.reportError('please specify long (!shaped-rest --long) or short (!shaped-rest --short) rest'); } } /** * Performs all of the short rest actions for the specified character * @param {Object[]} selectedChars - The characters to perform short rest actions on */ doShortRest(selectedChars) { _.each(selectedChars, (currentChar) => { const charId = currentChar.id; const charName = currentChar.get('name'); this.logger.debug(`Processing short rest for ${charName}:`); const traits = this.rechargeTraits(charId, 'short'); const msg = this.buildRestMessage('Short Rest', charName, charId, traits); this.roll20.sendChat(`character|${charId}`, msg); }); } /** * Performs all of the long rest actions for the specified character - including short rest actions * @param {Object[]} selectedChars - The characters to perform long rest actions on */ doLongRest(selectedChars) { _.each(selectedChars, (currentChar) => { const charId = currentChar.id; const charName = currentChar.get('name'); let healed = 0; this.logger.debug(`Processing long rest for ${charName}:`); let traits = this.rechargeTraits(charId, 'short'); traits = traits.concat(this.rechargeTraits(charId, 'long')); if (!this.myState.config.variants.rests.longNoHpFullHd) { healed = this.resetHP(currentChar); } const hd = this.regainHitDie(charId); const slots = this.regainSpellSlots(charId); const exhaus = this.reduceExhaustion(charId); const msg = this.buildRestMessage('Long Rest', charName, charId, traits, healed, hd, slots, exhaus); this.roll20.sendChat(`character|${charId}`, msg); }); } /** * Builds the rest message using the sheet's roll template to report the results of a rest * @param {string} restType - The type of rest performed; either 'Long Rest' or 'Short Rest' * @param {string} charName - The name of the character * @param {string} charId - The Roll20 character ID * @param {Object[]} traitNames - An array of the trait names that have been recharged * @param {int} healed - The number of HP healed * @param {Object[]} hdRegained - Array of objects each representing a die type and the number regained * @param {boolean} spellSlots - Whether or not spell slots were recharged * @param {boolean} exhaustion - Whether or not a level of exhaustoin was removed */ buildRestMessage(restType, charName, charId, traitNames, healed, hdRegained, spellSlots, exhaustion) { let msg = `&{template:5e-shaped} {{title=${restType}}} {{character_name=${charName}}}`; if (this.roll20.getAttrByName(charId, 'show_character_name') === '@{show_character_name_yes}') { msg += '{{show_character_name=1}}'; } if (hdRegained) { _.each(hdRegained, hd => { if (hd.quant > 0) { msg += `{{Hit Die Regained (${hd.die})=${hd.quant}}}`; } }); } if (traitNames) { msg += `{{Traits Recharged=${traitNames.join(', ')}}}`; } if (healed > 0) { msg += `{{heal=[[${healed}]]}}`; } if (spellSlots) { msg += '{{text_center=Spell Slots Regained}}'; } if (exhaustion) { msg += '{{text_top=Removed 1 Level Of Exhaustion}}'; } return msg; } /** * Recharges all of the repeating 'traits' section items that have a 'recharge' defined * @param {string} charId - the Roll20 character ID * @param {string} restType - the type of rest being performed; either 'Short Rest' or 'Long Rest' * @returns {Array} - An array of the trait names that were recharged */ rechargeTraits(charId, restType) { const traitNames = []; _.chain(this.roll20.findObjs({ type: 'attribute', characterid: charId })) .map(attribute => (attribute.get('name').match(/^repeating_trait_([^_]+)_recharge$/) || [])[1]) .reject(_.isUndefined) .uniq() .each(attId => { const traitPre = `repeating_trait_${attId}`; const rechargeAtt = this.roll20.getAttrByName(charId, `${traitPre}_recharge`); if (rechargeAtt.toLowerCase().indexOf(restType) !== -1) { const attName = this.roll20.getAttrByName(charId, `${traitPre}_name`); this.logger.debug(`Recharging '${attName}'`); traitNames.push(attName); const max = this.roll20.getAttrByName(charId, `${traitPre}_uses`, 'max'); if (max === undefined) { this.logger.error(`Tried to recharge the trait '${attName}' for character with id ${charId}, ` + 'but there were no uses defined.'); } else { this.roll20.setAttrByName(charId, `${traitPre}_uses`, max); } } }); return traitNames; } /** * Resets the HP of the specified character to its maximum value * @param {Object} character - the Roll20 character * @returns {int} - the number of HP that were healed */ resetHP(character) { const charId = character.id; this.logger.debug('Resetting HP to max'); const max = parseInt(this.roll20.getAttrByName(charId, 'HP', 'max'), 10); if (_.isNaN(max)) { this.reportError(`Can't recharge HP for character ${character.get('name')} because max HP is not set`); return 0; } const current = parseInt(this.roll20.getAttrByName(charId, 'HP', 'current') || 0, 10); this.roll20.setAttrByName(charId, 'HP', max); return max - current; } /** * Adds Hit die to the specified character based on PHB rules * @param {string} charId - the Roll20 character ID * @returns {Array} - Array of objects each representing a die type and the number regained */ regainHitDie(charId) { const hitDieRegained = []; this.logger.debug('Regaining Hit Die'); _.chain(this.roll20.findObjs({ type: 'attribute', characterid: charId })) .filter(attribute => (attribute.get('name').match(/^hd_d\d{1,2}$/))) .uniq() .each(hdAttr => { const max = parseInt(hdAttr.get('max'), 10); if (max > 0) { const oldCurrent = parseInt(hdAttr.get('current') || 0, 10); let newCurrent = oldCurrent; let regained = max === 1 ? 1 : Math.floor(max / 2); if (this.myState.config.variants.rests.longNoHpFullHd) { regained = max - oldCurrent; } newCurrent += regained; newCurrent = newCurrent > max ? max : newCurrent; this.roll20.setAttrByName(charId, hdAttr.get('name'), newCurrent); hitDieRegained.push({ die: hdAttr.get('name').replace(/hd_/, ''), quant: newCurrent - oldCurrent, }); } }); return hitDieRegained; } /** * Resets all (non warlock) spell slots of the specified character to their maximum values * @param {string} charId - the Roll20 character ID * @returns {boolean} - true if any spell slots were recharged; false otherwise */ regainSpellSlots(charId) { let slotsFound = false; this.logger.debug('Regaining Spell Slots'); _.chain(this.roll20.findObjs({ type: 'attribute', characterid: charId })) .filter(attribute => (attribute.get('name').match(/^spell_slots_l\d$/))) .uniq() .each(slotAttr => { const max = parseInt(slotAttr.get('max'), 10); if (max > 0) { this.roll20.setAttrByName(charId, slotAttr.get('name'), max); slotsFound = true; } }); return slotsFound; } /** * Reduces the specified character's level of exhaustion by 1 * @param {string} charId - the Roll20 character ID * @returns {boolean} - true if a level of exhaustion was reduced; false otherwise */ reduceExhaustion(charId) { this.logger.debug('Reducing Exhaustion'); const currentLevel = parseInt(this.roll20.getAttrByName(charId, 'exhaustion_level'), 10); if (currentLevel > 0) { this.roll20.setAttrByName(charId, 'exhaustion_level', currentLevel - 1); return true; } return false; } } module.exports = RestManager; /***/ }, /* 20 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); const ShapedModule = __webpack_require__(13); class TraitManager extends ShapedModule { addCommands(commandProcessor) { // no commands for this module return commandProcessor; } /** * Handles the click event of a trait when 'autoTraits' is true * Consumes one use of the clicked trait * @param {object} options - The message options */ handleTraitClick(options) { const traitId = _.chain(this.roll20.findObjs({ type: 'attribute', characterid: options.character.id })) .map(attribute => (attribute.get('name').match(/^repeating_trait_([^_]+)_name$/) || [])[1]) .reject(_.isUndefined) .uniq() .find(attId => this.roll20.getAttrByName(options.character.id, `repeating_trait_${attId}_name`) === options.title) .value(); const usesAttr = this.roll20.getAttrObjectByName(options.character.id, `repeating_trait_${traitId}_uses`); if (usesAttr) { if (usesAttr.get('current') > 0) { usesAttr.set('current', parseInt(usesAttr.get('current'), 10) - 1); } else { this.reportPublic('Trait Police', `${options.characterName} can't use ${options.title} because ` + 'they don\'t have any uses left.'); } } } } module.exports = TraitManager; /***/ }, /* 21 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); const ShapedModule = __webpack_require__(13); class AmmoManager extends ShapedModule { addCommands(commandProcessor) { return commandProcessor; } consumeAmmo(options, msg) { const ammoAttr = _.chain(this.roll20.findObjs({ type: 'attribute', characterid: options.character.id })) .filter(attribute => attribute.get('name').indexOf('repeating_ammo') === 0) .groupBy(attribute => attribute.get('name').replace(/(repeating_ammo_[^_]+).*/, '$1')) .find(attributeList => _.find(attributeList, attribute => attribute.get('name').match(/.*name$/) && attribute.get('current') === options.ammoName) ) .find(attribute => attribute.get('name').match(/.*qty$/)) .value(); if (!ammoAttr) { this.logger.error('No ammo attribute found corresponding to name $$$', options.ammoName); return; } let ammoUsed = 1; if (options.ammo) { const rollRef = options.ammo.match(/\$\[\[(\d+)\]\]/); if (rollRef) { const rollExpr = msg.inlinerolls[rollRef[1]].expression; const match = rollExpr.match(/\d+-(\d+)/); if (match) { ammoUsed = match[1]; } } } const val = parseInt(ammoAttr.get('current'), 10) || 0; ammoAttr.set('current', Math.max(0, val - ammoUsed)); } } module.exports = AmmoManager; /***/ }, /* 22 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; const _ = __webpack_require__(2); function getRenameMapper(newName, upperCaseValue) { return function renameMapper(key, value, output) { output[newName] = upperCaseValue && value ? value.toUpperCase() : value; }; } function upperCaseMapper(key, value, output) { output[key] = value ? value.toUpperCase() : value; } function identityMapper(key, value, output) { output[key] = value; } function booleanMapper(key, value, output) { if (value) { output[key] = 'Yes'; } } function durationMapper(key, value, output, spellObj) { let newDuration = spellObj.concentration ? 'CONCENTRATION_' : ''; newDuration += value.toUpperCase().replace(/\s/g, '_'); output[key] = newDuration; } function spellLevelMapper(key, value, output) { let spellLevel; if (value === 0) { spellLevel = 'CANTRIP'; } else { switch (value % 10) { case 1: spellLevel = `${value}ST_LEVEL`; break; case 2: spellLevel = `${value}ND_LEVEL`; break; case 3: spellLevel = `${value}RD_LEVEL`; break; default: spellLevel = `${value}TH_LEVEL`; break; } } output.spell_level = spellLevel; } function camelCaseFixMapper(key, value, output) { const newKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); output[newKey] = value; } function castingStatMapper(key, value, output) { if (value) { output.add_casting_modifier = 'Yes'; } } function castingTimeMapper(key, value, output) { output.casting_time = value && value.toUpperCase().replace(/\s/g, '_'); } function secondaryCastingStatMapper(key, value, output) { if (value) { output.add_second_casting_modifier = 'Yes'; } } function componentMapper(key, value, output) { const components = _.chain(value) .omit('materialCost') .map((propValue, propName) => { if (propName !== 'materialMaterial') { return propName.toUpperCase().slice(0, 1); } output.materials = propValue; return null; }) .compact() .value() .join('_'); if (components) { output.components = `COMPONENTS_${components}`; } } const saveAttackMappings = { ability: getRenameMapper('saving_throw_vs_ability', true), type: identityMapper, damage: identityMapper, damageBonus: camelCaseFixMapper, damageType: camelCaseFixMapper, saveSuccess: getRenameMapper('saving_throw_success'), saveFailure: getRenameMapper('saving_throw_failure'), higherLevelDice: camelCaseFixMapper, higherLevelDie: camelCaseFixMapper, secondaryDamage: getRenameMapper('second_damage'), secondaryDamageBonus: getRenameMapper('second_damage_bonus'), secondaryDamageType: getRenameMapper('second_damage_type'), higherLevelSecondaryDice: getRenameMapper('second_higher_level_dice'), higherLevelSecondaryDie: getRenameMapper('second_higher_level_die'), condition: getRenameMapper('saving_throw_condition'), castingStat: castingStatMapper, secondaryCastingStat: secondaryCastingStatMapper, }; function getObjectMapper(mappings) { return function objectMapper(key, value, output) { _.each(value, (propVal, propName) => { const mapper = mappings[propName]; if (!mapper) { throw new Error('Unrecognised property when attempting to convert to srd format: ' + `[${propName}] ${JSON.stringify(output)}`); } mapper(propName, propVal, output, value); }); }; } const spellMapper = getObjectMapper({ name: identityMapper, duration: durationMapper, level: spellLevelMapper, school: upperCaseMapper, emote: identityMapper, range: identityMapper, castingTime: castingTimeMapper, target: identityMapper, description(key, value, output) { output.content = value + (output.content ? `\n${output.content}` : ''); }, higherLevel(key, value, output) { output.content = output.content ? `${output.content}\n` : ''; output.content += value; }, ritual: booleanMapper, concentration: booleanMapper, save: getObjectMapper(saveAttackMappings), attack: getObjectMapper(saveAttackMappings), damage: getObjectMapper(saveAttackMappings), heal: getObjectMapper({ amount: getRenameMapper('heal'), castingStat: castingStatMapper, higherLevelDice: camelCaseFixMapper, higherLevelDie: camelCaseFixMapper, higherLevelAmount: getRenameMapper('higher_level_heal'), bonus: getRenameMapper('heal_bonus'), }), components: componentMapper, prepared(key, value, output) { if (value) { output.is_prepared = 'on'; } }, classes: _.noop, aoe: _.noop, source: _.noop, effects: _.noop, domains: _.noop, oaths: _.noop, circles: _.noop, patrons: _.noop, }); const monsterMapper = getObjectMapper({ name: getRenameMapper('character_name'), size: identityMapper, type: identityMapper, alignment: identityMapper, AC: getRenameMapper('ac_srd'), HP: getRenameMapper('hp_srd'), speed: getRenameMapper('npc_speed'), strength: identityMapper, dexterity: identityMapper, constitution: identityMapper, intelligence: identityMapper, wisdom: identityMapper, charisma: identityMapper, skills: getRenameMapper('skills_srd'), spells(key, value, output) { const splitSpells = _.partition(value, _.isObject); if (!_.isEmpty(splitSpells[1])) { output.spells_srd = splitSpells[1].join(', '); } if (!_.isEmpty(splitSpells[0])) { output.spells = splitSpells[0]; _.each(output.spells, spell => (spell.prepared = true)); } }, savingThrows: getRenameMapper('saving_throws_srd'), damageResistances: getRenameMapper('damage_resistances'), damageImmunities: getRenameMapper('damage_immunities'), conditionImmunities: getRenameMapper('condition_immunities'), damageVulnerabilities: getRenameMapper('damage_vulnerabilities'), senses: identityMapper, languages: identityMapper, challenge: identityMapper, traits: identityMapper, actions: identityMapper, reactions: identityMapper, regionalEffects: identityMapper, regionalEffectsFade: identityMapper, legendaryPoints: identityMapper, legendaryActions: identityMapper, lairActions: identityMapper, }); const pronounTokens = { '{{GENDER_PRONOUN_HE_SHE}}': 'nominative', '{{GENDER_PRONOUN_HIM_HER}}': 'accusative', '{{GENDER_PRONOUN_HIS_HER}}': 'possessive', '{{GENDER_PRONOUN_HIMSELF_HERSELF}}': 'reflexive', }; module.exports = { convertMonster(npcObject) { const output = {}; monsterMapper(null, npcObject, output); const actionTraitTemplate = _.template('**<%=data.name%>' + '<% if(data.recharge) { print(" (" + data.recharge + ")") } %>**: <%=data.text%>', { variable: 'data' }); const legendaryTemplate = _.template('**<%=data.name%>' + '<% if(data.cost && data.cost > 1){ print(" (Costs " + data.cost + " actions)") }%>**: <%=data.text%>', { variable: 'data' }); function lairRegionalTemplate(item) { return `**${item}`; } const simpleSectionTemplate = _.template('<%=data.title%>\n<% print(data.items.join("\\n")); %>', { variable: 'data' }); const legendarySectionTemplate = _.template('<%=data.title%>\n' + 'The <%=data.name%> can take <%=data.legendaryPoints%> legendary actions, choosing from the options below. ' + 'It can take only one legendary action at a time and only at the end of another creature\'s turn. ' + 'The <%=data.name%> regains spent legendary actions at the start of its turn.\n' + '<% print(data.items.join("\\n")) %>', { variable: 'data' }); const regionalSectionTemplate = _.template('<%=data.title%>\n<% print(data.items.join("\\n")); %>\n' + '**<%=data.regionalEffectsFade%>', { variable: 'data' }); const srdContentSections = [ { prop: 'traits', itemTemplate: actionTraitTemplate, sectionTemplate: simpleSectionTemplate }, { prop: 'actions', itemTemplate: actionTraitTemplate, sectionTemplate: simpleSectionTemplate }, { prop: 'reactions', itemTemplate: actionTraitTemplate, sectionTemplate: simpleSectionTemplate }, { prop: 'legendaryActions', itemTemplate: legendaryTemplate, sectionTemplate: legendarySectionTemplate }, { prop: 'lairActions', itemTemplate: lairRegionalTemplate, sectionTemplate: simpleSectionTemplate }, { prop: 'regionalEffects', itemTemplate: lairRegionalTemplate, sectionTemplate: regionalSectionTemplate }, ]; function makeDataObject(propertyName, itemList) { return { title: propertyName.replace(/([A-Z])/g, ' $1').replace(/^[a-z]/, letter => letter.toUpperCase()), name: output.character_name, legendaryPoints: output.legendaryPoints, regionalEffectsFade: output.regionalEffectsFade, items: itemList, }; } output.is_npc = 1; output.edit_mode = 'off'; output.content_srd = _.chain(srdContentSections) .map(sectionSpec => { const items = output[sectionSpec.prop]; delete output[sectionSpec.prop]; return _.map(items, sectionSpec.itemTemplate); }) .map((sectionItems, sectionIndex) => { const sectionSpec = srdContentSections[sectionIndex]; if (!_.isEmpty(sectionItems)) { return sectionSpec.sectionTemplate(makeDataObject(sectionSpec.prop, sectionItems)); } return null; }) .compact() .value() .join('\n'); delete output.legendaryPoints; return output; }, convertSpells(spellObjects, pronounInfo) { return _.map(spellObjects, spellObject => { const converted = {}; spellMapper(null, spellObject, converted); if (converted.emote) { _.each(pronounTokens, (pronounType, token) => { const replacement = pronounInfo[pronounType]; converted.emote = converted.emote.replace(new RegExp(token, 'g'), replacement); }); } return converted; }); }, }; /***/ }, /* 23 */ /***/ function(module, exports) { function sanitise(statblock, logger) { 'use strict'; logger.debug('Pre-sanitise: $$$', statblock); statblock = statblock .replace(/\s+([\.,;:])/g, '$1') .replace(/\n+/g, '#') .replace(/–/g, '-') .replace(/−/g, '-') // Watch out: this and the line above containing funny unicode versions of '-' .replace(/]*>/g, '#') .replace(/#+/g, '#') .replace(/\s*#\s*/g, '#') .replace(/(<([^>]+)>)/gi, '') .replace(/legendary actions/gi, 'Legendary Actions') .replace(/(\S)\sACTIONS/, '$1#ACTIONS') .replace(/#(?=[a-z]|DC)/g, ' ') .replace(/\s+/g, ' ') .replace(/#Hit:/gi, 'Hit:') .replace(/Hit:#/gi, 'Hit: ') .replace(/#Each /gi, 'Each ') .replace(/#On a successful save/gi, 'On a successful save') .replace(/DC#(\d+)/g, 'DC $1') .replace('LanguagesChallenge', 'Languages -\nChallenge') .replace('\' Speed', 'Speed') .replace(/(\w+) s([\s\.,])/g, '$1s$2') .replace(/#Medium or/gi, ' Medium or') .replace(/take#(\d+)/gi, 'take $1') .replace(/#/g, '\n') .replace(/>/g, '>') .replace(/</g, '<') .replace(/&/g, '&'); logger.debug('First stage cleaned statblock: $$$', statblock); // Sometimes the texts ends up like 'P a r a l y z i n g T o u c h . M e l e e S p e l l A t t a c k : + 1 t o h i t // In this case we can fix the title case stuff, because we can find the word boundaries. That will at least meaning // that the core statblock parsing will work. If this happens inside the lowercase body text, however, there's nothing // we can do about it because you need to understand the natural language to reinsert the word breaks properly. statblock = statblock.replace(/([A-Z])(\s[a-z]){2,}/g, (match, p1) => p1 + match.slice(1).replace(/\s([a-z])/g, '$1') ); // Conversely, sometimes words get mushed together. Again, we can only fix this for title case things, but that's // better than nothing statblock = statblock.replace(/([A-Z][a-z]+)(?=[A-Z])/g, '$1 '); // This covers abilites that end up as 'C O N' or similar statblock = statblock.replace(/^[A-Z]\s?[A-Z]\s?[A-Z](?=\s|$)/mg, match => match.replace(/\s/g, '')); statblock = statblock.replace(/^[A-Z ]+$/m, match => match.replace(/([A-Z])([A-Z]+)(?=\s|$)/g, (innerMatch, p1, p2) => p1 + p2.toLowerCase()) ); statblock = statblock.replace(/(\d+)\s*?plus\s*?((?:\d+d\d+)|(?:\d+))/gi, '$2 + $1'); /* eslint-disable quote-props */ const replaceObj = { 'Jly': 'fly', ',1\'': ',*', 'jday': '/day', 'abol eth': 'aboleth', 'ACT IONS': 'ACTIONS', 'Afrightened': 'A frightened', 'Alesser': 'A lesser', 'Athl etics': 'Athletics', 'blindn ess': 'blindness', 'blind sight': 'blindsight', 'bofh': 'both', 'brea stplate': 'breastplate', 'Can trips': 'Cantrips', 'choos in g': 'choosing', 'com muni cate': 'communicate', 'Constituti on': 'Constitution', 'creatu re': 'creature', 'darkvi sion': 'darkvision', 'dea ls': 'deals', 'di sease': 'disease', 'di stance': 'distance', 'fa lls': 'falls', 'fe et': 'feet', 'exha les': 'exhales', 'ex istence': 'existence', 'lfthe': 'If the', 'Ifthe': 'If the', 'ifthe': 'if the', 'lnt': 'Int', 'magica lly': 'magically', 'Med icine': 'Medicine', 'minlilte': 'minute', 'natura l': 'natural', 'ofeach': 'of each', 'ofthe': 'of the', 'on\'e': 'one', 'on ly': 'only', '0n': 'on', 'pass ive': 'passive', 'Perce ption': 'Perception', 'radi us': 'radius', 'ra nge': 'range', 'rega ins': 'regains', 'rest.oration': 'restoration', 'savin g': 'saving', 'si lvery': 'silvery', 's lashing': 'slashing', 'slas hing': 'slashing', 'slash in g': 'slashing', 'slash ing': 'slashing', 'Spel/casting': 'Spellcasting', 'successfu l': 'successful', 'ta rget': 'target', ' Th e ': ' The ', 't_urns': 'turns', 'unti l': 'until', 'withi n': 'within', 'tohit': 'to hit', 'At wi ll': 'At will', 'per-son': 'person', 'ab ility': 'ability', 'spe ll': 'spell', }; /* eslint-enable quote-props */ const re = new RegExp(Object.keys(replaceObj).join('|'), 'g'); statblock = statblock.replace(re, (matched) => replaceObj[matched]); statblock = statblock .replace(/,\./gi, ',') .replace(/:\./g, ':') .replace(/(\W)l(\W)/g, '$11$2') .replace(/\.([\w])/g, '. $1') .replace(/1 (levelStrings[level] = `${level}th level `)); const spellcastingHandler = { splitRegex: /(Cantrips|(?:1st|2nd|3rd|[4-9]th)\s*level)\.?\s*\(([^\)]+)\)\s*:/i, makeLevelDetailsObject(match) { const levelMatch = match[1].match(/\d/); return { level: levelMatch ? parseInt(levelMatch[0], 10) : 0, slots: match[2], }; }, setLevelDetailsString(levelDetails) { levelDetails.newText = `${levelStrings[levelDetails.level]}(${levelDetails.slots}): `; levelDetails.newText += levelDetails.spells.join(', '); }, }; const innateHandler = { splitRegex: /(At\s?will|\d\s?\/\s?day)(?:\s?each)?\s?:/i, makeLevelDetailsObject(match) { const usesMatch = match[1].match(/\d/); return { uses: usesMatch ? parseInt(usesMatch[0], 10) : 0, slots: match[2], }; }, setLevelDetailsString(levelDetails) { levelDetails.newText = levelDetails.uses === 0 ? 'At will' : `${levelDetails.uses}/day`; if (levelDetails.spells.length > 1) { levelDetails.newText += ' each'; } levelDetails.newText += ': '; levelDetails.newText += levelDetails.spells.join(', '); }, }; function processSpellcastingTrait(monster, traitName, traitHandler, entityLookup) { const trait = _.findWhere(monster.traits, { name: traitName }); if (trait) { let spellList = trait.text.substring(trait.text.indexOf(':') + 1).replace('\n', ' '); const castingDetails = trait.text.substring(0, trait.text.indexOf(':')); const levelDetails = []; let match; while ((match = traitHandler.splitRegex.exec(spellList)) !== null) { if (_.last(levelDetails)) { _.last(levelDetails).spells = spellList.slice(0, match.index); } levelDetails.push(traitHandler.makeLevelDetailsObject(match)); spellList = spellList.slice(match.index + match[0].length); } if (_.last(levelDetails)) { _.last(levelDetails).spells = spellList; } let hasCastBeforeCombat = false; const spellDetailsByLevel = _.chain(levelDetails) .each(perLevelDetails => { perLevelDetails.spells = _.chain(perLevelDetails.spells.replace(',*', '*,').split(',')) .map(_.partial(_.result, _, 'trim')) .map(spellName => { const spellNameMatch = spellName.match(/([^\(\*]+)(?:\(([^\)]+)\))?(\*)?/); hasCastBeforeCombat = hasCastBeforeCombat || !!spellNameMatch[3]; return { name: spellNameMatch[1].trim(), restriction: spellNameMatch[2], castBeforeCombat: !!spellNameMatch[3], toString() { return this.name + (this.restriction ? ` (${this.restriction})` : '') + (this.castBeforeCombat ? '*' : ''); }, toSpellArrayItem() { return this.name; }, }; }) .each(spell => { spell.object = entityLookup.findEntity('spells', spell.name, true); if (spell.object) { spell.name = spell.object.name; spell.toSpellArrayItem = function toSpellArrayItem() { return this.object; }; } }) .value(); }) .each(traitHandler.setLevelDetailsString) .value(); trait.text = `${castingDetails}:\n${_.pluck(spellDetailsByLevel, 'newText').join('\n')}`; if (hasCastBeforeCombat) { trait.text += `\n* The ${monster.name.toLowerCase()} casts these spells on itself before combat.`; } const spells = _.chain(spellDetailsByLevel) .pluck('spells') .flatten() .map(_.partial(_.result, _, 'toSpellArrayItem')) .union(monster.spells ? monster.spells : []) .sortBy('name') .sortBy('level') .value(); if (!_.isEmpty(spells)) { monster.spells = spells; } } return []; } module.exports = function mpp(monsters, entityLookup) { _.each(monsters, monster => { processSpellcastingTrait(monster, 'Spellcasting', spellcastingHandler, entityLookup); processSpellcastingTrait(monster, 'Innate Spellcasting', innateHandler, entityLookup); }); }; /***/ } /******/ ]);