├── .eslintignore
├── .eslintrc
├── .gitattributes
├── .gitignore
├── .idea
├── codeStyleSettings.xml
├── compiler.xml
├── copyright
│ └── profiles_settings.xml
├── encodings.xml
├── inspectionProfiles
│ ├── Project_Default.xml
│ └── profiles_settings.xml
├── jsLibraryMappings.xml
├── jsLinters
│ └── jshint.xml
├── modules.xml
├── scopes
│ ├── allSources.xml
│ ├── scope_settings.xml
│ └── tests.xml
└── vcs.xml
├── .nvmrc
├── .travis.yml
├── .vscode
└── settings.json
├── CHANGELOG.md
├── README.md
├── data
└── 5eSRDData.js
├── gulpfile.js
├── jsconfig.json
├── lib
├── chat-watcher.js
├── command-parser.js
├── entity-criteria-collector.js
├── entity-lookup-result-reporter.js
├── entity-lookup.js
├── entry-point.js
├── event-dispatcher.js
├── json-validator.js
├── migrator.js
├── modules
│ ├── ability-maker.js
│ ├── advantage-tracker.js
│ ├── ammo-manager.js
│ ├── config-ui.js
│ ├── death-save-manager.js
│ ├── entity-lister.js
│ ├── fx-manager.js
│ ├── hd-manager.js
│ ├── importer.js
│ ├── monster-manager.js
│ ├── new-character-configurer.js
│ ├── rest-manager.js
│ ├── sheetworker-chat-output.js
│ ├── spell-manager.js
│ └── uses-manager.js
├── parser.js
├── reporter.js
├── sanitise.js
├── shaped-config.js
├── shaped-module.js
├── srd-converter.js
├── user-error.js
└── utils.js
├── package.json
├── resources
├── mmFormatSpec.json
└── monstersSchema.json
├── roll20-shaped-scripts.iml
├── samples
├── customMonsterAndSpellsSample.js
├── monsterSample.json
└── spellSample.json
├── scripts
└── tob-parser.js
├── test
├── .eslintrc
├── data
│ ├── ancientGoldDragon.json
│ ├── ancientGoldDragon.txt
│ ├── ancientWhiteDragon.json
│ ├── ancientWhiteDragon.txt
│ ├── archmage.json
│ ├── archmage.txt
│ ├── beholder.json
│ ├── beholder.txt
│ ├── deathKnight.json
│ ├── deathKnight.txt
│ ├── halfDragonTroll.json
│ ├── halfDragonTroll.txt
│ ├── jermlaine.json
│ ├── jermlaine.txt
│ ├── lich.json
│ ├── lich.txt
│ ├── mindFlayer.json
│ ├── mindFlayer.txt
│ ├── priest.json
│ ├── priest.txt
│ ├── swarmOfQuippers.json
│ ├── swarmOfQuippers.txt
│ ├── talisTheWhite.json
│ └── talisTheWhite.txt
├── dummy-command-parser.js
├── dummy-entity-lookup.js
├── dummy-logger.js
├── dummy-reporter.js
├── dummy-roll20-object.js
├── test-ability-maker.js
├── test-ammo-manager.js
├── test-chat-watcher.js
├── test-command-parser.js
├── test-entity-criteria-collector.js
├── test-entity-lookup.js
├── test-json-validator.js
├── test-migrations.js
├── test-monster-manager.js
├── test-parser.js
├── test-rest-manager.js
├── test-shaped-config.js
├── test-spell-manager.js
├── test-srd-converter.js
├── test-uses-manager.js
└── test-utils.js
└── webpack.config.js
/.eslintignore:
--------------------------------------------------------------------------------
1 | 5eShapedCompanion.js
2 | samples/customMonsterAndSpellsSample.js
3 | node_modules
4 | data
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb-base",
3 | "parserOptions": {
4 | "ecmaVersion": 6,
5 | "sourceType": "script"
6 | },
7 | "rules": {
8 | "max-len": [
9 | "error",
10 | 120,
11 | 2
12 | ],
13 | "no-param-reassign": 0,
14 | "prefer-rest-params": 0,
15 | "class-methods-use-this": 0,
16 | "prefer-spread": 0,
17 | "no-underscore-dangle": 0,
18 | "no-plusplus": 0,
19 | "no-mixed-operators": 0,
20 | "no-continue": 0,
21 | "import/no-extraneous-dependencies": [
22 | "error",
23 | {
24 | "devDependencies": true,
25 | "optionalDependencies": false,
26 | "peerDependencies": false
27 | }
28 | ],
29 | "object-property-newline": [
30 | "error",
31 | {
32 | "allowMultiplePropertiesPerLine": true
33 | }
34 | ],
35 | "brace-style": [
36 | "error",
37 | "stroustrup",
38 | {
39 | "allowSingleLine": true
40 | }
41 | ],
42 | "space-before-function-paren": [
43 | "error",
44 | {
45 | "anonymous": "always",
46 | "named": "never"
47 | }
48 | ],
49 | "no-use-before-define": [
50 | "error",
51 | {
52 | "functions": false,
53 | "classes": false
54 | }
55 | ],
56 | "spaced-comment": [
57 | 2,
58 | "always",
59 | {
60 | "exceptions": [
61 | "/"
62 | ]
63 | }
64 | ],
65 | "no-nested-ternary": 0,
66 | "no-cond-assign": [
67 | 2,
68 | "except-parens"
69 | ]
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | 5eShapedCompanion.js
2 | .idea/workspace.xml
3 | .idea/misc.xml
4 | .DS_Store
5 | .idea/shelf/
6 | node_modules/
7 | scripts/tob.txt
--------------------------------------------------------------------------------
/.idea/codeStyleSettings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/.idea/copyright/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/.idea/encodings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/jsLibraryMappings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/jsLinters/jshint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/scopes/allSources.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/.idea/scopes/scope_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/scopes/tests.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | stable
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - '7.7.2'
4 | script: npm test
5 | before_deploy:
6 | - git config --global user.email "lucian@symposion.co.uk"
7 | - git config --global user.name "Travis-CI"
8 | - git config credential.helper "store --file=.git/credentials"
9 | - echo "https://${GH_TOKEN}:@github.com" > .git/credentials
10 | deploy:
11 | - provider: script
12 | skip_cleanup: true
13 | script: gulp release
14 | on:
15 | repo: symposion/roll20-shaped-scripts
16 | branch: master
17 | - provider: script
18 | skip_cleanup: true
19 | script: gulp developRelease
20 | on:
21 | repo: symposion/roll20-shaped-scripts
22 | branch: develop
23 | env:
24 | global:
25 | secure: GcbJQHdU76uXC+JFQhIH8g1C8yyGiKSEyh1526lAHUXaCoA8TjxjJstYJyrGBUw5nUxyehK397nOMm5ipOqrNXtPz9kCe3Z5u84R7rk1oTFgt4qvpMQGQO2xaBDrXrAcxs52s6No3YQ7xp5BfsyZn9LtHI0QK6z4Aol3feuErqL+K5NA5YV58XIHV3D4ymKqMjY5Xvyw/jUKMZJ8SnYpXllXcPmOieepPESynxQ7+C61ZkTCrISsi/92gnHnhwkVAiJfOi2JsheUsryJsf1tWXIqLTy0YTleKkqn1zCe5Gt2ac4bGRK2jIyreLtXCda0DKAv/Lezkf7pOV/5PEel3c7ekreQapRmCg4/HzoxPxI//H5VIXnXsVaIF7ZAHvBg2BIso9bqaSD2J5M9cQuJDNaPJDlXqJ1YCs4tK41PP8LVR5dN4wOhxJFo4ZumWl/u4Pv4lSi20yiJhrC6hkNcU6jnxkLyuZOhzBqXzPSmGrGVJ5BV1fSca5KvLDmOny8wqpPYegLlbn9j52WL9dDmklq4qLByo2xn1y6mwlY8pOqWyYXTJxyxs2tmRZga1a8lRtLLZowx9XRNsQsF+NyRWnu4FV9RoiN+FmQu4ZwSvBEwVU2RKu0r6alnmhUF74QYjAKdXYm+zWygW8oUhmNS3+JD8OY85+4eGDPaP4wQUOE=
26 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | // Place your settings in this file to overwrite default and user settings.
2 | {
3 | // Columns at which to show vertical rulers
4 | "editor.rulers": [
5 | 120
6 | ],
7 | // The number of spaces a tab is equal to.
8 | "editor.tabSize": 2
9 | }
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const gulp = require('gulp');
3 | const mocha = require('gulp-mocha');
4 | const eslint = require('gulp-eslint');
5 | const webpack = require('webpack-stream');
6 | const tagVersion = require('gulp-tag-version');
7 | const bump = require('gulp-bump');
8 | const git = require('gulp-git');
9 | const conventionalChangelog = require('gulp-conventional-changelog');
10 | const conventionalRecommendedBump = np(require('conventional-recommended-bump'));
11 | const gulpIgnore = require('gulp-ignore');
12 | const readPkg = require('read-pkg');
13 | const injectVersion = require('gulp-inject-version');
14 | const toc = require('gulp-doctoc');
15 | const webpackConfig = require('./webpack.config.js');
16 | const addSrc = require('gulp-add-src');
17 | const concat = require('gulp-concat');
18 |
19 | gulp.task('default', ['test', 'lint'], () => runWebpackBuild());
20 |
21 | gulp.task('lint', () =>
22 | gulp.src('./lib/**/*.js')
23 | .pipe(eslint())
24 | .pipe(eslint.format())
25 | .pipe(eslint.failAfterError())
26 | );
27 |
28 | gulp.task('test', () =>
29 | gulp.src('test/test-*.js', { read: false })
30 | .pipe(mocha())
31 | );
32 |
33 | gulp.task('buildReleaseVersionScript', ['bumpVersion'], () => runWebpackBuild());
34 |
35 | gulp.task('release', ['changelog', 'doctoc', 'buildReleaseVersionScript'], (done) => {
36 | // Get all the files to bump version in
37 | gulp.src(['./package.json', './CHANGELOG.md', './README.md'])
38 | .pipe(git.commit('chore(release): bump package version and update changelog [ci skip]', { emitData: true }))
39 | .pipe(gulpIgnore.exclude(/CHANGELOG.md|README.md/))
40 | // **tag it in the repository**
41 | .pipe(tagVersion({ prefix: '' }))
42 | .on('end', done);
43 | return undefined;
44 | });
45 |
46 | gulp.task('doctoc', () =>
47 | gulp.src('README.md')
48 | .pipe(toc({ depth: 2 }))
49 | .pipe(gulp.dest('./'))
50 | );
51 |
52 |
53 | gulp.task('changelog', ['bumpVersion'], () =>
54 | gulp.src('./CHANGELOG.md', { buffer: false })
55 | .pipe(conventionalChangelog({ preset: 'angular' }, { currentTag: readPkg.sync().version }))
56 | .pipe(gulp.dest('./'))
57 | );
58 |
59 | gulp.task('bumpVersion', (done) => {
60 | conventionalRecommendedBump({ preset: 'angular' })
61 | .then(result =>
62 | gulp.src('./package.json')
63 | .pipe(bump({ type: result.releaseType }))
64 | .pipe(gulp.dest('./'))
65 | .on('end', done)
66 | );
67 |
68 | return undefined;
69 | });
70 |
71 | function runWebpackBuild() {
72 | return gulp.src('./lib/entry-point.js')
73 | .pipe(webpack(webpackConfig))
74 | .pipe(injectVersion({
75 | replace: /%%GULP_INJECT_VERSION%%/g,
76 | }))
77 | .pipe(addSrc('./data/5eSRDData.js'))
78 | .pipe(concat('./5eShapedCompanion.js'))
79 | .pipe(gulp.dest('./'));
80 | }
81 |
82 | function np(method) {
83 | return function promiseWrapper() {
84 | const self = this;
85 | const args = Array.prototype.slice.call(arguments);
86 | return new Promise((resolve, reject) => {
87 | args.push((err, data) => {
88 | if (err !== null) {
89 | return reject(err);
90 | }
91 | return resolve(data);
92 | });
93 | return method.apply(self, args);
94 | });
95 | };
96 | }
97 |
98 | function sp(method) {
99 | return function promiseWrapper() {
100 | const self = this;
101 | const args = Array.prototype.slice(arguments);
102 | return new Promise((resolve) => {
103 | args.push(data => resolve(data));
104 | return method.apply(self, args);
105 | });
106 | };
107 | }
108 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // See http://go.microsoft.com/fwlink/?LinkId=759670
3 | // for the documentation about the jsconfig.json format
4 | "compilerOptions": {
5 | "target": "es6"
6 | },
7 | "exclude": [
8 | "node_modules",
9 | "bower_components",
10 | "jspm_packages",
11 | "tmp",
12 | "temp"
13 | ]
14 | }
--------------------------------------------------------------------------------
/lib/chat-watcher.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const _ = require('underscore');
4 |
5 | module.exports = class ChatWatcher {
6 | constructor(roll20, logger, eventDispatcher) {
7 | this.roll20 = roll20;
8 | this.logger = logger;
9 | this.eventDispatcher = eventDispatcher;
10 | this.chatListeners = [];
11 | logger.wrapModule(this);
12 | eventDispatcher.registerEventHandler('chat:message', (msg) => {
13 | if (msg.type !== 'api') {
14 | this.logger.debug('Processing message $$$', msg);
15 | this.triggerChatListeners(msg);
16 | }
17 | });
18 | }
19 |
20 | registerChatListener(triggerFields, handler) {
21 | const matchers = [];
22 | if (triggerFields && !_.isEmpty(triggerFields)) {
23 | matchers.push((msg, options) => _.intersection(triggerFields, _.keys(options)).length === triggerFields.length);
24 | }
25 | this.chatListeners.push({ matchers, handler });
26 | }
27 |
28 | triggerChatListeners(msg) {
29 | const options = this.getRollTemplateOptions(msg);
30 | this.logger.debug('Roll template options: $$$', options);
31 | options.playerId = msg.playerid;
32 | options.whisper = msg.type === 'whisper';
33 | _.each(this.chatListeners, (listener) => {
34 | if (_.every(listener.matchers, matcher => matcher(msg, options))) {
35 | listener.handler(options);
36 | }
37 | });
38 | }
39 |
40 | /**
41 | *
42 | * @returns {*}
43 | */
44 | getRollTemplateOptions(msg) {
45 | if (msg.rolltemplate === '5e-shaped') {
46 | const regex = /\{\{(.*?)}}/g;
47 | let match;
48 | const options = {};
49 | while ((match = regex.exec(ChatWatcher.processInlinerolls(msg)))) {
50 | if (match[1]) {
51 | const splitAttr = match[1].split('=');
52 | const propertyName = splitAttr[0].replace(/_([a-z])/g, (m, letter) => letter.toUpperCase());
53 | options[propertyName] = splitAttr.length === 2 ? splitAttr[1].replace(/\^\{/, '') : '';
54 | }
55 | }
56 | if (options.characterName) {
57 | options.character = this.roll20.findObjs({
58 | _type: 'character',
59 | name: options.characterName,
60 | })[0];
61 | }
62 | return options;
63 | }
64 | return {};
65 | }
66 |
67 | static processInlinerolls(msg) {
68 | if (_.has(msg, 'inlinerolls')) {
69 | return _.chain(msg.inlinerolls)
70 | .reduce((previous, current, index) => {
71 | previous[`$[[${index}]]`] = current.results.total || 0;
72 | return previous;
73 | }, {})
74 | .reduce((previous, current, index) => previous.replace(index.toString(), current), msg.content)
75 | .value();
76 | }
77 |
78 | return msg.content;
79 | }
80 |
81 | get logWrap() {
82 | return 'ChatWatcher';
83 | }
84 | };
85 |
--------------------------------------------------------------------------------
/lib/entity-criteria-collector.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const _ = require('underscore');
3 | const ShapedConfig = require('./shaped-config');
4 | const Utils = require('./utils');
5 |
6 | function makeKey(criterionName, value) {
7 | return `${criterionName}|${value}`;
8 | }
9 |
10 |
11 | module.exports = class EntityCriteriaCollector {
12 |
13 | constructor(criteriaList, logger, entityLookup, entityType) {
14 | this._criteria = criteriaList;
15 | this._entityLookup = entityLookup;
16 | this._criteriaFilters = {};
17 | this._dirty = true;
18 | this._entityType = entityType;
19 | this.logger = logger;
20 | criteriaList.forEach((criterion) => {
21 | criterion.values = [];
22 | criterion.displayName = _.isUndefined(criterion.displayName) ? criterion.name : criterion.displayName;
23 | });
24 | }
25 |
26 | getEntityProcessor() {
27 | return (entityInfo) => {
28 | this._dirty = true;
29 | return entityInfo;
30 | };
31 | }
32 |
33 | getCriteriaToDisplay(currentCriteria) {
34 | this.rebuildCriteria();
35 | let filteredCriteria = this.criteria;
36 | if (!_.isEmpty(currentCriteria)) {
37 | filteredCriteria = this._criteria
38 | .map((criterionToFilter) => {
39 | let baseKeySets = this._criteria
40 | .filter(criterion => currentCriteria[criterion.name] && criterion !== criterionToFilter)
41 | .map(criterion =>
42 | criterion.values
43 | .filter(value => _.contains(currentCriteria[criterion.name], value))
44 | .map(value => makeKey(criterion.name, value)))
45 | .concat([[undefined]]);
46 |
47 | baseKeySets = Utils.cartesianProductOf.apply(this, baseKeySets).map(keySet => _.compact(keySet));
48 |
49 | const filteredValues = criterionToFilter.values.filter((value) => {
50 | const testKey = makeKey(criterionToFilter.name, value);
51 | return baseKeySets.some(keySet => this._criteriaFilters[keySet.concat(testKey).sort().join(';')]);
52 | });
53 | const criterion = {
54 | name: criterionToFilter.name,
55 | values: filteredValues,
56 | displayName: criterionToFilter.displayName,
57 | };
58 | Object.defineProperties(criterion, {
59 | buildListEntry: { enumerable: false, value: criterionToFilter.buildListEntry },
60 | compare: { enumerable: false, value: criterionToFilter.compare },
61 | getValueText: { enumerable: false, value: criterionToFilter.getValueText },
62 | });
63 | return criterion;
64 | })
65 | .filter(criterion => !_.isEmpty(criterion.values));
66 | }
67 |
68 | filteredCriteria
69 | .forEach((criterion) => {
70 | criterion.values.sort(criterion.compare);
71 | });
72 |
73 | return filteredCriteria;
74 | }
75 |
76 | get criteria() {
77 | this.rebuildCriteria();
78 | return this._criteria;
79 | }
80 |
81 | get criteriaOptionsValidator() {
82 | this.rebuildCriteria();
83 | return _.object(this._criteria.map(criterion => [criterion.name, getValidator(criterion.validator)]));
84 | }
85 |
86 | rebuildCriteria() {
87 | if (!this._dirty) {
88 | return;
89 | }
90 |
91 | this.logger.debug('Rebuilding entity criteria for entity $$$', this._entityType);
92 | this._entityLookup.getAll(this._entityType).forEach((entity) => {
93 | const criteriaKeys = [];
94 | _.each(this._criteria, (criterion) => {
95 | const value = entity[criterion.name];
96 | (_.isArray(value) ? value : [value]).forEach((innerValue) => {
97 | innerValue = (criterion.transformer || _.identity)(innerValue);
98 | if (!_.isUndefined(innerValue)) {
99 | const criterionKey = makeKey(criterion.name, innerValue);
100 | criteriaKeys.push(criterionKey);
101 |
102 | if (!_.contains(criterion.values, innerValue)) {
103 | criterion.values.push(innerValue);
104 | }
105 | }
106 | });
107 | criterion.values.sort(criterion.compare);
108 | });
109 |
110 | criteriaKeys.sort();
111 | Utils.combine(criteriaKeys).forEach(key => (this._criteriaFilters[key] = 1));
112 | });
113 | this._dirty = false;
114 | }
115 | };
116 |
117 | function getValidator(validator) {
118 | if (!validator) {
119 | return ShapedConfig.arrayValidator;
120 | }
121 |
122 | return (value) => {
123 | const arrayResult = ShapedConfig.arrayValidator(value);
124 | arrayResult.converted = arrayResult.converted.map((val) => {
125 | const result = validator(val);
126 | arrayResult.valid = arrayResult.valid && result.valid;
127 | return result.converted;
128 | });
129 | return arrayResult;
130 | };
131 | }
132 |
--------------------------------------------------------------------------------
/lib/entity-lookup-result-reporter.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const _ = require('underscore');
3 |
4 | module.exports = class EntityLookupResultReporter {
5 |
6 | constructor(logger, reporter) {
7 | this.report = function report(result) {
8 | const summary = _.mapObject(result, (resultObject, type) => {
9 | if (type === 'errors') {
10 | return resultObject.length;
11 | }
12 |
13 | return _.mapObject(resultObject, operationResultArray => operationResultArray.length);
14 | });
15 | logger.info('Summary of adding $$$ entity group to the lookup: $$$', result.entityGroupName, summary);
16 | logger.debug('Details: $$$', result);
17 | if (!_.isEmpty(result.errors)) {
18 | const message = _.chain(result.errors)
19 | .groupBy('entity')
20 | .mapObject(entityErrors =>
21 | _.chain(entityErrors)
22 | .pluck('errors')
23 | .flatten()
24 | .value()
25 | )
26 | .map((errors, entityName) => `
${entityName}:`)
27 | .value();
28 |
29 | reporter.reportError(`JSON import error for ${result.entityGroupName} entity group:`);
30 | }
31 | };
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/lib/entry-point.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const Roll20 = require('roll20-wrapper');
3 | const parseModule = require('./parser');
4 | const Logger = require('roll20-logger');
5 | const EntityLookup = require('./entity-lookup');
6 | const EntityLookupResultReporter = require('./entity-lookup-result-reporter');
7 | const Reporter = require('./reporter');
8 | const makeCommandProc = require('./command-parser');
9 | const AbilityMaker = require('./modules/ability-maker');
10 | const ConfigUI = require('./modules/config-ui');
11 | const AdvantageTracker = require('./modules/advantage-tracker');
12 | const RestManager = require('./modules/rest-manager');
13 | const UsesManager = require('./modules/uses-manager');
14 | const AmmoManager = require('./modules/ammo-manager');
15 | const Importer = require('./modules/importer');
16 | const DeathSaveManager = require('./modules/death-save-manager');
17 | const HDManager = require('./modules/hd-manager');
18 | const FXManager = require('./modules/fx-manager');
19 | const SpellManager = require('./modules/spell-manager');
20 | const NewCharacterConfigurer = require('./modules/new-character-configurer');
21 | const srdConverter = require('./srd-converter');
22 | const UserError = require('./user-error');
23 | const EventDispatcher = require('./event-dispatcher');
24 | const ChatWatcher = require('./chat-watcher');
25 | const Utils = require('./utils');
26 | const _ = require('underscore');
27 | const ShapedConfig = require('./shaped-config');
28 | const EntityLister = require('./modules/entity-lister');
29 | const MonsterManager = require('./modules/monster-manager');
30 | const SheetWorkerChatOutput = require('./modules/sheetworker-chat-output');
31 |
32 | const roll20 = new Roll20();
33 | const myState = roll20.getState('ShapedScripts');
34 | const logger = new Logger('5eShapedCompanion', roll20);
35 | const entityLookup = new EntityLookup(logger);
36 | const reporter = new Reporter(roll20, 'Shaped Scripts');
37 |
38 | const MINIMUM_SHEET_VERSION = '11.5.0';
39 | const SHEET_API_VERSION = '1';
40 |
41 | const errorHandler = function errorHandler(e) {
42 | logger.prefixString = '';
43 | if (typeof e === 'string' || e instanceof parseModule.ParserError || e instanceof UserError) {
44 | reporter.reportError(e);
45 | logger.error('Error: $$$', e.toString());
46 | }
47 | else {
48 | logger.error(e.toString());
49 | logger.error(e.stack);
50 | reporter.reportError('An error occurred. Please see the log for more details.');
51 | }
52 | };
53 |
54 | roll20.logWrap = 'roll20';
55 | logger.wrapModule(entityLookup);
56 | logger.wrapModule(roll20);
57 | logger.wrapModule(srdConverter);
58 | const moduleList = getModuleList();
59 |
60 | roll20.on('ready', () => {
61 | logger.info('-=> ShapedScripts %%GULP_INJECT_VERSION%% <=-');
62 | const character = roll20.createObj('character', { name: 'SHAPED_VERSION_TESTER' });
63 | const campaignSize = roll20.findObjs({}).length;
64 | logger.debug('Campaign size: $$$', campaignSize);
65 | roll20.createAttrWithWorker(character.id, 'sheet_opened', 1, () => {
66 | runStartup(character, 0);
67 | });
68 | });
69 |
70 | function runStartup(character, retryCount) {
71 | const version = roll20.getAttrByName(character.id, 'version', 'current', true);
72 | const ed = new EventDispatcher(roll20, errorHandler, logger, reporter);
73 | const cw = new ChatWatcher(roll20, logger, ed);
74 | const commandProc = makeCommandProc('shaped', roll20, errorHandler, ed, version, logger);
75 | if (!version) {
76 | if (retryCount > 4) {
77 | const error = 'Couldn\'t find Shaped Character Sheet. This Shaped Companion Script requires the Shaped ' +
78 | 'Character Sheet to be installed in the campaign.';
79 | reporter.reportError(error);
80 | logger.error(error);
81 | commandProc.setDefaultCommandHandler(() => reporter.reportError(error));
82 | return;
83 | }
84 |
85 | logger.debug(`No version attribute found yet, delaying for another 4 seconds. Retry count ${retryCount}`);
86 | _.delay(runStartup.bind(null, character, ++retryCount), 4000);
87 | return;
88 | }
89 | const sheetAPIVersion = roll20.getAttrByName(character.id, 'script_compatibility_version');
90 | logger.info('Detected sheet version as : $$$', version);
91 |
92 | if (Utils.versionCompare(version, MINIMUM_SHEET_VERSION) < 0) {
93 | const error = `The Shaped companion script requires the Shaped sheet to be version ${MINIMUM_SHEET_VERSION} ` +
94 | `or higher. You're currently using version ${version}. Please install the latest Shaped sheet from github: ` +
95 | 'https://github.com/mlenser/roll20-character-sheets/tree/master/5eShaped';
96 | reporter.reportError(error);
97 | logger.error(error);
98 | commandProc.setDefaultCommandHandler(() => reporter.reportError(error));
99 | return;
100 | }
101 |
102 |
103 | if (SHEET_API_VERSION !== sheetAPIVersion) {
104 | const error = 'WARNING: Character sheet has been updated with breaking changes that this version of the ' +
105 | 'Companion Script does not yet support. Some features may not work as expected. Please check for an ' +
106 | 'updated version of the script.';
107 | reporter.reportError(error);
108 | logger.error(error);
109 | }
110 |
111 | const sc = new ShapedConfig({ roll20, reporter, logger, myState });
112 | sc.configure(commandProc, cw, ed);
113 | sc.runStartupSequence(commandProc, () => {
114 | commandProc.setDefaultCommandHandler(cmd =>
115 | reporter.reportError(`Unknown command ${cmd}`));
116 | moduleList.forEach(module => module.configure(commandProc, cw, ed));
117 | _.invoke(roll20.findObjs({ type: 'character', name: 'SHAPED_VERSION_TESTER' }), 'remove');
118 | });
119 | }
120 |
121 | module.exports = {
122 | addEntities(entities) {
123 | const elrr = new EntityLookupResultReporter(logger, reporter);
124 | try {
125 | if (typeof entities === 'string') {
126 | entities = JSON.parse(entities);
127 | }
128 |
129 | entityLookup.addEntities(entities, elrr);
130 | }
131 | catch (e) {
132 | reporter.reportError('JSON parse error, please see log for more information');
133 | logger.error(e.toString());
134 | logger.error(e.stack);
135 | }
136 | },
137 | };
138 |
139 | function getModuleList() {
140 | const deps = {
141 | roll20,
142 | reporter,
143 | logger,
144 | myState,
145 | parseModule,
146 | srdConverter,
147 | entityLookup,
148 | };
149 |
150 |
151 | return []
152 | .concat(new SheetWorkerChatOutput(deps))
153 | .concat(new AbilityMaker(deps))
154 | .concat(new EntityLister(deps))
155 | .concat(new Importer(deps))
156 | .concat(new SpellManager(deps))
157 | .concat(new NewCharacterConfigurer(deps))
158 | .concat(new ConfigUI(deps))
159 | .concat(new AdvantageTracker(deps))
160 | .concat(new UsesManager(deps))
161 | .concat(new RestManager(deps))
162 | .concat(new AmmoManager(deps))
163 | .concat(new MonsterManager(deps))
164 | .concat(new DeathSaveManager(deps))
165 | .concat(new HDManager(deps))
166 | .concat(new FXManager(deps));
167 | }
168 |
--------------------------------------------------------------------------------
/lib/event-dispatcher.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /* globals GroupInitiative: false */
3 | const _ = require('underscore');
4 |
5 | module.exports = class EventDispatcher {
6 |
7 | constructor(roll20, errorHandler, logger) {
8 | this.roll20 = roll20;
9 | this.addedTokenIds = [];
10 | this.errorHandler = errorHandler;
11 | this.logger = logger;
12 | this.addTokenListeners = [];
13 | this.attributeChangeHandlers = {};
14 | this.turnOrderChangeListeners = [];
15 | this.initPageListeners = [];
16 | logger.wrapModule(this);
17 | roll20.on('add:token', this.handleAddToken.bind(this));
18 | roll20.on('change:token', this.handleChangeTokenForAdd.bind(this));
19 | roll20.on('change:campaign:turnorder', (obj, prev) => {
20 | this.handleTurnOrderChange(obj.get('turnorder'), prev.turnorder);
21 | });
22 | roll20.on('change:campaign:initiativepage', (obj) => {
23 | this.initPageListeners.forEach(listener => listener(obj.get('initiativepage')));
24 | });
25 | if (typeof GroupInitiative !== 'undefined' && GroupInitiative.ObserveTurnOrderChange) {
26 | /* eslint-disable new-cap */
27 | // noinspection JSUnresolvedFunction
28 | GroupInitiative.ObserveTurnOrderChange(this.handleGroupInitTurnOrderChange.bind(this));
29 | /* eslint-enable new-cap */
30 | }
31 | if (typeof TurnMarker !== 'undefined') {
32 | roll20.on('chat:message', (msg) => {
33 | if (msg.type === 'api' && msg.content === '!eot') {
34 | const turnOrder = roll20.getCampaign().get('turnorder');
35 | _.defer(this.handleTurnOrderChange.bind(this, turnOrder));
36 | }
37 | });
38 | }
39 | roll20.on('change:attribute', (curr, prev) => {
40 | (this.attributeChangeHandlers[curr.get('name')] || []).forEach(handler => handler(curr, prev));
41 | });
42 | }
43 |
44 | /////////////////////////////////////////////////
45 | // Event Handlers
46 | /////////////////////////////////////////////////
47 | handleAddToken(token) {
48 | const represents = token.get('represents');
49 | if (_.isEmpty(represents)) {
50 | return;
51 | }
52 | const character = this.roll20.getObj('character', represents);
53 | if (!character) {
54 | return;
55 | }
56 | this.addedTokenIds.push(token.id);
57 |
58 | // URGH. Thanks Roll20.
59 | setTimeout(() => {
60 | const addedToken = this.roll20.getObj('graphic', token.id);
61 | if (addedToken) {
62 | this.handleChangeTokenForAdd(addedToken);
63 | }
64 | }, 100);
65 | }
66 |
67 | handleChangeTokenForAdd(token) {
68 | if (_.contains(this.addedTokenIds, token.id)) {
69 | this.addedTokenIds = _.without(this.addedTokenIds, token.id);
70 | this.addTokenListeners.forEach(listener => listener(token));
71 | }
72 | }
73 |
74 | handleTurnOrderChange(current, prev) {
75 | const prevOrder = prev ? JSON.parse(prev) : [];
76 | const currentOrder = current ? JSON.parse(current) : [];
77 | this.logger.debug('Turn order change, $$$, $$$', currentOrder, prevOrder);
78 | if (currentOrder.length > 0 &&
79 | (prevOrder.length === 0 || currentOrder[0].id !== prevOrder[0].id)) {
80 | this.turnOrderChangeListeners.forEach(listener => listener(currentOrder));
81 | }
82 | }
83 |
84 | handleGroupInitTurnOrderChange(current, prev) {
85 | this.handleTurnOrderChange(current, prev);
86 | if (current && current === '[]' && this.roll20.getCampaign().get('initiativepage') === false) {
87 | this.initPageListeners.forEach(listener => listener(false));
88 | }
89 | else if (prev && prev === '[]') {
90 | _.defer(() => {
91 | const initPage = this.roll20.getCampaign().get('initiativepage');
92 | if (initPage !== false) {
93 | this.initPageListeners.forEach(listener => listener(initPage));
94 | }
95 | });
96 | }
97 | }
98 |
99 | registerEventHandler(eventType, handler) {
100 | switch (eventType) {
101 | case 'add:token':
102 | this.addTokenListeners.push(this.wrapHandler(handler));
103 | break;
104 | case 'change:campaign:turnorder':
105 | this.turnOrderChangeListeners.push(this.wrapHandler(handler));
106 | break;
107 | case 'change:campaign:initiativepage':
108 | this.initPageListeners.push(this.wrapHandler(handler));
109 | break;
110 | default:
111 | this.roll20.on(eventType, this.wrapHandler(handler));
112 | }
113 | }
114 |
115 | registerAttributeChangeHandler(attributeName, handler) {
116 | this.attributeChangeHandlers[attributeName] = this.attributeChangeHandlers[attributeName] || [];
117 | this.attributeChangeHandlers[attributeName].push(this.wrapHandler(handler));
118 | }
119 |
120 | wrapHandler(handler) {
121 | const self = this;
122 | return function handlerWrapper() {
123 | try {
124 | const retVal = handler.apply(null, arguments);
125 | if (retVal instanceof Promise) {
126 | retVal.catch(self.errorHandler);
127 | }
128 | }
129 | catch (e) {
130 | self.errorHandler(e);
131 | }
132 | finally {
133 | self.logger.prefixString = '';
134 | }
135 | };
136 | }
137 |
138 | get logWrap() {
139 | return 'EventDispatcher';
140 | }
141 | };
142 |
--------------------------------------------------------------------------------
/lib/migrator.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const _ = require('underscore');
3 | const Utils = require('./utils');
4 |
5 |
6 | class Migrator {
7 |
8 | constructor(startVersion) {
9 | this._versions = [{ version: startVersion || 0.1, migrations: [] }];
10 | }
11 |
12 | skipToVersion(version) {
13 | this._versions.push({ version, migrations: [] });
14 | return this;
15 | }
16 |
17 | nextVersion() {
18 | const currentVersion = this._versions.slice(-1)[0].version;
19 | const nextVersion = (currentVersion * 10 + 1) / 10; // Avoid FP errors
20 | this._versions.push({ version: nextVersion, migrations: [] });
21 | return this;
22 | }
23 |
24 | addProperty(path, value) {
25 | const expandedProperty = Utils.createObjectFromPath(path, value);
26 | return this.transformConfig(config => Utils.deepExtend(config, expandedProperty),
27 | `Adding property ${path} with value ${value}`);
28 | }
29 |
30 | overwriteProperty(path, value) {
31 | return this.transformConfig((config) => {
32 | const parts = path.split('.');
33 | const obj = parts.length > 1 ? Utils.getObjectFromPath(config, parts.slice(0, -1).join('.')) : config;
34 | obj[parts.slice(-1)[0]] = value;
35 | return config;
36 | }, `Overwriting property ${path} with value ${JSON.stringify(value)}`);
37 | }
38 |
39 | copyProperty(oldPath, newPath) {
40 | return this.transformConfig(Migrator.propertyCopy.bind(null, oldPath, newPath),
41 | `Copying property from ${oldPath} to ${newPath}`);
42 | }
43 |
44 |
45 | static propertyCopy(oldPath, newPath, config) {
46 | const oldVal = Utils.getObjectFromPath(config, oldPath);
47 | if (!_.isUndefined(oldVal)) {
48 | const expandedProperty = Utils.createObjectFromPath(newPath, oldVal);
49 | Utils.deepExtend(config, expandedProperty);
50 | }
51 | return config;
52 | }
53 |
54 | static propertyDelete(path, config) {
55 | const parts = path.split('.');
56 | const obj = parts.length > 1 ? Utils.getObjectFromPath(config, parts.slice(0, -1).join('.')) : config;
57 | if (obj && !_.isUndefined(obj[parts.slice(-1)[0]])) {
58 | delete obj[parts.slice(-1)[0]];
59 | }
60 | return config;
61 | }
62 |
63 | deleteProperty(propertyPath) {
64 | return this.transformConfig(Migrator.propertyDelete.bind(null, propertyPath),
65 | `Deleting property ${propertyPath} from config`);
66 | }
67 |
68 | moveProperty(oldPath, newPath) {
69 | return this.transformConfig((config) => {
70 | config = Migrator.propertyCopy(oldPath, newPath, config);
71 | return Migrator.propertyDelete(oldPath, config);
72 | }, `Moving property from ${oldPath} to ${newPath}`);
73 | }
74 |
75 | transformConfig(transformer, message) {
76 | const lastVersion = this._versions.slice(-1)[0];
77 | lastVersion.migrations.push({ transformer, message });
78 | return this;
79 | }
80 |
81 | needsUpdate(state) {
82 | return !state.version || state.version < _.last(this._versions).version;
83 | }
84 |
85 | isValid(state) {
86 | return _.isEmpty(state) || state.version <= _.last(this._versions).version;
87 | }
88 |
89 | migrateConfig(state, logger) {
90 | logger.info('Checking config for upgrade, starting state: $$$', state);
91 | if (_.isEmpty(state)) {
92 | // working with a fresh install here
93 | state.version = 0;
94 | }
95 | if (!this._versions.find(version => version.version >= state.version)) {
96 | throw new Error(`Unrecognised schema state ${state.version} - cannot upgrade.`);
97 | }
98 |
99 | return this._versions
100 | .filter(version => version.version > state.version)
101 | .reduce((versionResult, version) => {
102 | logger.info('Upgrading schema to version $$$', version.version);
103 |
104 | versionResult = version.migrations.reduce((result, migration) => {
105 | logger.info(migration.message);
106 | return migration.transformer(result);
107 | }, versionResult);
108 | versionResult.version = version.version;
109 | logger.info('Post-upgrade state: $$$', versionResult);
110 | return versionResult;
111 | }, state);
112 | }
113 | }
114 |
115 | module.exports = Migrator;
116 |
--------------------------------------------------------------------------------
/lib/modules/ammo-manager.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const _ = require('underscore');
3 | const ShapedModule = require('../shaped-module');
4 | const ShapedConfig = require('../shaped-config');
5 |
6 | class AmmoManager extends ShapedModule {
7 |
8 | registerEventListeners(eventDispatcher) {
9 | eventDispatcher.registerEventHandler('change:campaign:initiativepage', (initPage) => {
10 | this.logger.debug('Initiative page changed to: $$$', initPage);
11 | if (initPage) {
12 | this.myState.ammoTracking = {};
13 | }
14 | else {
15 | this.reportTotalAmmoUse();
16 | this.myState.ammoTracking = {};
17 | }
18 | });
19 | }
20 |
21 | addCommands(commandProc) {
22 | commandProc
23 | .addCommand('recover-ammo', this.recoverAmmo.bind(this), true)
24 | .option('ammoAttr', ShapedConfig.getObjectValidator('attribute', this.roll20), true)
25 | .option('uses', ShapedConfig.integerValidator, true);
26 | }
27 |
28 | registerChatListeners(chatWatcher) {
29 | chatWatcher.registerChatListener(['ammoName', 'character'], this.consumeAmmo.bind(this));
30 | }
31 |
32 | consumeAmmo(options) {
33 | if (!this.roll20.checkCharacterFlag(options.character.id, 'ammo_auto_use')) {
34 | return;
35 | }
36 |
37 | const ammoAttr = _.chain(this.roll20.findObjs({ type: 'attribute', characterid: options.character.id }))
38 | .filter(attribute => attribute.get('name').indexOf('repeating_ammo') === 0)
39 | .groupBy(attribute => attribute.get('name').replace(/(repeating_ammo_[^_]+).*/, '$1'))
40 | .find(attributeList =>
41 | _.find(attributeList, attribute =>
42 | attribute.get('name').match(/.*name$/) && attribute.get('current') === options.ammoName)
43 | )
44 | .find(attribute => attribute.get('name').match(/.*uses$/))
45 | .value();
46 |
47 | if (!ammoAttr) {
48 | this.logger.error('No ammo attribute found corresponding to name $$$', options.ammoName);
49 | return;
50 | }
51 |
52 | if (options.ammo) {
53 | const ammoRemaining = parseInt(options.ammo, 10);
54 | if (ammoRemaining >= 0) {
55 | const current = parseInt(ammoAttr.get('current'), 10);
56 | ammoAttr.setWithWorker('current', ammoRemaining);
57 | const ammoTracking = this.getAmmoTracking();
58 | if (ammoTracking) {
59 | ammoTracking[ammoAttr.id] = (ammoTracking[ammoAttr.id] || 0) + current - ammoRemaining;
60 | }
61 | }
62 | else {
63 | this.reportResult('Ammo Police', `${options.characterName} can't use ${options.title} because ` +
64 | `they don't have enough ${options.ammoName} left`, options);
65 | }
66 | }
67 | }
68 |
69 | getAmmoTracking() {
70 | if (this.roll20.getCampaign().get('initiativepage')) {
71 | this.myState.ammoTracking = this.myState.ammoTracking || {};
72 | return this.myState.ammoTracking;
73 | }
74 | return null;
75 | }
76 |
77 | reportTotalAmmoUse() {
78 | if (!this.myState.config.sheetEnhancements.ammoRecovery) {
79 | return;
80 | }
81 | const recoveryStrings = _.chain(this.myState.ammoTracking)
82 | .map((used, id) => {
83 | const ammoAttr = this.roll20.getObj('attribute', id);
84 | if (!ammoAttr) {
85 | return null;
86 | }
87 | const ammoName = this.roll20.getAttrByName(ammoAttr.get('characterid'),
88 | ammoAttr.get('name').replace(/_uses/, '_name'));
89 | const char = this.roll20.getObj('character', ammoAttr.get('characterid'));
90 | return `${char.get('name')} used ${used} ${ammoName}. Recover`;
92 | })
93 | .compact()
94 | .value();
95 |
96 | if (!_.isEmpty(recoveryStrings)) {
97 | const msg = `- ${recoveryStrings.join('
- ')}
`;
98 | this.reportPlayer('Ammo Recovery', msg);
99 | }
100 | }
101 |
102 | recoverAmmo(options) {
103 | const ammoName = this.roll20.getAttrByName(options.ammoAttr.get('characterid'),
104 | options.ammoAttr.get('name').replace(/_uses/, '_name'));
105 | options.ammoAttr.setWithWorker({ current: options.ammoAttr.get('current') + options.uses });
106 | this.reportCharacter('Ammo Recovery', `You recover ${options.uses} ${ammoName}`,
107 | options.ammoAttr.get('characterid'));
108 | }
109 |
110 | }
111 |
112 | module.exports = AmmoManager;
113 |
--------------------------------------------------------------------------------
/lib/modules/death-save-manager.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const ShapedModule = require('./../shaped-module');
3 |
4 | module.exports = class DeathSaveManager extends ShapedModule {
5 |
6 | registerChatListeners(chatWatcher) {
7 | chatWatcher.registerChatListener(['deathSavingThrow', 'character', 'roll1'], this.handleDeathSave.bind(this));
8 | }
9 |
10 | handleDeathSave(options) {
11 | if (this.roll20.getAttrByName(options.character.id, 'shaped_d20') === '1d20') {
12 | return; // Sheet is set to Roll 2 - we don't know if the character has (dis)advantage so automation isn't possible
13 | }
14 | const currentHP = parseInt(this.roll20.getAttrByName(options.character.id, 'HP', 'current', true), 10);
15 | if (currentHP > 0) {
16 | this.reportResult('Death Saves', `${options.character.get('name')} has more than 0 HP and shouldn't be rolling ` +
17 | 'death saves', options);
18 | return;
19 | }
20 |
21 | const successes = this.roll20.getOrCreateAttr(options.character.id, 'death_saving_throw_successes');
22 | let successCount = successes.get('current');
23 | const failures = this.roll20.getOrCreateAttr(options.character.id, 'death_saving_throw_failures');
24 | let failureCount = failures.get('current');
25 | const result = parseInt(options.roll1, 10);
26 |
27 | switch (result) {
28 | case 1:
29 | failureCount += 2;
30 | break;
31 | case 20:
32 | failureCount = 0;
33 | successCount = 0;
34 |
35 | this.roll20.setAttrWithWorker(options.character.id, 'HP', 1);
36 | this.reportResult('Death Saves', `${options.character.get('name')} has recovered to 1 HP`, options);
37 | break;
38 | default:
39 | if (result >= 10) {
40 | successCount++;
41 | }
42 | else {
43 | failureCount++;
44 | }
45 | }
46 |
47 | if (failureCount >= 3) {
48 | failureCount = 3;
49 | this.reportResult('Death Saves', `${options.character.get('name')} has failed 3` +
50 | ' death saves and is now dead', options);
51 | }
52 | else if (successCount >= 3) {
53 | this.reportResult('Death Saves', `${options.character.get('name')} has succeeded 3` +
54 | ' death saves and is now stable', options);
55 | failureCount = 0;
56 | successCount = 0;
57 | }
58 | successes.setWithWorker({ current: successCount });
59 | failures.setWithWorker({ current: failureCount });
60 | }
61 | };
62 |
63 |
--------------------------------------------------------------------------------
/lib/modules/entity-lister.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const ShapedModule = require('../shaped-module');
3 | const _ = require('underscore');
4 | const Utils = require('../utils');
5 |
6 | module.exports = class EntityLister extends ShapedModule {
7 | constructor(deps) {
8 | super(deps);
9 | this.entities = {};
10 | this.entityLookup = deps.entityLookup;
11 | }
12 |
13 | addCommands(commandProc) {
14 | _.each(this.entities, (entity) => {
15 | const cmdNames = [`list-${entity.name}`, entity.name];
16 | const cmd = commandProc
17 | .addCommand(cmdNames, this.listEntity.bind(this, entity.name), entity.gmOnly)
18 | .options(entity.criteriaProvider.criteriaOptionsValidator);
19 |
20 | entity.entityManager.addOptionsForListCommand(cmd);
21 | });
22 | }
23 |
24 | addEntity(name, singularName, criteriaProvider, entityManager, gmOnly) {
25 | if (!this.entityLookup.hasEntities(name)) {
26 | throw new Error(`Invalid entity type ${name}`);
27 | }
28 |
29 | this.entities[name] = {
30 | name,
31 | singularName,
32 | criteriaProvider,
33 | entityManager,
34 | gmOnly,
35 | };
36 | }
37 |
38 | listEntity(entityName, options) {
39 | const entityRecord = this.entities[entityName];
40 | if (!entityRecord.entityManager.validateListCommandOptions(options)) {
41 | return;
42 | }
43 | const criteria = entityRecord.criteriaProvider.criteria;
44 | const criteriaNames = _.pluck(criteria, 'name');
45 | const suppliedCriteria = _.pick(options, criteriaNames);
46 | this.logger.debug('Looking for entities of type $$$ matching criteria $$$', entityName, suppliedCriteria);
47 | const entities = this.entityLookup.searchEntities(entityName, suppliedCriteria);
48 | this.logger.debug('Found: $$$', entities);
49 | const relistOptions = `--relist ${escape(JSON.stringify(suppliedCriteria))}`;
50 |
51 | const entitySpecificOptions = entityRecord.entityManager.getListCommandOptions(options);
52 |
53 | const buttoniser = entityRecord.entityManager.getButtoniser(options, relistOptions);
54 |
55 | const possibleCriteria = entityRecord.criteriaProvider.getCriteriaToDisplay(suppliedCriteria);
56 |
57 | const header = this.buildHeader(entityName, possibleCriteria, suppliedCriteria, entitySpecificOptions,
58 | entityRecord);
59 | const list = this.buildList(entities, buttoniser);
60 | this.reporter.sendPlayer(`&{template:5e-shaped}{{title=${Utils.toTitleCase(entityName)}}}` +
61 | `{{content=${header}
${list}}}`, options.playerId);
62 | }
63 |
64 |
65 | buildHeader(entityName, criteria, suppliedCriteria, entitySpecificOptions, entityRecord) {
66 | const criteriaList = criteria.map((criterion) => {
67 | if (criterion.buildListEntry) {
68 | return criterion.buildListEntry(suppliedCriteria, entitySpecificOptions);
69 | }
70 |
71 | const valueList = criterion.values.map((value) => {
72 | const selected = _.contains(suppliedCriteria[criterion.name], value);
73 | const className = selected ? 'selected' : '';
74 | const newOpts = buildNewOptionsString(suppliedCriteria, criterion.name, value);
75 | const valueText = criterion.getValueText ? criterion.getValueText(value) : value;
76 | return `` +
77 | `${valueText}`;
78 | }).join(', ');
79 | const name = Utils.toTitleCase(criterion.displayName);
80 | const nameHTML = !_.isEmpty(name) ? `${name}. ` : '';
81 | return `${nameHTML}${valueList}
`;
82 | }).join('');
83 |
84 | return `${this.getEntityPicker(entityRecord)}${criteriaList}`;
85 | }
86 |
87 | buildList(entities, buttoniser) {
88 | return '' +
89 | `${entities.map(entity => `${buttoniser(entity)}`).join('')}
`;
90 | }
91 |
92 | getEntityPicker(entityRecord) {
93 | const list = this.entityLookup.getKeys(entityRecord.name, true);
94 |
95 | if (!_.isEmpty(list)) {
96 | // title case the names for better display
97 | list.forEach((part, index) => (list[index] = Utils.toTitleCase(part)));
98 |
99 | // create a clickable button with a roll query to select an entity from the loaded json
100 | return `Select a ${entityRecord.singularName} by query`;
103 | }
104 | return `Could not find any ${entityRecord.name}.
Please ensure you have a properly formatted ` +
105 | `${entityRecord.name} json file.`;
106 | }
107 | };
108 |
109 | function buildNewOptionsString(suppliedCriteria, criterionToModify, valueToToggle) {
110 | const newCriteria = Utils.deepClone(suppliedCriteria);
111 | if (!newCriteria[criterionToModify]) {
112 | newCriteria[criterionToModify] = [valueToToggle];
113 | }
114 | else {
115 | const valueList = newCriteria[criterionToModify];
116 | newCriteria[criterionToModify] = _.contains(valueList, valueToToggle) ? _.without(valueList, valueToToggle) :
117 | valueList.concat(valueToToggle);
118 | }
119 | return _.reduce(newCriteria, (optionString, valueList, criterion) =>
120 | (_.isEmpty(valueList) ? optionString : `${optionString} --${criterion} ${valueList.join(',')}`), '');
121 | }
122 |
--------------------------------------------------------------------------------
/lib/modules/fx-manager.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const ShapedModule = require('./../shaped-module');
3 | const _ = require('underscore');
4 |
5 | module.exports = class FXManager extends ShapedModule {
6 |
7 | registerChatListeners(chatWatcher) {
8 | chatWatcher.registerChatListener(['fx', 'character'], this.handleFX.bind(this));
9 | }
10 |
11 | handleFX(options) {
12 | const parts = options.fx.split(' ');
13 | if (parts.length < 2 || _.some(parts.slice(0, 2), _.isEmpty)) {
14 | this.logger.warn('FX roll template variable is not formated correctly: [$$$]', options.fx);
15 | return;
16 | }
17 |
18 |
19 | const fxType = parts[0];
20 | const pointsOfOrigin = parts[1];
21 | let targetTokenId;
22 | const sourceCoords = {};
23 | const targetCoords = {};
24 | let fxCoords = [];
25 | let pageId;
26 |
27 | // noinspection FallThroughInSwitchStatementJS
28 | switch (pointsOfOrigin) {
29 | case 'sourceToTarget':
30 | case 'source':
31 | targetTokenId = parts[2];
32 | fxCoords.push(sourceCoords, targetCoords);
33 | break;
34 | case 'targetToSource':
35 | case 'target':
36 | targetTokenId = parts[2];
37 | fxCoords.push(targetCoords, sourceCoords);
38 | break;
39 | default:
40 | throw new Error(`Unrecognised pointsOfOrigin type in fx spec: ${pointsOfOrigin}`);
41 | }
42 |
43 | if (targetTokenId) {
44 | const targetToken = this.roll20.getObj('graphic', targetTokenId);
45 | pageId = targetToken.get('_pageid');
46 | targetCoords.x = targetToken.get('left');
47 | targetCoords.y = targetToken.get('top');
48 | }
49 | else {
50 | pageId = this.roll20.getCurrentPage(options.playerId).id;
51 | }
52 |
53 |
54 | const casterTokens = this.roll20.findObjs({ type: 'graphic', pageid: pageId, represents: options.character.id });
55 |
56 | if (casterTokens.length) {
57 | // If there are multiple tokens for the character on this page, then try and find one of them that is selected
58 | // This doesn't work without a selected token, and the only way we can get this is to use @{selected} which is a
59 | // pain for people who want to launch without a token selected if(casterTokens.length > 1) { const selected =
60 | // _.findWhere(casterTokens, {id: sourceTokenId}); if (selected) { casterTokens = [selected]; } }
61 | sourceCoords.x = casterTokens[0].get('left');
62 | sourceCoords.y = casterTokens[0].get('top');
63 | }
64 |
65 |
66 | if (!fxCoords[0]) {
67 | this.logger.warn('Couldn\'t find required point for fx for character $$$, casterTokens: $$$, fxSpec: $$$ ',
68 | options.character.id, casterTokens, options.fx);
69 | return;
70 | }
71 | else if (!fxCoords[1]) {
72 | fxCoords = fxCoords.slice(0, 1);
73 | }
74 |
75 | this.roll20.spawnFx(fxCoords, fxType, pageId);
76 | }
77 | };
78 |
79 |
--------------------------------------------------------------------------------
/lib/modules/hd-manager.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const ShapedModule = require('./../shaped-module');
3 |
4 | module.exports = class HDManager extends ShapedModule {
5 |
6 | registerChatListeners(chatWatcher) {
7 | chatWatcher.registerChatListener(['character', 'title'], this.handleHD.bind(this));
8 | }
9 |
10 | handleHD(options) {
11 | const match = options.title.match(/(\d+)d(\d+) HIT_DICE/);
12 | if (match && this.myState.config.sheetEnhancements.autoHD) {
13 | const hdCount = parseInt(match[1], 10);
14 | const hdSize = match[2];
15 | const hdAttr = this.roll20.getAttrObjectByName(options.character.id, `hd_d${hdSize}`);
16 | const hpAttr = this.roll20.getOrCreateAttr(options.character.id, 'HP');
17 | const maxReduction = parseInt(
18 | this.roll20.getAttrByName(options.character.id, 'hp_max_reduced', 'current', true), 10);
19 | const regained = Math.max(0, parseInt(options.roll1, 10));
20 | const fullMax = hpAttr.get('max') || Infinity;
21 | const reducedMax = maxReduction ? fullMax - maxReduction : fullMax;
22 | const newHp = Math.min(parseInt(hpAttr.get('current') || 0, 10) + regained, reducedMax);
23 |
24 | if (hdAttr) {
25 | if (hdCount <= hdAttr.get('current')) {
26 | hdAttr.setWithWorker('current', hdAttr.get('current') - hdCount);
27 | hpAttr.setWithWorker('current', newHp);
28 | if (!hpAttr.get('max')) {
29 | hpAttr.setWithWorker('max', newHp);
30 | }
31 | }
32 | else {
33 | this.reportResult('HD Police',
34 | `${options.characterName} can't use ${hdCount}d${hdSize} hit dice because they ` +
35 | `only have ${hdAttr.get('current')} left`, options);
36 | }
37 | }
38 | }
39 | }
40 | };
41 |
42 |
--------------------------------------------------------------------------------
/lib/modules/importer.js:
--------------------------------------------------------------------------------
1 | /* globals unescape */
2 | 'use strict';
3 | const ShapedModule = require('./../shaped-module');
4 | const _ = require('underscore');
5 | const Logger = require('roll20-logger');
6 |
7 | class Importer extends ShapedModule {
8 |
9 | runImportStage(character, attributes, name, msgStreamer) {
10 | const initialPromise = Promise.resolve(character);
11 | if (!_.isEmpty(attributes)) {
12 | this.logger.debug('Importing attributes for stage $$$: $$$', name, attributes);
13 | msgStreamer.stream(name);
14 | this.logger.debug(`${name} start`);
15 | if (this.logger.getLogLevel() >= Logger.levels.DEBUG) {
16 | this.logger.debug('Character attributes at start: $$$',
17 | this.roll20.findObjs({ type: 'attribute', characterid: character.id }));
18 | }
19 |
20 | const newPromise = new Promise(resolve => this.roll20.onSheetWorkerCompleted(() => {
21 | this.logger.debug(`Sheet worker completed for ${name}`);
22 | resolve(character);
23 | }));
24 | _.each(attributes, (attrVal, attrName) => {
25 | this.roll20.setAttrWithWorker(character.id, attrName, attrVal);
26 | });
27 |
28 | return newPromise;
29 | }
30 | return initialPromise;
31 | }
32 | }
33 |
34 | module.exports = Importer;
35 |
--------------------------------------------------------------------------------
/lib/modules/rest-manager.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const _ = require('underscore');
3 | const ShapedModule = require('./../shaped-module');
4 | const ShapedConfig = require('./../shaped-config');
5 |
6 | const REST_ATTRIBUTES = {
7 | turn: 'recharge_turn',
8 | short: 'short_rest',
9 | long: 'long_rest',
10 | };
11 |
12 | class RestManager extends ShapedModule {
13 |
14 | constructor(deps) {
15 | super(deps);
16 | this.sheetWorkerChatOutput = deps.sheetWorkerChatOutput;
17 | }
18 |
19 | addCommands(commandProcessor) {
20 | return commandProcessor.addCommand(['rest', 'recharge'], this.handleRest.bind(this), false)
21 | .option('type', (value) => {
22 | const converted = value.toLowerCase();
23 | return {
24 | valid: ['short', 'long', 'turn'].includes(value),
25 | converted,
26 | };
27 | }, true)
28 | .option('character', ShapedConfig.getCharacterValidator(this.roll20), false)
29 | .withSelection({
30 | character: {
31 | min: 0,
32 | max: Infinity,
33 | },
34 | });
35 | }
36 |
37 | registerEventListeners(eventDispatcher) {
38 | eventDispatcher.registerEventHandler('change:campaign:turnorder', (turnOrder) => {
39 | if (!_.isEmpty(turnOrder) && turnOrder[0].id !== '-1' && this.myState.config.sheetEnhancements.turnRecharges) {
40 | const graphic = this.roll20.getObj('graphic', turnOrder[0].id);
41 | const char = this.roll20.getObj('character', graphic.get('represents'));
42 | if (char) {
43 | this.doRest(char, 'turn');
44 | }
45 | }
46 | });
47 | eventDispatcher.registerAttributeChangeHandler('recharge_turn', (attr) => {
48 | const results = this.rechargeDieRollUses(attr.get('characterid'));
49 | this.reporter.sendCharacter(attr.get('characterid'), `{template:5e-shaped}{{title=Turn Recharge}}${results}`);
50 | });
51 | }
52 |
53 | handleRest(options) {
54 | let chars = options.selected.character;
55 | if (!_.isUndefined(options.character)) {
56 | chars = [options.character];
57 | }
58 | if (_.isEmpty(chars)) {
59 | this.reportError('Invalid options/selection', 'You must select at least one character or include --character ' +
60 | 'when calling !shaped-rest', options.playerId);
61 | }
62 | chars.forEach((char) => {
63 | this.doRest(char, options.type);
64 | });
65 | }
66 |
67 | doRest(char, type) {
68 | const attribute = REST_ATTRIBUTES[type];
69 | const currentVal = this.roll20.getAttrByName(char.id, attribute, 'current', true);
70 | this.roll20.setAttrWithWorker(char.id, attribute, !currentVal, () => {
71 | const output = this.roll20.getAttrObjectByName(char.id, 'sheet_chat_output');
72 | const additional = (type === 'turn') ? this.rechargeDieRollUses(char.id) : '';
73 | this.sheetWorkerChatOutput.displaySheetChatOutput(output, null, char.id, additional);
74 | });
75 | }
76 |
77 |
78 | rechargeDieRollUses(charId) {
79 | let resultText = '';
80 |
81 | _.chain(this.roll20.findObjs({ type: 'attribute', characterid: charId }))
82 | .filter(attribute => attribute.get('name').match(/^repeating_(?!armor|equipment|lairaction|regionaleffect).*$/))
83 | .groupBy(attribute => attribute.get('name').match(/^(repeating_[^_]+_[^_]+)_.*$/)[1])
84 | .pick((attributeGroup, prefix) => attributeGroup.some(attr => attr.get('name') === `${prefix}_recharge`))
85 | .each((attributeGroup) => {
86 | const attributesByName = _.object(attributeGroup
87 | .map(attr => [attr.get('name').match(/repeating_[^_]+_[^_]+_(.*)$/)[1], attr]));
88 | const name = attributesByName.name.get('current');
89 | const recharge = attributesByName.recharge.get('current');
90 | const usesAttr = attributesByName.uses;
91 | if (!usesAttr || !usesAttr.get('max')) {
92 | this.logger.error(`Tried to recharge the uses for '${name}' for character with id ${charId}, ` +
93 | 'but there were no uses defined.');
94 | return;
95 | }
96 |
97 | if (usesAttr.get('current') < usesAttr.get('max')) {
98 | const match = recharge.match(/RECHARGE_(\d)(?:_\d)?/);
99 | if (match) {
100 | const rechargeDieRoll = this.roll20.randomInteger(6);
101 |
102 | if (rechargeDieRoll >= parseInt(match[1], 10)) {
103 | usesAttr.setWithWorker({ current: usesAttr.get('max') });
104 | resultText += `{{${name} Recharged= ${usesAttr.get('max')}/${usesAttr.get('max')}` +
105 | `(Rolled a ${rechargeDieRoll})}}`;
106 | }
107 | else {
108 | resultText += `{{${name} Not Recharged= ${usesAttr.get('current')}/${usesAttr.get('max')}` +
109 | `(Rolled a ${rechargeDieRoll})}}`;
110 | }
111 | }
112 | }
113 | });
114 | return resultText;
115 | }
116 |
117 |
118 | }
119 |
120 | module.exports = RestManager;
121 |
--------------------------------------------------------------------------------
/lib/modules/sheetworker-chat-output.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const ShapedModule = require('./../shaped-module');
3 |
4 | module.exports = class SheetWorkerChatOutput extends ShapedModule {
5 | registerEventListeners(eventDispatcher) {
6 | eventDispatcher.registerAttributeChangeHandler('sheet_chat_output', this.displaySheetChatOutput.bind(this));
7 | }
8 |
9 | displaySheetChatOutput(chatAttr, prev, characterId, additionalOutput) {
10 | this.logger.debug('Chat output received: $$$', chatAttr);
11 | const sheetOutput = (chatAttr && chatAttr.get('current')) || '';
12 | characterId = characterId || chatAttr.get('characterid');
13 | additionalOutput = additionalOutput || '';
14 | const text = `${sheetOutput}${additionalOutput}`;
15 | if (text && text.length > 0) {
16 | const templateText = `&{template:5e-shaped} ${text}`;
17 | this.reporter.sendCharacter(characterId, templateText);
18 | if (chatAttr) {
19 | chatAttr.set('current', '');
20 | }
21 | }
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/lib/modules/uses-manager.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const ShapedModule = require('./../shaped-module');
3 | const _ = require('underscore');
4 |
5 | class UsesManager extends ShapedModule {
6 |
7 | registerChatListeners(chatWatcher) {
8 | chatWatcher.registerChatListener(['character', 'uses', 'repeatingItem'], this.handleUses.bind(this));
9 | chatWatcher.registerChatListener(['character', 'legendaryaction'], this.handleLegendary.bind(this));
10 | }
11 |
12 | /**
13 | * Handles the click event of a trait when 'autoTraits' is true
14 | * Consumes one use of the clicked trait
15 | * @param {object} options - The message options
16 | */
17 | handleUses(options) {
18 | if (!this.myState.config.sheetEnhancements.autoTraits) {
19 | return;
20 | }
21 |
22 | let perUse = parseInt(options.perUse || 1, 10);
23 | if (_.isNaN(perUse)) {
24 | this.reportError(`Character ${options.characterName} has an invalid 'Per Use" value [${options.perUse}] for ` +
25 | `${options.title} so uses could not be decremented.`, options.playerId);
26 | return;
27 | }
28 |
29 | perUse = perUse || 1;
30 |
31 | const usesAttr = this.roll20.getAttrObjectByName(options.character.id, `${options.repeatingItem}_uses`);
32 |
33 |
34 | if (usesAttr && usesAttr.get('max')) {
35 | const currentVal = parseInt(usesAttr.get('current'), 10);
36 | if (currentVal - perUse >= 0) {
37 | usesAttr.setWithWorker({ current: currentVal - perUse });
38 | }
39 | else {
40 | this.reportResult('Uses Police', `${options.characterName} can't use ${options.title} because ` +
41 | 'they don\'t have sufficient uses left.', options);
42 | }
43 | }
44 | }
45 |
46 | handleLegendary(options) {
47 | if (!this.myState.config.sheetEnhancements.autoTraits) {
48 | return;
49 | }
50 |
51 |
52 | let cost = 1;
53 | switch (options.cost) {
54 | case 'COSTS_2_ACTIONS':
55 | cost = 2;
56 | break;
57 | case 'COSTS_3_ACTIONS':
58 | cost = 3;
59 | break;
60 | default:
61 | // Do nothing
62 | }
63 |
64 | const legendaryAmountAttr = this.roll20.getAttrObjectByName(options.character.id, 'legendary_action_amount');
65 | if (!legendaryAmountAttr) {
66 | this.logger.error('No legendary action amount defined for character $$$ so can\'t decrement it',
67 | options.character.id);
68 | return;
69 | }
70 |
71 | const current = legendaryAmountAttr.get('current');
72 | if (cost > current) {
73 | this.reportResult('Uses Police', `${options.characterName} can't use ${options.title} because ` +
74 | 'they don\'t have sufficient legendary points left.', options);
75 | return;
76 | }
77 |
78 | legendaryAmountAttr.setWithWorker({ current: current - 1 });
79 | }
80 | }
81 |
82 | module.exports = UsesManager;
83 |
--------------------------------------------------------------------------------
/lib/reporter.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const _ = require('underscore');
3 |
4 | function makeNormalMessage(heading, text) {
5 | return `&{template:5e-shaped} {{title=${heading}}}{{content=${text}}}`;
6 | }
7 |
8 | function makeErrorMessage(scriptName, text) {
9 | return makeNormalMessage(`${scriptName} Error`, text);
10 | }
11 |
12 | function makeStreamHeader(heading) {
13 | return `&{template:5e-shaped} {{continuous_header=1}} {{title=${heading}}}`;
14 | }
15 |
16 | function makeStreamBody(text) {
17 | return `&{template:5e-shaped} {{content=${text}}} {{continuous=1}}`;
18 | }
19 |
20 | function makeStreamFooter(finalText) {
21 | return `&{template:5e-shaped} {{content=${finalText}}}{{continuous_footer=1}}`;
22 | }
23 |
24 |
25 | class Reporter {
26 |
27 | constructor(roll20, scriptName) {
28 | this.roll20 = roll20;
29 | this.scriptName = scriptName;
30 | }
31 |
32 | reportPublic(heading, text) {
33 | this.sendPublic(`${makeNormalMessage(heading, text)}`);
34 | }
35 |
36 | reportPlayer(heading, text, playerId) {
37 | this.sendToPlayerAndGm(`${makeNormalMessage(heading, text)}`, playerId);
38 | }
39 |
40 | reportCharacter(heading, text, characterId) {
41 | this.sendCharacter(characterId, makeNormalMessage(heading, text));
42 | }
43 |
44 | reportError(text, playerId) {
45 | this.sendToPlayerAndGm(makeErrorMessage(this.scriptName, text), playerId);
46 | }
47 |
48 | sendPublic(text) {
49 | this.roll20.sendChat('', text);
50 | }
51 |
52 | sendPlayer(text, playerId) {
53 | this.roll20.sendChat('', `/w ${this.getPlayerName(playerId)} ${text}`, null, { noarchive: true });
54 | }
55 |
56 | sendToPlayerAndGm(text, playerId) {
57 | this.roll20.sendChat('', `/w GM ${text}`, null, { noarchive: true });
58 | if (playerId && !this.roll20.playerIsGM(playerId)) {
59 | this.roll20.sendChat('', `/w ${this.getPlayerName(playerId)} ${text}`, null, { noarchive: true });
60 | }
61 | }
62 |
63 | getPlayerName(playerId) {
64 | return playerId ? `"${this.roll20.getObj('player', playerId).get('displayname')}"` : 'gm';
65 | }
66 |
67 | sendCharacter(characterId, text) {
68 | const character = this.roll20.getObj('character', characterId);
69 | const charName = character.get('name').replace(/"/g, '\'');
70 | this.roll20.sendChat(`character|${characterId}`, `/w "${charName}" ${text}`);
71 | if (!_.isEmpty(character.get('controlledby'))) {
72 | this.roll20.sendChat('', `/w gm ${text}`);
73 | }
74 | }
75 |
76 |
77 | getMessageBuilder(heading, isPublic, playerId) {
78 | const fields = { title: heading };
79 | const reporter = this;
80 | return {
81 | addField(name, content) {
82 | fields[name] = content;
83 | return this;
84 | },
85 | display() {
86 | const displayer = isPublic ? reporter.sendPublic : reporter.sendToPlayerAndGm;
87 | displayer.bind(reporter)(_.reduce(fields, (text, content, name) => `${text}{{${name}=${content}}}`,
88 | '&{template:5e-shaped}'), playerId);
89 | },
90 | };
91 | }
92 |
93 | getMessageStreamer(heading, playerId) {
94 | const sendChat = (text) => {
95 | this.roll20.sendChat('', `/w ${this.getPlayerName(playerId)} ${text}`, null, { noarchive: true });
96 | };
97 |
98 | sendChat(makeStreamHeader(heading));
99 | return {
100 | stream(message) {
101 | sendChat(makeStreamBody(message));
102 | },
103 | finish(finalMessage) {
104 | sendChat(makeStreamFooter(finalMessage));
105 | },
106 | };
107 | }
108 |
109 | toJSON() {
110 | return {
111 | scriptName: this.scriptName,
112 | };
113 | }
114 | }
115 |
116 |
117 | module.exports = Reporter;
118 |
--------------------------------------------------------------------------------
/lib/sanitise.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | function sanitise(statblock, logger, noOcrFixes) {
3 | logger.debug('Pre-sanitise: $$$', statblock);
4 | statblock = statblock
5 | .replace(/\s+([.,;:])/g, '$1')
6 | .replace(/\n+/g, '#')
7 | .replace(/–/g, '-')
8 | .replace(/‒/g, '-')
9 | .replace(/−/g, '-') // Watch out: this and the two lines above containing funny unicode versions of '-'
10 | .replace(/’/gi, '\'')
11 | .replace(/
]*>/g, '#')
12 | .replace(/#+/g, '#')
13 | .replace(/\s*#\s*/g, '#')
14 | .replace(//g, '#*')
15 | .replace(/(<([^>]+)>)/gi, '')
16 | .replace(/legendary actions/gi, 'Legendary Actions')
17 | .replace(/(\S)\sACTIONS/, '$1#ACTIONS')
18 | .replace(/LAIR#ACTIONS/gi, 'LAIR ACTIONS')
19 | .replace(/#(?=[a-z]|DC)/g, ' ')
20 | .replace(/\s+/g, ' ')
21 | .replace(/#Hit:/gi, 'Hit:')
22 | .replace(/Hit:#/gi, 'Hit: ')
23 | .replace(/#Each /gi, 'Each ')
24 | .replace(/#On a successful save/gi, 'On a successful save')
25 | .replace(/DC#(\d+)/g, 'DC $1')
26 | .replace('LanguagesChallenge', 'Languages -\nChallenge')
27 | .replace('\' Speed', 'Speed')
28 | .replace(/(\w+) s([\s.,])/g, '$1s$2')
29 | .replace(/#Medium or/gi, ' Medium or')
30 | .replace(/take#(\d+)/gi, 'take $1')
31 | .replace(/#/g, '\n')
32 | .replace(/>/g, '>')
33 | .replace(/</g, '<')
34 | .replace(/&/g, '&');
35 |
36 |
37 | logger.debug('First stage cleaned statblock: $$$', statblock);
38 |
39 | // Sometimes the texts ends up like 'P a r a l y z i n g T o u c h . M e l e e S p e l l A t t a c k : + 1 t o h i t
40 | // In this case we can fix the title case stuff, because we can find the word boundaries. That will at least meaning
41 | // that the core statblock parsing will work. If this happens inside the lowercase body text, however, there's
42 | // nothing we can do about it because you need to understand the natural language to reinsert the word breaks
43 | // properly.
44 | statblock = statblock.replace(/([A-Z])(\s[a-z]){2,}/g, (match, p1) =>
45 | p1 + match.slice(1).replace(/\s([a-z])/g, '$1')
46 | );
47 |
48 |
49 | // Conversely, sometimes words get mushed together. Again, we can only fix this for title case things, but that's
50 | // better than nothing
51 | statblock = statblock.replace(/([A-Z][a-z]+)(?=[A-Z])/g, '$1 ');
52 |
53 | // This covers abilites that end up as 'C O N' or similar
54 | statblock = statblock.replace(/^[A-Z]\s?[A-Z]\s?[A-Z](?=\s|$)/mg, match => match.replace(/\s/g, ''));
55 |
56 | statblock = statblock.replace(/^[A-Z '()-]+$/mg, match =>
57 | match.replace(/([A-Z])([A-Z'-]+)(?=\s|\)|$)/g, (innerMatch, p1, p2) => p1 + p2.toLowerCase())
58 | );
59 |
60 |
61 | statblock = statblock.replace(/(\d+)\s*?plus\s*?((?:\d+d\d+)|(?:\d+))/gi, '$2 + $1');
62 | /* eslint-disable quote-props */
63 | if (!noOcrFixes) {
64 | const replaceObj = {
65 | 'Jly': 'fly',
66 | ',1\'': ',*',
67 | 'jday': '/day',
68 | 'abol eth': 'aboleth',
69 | 'ACT IONS': 'ACTIONS',
70 | 'Afrightened': 'A frightened',
71 | 'Alesser': 'A lesser',
72 | 'Athl etics': 'Athletics',
73 | 'blindn ess': 'blindness',
74 | 'blind sight': 'blindsight',
75 | 'bofh': 'both',
76 | 'brea stplate': 'breastplate',
77 | 'Can trips': 'Cantrips',
78 | 'choos in g': 'choosing',
79 | 'com muni cate': 'communicate',
80 | 'Constituti on': 'Constitution',
81 | 'creatu re': 'creature',
82 | 'darkvi sion': 'darkvision',
83 | 'dea ls': 'deals',
84 | 'di sease': 'disease',
85 | 'di stance': 'distance',
86 | 'fa lls': 'falls',
87 | 'fe et': 'feet',
88 | 'exha les': 'exhales',
89 | 'ex istence': 'existence',
90 | 'lfthe': 'If the',
91 | 'Ifthe': 'If the',
92 | 'ifthe': 'if the',
93 | 'lnt': 'Int',
94 | 'magica lly': 'magically',
95 | 'Med icine': 'Medicine',
96 | 'minlilte': 'minute',
97 | 'natura l': 'natural',
98 | 'ofeach': 'of each',
99 | 'ofthe': 'of the',
100 | 'on\'e': 'one',
101 | 'on ly': 'only',
102 | '0n': 'on',
103 | 'pass ive': 'passive',
104 | 'Perce ption': 'Perception',
105 | 'radi us': 'radius',
106 | 'ra nge': 'range',
107 | 'rega ins': 'regains',
108 | 'rest.oration': 'restoration',
109 | 'savin g': 'saving',
110 | 'si lvery': 'silvery',
111 | 's lashing': 'slashing',
112 | 'slas hing': 'slashing',
113 | 'slash in g': 'slashing',
114 | 'slash ing': 'slashing',
115 | 'Spel/casting': 'Spellcasting',
116 | 'successfu l': 'successful',
117 | 'ta rget': 'target',
118 | ' Th e ': ' The ',
119 | 't_urns': 'turns',
120 | 'unti l': 'until',
121 | 'withi n': 'within',
122 | 'tohit': 'to hit',
123 | 'At wi ll': 'At will',
124 | 'per-son': 'person',
125 | 'ab ility': 'ability',
126 | 'spe ll': 'spell',
127 | };
128 | /* eslint-enable quote-props */
129 |
130 | const re = new RegExp(Object.keys(replaceObj).join('|'), 'g');
131 | statblock = statblock.replace(re, matched => replaceObj[matched]);
132 |
133 | statblock = statblock
134 | .replace(/,\./gi, ',')
135 | .replace(/:\./g, ':')
136 | .replace(/(\W)l(\W)/g, '$11$2')
137 | .replace(/\.([\w])/g, '. $1')
138 | .replace(/1
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/samples/monsterSample.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2",
3 | "monsters": [
4 | {
5 | "name": "Ancient Gold Dragon",
6 | "size": "Gargantuan",
7 | "type": "dragon",
8 | "alignment": "lawful good",
9 | "AC": "22 (natural armor)",
10 | "HP": "546 (28d20+252)",
11 | "speed": "40ft., fly 80ft., swim 40ft.",
12 | "strength": 30,
13 | "dexterity": 14,
14 | "constitution": 29,
15 | "intelligence": 18,
16 | "wisdom": 17,
17 | "charisma": 28,
18 | "savingThrows": "Dex +9, Con +16, Wis +10, Cha +16",
19 | "skills": "Insight +10, Perception +17, Persuasion +16, Stealth +9",
20 | "damageImmunities": "fire, cold",
21 | "senses": "blindsight 60ft., darkvision 120ft.",
22 | "languages": "Common, Draconic",
23 | "challenge": "24",
24 | "traits": [
25 | {
26 | "name": "Amphibious",
27 | "text": "The dragon can breathe air and water."
28 | },
29 | {
30 | "name": "Legendary Resistance",
31 | "recharge": "3/Day",
32 | "text": "If the dragon fails a saving throw, it can choose to succeed instead."
33 | }
34 | ],
35 | "actions": [
36 | {
37 | "name": "Multiattack",
38 | "text": "The dragon can use its Frightful Presence. It then makes three attacks: one with its bite and two with its claws."
39 | },
40 | {
41 | "name": "Bite",
42 | "text": "Melee Weapon Attack: +17 to hit, reach 15 ft., one target. Hit: 21 (2d10 + 10) piercing damage."
43 | },
44 | {
45 | "name": "Claw",
46 | "text": "Melee Weapon Attack: +17 to hit, reach 10 ft., one target. Hit: 17 (2d6 + 10) slashing damage."
47 | },
48 | {
49 | "name": "Tail",
50 | "text": "Melee Weapon Attack: +17 to hit, reach 20 ft., one target. Hit: 19 (2d8 + 10) bludgeoning damage."
51 | },
52 | {
53 | "name": "Frightful Presence",
54 | "text": "Each creature of the dragon's choice that is within 120 feet of the dragon and aware of it must succeed on a DC 24 Wisdom saving throw or become frightened for 1 minute. A creature can repeat the saving throw at the end of each of its turns, ending the effect on itself on a success. If a creature's saving throw is successful or the effect ends for it, the creature is immune to the dragon's Frightful Presence for the next 24 hours."
55 | },
56 | {
57 | "name": "Breath Weapons",
58 | "recharge": "Recharge 5-6",
59 | "text": "The dragon uses one of the following breath Weapons. Fire Breath. The dragon exhales fire in a 90-foot cone. Each creature in that area must make a DC 24 Dexterity saving throw, taking 71 (13d10) fire damage on a failed save, or half as much damage on a successful one.Weakening Breath. The dragon exhales gas in a 90-foot cone. Each creature in that area must succeed on a DC 24 Strength saving throw or have disadvantage on Strength-based Attack rolls, Strength Checks, and Strength saving throws for 1 minute. A creature can repeat the saving throw at the end of each of its turns, ending the effect on itself on a success."
60 | },
61 | {
62 | "name": "Change Shape",
63 | "text": "The dragon magically polymorphs into a humanoid or beast that has a challenge rating no higher than its own, or back into its true form. It reverts to its true form if it dies. Any equipment it is wearing or carrying is absorbed or borne by the new form (the dragon's choice).\nIn a new form, the dragon retains its alignment, hit points, Hit Dice, ability to speak, proficiencies, Legendary Resistance, lair actions, and Intelligence, Wisdom, and Charisma scores, as well as this action. Its statistics and capabilities are otherwise replaced by those of the new form, except any class features or legendary actions of that form."
64 | }
65 | ],
66 | "legendaryPoints": 3,
67 | "legendaryActions": [
68 | {
69 | "name": "Detect",
70 | "cost": 1,
71 | "text": "The dragon makes a Wisdom (Perception) check."
72 | },
73 | {
74 | "name": "Tail Attack",
75 | "cost": 1,
76 | "text": "The dragon makes a tail attack."
77 | },
78 | {
79 | "name": "Wing Attack",
80 | "cost": 2,
81 | "text": "The dragon beats its wings. Each creature within 15 ft. of the dragon must succeed on a DC 25 Dexterity saving throw or take 17 (2d6 + 10) bludgeoning damage and be knocked prone. The dragon can then fly up to half its flying speed."
82 | }
83 | ]
84 | }
85 | ]
86 | }
87 |
--------------------------------------------------------------------------------
/samples/spellSample.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2",
3 | "spells": [
4 | {
5 | "name": "Fireball",
6 | "description": "A bright streak flashes from your pointing finger to a point you choose within range and then blossoms with a low roar into an explosion of flame. Each creature in a 20-foot-radius sphere centered on that point must make a Dexterity saving throw. A target takes 8d6 fire damage on a failed save, or half as much damage on a successful one.\nThe fire spreads around corners. It ignites flammable objects in the area that aren't being worn or carried.",
7 | "higherLevel": "When you cast this spell using a spell slot of 4th level or higher, the damage increases by 1d6 for each slot level above 3rd.",
8 | "emote": "evokes a bright streak that flashes from {{GENDER_PRONOUN_HIS_HER}} pointing finger to a point and then blossoms with a low roar into an explosion of flame",
9 | "source": "phb 241",
10 | "range": "150 ft",
11 | "target": "each creature in the aoe",
12 | "aoe": "20 ft radius sphere centered on a point within range",
13 | "components": {
14 | "verbal": true,
15 | "somatic": true,
16 | "material": true,
17 | "materialMaterial": "A tiny ball of bat guano and sulfur"
18 | },
19 | "duration": "Instantaneous",
20 | "castingTime": "1 action",
21 | "level": 3,
22 | "school": "Evocation",
23 | "save": {
24 | "ability": "Dexterity",
25 | "damage": "8d6",
26 | "damageType": "fire",
27 | "saveSuccess": "half damage",
28 | "higherLevelDice": "1",
29 | "higherLevelDie": "d6"
30 | },
31 | "fx": {
32 | "type": "explode",
33 | "color": "fire",
34 | "pointsOfOrigin": "target"
35 | },
36 | "effects": "The fire spreads around corners. It ignites flammable objects in the area that aren't being worn or carried."
37 | }
38 | ]
39 | }
40 |
--------------------------------------------------------------------------------
/scripts/tob-parser.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const parseModule = require('../lib/parser');
5 | const sanitise = require('../lib/sanitise');
6 | const mpp = require('../lib/monster-post-processor');
7 | const EntityLookup = require('../lib/entity-lookup');
8 | const formatSpec = require('../resources/mmFormatSpec.json');
9 | const logger = require('../test/dummy-logger');
10 |
11 | const el = new EntityLookup();
12 | el.configureEntity('spells', [el.getMonsterSpellUpdater()], EntityLookup.getVersionChecker('0.2.1'));
13 |
14 | const parser = parseModule.getParser(formatSpec, logger);
15 | try {
16 | const json = parser.parse(sanitise(fs.readFileSync('./tob.txt', 'utf8'), logger, true));
17 | mpp(json.monsters, el);
18 | json.monsters.forEach((monster) => {
19 | if (monster.spells) {
20 | monster.spells = monster.spells.join(', ');
21 | }
22 | });
23 | fs.writeFileSync('./tob.json', JSON.stringify(json, null, 2), 'utf8');
24 | }
25 | catch (e) {
26 | /* eslint-disable no-console */
27 | console.log(e);
28 | console.log(e.message);
29 | console.log(e.statblock);
30 | /* eslint-enable no-console */
31 | }
32 |
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb-base",
3 | "parserOptions": {
4 | "ecmaVersion": 6,
5 | "sourceType": "script"
6 | },
7 | "rules": {
8 | "max-len": [
9 | "error",
10 | 120,
11 | 2
12 | ],
13 | "no-param-reassign": 0,
14 | "prefer-rest-params": 0,
15 | "class-methods-use-this": 0,
16 | "prefer-spread": 0,
17 | "no-underscore-dangle": 0,
18 | "no-plusplus": 0,
19 | "no-mixed-operators": 0,
20 | "no-continue": 0,
21 | "import/no-extraneous-dependencies": [
22 | "error",
23 | {
24 | "devDependencies": true,
25 | "optionalDependencies": false,
26 | "peerDependencies": false
27 | }
28 | ],
29 | "object-property-newline": [
30 | "error",
31 | {
32 | "allowMultiplePropertiesPerLine": true
33 | }
34 | ],
35 | "brace-style": [
36 | "error",
37 | "stroustrup",
38 | {
39 | "allowSingleLine": true
40 | }
41 | ],
42 | "space-before-function-paren": [
43 | "error",
44 | {
45 | "anonymous": "always",
46 | "named": "never"
47 | }
48 | ],
49 | "no-use-before-define": [
50 | "error",
51 | {
52 | "functions": false,
53 | "classes": false
54 | }
55 | ],
56 | "spaced-comment": [
57 | 2,
58 | "always",
59 | {
60 | "exceptions": [
61 | "/"
62 | ]
63 | }
64 | ],
65 | "no-nested-ternary": 0,
66 | "no-cond-assign": [
67 | 2,
68 | "except-parens"
69 | ],
70 | "prefer-arrow-callback": 0,
71 | "func-names": 0,
72 | "no-unused-expressions": 0,
73 | "no-console": 0
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/test/data/ancientGoldDragon.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "monsters": [
4 | {
5 | "name": "Ancient Gold Dragon",
6 | "size": "Gargantuan",
7 | "type": "dragon",
8 | "alignment": "lawful good",
9 | "AC": "22 (natural armor)",
10 | "HP": "546 (28d20 + 252)",
11 | "speed": "40 ft., fly 80 ft., swim 40 ft.",
12 | "strength": 30,
13 | "dexterity": 14,
14 | "constitution": 29,
15 | "intelligence": 18,
16 | "wisdom": 17,
17 | "charisma": 28,
18 | "savingThrows": "Dex +9, Con +16, Wis +10, Cha +16",
19 | "skills": "Insight +10, Perception +17, Persuasion +16, Stealth +9",
20 | "damageImmunities": "fire",
21 | "senses": "blindsight 60ft., darkvision 120ft.",
22 | "languages": "Common, Draconic",
23 | "challenge": "24",
24 | "traits": [
25 | {
26 | "name": "Amphibious",
27 | "text": "The dragon can breathe air and water."
28 | },
29 | {
30 | "name": "Legendary Resistance",
31 | "recharge": "3/Day",
32 | "text": "If the dragon fails a saving throw, it can choose to succeed instead."
33 | }
34 | ],
35 | "actions": [
36 | {
37 | "name": "Multiattack",
38 | "text": "The dragon can use its Frightful Presence. It then makes three attacks: one with its bite and two with its claws."
39 | },
40 | {
41 | "name": "Bite",
42 | "text": "Melee Weapon Attack:+17 to hit, reach 15 ft., one target. Hit: 21 (2d10 + 10) piercing damage."
43 | },
44 | {
45 | "name": "Claw",
46 | "text": "Melee Weapon Attack: +17 to hit, reach 10ft., one target. Hit: 17 (2d6 + 10) slashing damage."
47 | },
48 | {
49 | "name": "Tail",
50 | "text": "Melee Weapon Attack:+17 to hit, reach 20ft., one target. Hit: 19 (2d8 + 10) bludgeoning damage."
51 | },
52 | {
53 | "name": "Frightful Presence",
54 | "text": "Each creature of the dragon 's choice that is within 120 feet of the dragon and aware of it must succeed on a DC 24 Wisdom saving throw or become frightened for 1 minute. A creature can repeat the saving throw at the end of each of its turns, ending the effect on itself on a success. If a creature's saving throw is successful or the effect ends for it, the creature is immune to the dragon's Frightful Presence for the next 24 hours."
55 | },
56 | {
57 | "name": "Breath Weapons",
58 | "recharge": "Recharge 5-6",
59 | "text": "The dragon uses one of the following breath weapons."
60 | },
61 | {
62 | "name": "Fire Breath",
63 | "text": "The dragon exhales fire in a 90-foot cone. Each creature in that area must make a DC 24 Dexterity saving throw, taking 71 (13d10) fire damage on a failed save, or half as much damage on a successful one."
64 | },
65 | {
66 | "name": "Weakening Breath",
67 | "text": "The dragon exhales gas in a 90-foot cone. Each creature in that area must succeed on a DC 24 Strength saving throw or have disadvantage on Strength-based attack rolls, Strength checks, and Strength saving throws for 1 minute. A creature can repeat the saving throw at the end of each of its turns, ending the effect on itself on a success."
68 | },
69 | {
70 | "name": "Change Shape",
71 | "text": "The dragon magically polymorphs into a humanoid or beast that has a challenge rating no higher than its own, or back into its true form. It reverts to its true form if it dies. Any equipment it is wearing or carrying is absorbed or borne by the new form (the dragon's choice).\nIn a new form, the dragon retains its alignment, hit points, Hit Dice, ability to speak, proficiencies, Legendary Resistance, lair actions, and Intelligence, Wisdom, and Charisma scores, as well as this action. Its statistics and capabilities are otherwise replaced by those of the new form, except any class features or Legendary Actions of that form."
72 | }
73 | ],
74 | "legendaryPoints": 3,
75 | "legendaryActions": [
76 | {
77 | "name": "Detect",
78 | "text": "The dragon makes a Wisdom (Perception) check."
79 | },
80 | {
81 | "name": "Tail Attack",
82 | "text": "The dragon makes a tail attack."
83 | },
84 | {
85 | "name": "Wing Attack",
86 | "cost": "2",
87 | "text": "The dragon beats its wings. Each creature within 15 feet of the dragon must succeed on a DC 25 Dexterity saving throw or take 17 (2d6 + 10) bludgeoning damage and be knocked prone. The dragon can then fly up to halfits flying speed."
88 | }
89 | ]
90 | }
91 | ]
92 | }
--------------------------------------------------------------------------------
/test/data/ancientGoldDragon.txt:
--------------------------------------------------------------------------------
1 | Ancient Gold Dragon
2 | Gargantuan dragon, lawful good
3 | Armor Class 22 (natural armor) Hit Points 546 (28d20 + 252) Speed 40 ft., fly 80 ft. , swim 40 ft.
4 | STR DEX CON INT 30 (+10) 14 (+2) 29 (+9) 18 (+4)
5 | WIS 17 (+3)
6 | CHA 28 (+9)
7 | Saving Throws Dex +9, Con +16, Wis +10, Cha +16
8 | Skills Insight +10, Perception +17, Persuasion +16, Stealth +9 Damage Immunities fire
9 | Senses blindsight 60ft., darkvision 120ft., passive Perception 27 Languages Common, Draconic
10 | Challenge 24 (36,500 XP)
11 | Amphibious. The dragon can breathe air and water. Legendary Resistance (3jDay). If the dragon fails a saving
12 | throw, it can choose to succeed instead.
13 | ACTIONS
14 | Multiattack. The dragon can use its Frightful Presence. It then makes three attacks: one with its bite and two with its claws.
15 | Bite. Melee Weapon Attack:+17 to hit, reach 15 ft., one target. Hit : 21 (2d10 + 10) piercing damage.
16 | Claw. Melee Weapon Attack: +17 to hit, reach 10ft., one target. Hit: 17 (2d6 + 10) slashing damage.
17 | Tail. Melee Weapon Attack:+17 to hit, reach 20ft., one target. Hit: 19 (2d8 + 10) bludgeoning damage.
18 | Frightful Presence. Each creature of the dragon 's choice that is within 120 feet of the dragon and aware of it must succeed on a DC 24 Wisdom saving throw or become frightened for 1 minute. A creature can repeat the saving throw at the end of each of its turns, ending the effect on itself on a success. If a creature's saving throw is successful or the effect ends for it, the creature is immune to the dragon's Frightful Presence for the next 24 hours.
19 | Breath Weapons (Recharge 5-6}. The dragon uses one of the following breath weapons.
20 | Fire Breath. The dragon exhales fire in a 90-foot cone. Each creature in that area must make a DC 24 Dexterity saving throw, taking 71 (l3d10) fire damage on a failed save, or half as much damage on a successful one.
21 | Weakening Breath. The dragon exhales gas in a 90-foot cone. Each creature in that area must succeed on a DC 24 Strength saving throw or have disadvantage on Strength-based attack rolls, Strength checks, and Strength saving throws for 1 minute. A creature can repeat the saving throw at the end of each of its turns, ending the effect on itself on a success.
22 | Change Shape. The dragon magically polymorphs into a humanoid or beast that has a challenge rating no higher than its own, or back into its true form. It reverts to its true form if it dies. Any equipment it is wearing or carrying is absorbed or borne by the new form (the dragon's choice).
23 | In a new form, the dragon retains its alignment, hit points, Hit Dice, ability to speak, proficiencies, Legendary Resistance, lair actions, and Intelligence, Wisdom, and Charisma scores, as well as this action . Its statistics and capabilities are otherwise replaced by those of the new form, except any class features or legendary actions of that form .
24 | lEGENDARY ACTIONS
25 | The dragon can take 3 legendary actions, choosing from the options below. Only one legendary action option can be used at a time and only at the end of another creature's turn . The dragon regains spent legendary actions at the start of its turn.
26 | Detect. The dragon makes a Wisdom (Perception) check. Tail Attack. The dragon makes a tail attack.
27 | Wing Attack (Costs 2 Actions). The dragon beats its wings .
28 | Each creature within 15 feet of the dragon must succeed
29 | on a DC 25 Dexterity saving throw or take 17 (2d6 + 10) bludgeoning damage and be knocked prone. The dragon can then fly up to halfits flying speed.
30 |
--------------------------------------------------------------------------------
/test/data/ancientWhiteDragon.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "monsters": [
4 | {
5 | "name": "Ancient White Dragon",
6 | "size": "Gargantuan",
7 | "type": "dragon",
8 | "alignment": "chaotic evil",
9 | "AC": "20 (natural armor)",
10 | "HP": "333 (18d20 + 144)",
11 | "speed": "40 ft., burrow 40 ft., fly 80ft., swim 40 ft.",
12 | "strength": 26,
13 | "dexterity": 10,
14 | "constitution": 26,
15 | "intelligence": 10,
16 | "wisdom": 13,
17 | "skills": ", Stealth +6",
18 | "damageImmunities": "cold",
19 | "charisma": 14,
20 | "senses": "bl indsight 60ft., darkvis ion 120 ft.",
21 | "languages": "Common, Draconic",
22 | "challenge": "20",
23 | "traits": [
24 | {
25 | "name": "Ice Walk",
26 | "text": "The dragon can move across and climb icy surfaces without needing to make an ability check. Additionally, difficult terrain composed of ice or snow doesn't cost it extra moment."
27 | },
28 | {
29 | "name": "Legendary Resistance",
30 | "recharge": "3/Day",
31 | "text": "If the dragon fails a saving throw, it can choose to succeed instead."
32 | }
33 | ],
34 | "actions": [
35 | {
36 | "name": "Multiattack",
37 | "text": "The dragon can use its Frightful Presence. It then makes three attacks: one with its bite and two with its claws."
38 | },
39 | {
40 | "name": "Bite",
41 | "text": "Melee Weapon Attack:+ 14 to hit, reach 15ft., one target. Hit: 19 (2d10 + 8) piercing damage plus 9 (2d8) co ld damage."
42 | },
43 | {
44 | "name": "Claw",
45 | "text": "Melee Weapon Attack: +14 to hit, reach 10 f. t., one target. Hit: 15 (2d6 + 8) slashing damage."
46 | },
47 | {
48 | "name": "Tail",
49 | "text": "Melee Weapon Attack: +14 to hit, reach 20ft., one target. Hit: 17 (2d8 + 8) bludgeoning damage."
50 | },
51 | {
52 | "name": "Frightful Presence",
53 | "text": "Each creature of the dragon's choice that is within 120 feet of the dragon and aware of it must succeed on a DC 16 Wisdom saving throw or become frightened for 1 minute. A creature can repeat the saving throw at the end of each of its turns, end ing the effect on itself on a success. If a creature's saving throw is successful or the effect ends for it, the creature is immune to the dragon's Frightful Presence for the next 24 hours."
54 | },
55 | {
56 | "name": "Cold Breath",
57 | "recharge": "Recharge 5-6",
58 | "text": "The dragon exhales an icy blast in a 90-foot cone. Each creature in that area must make a DC 22\nConstitution saving throw, taking 72 (16d8) cold damage on a failed save, or half as much damage on a successful one."
59 | }
60 | ],
61 | "legendaryPoints": 3,
62 | "legendaryActions": [
63 | {
64 | "name": "Detect",
65 | "text": "The dragon makes a Wisdom (Perception) check."
66 | },
67 | {
68 | "name": "Tail Attack",
69 | "text": "The dragon makes a tail attack."
70 | },
71 | {
72 | "name": "Wing Attack",
73 | "cost": "2",
74 | "text": "The dragon beats its wings. Each creature within 15 feet of the dragon must succeed on a DC 22 Dexterity saving throw or take 15 (2d6 + 8) bludgeoning damage and be knocked prone. The dragon can then fly up to half its flying speed."
75 | }
76 | ],
77 | "lairActions": [
78 | "Freezing fog fills a 20-foot-radius sphere centered on a point the dragon can see within 120 feet of it. The fog spreads around corners, and its area is heavily obscured. Each creature in the fog when it appears must make a DC 10 Constitution saving throw, taking\n10 (3d6) cold damage on a failed save, or half as much damage on a successful one. A creature that ends its turn in the fog takes 10 (3d6) cold damage. A wind of at least 20 miles per hour disperses the fog. The fog otherwise lasts until the dragon uses this lair action again or until the dragon dies.",
79 | "Jagged ice shards fall from the ceiling, striking up to three creatures underneath that the dragon can see within 120 feet of it. The dragon makes one ranged attack roll (+7 to hit) against each target. On a hit, the target takes 10 (3d6) piercing damage.",
80 | "The dragon creates an opaque wall of ice on a solid surface it can see within 120 feet of it. The wall can be up to 30 feet long, 30 feet high, and 1 foot thick.\nWhen the wall appears, each creature within its area is pushed 5 feet out of the wall's space; appearing on whichever side of the wall it wants. Each 10-foot section of the wall has AC 5, 30 hit points, vulnerability to fire damage, and immunity to acid, cold, necrotic, poison, and psychic damage. The wall disappears when the dragon uses this lair action again or when the dragon dies."
81 | ],
82 | "regionalEffects": [
83 | "Freezing precipitation falls within 6 miles of the dragon's lair, sometimes forming blizzard conditions when the dragon is at rest.",
84 | "Icy walls block off areas in the dragon's lair. Each wall is 6 inches thick, and a 10-foot section has AC 5, 15 hit points, vulnerability to fire damage, and immunity to acid, cold, necrotic, poison, and psychic damage.\nIf the dragon wishes to move through a wall, it can do so without slowing down. The portion of the wall the dragon moves through is destroyed, however."
85 | ],
86 | "regionalEffectsFade": "If the dragon dies, the fog and precipitation fade within\n1 day. The ice walls melt over the course of 1d10 days."
87 | }
88 | ]
89 | }
--------------------------------------------------------------------------------
/test/data/ancientWhiteDragon.txt:
--------------------------------------------------------------------------------
1 | ANCIENT WHITE DRAGON
2 | Gargantuan dragon, chaotic evil
3 | Armor Class 20 (natural armor)
4 | Hit Points 333 (18d20 + 144)
5 | Speed 40 ft., burrow 40 ft., fly 80ft ., swim 40 ft.
6 | STR
7 | 26 (+8)
8 | DEX
9 | 10 (+0)
10 | CON
11 | 26 (+8)
12 | INT
13 | 10 (+0)
14 | WIS
15 | 13 (+1)
16 | Saving Throws Dex +6, Con + 14, Wis +7, Cha +8
17 | Skills Perception+ 13, Stealth +6
18 | Damage Immunities cold
19 | CHA
20 | 14 (+2)
21 | Senses bl indsight 60ft., darkvis ion 120 ft., passive Perception 23
22 | Languages Common, Draconic
23 | Challenge 20 (24,500 XP)
24 | Ice Walk. The dragon can move across and climb icy surfaces
25 | without needing to make an ability check. Additionally, difficult
26 | terrain composed of ice or snow doesn't cost it extra moment.
27 | Legendary Resistance (3JDay). If the dragon fails a saving
28 | throw, it can choose to succeed instead.
29 | ACTIONS
30 | Multiattack. The dragon can use its Frightful Presence. It then
31 | makes three attacks: one with its bite and two with its claws.
32 | Bite. Melee Weapon Attack:+ 14 to hit, reach 15ft., one target.
33 | Hit: 19 (2d10 + 8) piercing damage plus 9 (2d8) co ld damage.
34 | Claw. Melee Weapon Attack: +14 to hit, reach 10 f.t., one target.
35 | Hit: 15 (2d6 + 8) slashing damage.
36 | Tail. Melee Weapon Attack: +14 to hit, reach 20ft., one target.
37 | Hit: 17 (2d8 + 8) bludgeoning damage.
38 | Frightful Presence. Each creature of the dragon's choice that
39 | is within 120 feet of the dragon and aware of it must succeed
40 | on a DC 16 Wisdom saving throw or become frightened for 1
41 | minute. A creature can repeat the saving throw at the end of
42 | each of its turns , end ing the effect on itself on a success. If a
43 | creature's saving throw is successful or the effect ends for it,
44 | the creature is immune to the dragon's Frightful Presence for
45 | the next 24 hours .
46 | Cold Breath (Recharge 5-6}. The dragon exhales an icy blast in
47 | a 90-foot cone. Each creature in that area must make a DC 22
48 | Constitution saving throw, taking 72 (l6d8) cold damage on a
49 | failed save, or half as much damage on a successful one.
50 | LEGENDARY ACTIONS
51 | The dragon can take 3 legendary actions, choosing from the
52 | options below. Only one legendary action option can be used
53 | at a time and only at the end of another creature's turn . The
54 | dragon regains spent legendary actions at the start of its turn.
55 | Detect. The dragon makes a Wisdom (Perception) check.
56 | Tail Attack. The dragon makes a tail attack.
57 | Wing Attack (Costs 2 Actions) . The dragon beats its wings.
58 | Each creature within 15 feet of the dragon must succeed
59 | on a DC 22 Dexterity saving throw or take 15 (2d6 + 8)
60 | bludgeoning damage and be knocked prone. The dragon can
61 | then fly up to half its flying speed.
62 | LAIR ACTIONS
63 | On initiative count 20 (losing initiative ties), the dragon
64 | takes a lair action to cause one of the following effects;
65 | the dragon can't use the same effect two rounds in a row:
66 | • Freezing fog fills a 20-foot-radius sphere centered on
67 | a point the dragon can see within 120 feet of it. The
68 | fog spreads around corners, and its area is heavily
69 | obscured. Each creature in the fog when it appears
70 | must make a DC 10 Constitution saving throw, taking
71 | 10 (3d6) cold damage on a failed save, or half as much
72 | damage on a successful one. A creature that ends its
73 | turn in the fog takes 10 (3d6) cold damage. A wind of
74 | at least 20 miles per hour disperses the fog. The fog
75 | otherwise lasts until the dragon uses this lair action
76 | again or until the dragon dies.
77 | • Jagged ice shards fall from the ceiling, striking up to
78 | three creatures underneath that the dragon can see
79 | within 120 feet of it. The dragon makes one ranged
80 | attack roll (+7 to hit) against each target. On a hit, the
81 | target takes 10 (3d6) piercing damage.
82 | • The dragon creates an opaque wall of ice on a solid
83 | surface it can see within 120 feet of it. The wall can
84 | be up to 30 feet long, 30 feet high, and 1 foot thick.
85 | When the wall appears, each creature within its area
86 | is pushed 5 feet out of the wall's space; appearing on
87 | whichever side of the wall it wants. Each 10-foot section
88 | of the wall has AC 5, 30 hit points, vulnerability
89 | to fire damage, and immunity to acid, cold, necrotic,
90 | poison, and psychic damage. The wall disappears
91 | when the dragon uses this lair action again or when
92 | the dragon dies.
93 | REGIONAL EFFECTS
94 | The region containing a legendary white dragon's lair
95 | is warped by the dragon's magic, which creates one or
96 | more of the following effects:
97 | Chilly fog lightly obscures the land within 6 miles of
98 | the dragon's lair.
99 | • Freezing precipitation falls within 6 miles of the dragon's
100 | lair, sometimes forming blizzard conditions when
101 | the dragon is at rest.
102 | • Icy walls block off areas in the dragon's lair. Each wall
103 | is 6 inches thick, and a 10-foot section has AC 5, 15 hit
104 | points, vulnerability to fire damage, and immunity to
105 | acid, cold, necrotic, poison, and psychic damage.
106 | If the dragon wishes to move through a wall, it can
107 | do so without slowing down. The portion of the wall
108 | the dragon moves through is destroyed, however.
109 | If the dragon dies, the fog and precipitation fade within
110 | 1 day. The ice walls melt over the course of 1d10 days.
--------------------------------------------------------------------------------
/test/data/archmage.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "monsters": [
4 | {
5 | "name": "Archmage",
6 | "size": "Medium",
7 | "type": "humanoid (any race)",
8 | "alignment": "any alignment",
9 | "AC": "12 (1 5 with mage armor)",
10 | "HP": "99 (18d8 + 18)",
11 | "speed": "30ft.",
12 | "strength": 10,
13 | "dexterity": 14,
14 | "constitution": 12,
15 | "intelligence": 20,
16 | "wisdom": 15,
17 | "charisma": 16,
18 | "savingThrows": "Int +9, Wis +6",
19 | "skills": "Arcana +13, History +13",
20 | "languages": "any six languages",
21 | "challenge": "12",
22 | "traits": [
23 | {
24 | "name": "Magic Resistance",
25 | "text": "The archmage has advantage on saving throws against spells and other magical effects."
26 | },
27 | {
28 | "name": "Spellcasting",
29 | "text": "The archmage is an 18th-level spellcaster. Its spellcasting ability is Intelligence (spell save DC 17, +9 to hit with spell attacks). The archmage can cast disguise self and invisibility at will and has the following wizard spells prepared:\nCantrips (at will):fire bolt, light, mage hand, prestidigitation, shocking grasp\n1st level (4 slots): detect magic, identify, mage armor,* magic missile\n2nd level (3 slots): detect thoughts, mirror image, misty step 3rd level (3 slots): counterspell,fly, lightning bolt\n4th level (3 slots): banishment, fire shield, stoneskin*\n5th level (3 slots): cone ofcold, scrying, wall offorce\n6th level.(1 slot): globe ofinvulnerability 7th level (1 slot): teleport\n8th level (1 slot): mind blank*\n9th level (1 slot): time stop\n*The archmage casts these spells on itselfbefore combat."
30 | }
31 | ]
32 | }
33 | ]
34 | }
--------------------------------------------------------------------------------
/test/data/archmage.txt:
--------------------------------------------------------------------------------
1 | ARCHMAGE
2 | Medium humanoid (any race), any alignment
3 | Armor Class 12 (1 5 with mage armor) Hit Points 99 (18d8 + 18)
4 | Speed 30ft.
5 | STR 10 (+0)
6 | DEX 14 (+2)
7 | CON 12 (+1)
8 | INT 20 (+5)
9 | WIS 15 (+2)
10 | CHA 16 (+3)
11 | Saving Throws lnt +9, Wis +6
12 | Skills Arcana +13, History +13
13 | Damage Resistance damage from spells; nonmagical
14 | bludgeoning, piercing, and slashing (from stoneskin) Senses passive Perception 12
15 | Languages any six languages
16 | Challenge 12 (8,400 XP)
17 | Magic Resistance. The archmage has advantage on saving throws against spells and other magical effects.
18 | Spellcasting. The archmage is an 18th-level spellcaster. Its spellcasting ab ility is Intelligence (spe ll save DC 17, +9 to hit with spell attacks). The archmage can cast disguise self and invisibility at will and has the following wizard spells prepared:
19 | Cantrips (at will):.fire bolt, light, mage hand, prestidigitation, shocking grasp
20 | 1st level (4 slots): detect magic, identify, mage armor,1' magic missile
21 | 2nd level (3 slots): detect thoughts , mirror image, misty step 3rd level (3 slots): counterspeii,Jly, lightning bolt
22 | 4th level (3 slots): banishment, fire shield, stoneskin*
23 | 5th level (3 slots): cone ofcold, scrying, wall offorce
24 | 6th level .(1 slot): globe ofinvulnerability 7th level (l slot): teleport
25 | 8th level (l slot): mind blank1<
26 | 9th level (l slot): time stop
27 | 1 file.indexOf('PlayersHandbook') !== -1);
177 | const srd = jsonFiles.find(file => file.indexOf('SRD') !== -1);
178 | jsonFiles = _.without(jsonFiles, phb, srd);
179 | jsonFiles.unshift(phb);
180 | jsonFiles.unshift(srd);
181 |
182 | jsonFiles.forEach(function (jsonFile) {
183 | const data = JSON.parse(fs.readFileSync(jsonFile, 'utf8'));
184 | const name = jsonFile.replace(/.*\/([^.]+)\.json/, '$1');
185 | it(`loads ${name} correctly`, function () {
186 | const rr = new DummyResultReporter();
187 |
188 | data.name = name;
189 | el.addEntities(data, rr);
190 | const results = rr.results[name];
191 | if (data.spells) {
192 | expect(results.spells.withErrors).to.be.empty;
193 | expect(results.errors).to.be.empty;
194 | expect(results.spells.added.length + results.spells.patched.length).to.equal(data.spells.length);
195 | }
196 | if (data.monsters) {
197 | expect(results.monsters.deleted).to.be.empty;
198 | expect(results.monsters.withErrors).to.be.empty;
199 | expect(results.monsters.added.length + results.monsters.patched.length).to.equal(data.monsters.length);
200 | }
201 | });
202 | });
203 | });
204 | });
205 |
206 |
--------------------------------------------------------------------------------
/test/test-json-validator.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 |
4 | /* globals describe: false, it:false, before:false */
5 | const expect = require('chai').expect;
6 | const JSONValidator = require('../lib/json-validator');
7 | const spec = require('../resources/mmFormatSpec.json');
8 | const data = require('../samples/monsterSample.json');
9 | const glob = require('glob');
10 | const fs = require('fs');
11 | const _ = require('underscore');
12 |
13 | describe('json-validator', function () {
14 | if (process.env.CI) {
15 | return;
16 | }
17 |
18 |
19 | const jv = new JSONValidator(spec);
20 |
21 | it('validates correctly', function () {
22 | expect(jv.validate(data)).to.deep.equal({});
23 | });
24 |
25 |
26 | const monsterFiles = glob.sync('../5eshapedscriptdata/sources/{public,private}/*.json')
27 | .filter(file => file.indexOf('MonsterManual') === -1);
28 | expect(monsterFiles).to.not.be.empty;
29 | monsterFiles.forEach(function (jsonFile) {
30 | const monsters = _.pick(JSON.parse(fs.readFileSync(jsonFile, 'utf8')), 'monsters', 'version');
31 | if (monsters.monsters) {
32 | it(`validates ${jsonFile} correctly`, function () {
33 | expect(jv.validate(monsters)).to.deep.equal({});
34 | });
35 | }
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/test/test-migrations.js:
--------------------------------------------------------------------------------
1 | /* globals describe: false, it:false */
2 | 'use strict';
3 | const expect = require('chai').expect;
4 | const Migrator = require('../lib/migrator.js');
5 | const dl = require('./dummy-logger');
6 |
7 |
8 | describe('Migrator', function () {
9 | it('fails for missing version', function () {
10 | const mig = new Migrator();
11 | const config = { foo: 'bar' };
12 | mig.nextVersion().addProperty('test', 'testVal');
13 |
14 | expect(function () {
15 | mig.migrateConfig(config, dl);
16 | }).to.throw(Error);
17 | });
18 |
19 | it('migrates correctly', function () {
20 | const mig = new Migrator();
21 | const config = {
22 | version: 0.1,
23 | foo: 'foo',
24 | bar: 'bar',
25 | blort: {
26 | wibble: 'test',
27 | wibblePrime: 'test2',
28 | },
29 | };
30 | mig.nextVersion()
31 | .addProperty('newProp', 'newValue')
32 | .moveProperty('blort.wibble', 'newChild.newGrandChild')
33 | .nextVersion()
34 | .moveProperty('foo', 'blort.foo');
35 |
36 | expect(mig.migrateConfig(config, dl)).to.deep.equal({
37 | version: 0.3,
38 | bar: 'bar',
39 | blort: {
40 | wibblePrime: 'test2',
41 | foo: 'foo',
42 | },
43 | newProp: 'newValue',
44 | newChild: {
45 | newGrandChild: 'test',
46 | },
47 | });
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/test/test-parser.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /* globals describe: false, before:false, it:false */
4 | const chai = require('chai');
5 | const chaiAsPromised = require('chai-as-promised');
6 |
7 | chai.use(chaiAsPromised);
8 | require('chai').should();
9 | const Promise = require('bluebird'); // jshint ignore:line
10 | const fs = require('fs');
11 |
12 | const glob = require('glob');
13 | const parseModule = require('../lib/parser');
14 | const logger = require('./dummy-logger');
15 | const sanitise = require('../lib/sanitise');
16 |
17 | Promise.promisifyAll(fs);
18 |
19 | /**
20 | * @name readFileAsync
21 | * @memberOf fs
22 | */
23 |
24 | /**
25 | * @name sync
26 | * @memberOf glob
27 | */
28 |
29 |
30 | describe('Monster Manual tests', function () {
31 | let parser;
32 |
33 | before(function () {
34 | return fs.readFileAsync('./resources/mmFormatSpec.json', 'utf-8')
35 | .then(function (specText) {
36 | const parsed = JSON.parse(specText);
37 | parser = parseModule.getParser(parsed, logger);
38 | });
39 | });
40 |
41 |
42 | const files = glob.sync('./test/data/*.txt');
43 | files.forEach(function (file) {
44 | it(`correctly parses ${file.replace(/\.txt$/, '')}`, function () {
45 | return Promise.join(runTestForFile(parser, file),
46 | getExpectedOutputForFile(file),
47 | function (test, expected) {
48 | if (!expected) {
49 | return fs.writeFileSync(file.replace(/\.txt$/, '.json'), JSON.stringify(test, undefined, 2), 'utf8');
50 | }
51 | // noinspection JSUnresolvedVariable
52 | return test.should.deep.equal(expected);
53 | });
54 | });
55 | });
56 | });
57 |
58 |
59 | function runTestForFile(parser, file) {
60 | return fs.readFileAsync(file, 'utf-8').then(function (statblockText) {
61 | return runParse(parser, statblockText);
62 | });
63 | }
64 |
65 | function getExpectedOutputForFile(file) {
66 | const filename = file.replace(/\.txt$/, '.json');
67 | return fs.readFileAsync(filename, 'utf-8')
68 | .catch(() => null)
69 | .then(JSON.parse);
70 | }
71 |
72 |
73 | function runParse(parser, statBlockText) {
74 | try {
75 | return parser.parse(sanitise(statBlockText, logger));
76 | }
77 | catch (e) {
78 | console.log(e.stack);
79 | return e;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/test/test-rest-manager.js:
--------------------------------------------------------------------------------
1 | /* globals describe: false, it:false, beforeEach:false, before:false */
2 | 'use strict';
3 | require('chai').should();
4 | const expect = require('chai').expect;
5 | const _ = require('underscore');
6 | const Roll20 = require('roll20-wrapper');
7 | const sinon = require('sinon');
8 | const logger = require('./dummy-logger');
9 | const Roll20Object = require('./dummy-roll20-object');
10 | const RestManager = require('../lib/modules/rest-manager');
11 | const cp = require('./dummy-command-parser');
12 | const Reporter = require('./dummy-reporter');
13 |
14 | describe('rest-manager', function () {
15 | let restManager;
16 | let roll20;
17 | let char;
18 | let myState;
19 | let reporter;
20 |
21 | beforeEach(function () {
22 | roll20 = new Roll20();
23 | myState = { config: { variants: { rests: {} } } };
24 | reporter = new Reporter();
25 | restManager = new RestManager({
26 | roll20, reporter, logger, myState,
27 | });
28 | sinon.stub(roll20);
29 |
30 | char = new Roll20Object('character', { name: 'character' });
31 | restManager.configure(cp, null, { registerEventHandler: _.noop, registerAttributeChangeHandler: _.noop });
32 | this.skip();
33 | });
34 |
35 |
36 | describe('recoverUses', function () {
37 | it('deals with recharge-type uses for turn recharge', function () {
38 | const attributes = [
39 | new Roll20Object('attribute', { name: 'repeating_foo_XXX_name', current: 'attack1' }),
40 | new Roll20Object('attribute', { name: 'repeating_foo_XXX_uses', current: 1, max: 3 }),
41 | new Roll20Object('attribute', { name: 'repeating_foo_XXX_recharge', current: 'TURN' }),
42 | new Roll20Object('attribute', { name: 'repeating_foo_YYY_name', current: 'attack2' }),
43 | new Roll20Object('attribute', { name: 'repeating_foo_YYY_uses', current: 1, max: 3 }),
44 | new Roll20Object('attribute', { name: 'repeating_foo_YYY_recharge', current: 'RECHARGE_5_6' }),
45 | new Roll20Object('attribute', { name: 'repeating_foo_ZZZ_name', current: 'attack3' }),
46 | new Roll20Object('attribute', { name: 'repeating_foo_ZZZ_uses', current: 1, max: 3 }),
47 | new Roll20Object('attribute', { name: 'repeating_foo_ZZZ_recharge', current: 'LONG_REST' }),
48 | new Roll20Object('attribute', { name: 'repeating_foo_WWW_name', current: 'attack4' }),
49 | new Roll20Object('attribute', { name: 'repeating_foo_WWW_uses', current: 1 }),
50 | new Roll20Object('attribute', { name: 'repeating_foo_WWW_recharge', current: 'TURN' }),
51 | new Roll20Object('attribute', { name: 'repeating_foo_VVV_name', current: 'attack5' }),
52 | new Roll20Object('attribute', { name: 'repeating_foo_VVV_uses', current: 1, max: 3 }),
53 | new Roll20Object('attribute', { name: 'repeating_foo_VVV_recharge', current: 'RECHARGE_6' }),
54 | ];
55 | roll20.findObjs.returns(attributes);
56 | roll20.randomInteger.returns(5);
57 | const result = restManager.recoverUses(char, 'turn', 'turn');
58 | expect(attributes[1].props).to.have.property('current', 3);
59 | expect(attributes[4].props).to.have.property('current', 3);
60 | expect(attributes[7].props).to.have.property('current', 1);
61 | expect(attributes[10].props).to.have.property('current', 1);
62 | expect(result.uses).to.deep.equal(['attack1', 'attack2 (Rolled a 5)']);
63 | expect(result.usesNotRecharged).to.deep.equal(['attack5 (Rolled a 5)']);
64 | });
65 |
66 | it('deals with recharge-type uses for short rest', function () {
67 | const attributes = [
68 | new Roll20Object('attribute', { name: 'repeating_foo_XXX_name', current: 'attack1' }),
69 | new Roll20Object('attribute', { name: 'repeating_foo_XXX_uses', current: 1, max: 3 }),
70 | new Roll20Object('attribute', { name: 'repeating_foo_XXX_recharge', current: 'TURN' }),
71 | new Roll20Object('attribute', { name: 'repeating_foo_YYY_name', current: 'attack2' }),
72 | new Roll20Object('attribute', { name: 'repeating_foo_YYY_uses', current: 1, max: 3 }),
73 | new Roll20Object('attribute', { name: 'repeating_foo_YYY_recharge', current: 'RECHARGE_5_6' }),
74 | new Roll20Object('attribute', { name: 'repeating_foo_ZZZ_name', current: 'attack3' }),
75 | new Roll20Object('attribute', { name: 'repeating_foo_ZZZ_uses', current: 1, max: 3 }),
76 | new Roll20Object('attribute', { name: 'repeating_foo_ZZZ_recharge', current: 'LONG_REST' }),
77 | new Roll20Object('attribute', { name: 'repeating_foo_WWW_name', current: 'attack4' }),
78 | new Roll20Object('attribute', { name: 'repeating_foo_WWW_uses', current: 1 }),
79 | new Roll20Object('attribute', { name: 'repeating_foo_WWW_recharge', current: 'TURN' }),
80 | ];
81 | roll20.findObjs.returns(attributes);
82 | restManager.recoverUses(char, 'turn', 'short');
83 | expect(attributes[1].props).to.have.property('current', 3);
84 | expect(attributes[4].props).to.have.property('current', 3);
85 | expect(attributes[7].props).to.have.property('current', 1);
86 | expect(attributes[10].props).to.have.property('current', 1);
87 | });
88 | });
89 | });
90 |
--------------------------------------------------------------------------------
/test/test-shaped-config.js:
--------------------------------------------------------------------------------
1 | /* globals describe: false, it:false, beforeEach:false */
2 | 'use strict';
3 | const expect = require('chai').expect;
4 | const ShapedConfig = require('../lib/shaped-config.js');
5 | const dl = require('./dummy-logger');
6 | const Reporter = require('./dummy-reporter');
7 | const cp = require('./dummy-command-parser');
8 |
9 |
10 | describe('ShapedConfig', function () {
11 | let sc;
12 | let myState;
13 |
14 | beforeEach(function () {
15 | myState = {
16 | version: 0.1,
17 | config: {},
18 | };
19 | sc = new ShapedConfig({
20 | reporter: new Reporter(),
21 | logger: dl,
22 | myState,
23 | });
24 | });
25 |
26 | it('upgrades old Shaped Config correctly', function () {
27 | myState.config = {
28 | genderPronouns: [
29 | 'someStuff',
30 | ],
31 | };
32 | sc.configure(cp);
33 | sc.upgradeConfig();
34 |
35 | expect(myState.config.genderPronouns).to.have.lengthOf(3);
36 | });
37 |
38 | it('upgrades recent Shaped Config correctly', function () {
39 | myState.config = {
40 | newCharSettings: {
41 | savingThrowsHalfProf: false,
42 | mediumArmorMaxDex: 2,
43 | },
44 | };
45 | sc.upgradeConfig();
46 |
47 |
48 | const result = myState.config;
49 | expect(result.newCharSettings).to.have.property('houserules');
50 | expect(result.newCharSettings.houserules.saves).to.have.property('savingThrowsHalfProf', false);
51 | expect(result.newCharSettings.houserules).to.have.property('mediumArmorMaxDex', 2);
52 | expect(result.newCharSettings).not.to.have.property('savingThrowsHalfProf');
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/test/test-spell-manager.js:
--------------------------------------------------------------------------------
1 | /* globals describe: false, it:false, beforeEach:false, before:false */
2 | 'use strict';
3 | const expect = require('chai').expect;
4 | const Roll20 = require('roll20-wrapper');
5 | const SpellManager = require('../lib/modules/spell-manager');
6 | const sinon = require('sinon');
7 | const logger = require('./dummy-logger');
8 | const Reporter = require('./dummy-reporter');
9 | const Roll20Object = require('./dummy-roll20-object');
10 | const cp = require('./dummy-command-parser');
11 | const _ = require('underscore');
12 | const el = require('./dummy-entity-lookup');
13 |
14 | const spellAttributes = [
15 | new Roll20Object('attribute', { name: 'repeating_spell0_1_name', current: 'Mage Hand' }),
16 | new Roll20Object('attribute', { name: 'repeating_spell0_1_level', current: 0 }),
17 | new Roll20Object('attribute', { name: 'repeating_spell0_1_content', current: 'Mage hand Content' }),
18 | new Roll20Object('attribute', { name: 'repeating_spell1_2_name', current: 'Shield *' }),
19 | new Roll20Object('attribute', { name: 'repeating_spell1_2_level', current: 1 }),
20 | new Roll20Object('attribute', { name: 'repeating_spell1_3_name', current: 'Thunderwave (self only)' }),
21 | new Roll20Object('attribute', { name: 'repeating_spell5_4_name', current: 'Dimension Door (self only)' }),
22 | new Roll20Object('attribute', { name: 'repeating_spell5_4_content', current: 'Dimension Door content' }),
23 | new Roll20Object('attribute', { name: 'repeating_spell6_5_name', current: 'Disintegrate' }),
24 | new Roll20Object('attribute', { name: 'repeating_spell3_6_name', current: 'Counterspell' }),
25 | ];
26 |
27 | describe('spell-manager', function () {
28 | let roll20;
29 | let spellManager;
30 |
31 | beforeEach(function () {
32 | roll20 = new Roll20();
33 | const reporter = new Reporter();
34 | const myState = { config: { sheetEnhancements: { autoSpellSlots: true } } };
35 | spellManager = new SpellManager({
36 | roll20,
37 | reporter,
38 | logger,
39 | myState,
40 | entityLookup: el.entityLookup,
41 | entityLister: { addEntity: _.noop },
42 | srdConverter: { convertSpells: _.identity },
43 | });
44 |
45 | spellManager.configure(cp, { registerChatListener: _.noop }, { registerEventHandler: _.noop });
46 | });
47 |
48 | describe('handleSpellCast', function () {
49 | it('should deal with cantrips correctly', function () {
50 | const mock = sinon.mock(roll20);
51 | const char = new Roll20Object('character');
52 | char.set('name', 'Bob');
53 |
54 | mock.expects('getAttrObjectByName').never();
55 | mock.expects('getAttrByName').withArgs(char.id, 'automatically_expend_spell_resources').returns(1);
56 | spellManager.handleSpellCast({ castAsLevel: '', character: char, spellLevel: 'CANTRIP' });
57 | mock.verify();
58 | });
59 |
60 | it('should deal with normal spells correctly', function () {
61 | sinon.stub(roll20);
62 | const char = new Roll20Object('character');
63 | char.set('name', 'Bob');
64 | roll20.getAttrByName.withArgs(char.id, 'automatically_expend_spell_resources').returns(1);
65 | const slotsAttr = new Roll20Object('attribute', { name: 'spell_slots_l5', current: 2 });
66 | roll20.getAttrObjectByName.withArgs(char.id, 'spell_slots_l5').returns(slotsAttr);
67 | roll20.getAttrObjectByName.withArgs(char.id, 'warlock_spell_slots').returns(null);
68 | roll20.getAttrObjectByName.withArgs(char.id, 'spell_points').returns(null);
69 |
70 | spellManager.handleSpellCast({ castAsLevel: 5, character: char });
71 | expect(slotsAttr.props).to.have.property('current', 1);
72 | });
73 | });
74 |
75 | describe('#spellAttributesForCharacter', function () {
76 | it('groups spell attributes correctly', function () {
77 | const char = new Roll20Object('character', { name: 'character' });
78 | const roll20Mock = sinon.mock(roll20);
79 |
80 | roll20Mock.expects('findObjs').returns(spellAttributes);
81 | const spells = spellManager.getSpellAttributesForCharacter(char);
82 | expect(_.size(spells)).to.equal(6);
83 | expect(spells).to.have.all.keys(['mage hand', 'shield', 'thunderwave (self only)', 'dimension door (self only)',
84 | 'disintegrate', 'counterspell']);
85 | roll20Mock.verify();
86 | });
87 |
88 | it('creates attribute list for import spells', function () {
89 | const char = new Roll20Object('character', { name: 'character' });
90 | const roll20Mock = sinon.mock(roll20);
91 | roll20Mock.expects('findObjs').returns(spellAttributes);
92 |
93 | const attributes = spellManager.getSpellAttributesForImport(char, {},
94 | [{ name: 'Banishment', content: 'Banishment content' }, { name: 'Shield', content: 'Shield content' }], true);
95 | expect(JSON.parse(attributes.import_data).spell_data).to.deep.equal([
96 | { name: 'Shield', rowId: '2', level: 1, source: 'unnamed' },
97 | { name: 'Thunderwave (self only)', rowId: '3', level: 1, source: 'unnamed' },
98 | { name: 'Disintegrate', rowId: '5', level: 6, source: 'unnamed' },
99 | { name: 'Counterspell', rowId: '6', level: 3, source: 'unnamed' },
100 | { name: 'Banishment', content: 'Banishment content' },
101 | ]);
102 | roll20Mock.verify();
103 | });
104 |
105 | it('omits spells when overwrite not set', function () {
106 | const char = new Roll20Object('character', { name: 'character' });
107 | const roll20Mock = sinon.mock(roll20);
108 | roll20Mock.expects('findObjs').returns(spellAttributes);
109 |
110 | const attributes = spellManager.getSpellAttributesForImport(char, {},
111 | [{ name: 'Banishment', content: 'Banishment content' }, { name: 'Mage Hand', content: 'Mage hand content' }],
112 | false);
113 | expect(JSON.parse(attributes.import_data).spell_data).to.deep.equal([
114 | { name: 'Shield', rowId: '2', level: 1, source: 'unnamed' },
115 | { name: 'Thunderwave (self only)', rowId: '3', level: 1, source: 'unnamed' },
116 | { name: 'Disintegrate', rowId: '5', level: 6, source: 'unnamed' },
117 | { name: 'Counterspell', rowId: '6', level: 3, source: 'unnamed' },
118 | { name: 'Banishment', content: 'Banishment content' },
119 | ]);
120 | roll20Mock.verify();
121 | });
122 | });
123 | });
124 |
--------------------------------------------------------------------------------
/test/test-srd-converter.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /* globals describe: false, it:false, before:false */
4 | require('chai').should();
5 | const srdConverter = require('../lib/srd-converter');
6 | const fs = require('fs');
7 | const glob = require('glob');
8 |
9 | describe('srd-converter', function () {
10 | describe('#convertMonster', function () {
11 | const fullObject = {
12 | name: 'Wobbler',
13 | traits: [
14 | { name: 'Trait One', recharge: '1/day', text: 'trait text blah blah\nblah' },
15 | { name: 'Trait Two', text: 'trait 2 text blah blah\nblah' },
16 | ],
17 | actions: [
18 | { name: 'Action One', recharge: '5-6', text: 'action text blah blah\nblah' },
19 | { name: 'Action Two', text: 'action 2 text blah blah\nblah' },
20 | ],
21 | reactions: [
22 | { name: 'Reaction One', recharge: '5-6', text: 'reaction text blah blah\nblah' },
23 | { name: 'Reaction Two', text: 'reaction 2 text blah blah\nblah' },
24 | ],
25 | legendaryPoints: 3,
26 | legendaryActions: [
27 | { name: 'Legendary Action One', cost: 1, text: 'legendary text blah blah\nblah' },
28 | { name: 'Legendary Action Two', cost: 2, text: 'legendary 2 text blah blah\nblah' },
29 | ],
30 | };
31 |
32 | const emptyObject = {
33 | name: 'Wobbler',
34 | };
35 |
36 | const emptyArrayObject = {
37 | name: 'Wobbler',
38 | traits: [],
39 | actions: [],
40 | reactions: [],
41 | legendaryActions: [],
42 | };
43 |
44 | const someMissing = {
45 | name: 'Wobbler',
46 | traits: [
47 | { name: 'Trait Two', text: 'trait 2 text blah blah\nblah' },
48 | ],
49 | actions: [
50 | { name: 'Action One', recharge: '5-6', text: 'action text blah blah\nblah' },
51 | { name: 'Action Two', text: 'action 2 text blah blah\nblah' },
52 | ],
53 | };
54 |
55 |
56 | it('correctly concatenates a full object', function () {
57 | // noinspection JSUnresolvedVariable
58 | const converted = srdConverter.convertMonster(fullObject);
59 | converted.should.have.property('content_srd',
60 | '\n Traits\n' +
61 | '**Trait One (1/day)**: trait text blah blah\nblah\n' +
62 | '**Trait Two**: trait 2 text blah blah\nblah\n' +
63 | '\n Actions\n' +
64 | '**Action One (5-6)**: action text blah blah\nblah\n' +
65 | '**Action Two**: action 2 text blah blah\nblah\n' +
66 | '\n Reactions\n' +
67 | '**Reaction One (5-6)**: reaction text blah blah\nblah\n' +
68 | '**Reaction Two**: reaction 2 text blah blah\nblah\n' +
69 | '\n Legendary Actions\n' +
70 | 'The Wobbler can take 3 legendary actions, choosing from the options below. ' +
71 | 'It can take only one legendary action at a time and only at the end of another creature\'s turn. ' +
72 | 'The Wobbler regains spent legendary actions at the start of its turn.\n' +
73 | '**Legendary Action One**: legendary text blah blah\nblah\n' +
74 | '**Legendary Action Two (Costs 2 actions)**: legendary 2 text blah blah\nblah');
75 |
76 | converted.should.not.have.any.keys('traits', 'actions', 'reactions', 'legendaryActions', 'legendary_actions');
77 | });
78 |
79 | it('correctly adds extra fields', function () {
80 | // noinspection JSUnresolvedVariable
81 | const converted = srdConverter.convertMonster(fullObject);
82 | converted.should.have.property('is_npc', 1);
83 | converted.should.have.property('edit_mode', 0);
84 | converted.should.not.contain.any.keys('traits', 'actions', 'reactions', 'legendaryActions', 'legendary_actions');
85 | });
86 |
87 | it('correctly concatenates an empty object', function () {
88 | // noinspection JSUnresolvedVariable
89 | const converted = srdConverter.convertMonster(emptyObject);
90 | converted.should.have.property('content_srd', '');
91 | converted.should.not.contain.any.keys('traits', 'actions', 'reactions', 'legendaryActions', 'legendary_actions');
92 | });
93 |
94 | it('correctly concatenates an object with empty arrays', function () {
95 | // noinspection JSUnresolvedVariable
96 | const converted = srdConverter.convertMonster(emptyArrayObject);
97 | converted.should.have.property('content_srd', '');
98 | converted.should.not.have.any.keys('traits', 'actions', 'reactions', 'legendaryActions', 'legendary_actions');
99 | });
100 |
101 | it('correctly concatenates a medium object', function () {
102 | // noinspection JSUnresolvedVariable
103 | const converted = srdConverter.convertMonster(someMissing);
104 | converted.should.have.property('content_srd',
105 | '\n Traits\n' +
106 | '**Trait Two**: trait 2 text blah blah\nblah\n' +
107 | '\n Actions\n' +
108 | '**Action One (5-6)**: action text blah blah\nblah\n' +
109 | '**Action Two**: action 2 text blah blah\nblah');
110 | converted.should.not.have.any.keys('traits', 'actions', 'reactions', 'legendaryActions', 'legendary_actions');
111 | });
112 | });
113 |
114 | describe('#convertJson', function () {
115 | if (process.env.CI) {
116 | return;
117 | }
118 |
119 | const monsterFiles = glob.sync('../5eshapedscriptdata/sources/{public,private}/*.json');
120 | monsterFiles.should.not.be.empty;
121 | monsterFiles.forEach(function (jsonFile) {
122 | describe(`JSON file: ${jsonFile}`, function () {
123 | const json = JSON.parse(fs.readFileSync(jsonFile, 'utf8'));
124 | if (json.monsters) {
125 | json.monsters.forEach(function (monster) {
126 | it(`convert ${monster.name}`, function () {
127 | srdConverter.convertMonster(monster);
128 | });
129 | });
130 | }
131 | if (json.spells) {
132 | it('should parse spell correctly', function () {
133 | srdConverter.convertSpells(json.spells.filter(spell => !spell.newName), 'female');
134 | });
135 | }
136 | });
137 | });
138 | });
139 | });
140 |
--------------------------------------------------------------------------------
/test/test-uses-manager.js:
--------------------------------------------------------------------------------
1 | /* globals describe: false, it:false, beforeEach:false, before:false */
2 | 'use strict';
3 | require('chai').should();
4 | const expect = require('chai').expect;
5 | const Roll20 = require('roll20-wrapper');
6 | const sinon = require('sinon');
7 | const logger = require('./dummy-logger');
8 | const Roll20Object = require('./dummy-roll20-object');
9 | const UsesManager = require('../lib/modules/uses-manager');
10 | const cp = require('./dummy-command-parser');
11 | const Reporter = require('./dummy-reporter');
12 | const _ = require('underscore');
13 |
14 |
15 | describe('uses-manager', function () {
16 | let usesManager;
17 | let roll20;
18 | let char;
19 | let reporter;
20 |
21 | beforeEach(function () {
22 | roll20 = new Roll20();
23 | reporter = new Reporter();
24 | usesManager =
25 | new UsesManager({ roll20, reporter, logger, myState: { config: { sheetEnhancements: { autoTraits: true } } } });
26 | sinon.stub(roll20);
27 | char = new Roll20Object('character', { name: 'character' });
28 | usesManager.configure(cp, { registerChatListener: _.noop });
29 | });
30 |
31 | describe('handleUses', function () {
32 | it('reports error for bad per_uses value', function () {
33 | usesManager.handleUses({ character: char, repeatingItem: 'repItem', perUse: 'Fibble' });
34 | expect(reporter.errors).to.have.lengthOf(1);
35 | });
36 |
37 | it('defaults to 1 for no per_use', function () {
38 | const attr = new Roll20Object('attribute', { name: 'uses', max: 3, current: 1 });
39 | roll20.getAttrObjectByName.returns(attr);
40 | usesManager.handleUses({ character: char, repeatingItem: 'repItem' });
41 | expect(attr.props).to.have.property('current', 0);
42 | });
43 |
44 | it('defaults to 1 for 0 per_use', function () {
45 | const attr = new Roll20Object('attribute', { name: 'uses', max: 3, current: 1 });
46 | roll20.getAttrObjectByName.returns(attr);
47 | usesManager.handleUses({ character: char, repeatingItem: 'repItem', perUse: 0 });
48 | expect(attr.props).to.have.property('current', 0);
49 | });
50 |
51 | it('decrements multiple for peruse > 1', function () {
52 | const attr = new Roll20Object('attribute', { name: 'uses', max: 3, current: 2 });
53 | roll20.getAttrObjectByName.returns(attr);
54 | usesManager.handleUses({ character: char, repeatingItem: 'repItem', perUse: 2 });
55 | expect(attr.props).to.have.property('current', 0);
56 | });
57 |
58 |
59 | it('fails for insufficient uses', function () {
60 | const attr = new Roll20Object('attribute', { name: 'uses', max: 3, current: 1 });
61 | roll20.getAttrObjectByName.returns(attr);
62 | usesManager.handleUses({ character: char, repeatingItem: 'repItem', perUse: 2 });
63 | expect(attr.props).to.have.property('current', 1);
64 | expect(reporter.messages).to.have.lengthOf(1);
65 | });
66 |
67 | it('ignores traits with no max uses', function () {
68 | const attr = new Roll20Object('attribute', { name: 'uses', current: 1 });
69 | roll20.getAttrObjectByName.returns(attr);
70 | usesManager.handleUses({ character: char, repeatingItem: 'repItem', perUse: 2 });
71 | expect(attr.props).to.have.property('current', 1);
72 | expect(reporter.messages).to.have.lengthOf(0);
73 | });
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/test/test-utils.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /* globals describe: false, it:false */
4 | const expect = require('chai').expect;
5 | const Utils = require('../lib/utils');
6 |
7 | describe('utils', function () {
8 | describe('#deepExtend', function () {
9 | it('parse options correctly', function () {
10 | const result = Utils.deepExtend({ foo: 'bar', blort: ['wibble'] }, {
11 | foo: 'barprime',
12 | blort: [undefined, 'bumble'],
13 | newVal: { funky: 'raw' },
14 | });
15 | expect(result).to.deep.equal({
16 | blort: [
17 | 'wibble',
18 | 'bumble',
19 | ],
20 | foo: 'barprime',
21 | newVal: {
22 | funky: 'raw',
23 | },
24 | });
25 | });
26 |
27 |
28 | it('should extend arrays properly', function () {
29 | const result = Utils.deepExtend({ foo: ['one', 'two'] }, { foo: [undefined, undefined, 'three'] });
30 | expect(result).to.deep.equal({ foo: ['one', 'two', 'three'] });
31 | });
32 | });
33 |
34 | describe('#createObjectFromPath', function () {
35 | it('create from path correctly', function () {
36 | const result = Utils.createObjectFromPath('foo.bar[1].blort[2]', 'testVal');
37 | const expected = {
38 | foo: {
39 | bar: [],
40 | },
41 | };
42 | expected.foo.bar[1] = {
43 | blort: [],
44 | };
45 | expected.foo.bar[1].blort[2] = 'testVal';
46 | expect(result).to.deep.equal(expected);
47 | });
48 | });
49 |
50 | describe('flattenObject', function () {
51 | it('flattens object correctly', function () {
52 | const input = {
53 | key1: 'value1',
54 | key2: 'value2',
55 | key3: {
56 | innerKey1: 'innerValue1',
57 | innerKey2: {
58 | innermostKey1: 'innermostValue1',
59 | innermostKey2: 'innermostValue2',
60 | },
61 | },
62 | key4: 'value4',
63 | key5: {
64 | innerKey3: 'innerValue3',
65 | },
66 | key6: 'value6',
67 | };
68 |
69 | expect(Utils.flattenObject(input)).to.deep.equal({
70 | key1: 'value1',
71 | key2: 'value2',
72 | innerKey1: 'innerValue1',
73 | innermostKey1: 'innermostValue1',
74 | innermostKey2: 'innermostValue2',
75 | key4: 'value4',
76 | innerKey3: 'innerValue3',
77 | key6: 'value6',
78 | });
79 | });
80 | });
81 |
82 | describe('versionCompare', function () {
83 | it('checks versions correctly', function () {
84 | expect(Utils.versionCompare('1.2.2', '2.2.2')).to.be.below(0);
85 | expect(Utils.versionCompare('10.2.2', '2.2.2')).to.be.above(0);
86 | expect(Utils.versionCompare('1.2.20', '1.1.21')).to.be.above(0);
87 | expect(Utils.versionCompare('1.2.2', '1.2.2')).to.equal(0);
88 | expect(Utils.versionCompare('1.20.2', '1.19.0')).to.be.above(0);
89 | expect(Utils.versionCompare(undefined, undefined)).to.equal(0);
90 | expect(Utils.versionCompare(undefined, '9.2.2')).to.equal(-1);
91 | });
92 | });
93 |
94 | describe('combine', function () {
95 | it('combines correctly', function () {
96 | expect(Utils.combine([1, 2, 3, 4])).to.deep.equal([
97 | '1',
98 | '1;2',
99 | '1;2;3',
100 | '1;2;3;4',
101 | '1;2;4',
102 | '1;3',
103 | '1;3;4',
104 | '1;4',
105 | '2',
106 | '2;3',
107 | '2;3;4',
108 | '2;4',
109 | '3',
110 | '3;4',
111 | '4',
112 | ]);
113 | });
114 | });
115 |
116 | describe('cartesianProductOf', function () {
117 | it('combined correctly', function () {
118 | expect(Utils.cartesianProductOf([1, 2], [3, 4, 5], [6, 7])).to.deep.equal([
119 | [1, 3, 6],
120 | [1, 3, 7],
121 | [1, 4, 6],
122 | [1, 4, 7],
123 | [1, 5, 6],
124 | [1, 5, 7],
125 | [2, 3, 6],
126 | [2, 3, 7],
127 | [2, 4, 6],
128 | [2, 4, 7],
129 | [2, 5, 6],
130 | [2, 5, 7],
131 | ]);
132 | expect(Utils.cartesianProductOf([], [1, 2], [3])).to.deep.equal([]);
133 | });
134 | });
135 | });
136 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | module: {
3 | loaders: [
4 | { test: /\.json$/, loader: 'json' },
5 | ],
6 | },
7 | output: {
8 | path: __dirname,
9 | filename: '5eShapedCompanion.js',
10 | library: 'ShapedScripts',
11 | },
12 | externals: {
13 | underscore: '_',
14 | },
15 | };
16 |
--------------------------------------------------------------------------------