From 35ba53896ea0f8e4c099456e5f00d37bc66071a4 Mon Sep 17 00:00:00 2001 From: Lucian Date: Sun, 26 Feb 2017 01:28:16 +0100 Subject: [PATCH] feat(config): Check against sheet version on startup closes #12 --- lib/chat-watcher.js | 84 ++++ lib/command-parser.js | 30 +- lib/entry-point.js | 77 +++- lib/event-dispatcher.js | 90 +++++ lib/{ => modules}/ability-maker.js | 6 +- lib/{ => modules}/advantage-tracker.js | 25 +- lib/{ => modules}/ammo-manager.js | 8 +- lib/{ => modules}/config-ui.js | 6 +- lib/modules/death-save-manager.js | 62 +++ lib/modules/fx-manager.js | 78 ++++ lib/modules/hd-manager.js | 37 ++ lib/{ => modules}/importer.js | 16 +- lib/{ => modules}/rest-manager.js | 6 +- lib/modules/spell-manager.js | 59 +++ lib/modules/token-bar-configurer.js | 73 ++++ lib/{ => modules}/uses-manager.js | 8 +- lib/shaped-module.js | 15 +- lib/shaped-script.js | 524 ------------------------- lib/utils.js | 5 + test/test-ability-maker.js | 2 +- test/test-ammo-manager.js | 6 +- test/test-chat-watcher.js | 33 ++ test/test-command-parser.js | 7 +- test/test-importer.js | 17 +- test/test-rest-manager.js | 2 +- test/test-shaped-script.js | 92 ----- test/test-spell-manager.js | 51 +++ test/test-uses-manager.js | 6 +- test/test-utils.js | 10 + 29 files changed, 751 insertions(+), 684 deletions(-) create mode 100644 lib/chat-watcher.js create mode 100644 lib/event-dispatcher.js rename lib/{ => modules}/ability-maker.js (98%) rename lib/{ => modules}/advantage-tracker.js (87%) rename lib/{ => modules}/ammo-manager.js (82%) rename lib/{ => modules}/config-ui.js (99%) create mode 100644 lib/modules/death-save-manager.js create mode 100644 lib/modules/fx-manager.js create mode 100644 lib/modules/hd-manager.js rename lib/{ => modules}/importer.js (98%) rename lib/{ => modules}/rest-manager.js (98%) create mode 100644 lib/modules/spell-manager.js create mode 100644 lib/modules/token-bar-configurer.js rename lib/{ => modules}/uses-manager.js (88%) delete mode 100644 lib/shaped-script.js create mode 100644 test/test-chat-watcher.js delete mode 100644 test/test-shaped-script.js create mode 100644 test/test-spell-manager.js diff --git a/lib/chat-watcher.js b/lib/chat-watcher.js new file mode 100644 index 0000000..d0ba5e5 --- /dev/null +++ b/lib/chat-watcher.js @@ -0,0 +1,84 @@ +'use strict'; + +const _ = require('underscore'); + +module.exports = class ChatWatcher { + constructor(roll20, logger, eventDispatcher) { + this.roll20 = roll20; + this.logger = logger; + this.eventDispatcher = eventDispatcher; + this.chatListeners = []; + logger.wrapModule(this); + eventDispatcher.registerEventHandler('chat:message', (msg) => { + if (msg.type !== 'api') { + this.triggerChatListeners(msg); + } + }); + } + + registerChatListener(triggerFields, handler) { + const matchers = []; + if (triggerFields && !_.isEmpty(triggerFields)) { + matchers.push((msg, options) => { + this.logger.debug('Matching options: $$$ against triggerFields $$$', options, triggerFields); + return _.intersection(triggerFields, _.keys(options)).length === triggerFields.length; + }); + } + this.chatListeners.push({ matchers, handler }); + } + + triggerChatListeners(msg) { + const options = this.getRollTemplateOptions(msg); + this.logger.debug('Roll template options: $$$', options); + _.each(this.chatListeners, (listener) => { + if (_.every(listener.matchers, matcher => matcher(msg, options))) { + listener.handler(options, msg); + } + }); + } + + /** + * + * @returns {*} + */ + getRollTemplateOptions(msg) { + if (msg.rolltemplate === '5e-shaped') { + const regex = /\{\{(.*?)}}/g; + let match; + const options = {}; + while ((match = regex.exec(ChatWatcher.processInlinerolls(msg)))) { + 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 = this.roll20.findObjs({ + _type: 'character', + name: options.characterName, + })[0]; + } + return options; + } + return {}; + } + + static 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; + } + + get logWrap() { + return 'ChatWatcher'; + } +}; diff --git a/lib/command-parser.js b/lib/command-parser.js index ca1c129..3671707 100644 --- a/lib/command-parser.js +++ b/lib/command-parser.js @@ -2,7 +2,6 @@ const _ = require('underscore'); const utils = require('./utils'); const UserError = require('./user-error'); -const ShapedModule = require('./shaped-module'); function getParser(optionString, validator) { @@ -270,27 +269,10 @@ function processSelection(selection, constraints, roll20, requiredCharVersion) { }, {}); } -module.exports = function commandParser(rootCommand, roll20, errorHandler) { +module.exports = function commandParser(rootCommand, roll20, errorHandler, eventDispatcher, requiredCharVersion) { const commands = {}; - let requiredCharVersion = null; - return { - setRequiredCharacterVersion(version) { - requiredCharVersion = version; - }, - - 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); - }, + const cp = { processCommand(msg) { const prefix = `!${rootCommand}-`; if (msg.type === 'api' && msg.content.indexOf(prefix) === 0) { @@ -309,6 +291,14 @@ module.exports = function commandParser(rootCommand, roll20, errorHandler) { } }, + 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; + }, + logWrap: 'commandParser', }; + eventDispatcher.registerEventHandler('chat:message', cp.processCommand.bind(cp)); + return cp; }; diff --git a/lib/entry-point.js b/lib/entry-point.js index 72b75c2..3d52683 100644 --- a/lib/entry-point.js +++ b/lib/entry-point.js @@ -7,7 +7,25 @@ const EntityLookup = require('./entity-lookup'); const JSONValidator = require('./json-validator'); const EntityLookupResultReporter = require('./entity-lookup-result-reporter'); const Reporter = require('./reporter'); -const ShapedScripts = require('./shaped-script'); +const makeCommandProc = require('./command-parser'); +const AbilityMaker = require('./modules/ability-maker'); +const ConfigUI = require('./modules/config-ui'); +const AdvantageTracker = require('./modules/advantage-tracker'); +const RestManager = require('./modules/rest-manager'); +const UsesManager = require('./modules/uses-manager'); +const AmmoManager = require('./modules/ammo-manager'); +const Importer = require('./modules/importer'); +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 srdConverter = require('./srd-converter'); +const UserError = require('./user-error'); +const Migrator = require('./migrations'); +const EventDispatcher = require('./event-dispatcher'); +const ChatWatcher = require('./chat-watcher'); +const utils = require('./utils'); const roll20 = new Roll20(); @@ -15,9 +33,23 @@ const myState = roll20.getState('ShapedScripts'); const logger = new Logger('5eShapedCompanion', roll20); const el = new EntityLookup(); const reporter = new Reporter(roll20, 'Shaped Scripts'); -const shaped = new ShapedScripts(logger, myState, roll20, parseModule.getParser(mmFormat, logger), el, reporter); + +const errorHandler = function errorHandler(e) { + if (typeof e === 'string' || e instanceof parseModule.ParserError || e instanceof UserError) { + reporter.reportError(e); + logger.error('Error: $$$', e.toString()); + } + else { + logger.error(e.toString()); + logger.error(e.stack); + reporter.reportError('An error occurred. Please see the log for more details.'); + } +}; + const elrr = new EntityLookupResultReporter(logger, reporter); +const MINIMUM_SHEET_VERSION = '9.2.2'; + roll20.logWrap = 'roll20'; logger.wrapModule(el); @@ -29,8 +61,27 @@ el.configureEntity('monsters', [EntityLookup.jsonValidatorAsEntityProcessor(json el.configureEntity('spells', [], EntityLookup.getVersionChecker('1.0.0', 'spells')); roll20.on('ready', () => { - shaped.checkInstall(); - shaped.registerEventHandlers(); + logger.info('-=> ShapedScripts %%GULP_INJECT_VERSION%% <=-'); + Migrator.migrateShapedConfig(myState, logger); + const character = roll20.createObj('character', { name: 'SHAPED_VERSION_TESTER' }); + setTimeout(() => { + roll20.createAttrWithWorker(character.id, 'sheet_opened', 1, () => { + const version = roll20.getAttrByName(character.id, 'version'); + setTimeout(() => { + character.remove(); + }, 1000); + logger.info('Detected sheet version as : $$$', version); + if (utils.versionCompare(version, MINIMUM_SHEET_VERSION) < 0) { + reporter.reportError(`Incompatible sheet version. You need at least version ${MINIMUM_SHEET_VERSION} to use ` + + 'this script.'); + return; + } + const ed = new EventDispatcher(roll20, errorHandler, logger, reporter); + const cw = new ChatWatcher(roll20, logger, ed); + const commandProc = makeCommandProc('shaped', roll20, errorHandler, ed, version); + getModuleList().forEach(module => module.configure(roll20, reporter, logger, myState, commandProc, cw, ed)); + }); + }, 400); }); module.exports = { @@ -52,3 +103,21 @@ module.exports = { } }, }; + +function getModuleList() { + const abilityMaker = new AbilityMaker(); + return [ + abilityMaker, + new ConfigUI(), + new AdvantageTracker(), + new UsesManager(), + new RestManager(), + new AmmoManager(), + new Importer(el, parseModule.getParser(mmFormat, logger), abilityMaker, srdConverter), + new DeathSaveManager(), + new HDManager(), + new FXManager(), + new SpellManager(), + new TokenBarConfigurer(), + ]; +} diff --git a/lib/event-dispatcher.js b/lib/event-dispatcher.js new file mode 100644 index 0000000..e67f235 --- /dev/null +++ b/lib/event-dispatcher.js @@ -0,0 +1,90 @@ +'use strict'; +const _ = require('underscore'); + +module.exports = class EventDispatcher { + + constructor(roll20, errorHandler, logger, reporter) { + this.roll20 = roll20; + this.addedTokenIds = []; + this.errorHandler = errorHandler; + this.logger = logger; + this.reporter = reporter; + this.addTokenListeners = []; + this.attributeChangeHandlers = {}; + logger.wrapModule(this); + roll20.on('add:token', this.handleAddToken.bind(this)); + roll20.on('change:token', this.handleChangeTokenForAdd.bind(this)); + roll20.on('chat:message', (msg) => { + if (msg.playerid !== 'API') { + reporter.setPlayer(msg.playerid); + } + }); + roll20.on('change:attribute', (curr, prev) => { + (this.attributeChangeHandlers[curr.get('name')] || []).forEach(handler => handler(curr, prev)); + }); + } + + ///////////////////////////////////////////////// + // Event Handlers + ///////////////////////////////////////////////// + handleAddToken(token) { + const represents = token.get('represents'); + if (_.isEmpty(represents)) { + return; + } + const character = this.roll20.getObj('character', represents); + if (!character) { + return; + } + this.addedTokenIds.push(token.id); + + // URGH. Thanks Roll20. + setTimeout(() => { + const addedToken = this.roll20.getObj('graphic', token.id); + if (addedToken) { + this.handleChangeTokenForAdd(addedToken); + } + }, 100); + } + + handleChangeTokenForAdd(token) { + if (_.contains(this.addedTokenIds, token.id)) { + this.addedTokenIds = _.without(this.addedTokenIds, token.id); + this.addTokenListeners.forEach(listener => listener(token)); + // this.setTokenBarsOnDrop(token, true); + } + } + + registerEventHandler(eventType, handler) { + if (eventType === 'add:token') { + this.addTokenListeners.push(this.wrapHandler(handler)); + } + else { + this.roll20.on(eventType, this.wrapHandler(handler)); + } + } + + registerAttributeChangeHandler(attributeName, handler) { + this.attributeChangeHandlers[attributeName] = this.attributeChangeHandlers[attributeName] || []; + this.attributeChangeHandlers[attributeName].push(this.wrapHandler(handler)); + } + + wrapHandler(handler) { + const self = this; + return function handlerWrapper() { + try { + handler.apply(null, arguments); + } + catch (e) { + self.errorHandler(e); + } + finally { + self.logger.prefixString = ''; + } + }; + } + + get logWrap() { + return 'EventDispatcher'; + } +}; diff --git a/lib/ability-maker.js b/lib/modules/ability-maker.js similarity index 98% rename from lib/ability-maker.js rename to lib/modules/ability-maker.js index 708c9ac..6a9a3b5 100644 --- a/lib/ability-maker.js +++ b/lib/modules/ability-maker.js @@ -1,8 +1,8 @@ 'use strict'; const _ = require('underscore'); -const utils = require('./utils'); -const ShapedModule = require('./shaped-module'); -const ShapedConfig = require('./shaped-config'); +const utils = require('./../utils'); +const ShapedModule = require('./../shaped-module'); +const ShapedConfig = require('./../shaped-config'); const RECHARGE_LOOKUP = { TURN: '(T)', diff --git a/lib/advantage-tracker.js b/lib/modules/advantage-tracker.js similarity index 87% rename from lib/advantage-tracker.js rename to lib/modules/advantage-tracker.js index 14d5484..b36a624 100644 --- a/lib/advantage-tracker.js +++ b/lib/modules/advantage-tracker.js @@ -1,8 +1,8 @@ 'use strict'; const _ = require('underscore'); -const utils = require('./utils.js'); -const ShapedModule = require('./shaped-module'); -const ShapedConfig = require('./shaped-config'); +const utils = require('./../utils.js'); +const ShapedModule = require('./../shaped-module'); +const ShapedConfig = require('./../shaped-config'); const rollOptions = { normal: { @@ -41,6 +41,23 @@ class AdvantageTracker extends ShapedModule { }); } + registerEventListeners(eventDispatcher) { + eventDispatcher.registerEventHandler('add:token', this.handleTokenAdded.bind(this)); + eventDispatcher.registerAttributeChangeHandler('shaped_d20', this.handleRollOptionChange.bind(this)); + } + + registerChatListeners(chatWatcher) { + chatWatcher.registerChatListener(['character', '2d20kh1'], this.handleD20Roll.bind(this)); + chatWatcher.registerChatListener(['character', '2d20kl1'], this.handleD20Roll.bind(this)); + } + + handleD20Roll(options) { + const autoRevertOptions = this.roll20.getAttrByName(options.character.id, 'auto_revert_advantage'); + if (autoRevertOptions === 1 || autoRevertOptions === '1') { + this.setRollOption('normal', [options.character]); + } + } + process(options) { let type; @@ -104,7 +121,7 @@ class AdvantageTracker extends ShapedModule { } } - handleTokenChange(token) { + handleTokenAdded(token) { this.logger.debug('AT: Updating New Token'); if (this.shouldShowMarkers() && token.get('represents') !== '') { const character = this.roll20.getObj('character', token.get('represents')); diff --git a/lib/ammo-manager.js b/lib/modules/ammo-manager.js similarity index 82% rename from lib/ammo-manager.js rename to lib/modules/ammo-manager.js index 7470247..56f2640 100644 --- a/lib/ammo-manager.js +++ b/lib/modules/ammo-manager.js @@ -1,11 +1,11 @@ 'use strict'; const _ = require('underscore'); -const ShapedModule = require('./shaped-module'); +const ShapedModule = require('./../shaped-module'); class AmmoManager extends ShapedModule { - addCommands(commandProcessor) { - return commandProcessor; + registerChatListeners(chatWatcher) { + chatWatcher.registerChatListener(['ammoName', 'character'], this.consumeAmmo.bind(this)); } consumeAmmo(options, msg) { @@ -18,7 +18,7 @@ class AmmoManager extends ShapedModule { .groupBy(attribute => attribute.get('name').replace(/(repeating_ammo_[^_]+).*/, '$1')) .find(attributeList => _.find(attributeList, attribute => - attribute.get('name').match(/.*name$/) && attribute.get('current') === options.ammoName) + attribute.get('name').match(/.*name$/) && attribute.get('current') === options.ammoName) ) .find(attribute => attribute.get('name').match(/.*qty$/)) .value(); diff --git a/lib/config-ui.js b/lib/modules/config-ui.js similarity index 99% rename from lib/config-ui.js rename to lib/modules/config-ui.js index 739c25f..84b1f2d 100644 --- a/lib/config-ui.js +++ b/lib/modules/config-ui.js @@ -1,8 +1,8 @@ 'use strict'; const _ = require('underscore'); -const utils = require('./utils'); -const ShapedModule = require('./shaped-module'); -const ShapedConfig = require('./shaped-config'); +const utils = require('./../utils'); +const ShapedModule = require('./../shaped-module'); +const ShapedConfig = require('./../shaped-config'); class ConfigUi extends ShapedModule { diff --git a/lib/modules/death-save-manager.js b/lib/modules/death-save-manager.js new file mode 100644 index 0000000..eb2d8d3 --- /dev/null +++ b/lib/modules/death-save-manager.js @@ -0,0 +1,62 @@ +'use strict'; +const ShapedModule = require('./../shaped-module'); + +module.exports = class DeathSaveManager extends ShapedModule { + + registerChatListeners(chatWatcher) { + chatWatcher.registerChatListener(['deathSavingThrow', 'character', 'roll1'], this.handleDeathSave.bind(this)); + } + + handleDeathSave(options) { + 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 = this.roll20.getAttrByName(options.character.id, 'HP'); + if (currentHP !== 0 && currentHP !== '0') { + this.reportPublic('Death Saves', `${options.character.get('name')} has more than 0 HP and shouldn't be rolling ` + + 'death saves'); + return; + } + + const successes = this.roll20.getOrCreateAttr(options.character.id, 'death_saving_throw_successes'); + let successCount = successes.get('current'); + const failures = this.roll20.getOrCreateAttr(options.character.id, 'death_saving_throw_failures'); + let failureCount = failures.get('current'); + const result = parseInt(options.roll1, 10); + + switch (result) { + case 1: + failureCount += 2; + break; + case 20: + failureCount = 0; + successCount = 0; + + this.roll20.setAttrWithWorker(options.character.id, 'HP', 1); + this.reportPublic('Death Saves', `${options.character.get('name')} has recovered to 1 HP`); + break; + default: + if (result >= 10) { + successCount++; + } + else { + failureCount++; + } + } + + if (failureCount >= 3) { + failureCount = 3; + this.reportPublic('Death Saves', `${options.character.get('name')} has failed 3` + + ' death saves and is now dead'); + } + else if (successCount >= 3) { + this.reportPublic('Death Saves', `${options.character.get('name')} has succeeded 3` + + ' death saves and is now stable'); + failureCount = 0; + successCount = 0; + } + successes.setWithWorker({ current: successCount }); + failures.setWithWorker({ current: failureCount }); + } +}; + diff --git a/lib/modules/fx-manager.js b/lib/modules/fx-manager.js new file mode 100644 index 0000000..7747995 --- /dev/null +++ b/lib/modules/fx-manager.js @@ -0,0 +1,78 @@ +'use strict'; +const ShapedModule = require('./../shaped-module'); +const _ = require('underscore'); + +module.exports = class FXManager extends ShapedModule { + + registerChatListeners(chatWatcher) { + chatWatcher.registerChatListener(['fx', 'character'], this.handleFX.bind(this)); + } + + handleFX(options, msg) { + const parts = options.fx.split(' '); + if (parts.length < 2 || _.some(parts.slice(0, 2), _.isEmpty)) { + this.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 = this.roll20.getObj('graphic', targetTokenId); + pageId = targetToken.get('_pageid'); + targetCoords.x = targetToken.get('left'); + targetCoords.y = targetToken.get('top'); + } + else { + pageId = this.roll20.getCurrentPage(msg.playerid).id; + } + + + const casterTokens = this.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]) { + this.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); + } + + this.roll20.spawnFx(fxCoords, fxType, pageId); + } +}; + diff --git a/lib/modules/hd-manager.js b/lib/modules/hd-manager.js new file mode 100644 index 0000000..163690b --- /dev/null +++ b/lib/modules/hd-manager.js @@ -0,0 +1,37 @@ +'use strict'; +const ShapedModule = require('./../shaped-module'); + +module.exports = class HDManager extends ShapedModule { + + registerChatListeners(chatWatcher) { + chatWatcher.registerChatListener(['character', 'title'], this.handleHD.bind(this)); + } + + handleHD(options) { + const match = options.title.match(/(\d+)d(\d+) HIT_DICE/); + if (match && this.myState.config.sheetEnhancements.autoHD) { + const hdCount = parseInt(match[1], 10); + 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 regained = Math.max(0, parseInt(options.roll1, 10)); + const newHp = Math.min(parseInt(hpAttr.get('current') || 0, 10) + regained, hpAttr.get('max') || Infinity); + + if (hdAttr) { + if (hdCount <= hdAttr.get('current')) { + hdAttr.setWithWorker('current', hdAttr.get('current') - hdCount); + hpAttr.setWithWorker('current', newHp); + if (!hpAttr.get('max')) { + hpAttr.setWithWorker('max', newHp); + } + } + else { + this.reportPublic('HD Police', + `${options.characterName} can't use ${hdCount}d${hdSize} hit dice because they ` + + `only have ${hdAttr.get('current')} left`); + } + } + } + } +}; + diff --git a/lib/importer.js b/lib/modules/importer.js similarity index 98% rename from lib/importer.js rename to lib/modules/importer.js index b8d2053..bc8f2da 100644 --- a/lib/importer.js +++ b/lib/modules/importer.js @@ -1,10 +1,10 @@ /* globals unescape */ 'use strict'; -const ShapedModule = require('./shaped-module'); -const ShapedConfig = require('./shaped-config'); +const ShapedModule = require('./../shaped-module'); +const ShapedConfig = require('./../shaped-config'); const _ = require('underscore'); -const sanitise = require('./sanitise'); -const utils = require('./utils'); +const sanitise = require('./../sanitise'); +const utils = require('./../utils'); const Logger = require('roll20-logger'); class Importer extends ShapedModule { @@ -94,6 +94,14 @@ class Importer extends ShapedModule { }); } + 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) => { diff --git a/lib/rest-manager.js b/lib/modules/rest-manager.js similarity index 98% rename from lib/rest-manager.js rename to lib/modules/rest-manager.js index b3fb021..43b8089 100644 --- a/lib/rest-manager.js +++ b/lib/modules/rest-manager.js @@ -1,8 +1,8 @@ 'use strict'; const _ = require('underscore'); -const ShapedModule = require('./shaped-module'); -const ShapedConfig = require('./shaped-config'); -const utils = require('./utils'); +const ShapedModule = require('./../shaped-module'); +const ShapedConfig = require('./../shaped-config'); +const utils = require('./../utils'); class RestManager extends ShapedModule { diff --git a/lib/modules/spell-manager.js b/lib/modules/spell-manager.js new file mode 100644 index 0000000..c885336 --- /dev/null +++ b/lib/modules/spell-manager.js @@ -0,0 +1,59 @@ +'use strict'; +const ShapedModule = require('./../shaped-module'); +const _ = require('underscore'); + +module.exports = class SpellManager extends ShapedModule { + + registerChatListeners(chatWatcher) { + chatWatcher.registerChatListener(['character', 'spell', 'castAsLevel'], this.handleSpellCast.bind(this)); + } + + handleSpellCast(options) { + if (options.ritual || !this.myState.config.sheetEnhancements.autoSpellSlots || + options.spellLevel === 'CANTRIP' || options.spellRepeat) { + return; + } + + const castingLevel = parseInt(options.castAsLevel, 10); + if (_.isNaN(castingLevel)) { + this.logger.error('Bad casting level [$$$]', options.castAsLevel); + this.reportError('An error occured with spell slots, see the log for more details'); + return; + } + + const spellPointsAttr = this.roll20.getAttrObjectByName(options.character.id, 'spell_points'); + if (spellPointsAttr && spellPointsAttr.get('current')) { + const spellPointsLimit = parseInt(this.roll20.getAttrByName(options.character.id, 'spell_points_limit'), 10); + const cost = castingLevel + Math.floor(castingLevel / 3) + 1; + if (castingLevel <= spellPointsLimit && cost <= spellPointsAttr.get('current')) { + spellPointsAttr.setWithWorker({ current: spellPointsAttr.get('current') - cost }); + return; + } + } + + + const spellSlotAttr = this.roll20.getAttrObjectByName(options.character.id, `spell_slots_l${options.castAsLevel}`); + const warlockSlotsAttr = this.roll20.getAttrObjectByName(options.character.id, 'warlock_spell_slots'); + if (warlockSlotsAttr && warlockSlotsAttr.get('current')) { + const warlockSlotsLevelString = this.roll20.getAttrByName(options.character.id, 'warlock_spells_max_level'); + this.logger.debug('Warlock slots level: $$$', warlockSlotsLevelString); + const warlockSlotsLevel = warlockSlotsLevelString ? parseInt(warlockSlotsLevelString.substring(0, 1), 10) : 0; + this.logger.debug('Parsed warlock slots level: $$$', warlockSlotsLevel); + if (warlockSlotsLevel === castingLevel) { + this.logger.debug('Decrementing warlock spell slots attribute $$$', warlockSlotsAttr); + warlockSlotsAttr.setWithWorker({ current: warlockSlotsAttr.get('current') - 1 }); + return; + } + } + + if (spellSlotAttr && spellSlotAttr.get('current')) { + this.logger.debug('Decrementing normal spell slots attribute $$$', spellSlotAttr); + spellSlotAttr.setWithWorker({ current: spellSlotAttr.get('current') - 1 }); + } + else { + this.reportPublic('Slots Police', `${options.characterName} cannot cast ${options.title} at level ` + + `${options.castAsLevel} because they don't have enough spell slots/points.`); + } + } +}; + diff --git a/lib/modules/token-bar-configurer.js b/lib/modules/token-bar-configurer.js new file mode 100644 index 0000000..70ea316 --- /dev/null +++ b/lib/modules/token-bar-configurer.js @@ -0,0 +1,73 @@ +'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/lib/uses-manager.js b/lib/modules/uses-manager.js similarity index 88% rename from lib/uses-manager.js rename to lib/modules/uses-manager.js index c47bd60..2ea9537 100644 --- a/lib/uses-manager.js +++ b/lib/modules/uses-manager.js @@ -1,12 +1,12 @@ 'use strict'; -const ShapedModule = require('./shaped-module'); +const ShapedModule = require('./../shaped-module'); const _ = require('underscore'); class UsesManager extends ShapedModule { - addCommands(commandProcessor) { - // no commands for this module - return commandProcessor; + registerChatListeners(chatWatcher) { + chatWatcher.registerChatListener(['character', 'uses', 'repeatingItem'], this.handleUses.bind(this)); + chatWatcher.registerChatListener(['character', 'legendary'], this.handleLegendary.bind(this)); } /** diff --git a/lib/shaped-module.js b/lib/shaped-module.js index 788bc04..d4f00a1 100644 --- a/lib/shaped-module.js +++ b/lib/shaped-module.js @@ -1,17 +1,28 @@ 'use strict'; module.exports = class ShapedModule { - configure(roll20, reporter, logger, myState, commandProcessor) { + + configure(roll20, reporter, logger, myState, commandProcessor, chatWatcher, eventDispatcher) { this.roll20 = roll20; this.reporter = reporter; this.logger = logger; this.myState = myState; logger.wrapModule(this); this.addCommands(commandProcessor); + this.registerChatListeners(chatWatcher); + this.registerEventListeners(eventDispatcher); } addCommands(/* commandProcessor */) { - throw new Error('Subclasses must implement addCommands'); + this.logger.debug('$$$ has no commands', this.constructor.name); + } + + registerChatListeners(/* chatWatcher */) { + this.logger.debug('$$$ has no chat watchers', this.constructor.name); + } + + registerEventListeners(/* eventDispatcher */) { + this.logger.debug('$$$ has no event listeners', this.constructor.name); } reportPublic(heading, text) { diff --git a/lib/shaped-script.js b/lib/shaped-script.js deleted file mode 100644 index 6c780f3..0000000 --- a/lib/shaped-script.js +++ /dev/null @@ -1,524 +0,0 @@ -'use strict'; -const _ = require('underscore'); -const parseModule = require('./parser'); -const makeCommandProc = require('./command-parser'); -const UserError = require('./user-error'); -const Migrator = require('./migrations'); -// Modules -const AbilityMaker = require('./ability-maker'); -const ConfigUI = require('./config-ui'); -const AdvantageTracker = require('./advantage-tracker'); -const RestManager = require('./rest-manager'); -const UsesManager = require('./uses-manager'); -const AmmoManager = require('./ammo-manager'); -const Importer = require('./importer'); -const srdConverter = require('./srd-converter'); - -/** - * @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) { - let addedTokenIds = []; - const reportPublic = reporter.reportPublic.bind(reporter); - const reportError = reporter.reportError.bind(reporter); - const errorHandler = function errorHandler(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.'); - } - }; - const commandProc = makeCommandProc('shaped', roll20, errorHandler); - const chatWatchers = []; - const advantageTracker = new AdvantageTracker(); - const usesManager = new UsesManager(); - const ammoManager = new AmmoManager(); - const abilityMaker = new AbilityMaker(); - const importer = new Importer(entityLookup, parser, abilityMaker, srdConverter); - const modules = [ - abilityMaker, - new ConfigUI(), - advantageTracker, - usesManager, - new RestManager(), - ammoManager, - importer, - ]; - - modules.forEach(module => module.configure(roll20, reporter, logger, myState, commandProc)); - - /** - * - * @param {ChatMessage} msg - */ - this.handleInput = function handleInput(msg) { - logger.debug(msg); - if (msg.playerid === 'API') { - return; - } - - reporter.setPlayer(msg.playerid); - if (msg.type !== 'api') { - this.triggerChatWatchers(msg); - return; - } - - commandProc.processCommand(msg); - }; - - ///////////////////////////////////////////////// - // 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) { - if (myState.config.newCharSettings.applyToAll) { - importer.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}`, null, { noarchive: true }); - 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); - } - }); - }; - - 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.setWithWorker('current', hdAttr.get('current') - hdCount); - hpAttr.setWithWorker('current', newHp); - if (!hpAttr.get('max')) { - hpAttr.setWithWorker('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) { - const autoRevertOptions = roll20.getAttrByName(options.character.id, 'auto_revert_advantage'); - if (autoRevertOptions === 1) { - advantageTracker.setRollOption('normal', [options.character]); - } - }; - - this.handleDeathSave = function handleDeathSave(options, msg) { - if (roll20.getAttrByName(options.character.id, 'shaped_d20') === '1d20') { - return; - } - const currentHP = roll20.getAttrByName(options.character.id, 'HP'); - if (currentHP !== 0 && currentHP !== '0') { - reportPublic('Death Saves', `${options.character.get('name')} has more than 0 HP and shouldn't be rolling death` + - ' saves'); - return; - } - - const successes = roll20.getAttrObjectByName(options.character.id, 'death_saving_throw_successes'); - let successCount = successes.get('current'); - const failures = roll20.getAttrObjectByName(options.character.id, 'death_saving_throw_failures'); - let failureCount = failures.get('current'); - const result = this.getRollValue(msg, options.roll1); - - switch (result) { - case 1: - failureCount += 2; - break; - case 20: - failureCount = 0; - successCount = 0; - - roll20.setAttrWithWorker(options.character.id, 'HP', 1); - reportPublic('Death Saves', `${options.character.get('name')} has recovered to 1 HP`); - break; - default: - if (result >= 10) { - successCount++; - } - else { - failureCount++; - } - } - - if (failureCount >= 3) { - failureCount = 3; - reportPublic('Death Saves', `${options.character.get('name')} has failed 3` + - ' death saves and is now dead'); - } - else if (successCount >= 3) { - reportPublic('Death Saves', `${options.character.get('name')} has succeeded 3` + - ' death saves and is now stable'); - failureCount = 0; - successCount = 0; - } - successes.setWithWorker({ current: successCount }); - failures.setWithWorker({ current: failureCount }); - }; - - 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; - }; - - this.handleSpellCast = function handleSpellCast(options) { - if (options.ritual || !myState.config.sheetEnhancements.autoSpellSlots || - options.spellLevel === 'CANTRIP' || options.spellRepeat) { - return; - } - - const castingLevel = parseInt(options.castAsLevel, 10); - if (_.isNaN(castingLevel)) { - logger.error('Bad casting level [$$$]', options.castAsLevel); - reportError('An error occured with spell slots, see the log for more details'); - return; - } - - const spellPointsAttr = roll20.getAttrObjectByName(options.character.id, 'spell_points'); - if (spellPointsAttr && spellPointsAttr.get('current')) { - const spellPointsLimit = parseInt(roll20.getAttrByName(options.character.id, 'spell_points_limit'), 10); - const cost = castingLevel + Math.floor(castingLevel / 3) + 1; - if (castingLevel <= spellPointsLimit && cost <= spellPointsAttr.get('current')) { - spellPointsAttr.setWithWorker({ current: spellPointsAttr.get('current') - cost }); - return; - } - } - - - const spellSlotAttr = roll20.getAttrObjectByName(options.character.id, `spell_slots_l${options.castAsLevel}`); - const warlockSlotsAttr = roll20.getAttrObjectByName(options.character.id, 'warlock_spell_slots'); - if (warlockSlotsAttr && warlockSlotsAttr.get('current')) { - const warlockSlotsLevelString = roll20.getAttrByName(options.character.id, 'warlock_spells_max_level'); - logger.debug('Warlock slots level: $$$', warlockSlotsLevelString); - const warlockSlotsLevel = warlockSlotsLevelString ? parseInt(warlockSlotsLevelString.substring(0, 1), 10) : 0; - logger.debug('Parsed warlock slots level: $$$', warlockSlotsLevel); - if (warlockSlotsLevel === castingLevel) { - logger.debug('Decrementing warlock spell slots attribute $$$', warlockSlotsAttr); - warlockSlotsAttr.setWithWorker({ current: warlockSlotsAttr.get('current') - 1 }); - return; - } - } - - if (spellSlotAttr && spellSlotAttr.get('current')) { - logger.debug('Decrementing normal spell slots attribute $$$', spellSlotAttr); - spellSlotAttr.setWithWorker({ current: spellSlotAttr.get('current') - 1 }); - } - else { - reportPublic('Slots Police', `${options.characterName} cannot cast ${options.title} at level ` + - `${options.castAsLevel} because they don't have enough spell slots/points.`); - } - }; - - /** - * - * @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.checkInstall = function checkInstall() { - logger.info('-=> ShapedScripts %%GULP_INJECT_VERSION%% <=-'); - Migrator.migrateShapedConfig(myState, logger); - const character = roll20.createObj('character', { name: 'SHAPED_VERSION_TESTER' }); - setTimeout(() => { - roll20.createAttrWithWorker(character.id, 'sheet_opened', 1, () => { - const version = roll20.getAttrByName(character.id, 'version'); - character.remove(); - commandProc.setRequiredCharacterVersion(version); - logger.info('Detected sheet version as : $$$', version); - }); - }, 400); - }; - - this.wrapHandler = function wrapHandler(handler) { - const self = this; - return function handlerWrapper() { - try { - handler.apply(self, arguments); - } - catch (e) { - errorHandler(e); - } - 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 = { - shaped_d20: 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(ammoManager.consumeAmmo.bind(ammoManager), ['ammoName', 'character']); - this.registerChatWatcher(this.handleFX, ['fx', 'character']); - this.registerChatWatcher(this.handleHD, ['character', 'title']); - this.registerChatWatcher(this.handleD20Roll, ['character', '2d20kh1']); - this.registerChatWatcher(this.handleD20Roll, ['character', '2d20kl1']); - this.registerChatWatcher(usesManager.handleUses.bind(usesManager), ['character', 'uses', 'repeatingItem']); - this.registerChatWatcher(usesManager.handleLegendary.bind(usesManager), ['character', 'legendary']); - this.registerChatWatcher(this.handleSpellCast, ['character', 'spell', 'castAsLevel']); - }; - - logger.wrapModule(this); -} - -ShapedScripts.prototype.logWrap = 'ShapedScripts'; diff --git a/lib/utils.js b/lib/utils.js index dfc9297..6b1320e 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -216,4 +216,9 @@ module.exports = { camelToSnakeCase(string) { return string.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); }, + + versionCompare(v1, v2) { + return _.zip(v1.split('.'), v2.split('.')) + .reduce((result, versionPart) => result || (parseInt(versionPart[0], 10) - parseInt(versionPart[1], 10)), 0); + }, }; diff --git a/test/test-ability-maker.js b/test/test-ability-maker.js index 6700d45..8068c0e 100644 --- a/test/test-ability-maker.js +++ b/test/test-ability-maker.js @@ -2,7 +2,7 @@ 'use strict'; const Roll20 = require('roll20-wrapper'); const expect = require('chai').expect; -const AbilityMaker = require('../lib/ability-maker'); +const AbilityMaker = require('../lib/modules/ability-maker'); const sinon = require('sinon'); const logger = require('./dummy-logger'); const Reporter = require('./dummy-reporter'); diff --git a/test/test-ammo-manager.js b/test/test-ammo-manager.js index e1aa7e0..2b0114d 100644 --- a/test/test-ammo-manager.js +++ b/test/test-ammo-manager.js @@ -2,11 +2,12 @@ 'use strict'; require('chai').should(); const Roll20 = require('roll20-wrapper'); -const AmmoManager = require('../lib/ammo-manager'); +const AmmoManager = require('../lib/modules/ammo-manager'); const sinon = require('sinon'); const logger = require('./dummy-logger'); const Reporter = require('./dummy-reporter'); const cp = require('./dummy-command-parser'); +const _ = require('underscore'); /** * Test attribute @@ -66,7 +67,8 @@ describe('ammo-manager', function () { roll20.checkCharacterFlag.withArgs(characterStub.id, 'ammo_auto_use').returns(true); const ammoManager = new AmmoManager(); - ammoManager.configure(roll20, new Reporter(), logger, { config: { updateAmmo: true } }, null, cp); + ammoManager.configure(roll20, new Reporter(), logger, { config: { updateAmmo: true } }, cp, + { registerChatListener: _.noop }); const msg = { rolltemplate: '5e-shaped', diff --git a/test/test-chat-watcher.js b/test/test-chat-watcher.js new file mode 100644 index 0000000..43f9af1 --- /dev/null +++ b/test/test-chat-watcher.js @@ -0,0 +1,33 @@ +/* globals describe: false, it:false, beforeEach:false, before:false */ +'use strict'; +const Roll20 = require('roll20-wrapper'); +const ChatWatcher = require('../lib/chat-watcher'); +const sinon = require('sinon'); +const logger = require('./dummy-logger'); +const _ = require('underscore'); + + +describe('chat-watcher', function () { + let roll20; + let cw; + + beforeEach(function () { + roll20 = new Roll20(); + cw = new ChatWatcher(roll20, logger, { registerEventHandler: _.noop }); + }); + + describe('#triggerChatWatchers', function () { + it('trigger hd watcher', function () { + sinon.stub(roll20); + const characterStub = { id: 'myid' }; + roll20.findObjs.withArgs({ _type: 'character', name: 'Bob' }).returns([characterStub]); + const msg = { + rolltemplate: '5e-shaped', + content: '{{uses=@{Bellaluna|hd_d10}}}{{uses_max=@{Bellaluna|hd_d10|max}}{{character_name=Bob}}' + + '@{Bob|attacher_hit_dice}', + inlinerolls: [{ expression: '50-2', results: { total: 48 } }], + }; + cw.triggerChatListeners(msg); + }); + }); +}); diff --git a/test/test-command-parser.js b/test/test-command-parser.js index 9b6c1bd..bac2e53 100644 --- a/test/test-command-parser.js +++ b/test/test-command-parser.js @@ -5,6 +5,7 @@ const expect = require('chai').expect; const cp = require('../lib/command-parser'); const sinon = require('sinon'); const Roll20 = require('roll20-wrapper'); +const _ = require('underscore'); function testValidator(value) { return { @@ -20,7 +21,7 @@ describe('command-parser', function () { describe('#command', function () { it('parse options correctly', function () { let result = {}; - cp('shaped', roll20) + cp('shaped', roll20, null, { registerEventHandler: _.noop }, 1) .addCommand('config', function (object) { result = object; }) @@ -58,7 +59,7 @@ describe('command-parser', function () { key2: 'value2', }; let result = {}; - const myCp = cp('shaped', roll20) + const myCp = cp('shaped', roll20, null, { registerEventHandler: _.noop }, 1) .addCommand('stuff', function (object) { result = object; }) @@ -82,7 +83,7 @@ describe('command-parser', function () { describe('#missingParam', function () { it('accepts supplied required param', function () { let result = {}; - const myCp = cp('shaped', roll20) + const myCp = cp('shaped', roll20, null, { registerEventHandler: _.noop }, 1) .addCommand('stuff', function (object) { result = object; }) diff --git a/test/test-importer.js b/test/test-importer.js index fb91b40..671cb17 100644 --- a/test/test-importer.js +++ b/test/test-importer.js @@ -9,7 +9,7 @@ const logger = require('./dummy-logger'); const Reporter = require('./dummy-reporter'); const Roll20Object = require('./dummy-roll20-object'); const el = require('./dummy-entity-lookup'); -const Importer = require('../lib/importer'); +const Importer = require('../lib/modules/importer'); const cp = require('./dummy-command-parser'); /** @@ -159,7 +159,7 @@ describe('importer', function () { const attributes = {}; const streamer = { stream: _.noop }; const importer = new Importer(el.entityLookup, null, null); - importer.configure(roll20, new Reporter(), logger, {}, cp); + importer.configure(roll20, new Reporter(), logger, {}, cp, null, { registerEventHandler: _.noop }); _.times(100, index => (attributes[`attr${index}`] = index)); roll20Mock.expects('onSheetWorkerCompleted').twice().yieldsAsync(); roll20Mock.expects('setAttrWithWorker').exactly(100); @@ -173,7 +173,7 @@ describe('importer', function () { const char = new Roll20Object('character', { name: 'character' }); const roll20Mock = sinon.mock(roll20); const importer = new Importer(el.entityLookup, null, null); - importer.configure(roll20, new Reporter(), logger, {}, cp); + importer.configure(roll20, new Reporter(), logger, {}, cp, null, { registerEventHandler: _.noop }); roll20Mock.expects('findObjs').returns(spellAttributes); const spells = importer.getSpellAttributesForCharacter(char); @@ -190,7 +190,7 @@ describe('importer', function () { convertSpells: _.identity, }; const importer = new Importer(el.entityLookup, null, null, srdConverterStub); - importer.configure(roll20, new Reporter(), logger, {}, cp); + importer.configure(roll20, new Reporter(), logger, {}, cp, null, { registerEventHandler: _.noop }); roll20Mock.expects('findObjs').returns(spellAttributes); const attributes = importer.getSpellAttributesForImport(char, {}, @@ -212,7 +212,7 @@ describe('importer', function () { convertSpells: _.identity, }; const importer = new Importer(el.entityLookup, null, null, srdConverterStub); - importer.configure(roll20, new Reporter(), logger, {}, cp); + importer.configure(roll20, new Reporter(), logger, {}, cp, null, { registerEventHandler: _.noop }); roll20Mock.expects('findObjs').returns(spellAttributes); const attributes = importer.getSpellAttributesForImport(char, {}, @@ -233,7 +233,7 @@ describe('importer', function () { let importer; before(function () { importer = new Importer(el.entityLookup, null, null); - importer.configure(roll20, new Reporter(), logger, {}, cp); + importer.configure(roll20, new Reporter(), logger, {}, cp, null, { registerEventHandler: _.noop }); }); it('should configure senses correctly', function () { @@ -279,7 +279,7 @@ describe('importer', function () { it('debrokens', function () { const importer = new Importer(el.entityLookup, null, null); sinon.stub(roll20); - importer.configure(roll20, new Reporter(), logger, {}, cp); + importer.configure(roll20, new Reporter(), logger, {}, cp, null, { registerEventHandler: _.noop }); const attributes = [ new Roll20Object('attribute', { name: 'foo', current: 1 }), new Roll20Object('attribute', { name: 'foo', max: 2 }), @@ -309,7 +309,8 @@ function runImportMonsterTest(roll20, monsters, options, preConfigure, expectati sinon.stub(roll20, 'getObj'); sinon.stub(roll20, 'setDefaultTokenForCharacter'); const importer = new Importer(el.entityLookup, null, null, { convertMonster: _.identity }); - importer.configure(roll20, new Reporter(), logger, { config: { tokenSettings: { light: {} } } }, cp); + importer.configure(roll20, new Reporter(), logger, { config: { tokenSettings: { light: {} } } }, cp, null, + { registerEventHandler: _.noop }); const token = new Roll20Object('graphic'); token.set('imgsrc', 'imgsrc'); diff --git a/test/test-rest-manager.js b/test/test-rest-manager.js index 696d2cd..4b4d686 100644 --- a/test/test-rest-manager.js +++ b/test/test-rest-manager.js @@ -7,7 +7,7 @@ const Roll20 = require('roll20-wrapper'); const sinon = require('sinon'); const logger = require('./dummy-logger'); const Roll20Object = require('./dummy-roll20-object'); -const RestManager = require('../lib/rest-manager'); +const RestManager = require('../lib/modules/rest-manager'); const cp = require('./dummy-command-parser'); const testRests = [ diff --git a/test/test-shaped-script.js b/test/test-shaped-script.js deleted file mode 100644 index 373e023..0000000 --- a/test/test-shaped-script.js +++ /dev/null @@ -1,92 +0,0 @@ -/* globals describe: false, it:false, beforeEach:false, before:false */ -'use strict'; -require('chai').should(); -const expect = require('chai').expect; -const Roll20 = require('roll20-wrapper'); -const ShapedScripts = require('../lib/shaped-script'); -const sinon = require('sinon'); -const logger = require('./dummy-logger'); -const Reporter = require('./dummy-reporter'); -const Roll20Object = require('./dummy-roll20-object'); -const el = require('./dummy-entity-lookup'); - -/** - * Test attribute - * @param name - * @param value - * @constructor - */ -function Attribute(name, value) { - this.name = name; - this.value = value; -} - -// noinspection JSUnusedGlobalSymbols -Attribute.prototype.get = function (propName) { - switch (propName) { - case 'current': - return this.value; - case 'name': - return this.name; - default: - throw new Error(`Unrecognised property name ${propName}`); - } -}; - -describe('shaped-script', function () { - let roll20; - - beforeEach(function () { - roll20 = new Roll20(); - }); - - describe('#triggerChatWatchers', function () { - it('trigger hd watcher', function () { - sinon.stub(roll20); - const characterStub = { id: 'myid' }; - roll20.findObjs.withArgs({ _type: 'character', name: 'Bob' }).returns([characterStub]); - const shapedScript = new ShapedScripts(logger, { config: { updateAmmo: true } }, roll20, null, el.entityLookup, - new Reporter()); - shapedScript.registerEventHandlers(); - const msg = { - rolltemplate: '5e-shaped', - content: '{{uses=@{Bellaluna|hd_d10}}}{{uses_max=@{Bellaluna|hd_d10|max}}{{character_name=Bob}}' + - '@{Bob|attacher_hit_dice}', - inlinerolls: [{ expression: '50-2' }], - }; - shapedScript.triggerChatWatchers(msg); - }); - }); - - describe('handleSpellCast', function () { - it('should deal with cantrips correctly', function () { - const mock = sinon.mock(roll20); - const char = new Roll20Object('character'); - char.set('name', 'Bob'); - const reporter = new Reporter(); - const shapedScript = new ShapedScripts(logger, { config: { sheetEnhancements: { autoSpellSlots: true } } }, - roll20, null, el.entityLookup, reporter); - - mock.expects('getAttrObjectByName').never(); - shapedScript.handleSpellCast({ castAsLevel: '', character: char, spellLevel: 'CANTRIP' }); - mock.verify(); - }); - - it('should deal with normal spells correctly', function () { - sinon.stub(roll20); - const char = new Roll20Object('character'); - char.set('name', 'Bob'); - const reporter = new Reporter(); - const shapedScript = new ShapedScripts(logger, { config: { sheetEnhancements: { autoSpellSlots: true } } }, - roll20, null, el.entityLookup, reporter); - - const slotsAttr = new Roll20Object('attribute', { name: 'spell_slots_l5', current: 2 }); - roll20.getAttrObjectByName.withArgs(char.id, 'spell_slots_l5').returns(slotsAttr); - roll20.getAttrObjectByName.withArgs(char.id, 'warlock_spell_slots').returns(null); - roll20.getAttrObjectByName.withArgs(char.id, 'spell_points').returns(null); - - shapedScript.handleSpellCast({ castAsLevel: 5, character: char }); - expect(slotsAttr.props).to.have.property('current', 1); - }); - }); -}); diff --git a/test/test-spell-manager.js b/test/test-spell-manager.js new file mode 100644 index 0000000..16757a1 --- /dev/null +++ b/test/test-spell-manager.js @@ -0,0 +1,51 @@ +/* globals describe: false, it:false, beforeEach:false, before:false */ +'use strict'; +const expect = require('chai').expect; +const Roll20 = require('roll20-wrapper'); +const SpellManager = require('../lib/modules/spell-manager'); +const sinon = require('sinon'); +const logger = require('./dummy-logger'); +const Reporter = require('./dummy-reporter'); +const Roll20Object = require('./dummy-roll20-object'); +const cp = require('./dummy-command-parser'); +const _ = require('underscore'); + + +describe('shaped-script', function () { + let roll20; + let spellManager; + + beforeEach(function () { + roll20 = new Roll20(); + spellManager = new SpellManager(); + const reporter = new Reporter(); + spellManager.configure(roll20, reporter, logger, { config: { sheetEnhancements: { autoSpellSlots: true } } }, cp, + { registerChatListener: _.noop }, { registerEventHandler: _.noop }); + }); + + describe('handleSpellCast', function () { + it('should deal with cantrips correctly', function () { + const mock = sinon.mock(roll20); + const char = new Roll20Object('character'); + char.set('name', 'Bob'); + + mock.expects('getAttrObjectByName').never(); + spellManager.handleSpellCast({ castAsLevel: '', character: char, spellLevel: 'CANTRIP' }); + mock.verify(); + }); + + it('should deal with normal spells correctly', function () { + sinon.stub(roll20); + const char = new Roll20Object('character'); + char.set('name', 'Bob'); + + const slotsAttr = new Roll20Object('attribute', { name: 'spell_slots_l5', current: 2 }); + roll20.getAttrObjectByName.withArgs(char.id, 'spell_slots_l5').returns(slotsAttr); + roll20.getAttrObjectByName.withArgs(char.id, 'warlock_spell_slots').returns(null); + roll20.getAttrObjectByName.withArgs(char.id, 'spell_points').returns(null); + + spellManager.handleSpellCast({ castAsLevel: 5, character: char }); + expect(slotsAttr.props).to.have.property('current', 1); + }); + }); +}); diff --git a/test/test-uses-manager.js b/test/test-uses-manager.js index 8ae9357..62ac802 100644 --- a/test/test-uses-manager.js +++ b/test/test-uses-manager.js @@ -6,9 +6,10 @@ const Roll20 = require('roll20-wrapper'); const sinon = require('sinon'); const logger = require('./dummy-logger'); const Roll20Object = require('./dummy-roll20-object'); -const UsesManager = require('../lib/uses-manager'); +const UsesManager = require('../lib/modules/uses-manager'); const cp = require('./dummy-command-parser'); const Reporter = require('./dummy-reporter'); +const _ = require('underscore'); describe('uses-manager', function () { @@ -23,7 +24,8 @@ describe('uses-manager', function () { usesManager = new UsesManager(); sinon.stub(roll20); char = new Roll20Object('character', { name: 'character' }); - usesManager.configure(roll20, reporter, logger, { config: { sheetEnhancements: { autoTraits: true } } }, null, cp); + usesManager.configure(roll20, reporter, logger, { config: { sheetEnhancements: { autoTraits: true } } }, cp, + { registerChatListener: _.noop }); }); describe('handleUses', function () { diff --git a/test/test-utils.js b/test/test-utils.js index 9cfab6f..f0a95d3 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -78,4 +78,14 @@ describe('utils', function () { }); }); }); + + describe('versionCompare', function () { + it('checks versions correctly', function () { + expect(utils.versionCompare('1.2.2', '2.2.2')).to.be.below(0); + expect(utils.versionCompare('10.2.2', '2.2.2')).to.be.above(0); + expect(utils.versionCompare('1.2.20', '1.1.21')).to.be.above(0); + expect(utils.versionCompare('1.2.2', '1.2.2')).to.equal(0); + expect(utils.versionCompare('1.20.2', '1.19.0')).to.be.above(0); + }); + }); });