From 9ce30c91d165741e41d0f2350febf0606fa6b70f Mon Sep 17 00:00:00 2001 From: Lucian Date: Tue, 28 Mar 2017 10:33:49 +0200 Subject: [PATCH 1/6] feat(import): Make MM drag configure new characters closes #430 --- lib/entry-point.js | 6 +- lib/modules/monster-manager.js | 252 +------------------- lib/modules/new-character-configurer.js | 302 ++++++++++++++++++++++++ lib/modules/token-bar-configurer.js | 73 ------ test/test-monster-manager.js | 102 ++++---- 5 files changed, 358 insertions(+), 377 deletions(-) create mode 100644 lib/modules/new-character-configurer.js delete mode 100644 lib/modules/token-bar-configurer.js diff --git a/lib/entry-point.js b/lib/entry-point.js index 1f1d098..ba05399 100644 --- a/lib/entry-point.js +++ b/lib/entry-point.js @@ -17,7 +17,7 @@ const DeathSaveManager = require('./modules/death-save-manager'); const HDManager = require('./modules/hd-manager'); const FXManager = require('./modules/fx-manager'); const SpellManager = require('./modules/spell-manager'); -const TokenBarConfigurer = require('./modules/token-bar-configurer'); +const NewCharacterConfigurer = require('./modules/new-character-configurer'); const srdConverter = require('./srd-converter'); const UserError = require('./user-error'); const EventDispatcher = require('./event-dispatcher'); @@ -68,7 +68,7 @@ roll20.on('ready', () => { }); function runStartup(character, retryCount) { - const version = roll20.getAttrByName(character.id, 'version'); + const version = roll20.getAttrByName(character.id, 'version', 'current', true); const ed = new EventDispatcher(roll20, errorHandler, logger, reporter); const cw = new ChatWatcher(roll20, logger, ed); const commandProc = makeCommandProc('shaped', roll20, errorHandler, ed, version, logger); @@ -150,10 +150,10 @@ function getModuleList() { return [] .concat(new SheetWorkerChatOutput(deps)) .concat(new AbilityMaker(deps)) - .concat(new TokenBarConfigurer(deps)) .concat(new EntityLister(deps)) .concat(new Importer(deps)) .concat(new SpellManager(deps)) + .concat(new NewCharacterConfigurer(deps)) .concat(new ConfigUI(deps)) .concat(new AdvantageTracker(deps)) .concat(new UsesManager(deps)) diff --git a/lib/modules/monster-manager.js b/lib/modules/monster-manager.js index 512f7b8..d0da742 100644 --- a/lib/modules/monster-manager.js +++ b/lib/modules/monster-manager.js @@ -35,7 +35,7 @@ module.exports = class MonsterManager extends ShapedModule { this.parser = deps.parseModule.getParser(mmFormat, this.logger); this.abilityMaker = deps.abilityMaker; this.srdConverter = deps.srdConverter; - this.tokenBarConfigurer = deps.tokenBarConfigurer; + this.newCharacterConfigurer = deps.newCharacterConfigurer; this.entityLister = deps.entityLister; this.importer = deps.importer; this.spellManager = deps.spellManager; @@ -86,14 +86,6 @@ module.exports = class MonsterManager extends ShapedModule { max: Infinity, }, }) - // !shaped-token-defaults - .addCommand(['token-defaults', 'apply-defaults'], this.applyTokenDefaults.bind(this), false) - .withSelection({ - graphic: { - min: 1, - max: Infinity, - }, - }) .addCommand('update-character', this.updateCharacter.bind(this), true) .withSelection({ character: { @@ -109,49 +101,6 @@ module.exports = class MonsterManager extends ShapedModule { .option('relist', ShapedConfig.jsonValidator, false); } - registerEventListeners(eventDispatcher) { - eventDispatcher.registerEventHandler('add:character', (character) => { - if (this.myState.config.newCharSettings.applyToAll) { - this.applyCharacterDefaults(character); - } - }); - } - - applyTokenDefaults(options) { - const messageBody = _.chain(options.selected.graphic) - .map((token) => { - const represents = token.get('represents'); - const character = this.roll20.getObj('character', represents); - if (character) { - this.applyCharacterDefaults(character); - this.createTokenActions(character); - this.getTokenConfigurer(token)(character); - const isNpc = this.roll20.getAttrByName(character.id, 'is_npc'); - let sensesString; - if (isNpc === 1) { - sensesString = this.roll20.getAttrByName(character.id, 'senses'); - } - else { - sensesString = ['blindsight', 'darkvision', 'tremorsense', 'truesight'] - .map(sense => [sense, this.roll20.getAttrByName(character.id, sense)]) - .filter(senseInfo => senseInfo[1]) - .map(senseInfo => `${senseInfo[0]} ${senseInfo[1]}`) - .join(','); - } - this.getTokenVisionConfigurer(token, sensesString)(character); - this.getDefaultTokenPersister(token)(character); - return character.get('name'); - } - return null; - }) - .compact() - .value() - .join('
  • '); - - this.reportPlayer('Apply Defaults', `Character and token defaults applied for:`, - options.playerId); - } - importStatblock(options) { this.logger.info('Importing statblocks for tokens $$$', options.selected.graphic); return Promise.all(options.selected.graphic.map((token) => { @@ -272,20 +221,9 @@ module.exports = class MonsterManager extends ShapedModule { } characterRetrievalStrategies.push(this.creationRetrievalStrategy.bind(this)); - characterProcessors.push(this.monsterDataPopulator.bind(this)); - characterProcessors.push(this.applyCharacterDefaults.bind(this)); - characterProcessors.push(this.createTokenActions.bind(this)); - - if (token) { - characterProcessors.push(this.getAvatarCopier(token).bind(this)); - if (_.size(monsters) === 1) { - characterProcessors.push(this.getTokenConfigurer(token, true).bind(this)); - characterProcessors.push(this.getTokenVisionConfigurer(token, monsters[0].senses)); - characterProcessors.push(this.getTokenBarSetter(token).bind(this)); - characterProcessors.push(this.getDefaultTokenPersister(token)); - } - } + characterProcessors.push(this.monsterDataPopulator.bind(this)); + characterProcessors.push(character => this.newCharacterConfigurer.configureCharacter(token, character)); const errors = []; const importedList = []; @@ -329,7 +267,7 @@ module.exports = class MonsterManager extends ShapedModule { this.logger.debug('Converted monster data: $$$', data); let charPromise = Promise.resolve(character); - if (!this.roll20.getAttrByName(character.id, 'version')) { + if (!this.roll20.getAttrByName(character.id, 'version', 'current', true)) { charPromise = this.importer.runImportStage(character, { sheet_opened: 1 }, 'Creating character', msg); } @@ -347,7 +285,7 @@ module.exports = class MonsterManager extends ShapedModule { return (name, errors) => { if (token) { const character = this.roll20.getObj('character', token.get('represents')); - if (character && this.roll20.getAttrByName(character.id, 'locked')) { + if (character && this.roll20.getAttrByName(character.id, 'locked', 'current', true)) { errors.push(`Character with name ${character.get('name')} and id ${character.id}` + ' was locked and cannot be overwritten'); return null; @@ -365,7 +303,7 @@ module.exports = class MonsterManager extends ShapedModule { return null; } - if (chars[0] && this.roll20.getAttrByName(chars[0].id, 'locked')) { + if (chars[0] && this.roll20.getAttrByName(chars[0].id, 'locked', 'current', true)) { errors.push(`Character with name ${chars[0].get('name')} and id ${chars[0].id}` + ' was locked and cannot be overwritten'); return null; @@ -384,182 +322,6 @@ module.exports = class MonsterManager extends ShapedModule { return this.roll20.createObj('character', { name }); } - getAvatarCopier(token) { - return function avatarCopier(character) { - character.set('avatar', token.get('imgsrc')); - return character; - }; - } - - applyCharacterDefaults(character) { - const completionPromise = new Promise(resolve => this.roll20.onSheetWorkerCompleted(() => resolve(character))); - const defaults = _.chain(Utils.flattenObject(_.omit(this.myState.config.newCharSettings, 'tokenActions'))) - .reduce((result, value, key) => { - const attrName = ShapedConfig.configToAttributeLookup[key]; - if (attrName) { - result[attrName] = value; - } - return result; - }, {}) - .value(); - - this.logger.debug('Setting character defaults $$$', defaults); - - _.each(defaults, (value, key) => { - let attribute = this.roll20.getAttrObjectByName(character.id, key); - if (value === '***default***' || (_.isBoolean(value) && !value)) { - if (attribute) { - this.logger.debug('Removing attribute $$$', key); - attribute.removeWithWorker(); - } - } - else { - if (!attribute) { - attribute = this.roll20.createObj('attribute', { characterid: character.id, name: key }); - } - this.logger.debug('Setting attribute $$$ to $$$', key, value); - attribute.setWithWorker('current', _.isBoolean(value) ? 1 : value); - } - }); - return completionPromise.then(() => { - this.logger.debug('Finished setting character defaults for $$$', character.get('name')); - return character; - }); - } - - createTokenActions(character) { - const abilityNames = _.chain(this.myState.config.newCharSettings.tokenActions) - .omit('showRecharges') - .map((action, actionName) => (action === true ? actionName : action)) - .compact() - .values() - .value(); - this.abilityMaker.addAbilitiesByName(abilityNames, character, - this.myState.config.newCharSettings.tokenActions.showRecharges); - return character; - } - - getTokenConfigurer(token, monsterImport) { - return (character) => { - const isNpcLiteral = this.roll20.getAttrByName(character.id, 'is_npc'); - const isNpc = (isNpcLiteral === 1 || isNpcLiteral === '1' || monsterImport); - this.logger.debug('isNPC $$$ $$$', isNpcLiteral, isNpc); - token.set('represents', character.id); - const settings = this.myState.config.tokenSettings; - if (monsterImport) { - const name = _.isEmpty(settings.monsterTokenName) ? character.get('name') : settings.monsterTokenName; - token.set('name', name); - } - if (settings.number && isNpc && 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)) { - // We create attribute here to ensure we have control over the id - const attribute = this.roll20.getOrCreateAttr(character.id, bar.attribute); - if (attribute) { - if (bar.link && !(bar.link === 'pcOnly' && isNpc)) { - token.set(`${barName}_link`, attribute.id); - } - else { - token.set(`${barName}_link`, ''); - } - token.set(`${barName}_value`, attribute.get('current')); - if (bar.max) { - token.set(`${barName}_max`, attribute.get('max')); - } - else { - token.set(`${barName}_max`, ''); - } - token.set(`showplayers_${barName}`, bar.showPlayers); - } - } - }); - - // 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); - }); - - this.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); - return character; - }; - } - - getTokenVisionConfigurer(token, sensesString) { - if (_.isEmpty(sensesString)) { - this.logger.debug('Empty senses string, using default values'); - return _.identity; - } - - 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, Math.round(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(character) { - senses.forEach(sense => sense.configureVision()); - return character; - }; - } - - getTokenBarSetter(token) { - return (character) => { - this.tokenBarConfigurer.setTokenBarsOnDrop(token, true); - return character; - }; - } - - getDefaultTokenPersister(token) { - return (character) => { - this.roll20.setDefaultTokenForCharacter(character, token); - return character; - }; - } updateCharacter(options) { if (options.all) { @@ -575,7 +337,7 @@ module.exports = class MonsterManager extends ShapedModule { const msg = this.reporter.getMessageStreamer(`Updating ${count} characters`); return options.selected.character.reduce((promise, character, index) => promise.then(() => { - const sheetOpened = this.roll20.getAttrByName(character.id, 'sheet_opened'); + const sheetOpened = this.roll20.getAttrByName(character.id, 'sheet_opened', 'current', true); return this.importer .runImportStage(character, { sheet_opened: sheetOpened === 1 ? 0 : 1 }, `Updating character ${index + 1} - ${character.get('name')}`, msg); diff --git a/lib/modules/new-character-configurer.js b/lib/modules/new-character-configurer.js new file mode 100644 index 0000000..419605b --- /dev/null +++ b/lib/modules/new-character-configurer.js @@ -0,0 +1,302 @@ +'use strict'; +const _ = require('underscore'); +const ShapedModule = require('./../shaped-module'); +const ChatWatcher = require('./../chat-watcher'); +const ShapedConfig = require('./../shaped-config'); +const Utils = require('../utils'); + +module.exports = class NewCharacterConfigurer extends ShapedModule { + + constructor(deps) { + super(deps); + this.newCharacterPromises = {}; + this.abilityMaker = deps.abilityMaker; + this.spellManager = deps.spellManager; + } + + addCommands(commandProcessor) { + // !shaped-token-defaults + return commandProcessor.addCommand(['apply-defaults'], this.applyDefaults.bind(this), false) + .withSelection({ + graphic: { + min: 1, + max: Infinity, + }, + }); + } + + registerEventListeners(eventDispatcher) { + const pendingNewCharacters = {}; + eventDispatcher.registerEventHandler('add:token', this.configureNewToken.bind(this)); + eventDispatcher.registerEventHandler('add:character', (char) => { + this.logger.debug('New character added: $$$', char); + this.newCharacterPromises[char.id] = new Promise((resolve) => { + pendingNewCharacters[char.id] = { resolve }; + }); + }); + eventDispatcher.registerAttributeChangeHandler('sheet_processing_complete', (attr) => { + this.logger.debug('sheet_processing_complete change: $$$', attr.get('current')); + const char = this.roll20.getObj('character', attr.get('characterid')); + const pending = pendingNewCharacters[char.id]; + + const resolvePending = (expandSpells) => { + delete pendingNewCharacters[char.id]; + clearTimeout(pending.timeoutId); + let promise = this.applyCharacterDefaults(char); + + if (expandSpells) { + promise = promise.then(() => this.spellManager.importData(char, [], false, { stream: _.noop })); + } + return promise.then(pending.resolve); + }; + + switch (attr.get('current')) { + case 'initialize': + pending.timeoutId = _.delay(resolvePending, 2000); + return null; + case 'drag_from_srd': + return resolvePending(true); + default: + // Noop + } + return null; + }); + } + + + applyDefaults(options) { + return Promise.all(_.chain(options.selected.graphic) + .map((token) => { + const represents = token.get('represents'); + const character = this.roll20.getObj('character', represents); + return character && this.configureCharacter(token, character); + }) + .compact() + .value()) + .then((characters) => { + const messageBody = characters.map(character => character.get('name')).join('
  • '); + this.reportPlayer('Apply Defaults', `Character and token defaults applied for:`, + options.playerId); + }); + } + + configureCharacter(token, character) { + this.logger.debug('Configuring character: $$$', character); + return this.applyCharacterDefaults(character).then(() => { + this.configureCoreTokenSettings(token, character); + this.setTokenBarsOnDrop(token, character); + return character.get('name'); + }); + } + + configureNewToken(token) { + this.logger.debug('New token added: $$$', token); + const character = this.roll20.getObj('character', token.get('represents')); + if (!character) { + return null; + } + + if (this.newCharacterPromises[character.id]) { + return this.newCharacterPromises[character.id].then(() => { + this.configureCoreTokenSettings(token, character); + this.setTokenBarsOnDrop(token, character); + delete this.newCharacterPromises[character.id]; + }); + } + + this.setTokenBarsOnDrop(token, character); + return null; + } + + copyAvatar(token, character) { + const imgsrc = token.get('imgsrc'); + if ((imgsrc.startsWith('https://s3.amazonaws.com/files.staging.d20.io/images/') || + imgsrc.startsWith('https://s3.amazonaws.com/files.d20.io/images/')) + && imgsrc.replace(/\/[^.]+\.png/, '') !== character.get('avatar').replace(/\/[^.]+\.png/, '')) { + const fixedImgSrc = imgsrc.test(/\?\d+$/) ? imgsrc : `${imgsrc}`; + character.set('avatar', fixedImgSrc); + } + } + + configureCoreTokenSettings(token, character) { + this.logger.debug('Configuring core token settings for character $$$', character.get('name')); + const isNpcLiteral = this.roll20.getAttrByName(character.id, 'is_npc'); + const isNpc = (isNpcLiteral === 1 || isNpcLiteral === '1'); + + token.set('represents', character.id); + const settings = this.myState.config.tokenSettings; + if (isNpc) { + const name = _.isEmpty(settings.monsterTokenName) ? character.get('name') : settings.monsterTokenName; + token.set('name', name); + + if (settings.number && !token.get('name').includes('%%NUMBERED%%')) { + const baseName = token.get('name').replace(/(.*) \d+$/, '$1'); + token.set('name', `${baseName} %%NUMBERED%%`); + } + } + + _.chain(settings) + .pick(['bar1', 'bar2', 'bar3']) + .each((bar, barName) => { + if (!_.isEmpty(bar.attribute)) { + // We create attribute here to ensure we have control over the id + const attribute = this.roll20.getOrCreateAttr(character.id, bar.attribute); + if (attribute) { + if (bar.link && !(bar.link === 'pcOnly' && isNpc)) { + token.set(`${barName}_link`, attribute.id); + } + else { + token.set(`${barName}_link`, ''); + } + token.set(`showplayers_${barName}`, bar.showPlayers); + } + } + }); + + // 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); + }); + + this.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.configureTokenVision(token, character); + this.copyAvatar(token, character); + this.roll20.setDefaultTokenForCharacter(character, token); + } + + configureTokenVision(token, character) { + function fullRadiusLightConfigurer(lightRadius) { + token.set('light_radius', Math.max(token.get('light_radius') || 0, lightRadius)); + token.set('light_dimradius', Math.max(token.get('light_dimradius') || 0, lightRadius)); + } + + function darkvisionLightConfigurer(lightRadius) { + token.set('light_radius', Math.max(token.get('light_radius') || 0, Math.round(lightRadius * 1.1666666))); + if (!token.get('light_dimradius')) { + token.set('light_dimradius', -5); + } + } + + ['blindsight', 'truesight', 'tremorsense', 'darkvision'].forEach((sense) => { + const radius = this.roll20.getAttrByName(character.id, sense, 'current', true); + if (radius) { + const configureVision = (sense === 'darkvision' ? darkvisionLightConfigurer : fullRadiusLightConfigurer); + configureVision(parseInt(radius, 10)); + } + }); + } + + + setTokenBarsOnDrop(token, character) { + this.logger.debug('Setting token bars on drop $$$', token); + + function setBar(barName, bar, value) { + if (value) { + token.set(`${barName}_value`, value); + if (bar.max) { + token.set(`${barName}_max`, value); + } + } + } + + _.chain(this.myState.config.tokenSettings) + .pick('bar1', 'bar2', 'bar3') + .each((bar, barName) => { + if (bar.attribute && !token.get(`${barName}_link`)) { + if (bar.attribute === 'HP' && this.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(this.roll20.getAttrByName(character.id, 'hp_formula', 'current', true))) { + this.logger.debug('Ignoring character $$$ for rolling HP - has no hp_formula attribute', + character.get('name')); + return; + } + this.roll20.sendChat('', `%{${character.get('name')}|shaped_npc_hp}`, (results) => { + if (results && results.length === 1) { + const message = ChatWatcher.processInlinerolls(results[0]); + if (!results[0].inlinerolls || !results[0].inlinerolls[0]) { + this.logger.warn('HP roll didn\'t have the expected structure. This is what we got back: $$$', + results[0]); + } + else { + this.roll20.sendChat('HP Roller', `/w GM &{template:5e-shaped} ${message}`, null, + { noarchive: true }); + setBar(barName, bar, results[0].inlinerolls[0].results.total); + } + } + }); + } + else { + setBar(barName, bar, this.roll20.getAttrByName(character.id, bar.attribute)); + } + } + }); + } + + applyCharacterDefaults(character) { + const completionPromise = new Promise(resolve => this.roll20.onSheetWorkerCompleted(() => resolve(character))); + const defaults = _.chain(Utils.flattenObject(_.omit(this.myState.config.newCharSettings, 'tokenActions'))) + .reduce((result, value, key) => { + const attrName = ShapedConfig.configToAttributeLookup[key]; + if (attrName) { + result[attrName] = value; + } + return result; + }, {}) + .value(); + + this.logger.debug('Setting character defaults $$$', defaults); + + _.each(defaults, (value, key) => { + let attribute = this.roll20.getAttrObjectByName(character.id, key); + if (value === '***default***' || (_.isBoolean(value) && !value)) { + if (attribute) { + this.logger.debug('Removing attribute $$$', key); + attribute.removeWithWorker(); + } + } + else { + if (!attribute) { + attribute = this.roll20.createObj('attribute', { characterid: character.id, name: key }); + } + this.logger.debug('Setting attribute $$$ to $$$', key, value); + attribute.setWithWorker('current', _.isBoolean(value) ? 1 : value); + } + }); + + this.createTokenActions(character); + + return completionPromise.then(() => { + this.logger.debug('Finished setting character defaults for $$$', character.get('name')); + _.delay(() => delete this.newCharacterPromises[character.id], 5000); + return character; + }); + } + + createTokenActions(character) { + const abilityNames = _.chain(this.myState.config.newCharSettings.tokenActions) + .omit('showRecharges') + .map((action, actionName) => (action === true ? actionName : action)) + .compact() + .values() + .value(); + this.abilityMaker.addAbilitiesByName(abilityNames, character, + this.myState.config.newCharSettings.tokenActions.showRecharges); + return character; + } +}; diff --git a/lib/modules/token-bar-configurer.js b/lib/modules/token-bar-configurer.js deleted file mode 100644 index 70ea316..0000000 --- a/lib/modules/token-bar-configurer.js +++ /dev/null @@ -1,73 +0,0 @@ -'use strict'; -const _ = require('underscore'); -const ShapedModule = require('./../shaped-module'); -const ChatWatcher = require('./../chat-watcher'); - -module.exports = class TokenBarConfigurer extends ShapedModule { - - registerEventListeners(eventDispatcher) { - eventDispatcher.registerEventHandler('change:attribute', (curr) => { - const barAttributes = _.chain(this.myState.config.tokenSettings) - .pick('bar1', 'bar2', 'bar3') - .pluck('attribute') - .compact() - .map(attrName => (attrName === 'HP' ? 'hp_formula' : attrName)) - .value(); - - if (_.contains(barAttributes, curr.get('name'))) { - this.roll20.findObjs({ type: 'graphic', represents: curr.get('characterid') }) - .forEach(token => this.setTokenBarsOnDrop(token, false)); - } - }); - eventDispatcher.registerEventHandler('add:token', token => this.setTokenBarsOnDrop(token, true)); - } - - setTokenBarsOnDrop(token, overwrite) { - const character = this.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(this.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' && this.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(this.roll20.getAttrByName(character.id, 'hp_formula'))) { - this.logger.debug('Ignoring character $$$ for rolling HP - has no hp_formula attribute', - character.get('name')); - return; - } - this.roll20.sendChat('', `%{${character.get('name')}|shaped_npc_hp}`, (results) => { - if (results && results.length === 1) { - const message = ChatWatcher.processInlinerolls(results[0]); - if (!results[0].inlinerolls || !results[0].inlinerolls[0]) { - this.logger.warn('HP roll didn\'t have the expected structure. This is what we got back: $$$', - results[0]); - } - else { - this.roll20.sendChat('HP Roller', `/w GM &{template:5e-shaped} ${message}`, null, - { noarchive: true }); - setBar(barName, bar, results[0].inlinerolls[0].results.total); - } - } - }); - } - else { - setBar(barName, bar, this.roll20.getAttrByName(character.id, bar.attribute)); - } - } - }); - } -}; diff --git a/test/test-monster-manager.js b/test/test-monster-manager.js index 81c858b..50fdfbe 100644 --- a/test/test-monster-manager.js +++ b/test/test-monster-manager.js @@ -53,12 +53,16 @@ describe('monster-manager', function () { }, }, srdConverter: { convertMonster: _.identity, convertSpells: _.identity }, - tokenBarConfigurer: { setTokenBarsOnDrop: _.identity }, + newCharacterConfigurer: { configureCharacter(token, character) { return character; } }, entityLister: { addEntity: _.noop }, }); - monsterManager.configure(cp, null, { registerEventHandler: _.noop }); + monsterManager.configure(cp, null, { registerEventHandler: _.noop, registerAttributeChangeHandler: _.noop }); }); + // after(function() { + // sinon.restore + // }); + describe('#importMonsters', function () { it('token, no existing character, one monster', function () { const monsters = [ @@ -72,8 +76,6 @@ describe('monster-manager', function () { roll20.createObj.withArgs('character', { name: 'monster' }).returns(character); }, function (character, attributes, token) { - expect(token.props.represents).to.equal(character.id); - expect(character.props.avatar).to.equal('imgsrc'); }); }); @@ -90,8 +92,6 @@ describe('monster-manager', function () { roll20.createObj.withArgs('character', { name: 'monster' }).returns(character); }, function (character, attributes, token) { - expect(token.props.represents).to.equal(character.id); - expect(character.props.avatar).to.equal('imgsrc'); }); }); @@ -108,8 +108,6 @@ describe('monster-manager', function () { roll20.getObj.withArgs('character', character.id).returns(character); }, function (character, attributes, token) { - expect(token.props.represents).to.equal(character.id); - expect(character.props.avatar).to.equal('imgsrc'); }); }); @@ -127,8 +125,6 @@ describe('monster-manager', function () { roll20.getObj.withArgs('character', character.id).returns(character); }, function (character, attributes, token) { - expect(token.props.represents).to.equal(character.id); - expect(character.props.avatar).to.equal('imgsrc'); }); }); @@ -145,51 +141,49 @@ describe('monster-manager', function () { roll20.findObjs.withArgs({ type: 'character', name: 'monster' }).returns([character]); }, function (character, attributes, token) { - expect(token.props.represents).to.equal(character.id); - expect(character.props.avatar).to.equal('imgsrc'); }); }); }); - describe('#getTokenVisionConfigurer', function () { - it('should configure senses correctly', function () { - const token = new Roll20Object('graphic'); - monsterManager.getTokenVisionConfigurer(token, 'blindsight 80ft. tremorsense 60ft.')(); - expect(token.props).to.have.property('light_radius', 80); - expect(token.props).to.have.property('light_dimradius', 80); - }); - - it('should handle senses with conditions', function () { - const token = new Roll20Object('graphic'); - monsterManager.getTokenVisionConfigurer(token, - 'blindsight 30 ft. or 10 ft. while deafened (blind beyond this radius)')(); - expect(token.props).to.have.property('light_radius', 30); - expect(token.props).to.have.property('light_dimradius', 30); - }); - - it('should handle darkvision with another sense', function () { - const token = new Roll20Object('graphic'); - monsterManager.getTokenVisionConfigurer(token, - 'darkvision 40ft., tremorsense 20ft.')(); - expect(token.props).to.have.property('light_radius', Math.round(40 * 1.1666666)); - expect(token.props).to.have.property('light_dimradius', 20); - }); - - it('should handle darkvision with another better sense', function () { - const token = new Roll20Object('graphic'); - monsterManager.getTokenVisionConfigurer(token, - 'darkvision 30ft., tremorsense 40ft.')(); - expect(token.props).to.have.property('light_radius', 40); - expect(token.props).to.have.property('light_dimradius', 40); - }); - - it('should handle darkvision on its own', function () { - const token = new Roll20Object('graphic'); - monsterManager.getTokenVisionConfigurer(token, 'darkvision 30ft.')(); - expect(token.props).to.have.property('light_radius', Math.round(30 * 1.1666666)); - expect(token.props).to.have.property('light_dimradius', -5); - }); - }); + // describe('#getTokenVisionConfigurer', function () { + // it('should configure senses correctly', function () { + // const token = new Roll20Object('graphic'); + // monsterManager.getTokenVisionConfigurer(token, 'blindsight 80ft. tremorsense 60ft.')(); + // expect(token.props).to.have.property('light_radius', 80); + // expect(token.props).to.have.property('light_dimradius', 80); + // }); + // + // it('should handle senses with conditions', function () { + // const token = new Roll20Object('graphic'); + // monsterManager.getTokenVisionConfigurer(token, + // 'blindsight 30 ft. or 10 ft. while deafened (blind beyond this radius)')(); + // expect(token.props).to.have.property('light_radius', 30); + // expect(token.props).to.have.property('light_dimradius', 30); + // }); + // + // it('should handle darkvision with another sense', function () { + // const token = new Roll20Object('graphic'); + // monsterManager.getTokenVisionConfigurer(token, + // 'darkvision 40ft., tremorsense 20ft.')(); + // expect(token.props).to.have.property('light_radius', Math.round(40 * 1.1666666)); + // expect(token.props).to.have.property('light_dimradius', 20); + // }); + // + // it('should handle darkvision with another better sense', function () { + // const token = new Roll20Object('graphic'); + // monsterManager.getTokenVisionConfigurer(token, + // 'darkvision 30ft., tremorsense 40ft.')(); + // expect(token.props).to.have.property('light_radius', 40); + // expect(token.props).to.have.property('light_dimradius', 40); + // }); + // + // it('should handle darkvision on its own', function () { + // const token = new Roll20Object('graphic'); + // monsterManager.getTokenVisionConfigurer(token, 'darkvision 30ft.')(); + // expect(token.props).to.have.property('light_radius', Math.round(30 * 1.1666666)); + // expect(token.props).to.have.property('light_dimradius', -5); + // }); + // }); function runImportMonsterTest(monsters, options, preConfigure, expectationChecker) { @@ -198,7 +192,6 @@ describe('monster-manager', function () { sinon.stub(roll20, 'getAttrByName'); sinon.stub(roll20, 'sendChat'); sinon.stub(roll20, 'getObj'); - sinon.stub(roll20, 'setDefaultTokenForCharacter'); const token = new Roll20Object('graphic'); token.set('imgsrc', 'imgsrc'); @@ -215,7 +208,6 @@ describe('monster-manager', function () { attributes[name] = attr; }); - roll20.findObjs.withArgs({ type: 'attribute', characterid: character.id }).returns([]); sinon.stub(roll20, 'getOrCreateAttr', function (characterId, name) { expect(characterId).to.equal(character.id); const attr = new Roll20Object('attribute'); @@ -223,10 +215,8 @@ describe('monster-manager', function () { attr.remove = _.noop; return attr; }); - roll20.getAttrByName.withArgs(character.id, 'locked').returns(null); - sinon.stub(monsterManager, 'applyCharacterDefaults').returns(character); + roll20.getAttrByName.withArgs(character.id, 'locked', 'current', true).returns(null); sinon.stub(monsterManager, 'monsterDataPopulator').returns(character); - sinon.stub(monsterManager, 'createTokenActions').returns(character); return monsterManager.importMonsters(monsters, options, token, []).then(() => { expectationChecker(character, attributes, token); From c2300effd24d13be1756cf41108b4603688d2e78 Mon Sep 17 00:00:00 2001 From: Lucian Date: Tue, 4 Apr 2017 22:44:00 +0200 Subject: [PATCH 2/6] fix: Prevent missing attribute Roll20 error --- lib/command-parser.js | 2 +- lib/modules/ability-maker.js | 2 +- lib/modules/death-save-manager.js | 2 +- lib/modules/hd-manager.js | 3 ++- lib/modules/rest-manager.js | 2 +- lib/modules/spell-manager.js | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/command-parser.js b/lib/command-parser.js index 5badc3a..3c41645 100644 --- a/lib/command-parser.js +++ b/lib/command-parser.js @@ -244,7 +244,7 @@ function processSelection(selection, constraints, roll20, requiredCharVersion) { if (type === 'character' && object) { const char = roll20.getObj('character', object.get('represents')); if (!constraintDetails.anyVersion) { - const version = roll20.getAttrByName(char.id, 'version'); + const version = roll20.getAttrByName(char.id, 'version', 'current', true); if (version !== requiredCharVersion) { throw new UserError(`Character ${char.get('name')} is not at the required sheet version ` + `[${requiredCharVersion}], but instead [${version}]. Try opening the character sheet or running ` + diff --git a/lib/modules/ability-maker.js b/lib/modules/ability-maker.js index 98aa5eb..52fc0ae 100644 --- a/lib/modules/ability-maker.js +++ b/lib/modules/ability-maker.js @@ -73,7 +73,7 @@ class RepeatingAbilityMaker extends MacroMaker { let name = Utils.toTitleCase(repeatingName); if (options.showRecharges) { const recharge = this.roll20.getAttrByName(character.id, - `repeating_${this.repeatingSection}_${repeatingId}_recharge`); + `repeating_${this.repeatingSection}_${repeatingId}_recharge`, 'current', true); if (RECHARGE_LOOKUP[recharge]) { name += ` ${RECHARGE_LOOKUP[recharge]}`; } diff --git a/lib/modules/death-save-manager.js b/lib/modules/death-save-manager.js index a9edbb0..486269d 100644 --- a/lib/modules/death-save-manager.js +++ b/lib/modules/death-save-manager.js @@ -11,7 +11,7 @@ module.exports = class DeathSaveManager extends ShapedModule { if (this.roll20.getAttrByName(options.character.id, 'shaped_d20') === '1d20') { return; // Sheet is set to Roll 2 - we don't know if the character has (dis)advantage so automation isn't possible } - const currentHP = parseInt(this.roll20.getAttrByName(options.character.id, 'HP'), 10); + const currentHP = parseInt(this.roll20.getAttrByName(options.character.id, 'HP', 'current', true), 10); if (currentHP > 0) { this.reportResult('Death Saves', `${options.character.get('name')} has more than 0 HP and shouldn't be rolling ` + 'death saves', options); diff --git a/lib/modules/hd-manager.js b/lib/modules/hd-manager.js index 35009f3..6b386df 100644 --- a/lib/modules/hd-manager.js +++ b/lib/modules/hd-manager.js @@ -14,7 +14,8 @@ module.exports = class HDManager extends ShapedModule { const hdSize = match[2]; const hdAttr = this.roll20.getAttrObjectByName(options.character.id, `hd_d${hdSize}`); const hpAttr = this.roll20.getOrCreateAttr(options.character.id, 'HP'); - const maxReduction = parseInt(this.roll20.getAttrByName(options.character.id, 'hp_max_reduced'), 10); + const maxReduction = parseInt( + this.roll20.getAttrByName(options.character.id, 'hp_max_reduced', 'current', true), 10); const regained = Math.max(0, parseInt(options.roll1, 10)); const fullMax = hpAttr.get('max') || Infinity; const reducedMax = maxReduction ? fullMax - maxReduction : fullMax; diff --git a/lib/modules/rest-manager.js b/lib/modules/rest-manager.js index dd0bc26..8090535 100644 --- a/lib/modules/rest-manager.js +++ b/lib/modules/rest-manager.js @@ -66,7 +66,7 @@ class RestManager extends ShapedModule { doRest(char, type) { const attribute = REST_ATTRIBUTES[type]; - const currentVal = this.roll20.getAttrByName(char.id, attribute); + const currentVal = this.roll20.getAttrByName(char.id, attribute, 'current', true); this.roll20.setAttrWithWorker(char.id, attribute, !currentVal, () => { const output = this.roll20.getAttrObjectByName(char.id, 'sheet_chat_output'); const additional = (type === 'turn') ? this.rechargeDieRollUses(char.id) : ''; diff --git a/lib/modules/spell-manager.js b/lib/modules/spell-manager.js index 9dada45..dcd5dd3 100644 --- a/lib/modules/spell-manager.js +++ b/lib/modules/spell-manager.js @@ -339,7 +339,7 @@ module.exports = class SpellManager extends ShapedModule { } getPronounInfo(character) { - const gender = this.roll20.getAttrByName(character.id, 'gender'); + const gender = this.roll20.getAttrByName(character.id, 'gender', 'current', true); const defaultIndex = Math.min(this.myState.config.defaultGenderIndex, this.myState.config.genderPronouns.length); const defaultPronounInfo = this.myState.config.genderPronouns[defaultIndex]; From 27edbb0992ced199b7ac95923b4a86bf0893b30d Mon Sep 17 00:00:00 2001 From: Lucian Date: Tue, 4 Apr 2017 22:44:38 +0200 Subject: [PATCH 3/6] fix: Prevent errors from occasionally being swallowed --- lib/event-dispatcher.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/event-dispatcher.js b/lib/event-dispatcher.js index a572ddf..32998e6 100644 --- a/lib/event-dispatcher.js +++ b/lib/event-dispatcher.js @@ -121,7 +121,10 @@ module.exports = class EventDispatcher { const self = this; return function handlerWrapper() { try { - handler.apply(null, arguments); + const retVal = handler.apply(null, arguments); + if (retVal instanceof Promise) { + retVal.catch(self.errorHandler); + } } catch (e) { self.errorHandler(e); From f9b3170ae189e3099f513f3bae8858e488ab652e Mon Sep 17 00:00:00 2001 From: Lucian Date: Tue, 4 Apr 2017 22:45:12 +0200 Subject: [PATCH 4/6] docs: Add --as to documentation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e07f023..f30b04a 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ Imports details of named monsters from a database of custom monsters loaded as a * **--** (e.g. **--Lich**) specifies a monster to import. You may supply multiple monsters as separate options, or you may supply multiple in one option separated by commas (**--Ghoul, Zombie, Ghost**) * **--overwrite** if the selected token already represents a character in the journal, the import will fail to avoid accidentally overwriting data, unless you supply this option to confirm that you wish to do so. * **--replace** if there is already a character in the journal with the same name as the one you are importing, the import will fail, whether or not the current token represents that character. This is to avoid creating loads of duplicates by mistake, which is almost never what you want to do. If you supply **--replace** the script will overwrite any character with the same name, unless there is more than one, in which case it will fail rather than risking overwriting the wrong one. Note that **--replace** implies **--overwrite**. +* **--as ** if supplied, the new monster will be given the name specified instead of the default name defined in the database. ### Selection You may no or 1 tokens selected when running this command: @@ -300,7 +301,6 @@ You must have at least one token that represents a character selected for this c Display configuration UI to change default behaviours. The significance of all the options is detailed [below](#configuration) ## !shaped-apply-defaults -* Alias !shaped-token-defaults * Apply the same defaults that are used when setting up tokens on import to whatever tokens are currently selected. Useful for mass-configuring manually created tokens. See [below](#config-token-settings) for more details on what these options are. ### Selection From 17e3b49d4b199830381db46b0ca02388b85df963 Mon Sep 17 00:00:00 2001 From: Lucian Date: Thu, 6 Apr 2017 23:09:56 +0200 Subject: [PATCH 5/6] chore: Bump minimum sheet version --- lib/entry-point.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/entry-point.js b/lib/entry-point.js index ba05399..a64dcfe 100644 --- a/lib/entry-point.js +++ b/lib/entry-point.js @@ -35,7 +35,7 @@ const logger = new Logger('5eShapedCompanion', roll20); const entityLookup = new EntityLookup(logger); const reporter = new Reporter(roll20, 'Shaped Scripts'); -const MINIMUM_SHEET_VERSION = '11.2.0'; +const MINIMUM_SHEET_VERSION = '11.4.0'; const SHEET_API_VERSION = '1'; const errorHandler = function errorHandler(e) { From 106155fbe3be9eb8fd3309afa191299c810fdca9 Mon Sep 17 00:00:00 2001 From: Lucian Date: Fri, 7 Apr 2017 20:13:59 +0200 Subject: [PATCH 6/6] fix(token-defaults): Fix a few bugs with new token defaults code BREAKING CHANGES: This version will no longer work with sheets prior to 11.4.0 --- lib/modules/new-character-configurer.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/lib/modules/new-character-configurer.js b/lib/modules/new-character-configurer.js index 419605b..da90326 100644 --- a/lib/modules/new-character-configurer.js +++ b/lib/modules/new-character-configurer.js @@ -83,9 +83,11 @@ module.exports = class NewCharacterConfigurer extends ShapedModule { configureCharacter(token, character) { this.logger.debug('Configuring character: $$$', character); return this.applyCharacterDefaults(character).then(() => { - this.configureCoreTokenSettings(token, character); - this.setTokenBarsOnDrop(token, character); - return character.get('name'); + if (token) { + this.configureCoreTokenSettings(token, character); + this.setTokenBarsOnDrop(token, character); + } + return character; }); } @@ -113,7 +115,7 @@ module.exports = class NewCharacterConfigurer extends ShapedModule { if ((imgsrc.startsWith('https://s3.amazonaws.com/files.staging.d20.io/images/') || imgsrc.startsWith('https://s3.amazonaws.com/files.d20.io/images/')) && imgsrc.replace(/\/[^.]+\.png/, '') !== character.get('avatar').replace(/\/[^.]+\.png/, '')) { - const fixedImgSrc = imgsrc.test(/\?\d+$/) ? imgsrc : `${imgsrc}`; + const fixedImgSrc = imgsrc.match(/\?\d+$/) ? imgsrc : `${imgsrc}`; character.set('avatar', fixedImgSrc); } } @@ -142,12 +144,18 @@ module.exports = class NewCharacterConfigurer extends ShapedModule { // We create attribute here to ensure we have control over the id const attribute = this.roll20.getOrCreateAttr(character.id, bar.attribute); if (attribute) { + const value = attribute.get('current'); + const max = attribute.get('max'); if (bar.link && !(bar.link === 'pcOnly' && isNpc)) { token.set(`${barName}_link`, attribute.id); } else { token.set(`${barName}_link`, ''); } + token.set(`${barName}_value`, value); + if (bar.max) { + token.set(`${barName}_max`, max); + } token.set(`showplayers_${barName}`, bar.showPlayers); } } @@ -205,11 +213,11 @@ module.exports = class NewCharacterConfigurer extends ShapedModule { setTokenBarsOnDrop(token, character) { this.logger.debug('Setting token bars on drop $$$', token); - function setBar(barName, bar, value) { + function setBar(barName, bar, value, max) { if (value) { token.set(`${barName}_value`, value); if (bar.max) { - token.set(`${barName}_max`, value); + token.set(`${barName}_max`, max || value); } } } @@ -242,7 +250,8 @@ module.exports = class NewCharacterConfigurer extends ShapedModule { }); } else { - setBar(barName, bar, this.roll20.getAttrByName(character.id, bar.attribute)); + setBar(barName, bar, this.roll20.getAttrByName(character.id, bar.attribute), + this.roll20.getAttrByName(character.id, bar.attribute, 'max')); } } });