Merge commit 'd7abb591e2b47c088b15ed23e825280338b02632'

This commit is contained in:
Stefan Zermatten
2023-06-22 12:06:15 +02:00
240 changed files with 7361 additions and 4193 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "app/packages/redis-oplog"]
path = app/packages/redis-oplog
url = https://github.com/ramezrafla/redis-oplog.git

View File

@@ -1,7 +1,7 @@
DiceCloud
========
This is the repo for [DiceCloud](dicecloud.com).
This is the repo for [DiceCloud](https://dicecloud.com).
DiceCloud is a free, auditable, real-time character sheet for D&D 5e.

View File

@@ -3,29 +3,30 @@
# 'meteor add' and 'meteor remove' will edit this file for you,
# but you can also edit it by hand.
accounts-password@2.3.1
zegenie:redis-oplog
accounts-password@2.3.4
random@1.2.1
underscore@1.0.11
underscore@1.0.13
dburles:mongo-collection-instances
accounts-google@1.4.0
email@2.2.2
email@2.2.5
meteor-base@1.5.1
mobile-experience@1.1.0
mongo@1.16.1
mongo@1.16.6
session@1.2.1
tracker@1.2.1
logging@1.3.1
tracker@1.3.2
logging@1.3.2
reload@1.3.1
ejson@1.1.3
check@1.3.2
standard-minifier-js@2.8.1
shell-server@0.5.0
ecmascript@0.16.3
ecmascript@0.16.7
es5-shim@4.8.0
service-configuration@1.3.1
dynamic-import@0.7.2
ddp-rate-limiter@1.1.1
rate-limit@1.0.9
dynamic-import@0.7.3
ddp-rate-limiter@1.2.0
rate-limit@1.1.1
mdg:validated-method
static-html@1.3.2
aldeed:collection2
@@ -38,7 +39,7 @@ simple:rest-method-mixin
mikowals:batch-insert
peerlibrary:subscription-data
zer0th:meteor-vuetify-loader
akryum:vue-component
akryum:vue-component@0.15.2
akryum:vue-router2
percolate:migrations
meteortesting:mocha
@@ -46,6 +47,7 @@ ostrio:files
simple:rest-bearer-token-parser
simple:rest-json-error-handler
littledata:synced-cron
mdg:meteor-apm-agent
typescript@4.5.4
#mdg:meteor-apm-agent
typescript@4.9.4
seba:minifiers-autoprefixer
mixmax:smart-disconnect

View File

@@ -1 +1 @@
METEOR@2.8.1
METEOR@2.12

View File

@@ -1,7 +1,7 @@
accounts-base@2.2.5
accounts-base@2.2.8
accounts-google@1.4.0
accounts-oauth@1.4.1
accounts-password@2.3.1
accounts-oauth@1.4.2
accounts-password@2.3.4
accounts-patreon@0.1.0
akryum:npm-check@0.1.2
akryum:vue-component@0.15.2
@@ -12,7 +12,7 @@ aldeed:collection2@3.5.0
aldeed:schema-index@3.0.0
allow-deny@1.1.1
autoupdate@1.8.0
babel-compiler@7.9.2
babel-compiler@7.10.4
babel-runtime@1.5.1
base64@1.0.12
binary-heap@1.0.11
@@ -21,7 +21,7 @@ boilerplate-generator@1.7.1
bozhao:link-accounts@2.6.1
caching-compiler@1.2.2
caching-html-compiler@1.2.1
callback-hook@1.4.0
callback-hook@1.5.1
check@1.3.2
coffeescript@2.4.1
coffeescript-compiler@2.4.1
@@ -29,20 +29,20 @@ dburles:mongo-collection-instances@0.3.6
ddp@1.4.1
ddp-client@2.6.1
ddp-common@1.4.0
ddp-rate-limiter@1.1.1
ddp-server@2.6.0
ddp-rate-limiter@1.2.0
ddp-server@2.6.1
diff-sequence@1.1.2
dynamic-import@0.7.2
ecmascript@0.16.3
ecmascript-runtime@0.8.0
dynamic-import@0.7.3
ecmascript@0.16.7
ecmascript-runtime@0.8.1
ecmascript-runtime-client@0.12.1
ecmascript-runtime-server@0.11.0
ejson@1.1.3
email@2.2.2
email@2.2.5
es5-shim@4.8.0
fetch@0.1.2
fetch@0.1.3
geojson-utils@1.0.11
google-oauth@1.4.2
google-oauth@1.4.3
hot-code-push@1.0.4
html-tools@1.1.3
htmljs@1.1.1
@@ -52,36 +52,34 @@ inter-process-messaging@0.1.1
lai:collection-extensions@0.3.0
launch-screen@1.3.0
littledata:synced-cron@1.5.1
livedata@1.0.18
localstorage@1.2.0
logging@1.3.1
mdg:meteor-apm-agent@3.5.1
mdg:validated-method@1.2.0
meteor@1.10.2
logging@1.3.2
mdg:validated-method@1.3.0
meteor@1.11.2
meteor-base@1.5.1
meteortesting:browser-tests@1.3.5
meteortesting:mocha@2.0.3
meteortesting:browser-tests@1.4.2
meteortesting:mocha@2.1.0
meteortesting:mocha-core@8.1.2
mikowals:batch-insert@1.3.0
minifier-css@1.6.1
minifier-css@1.6.4
minifier-js@2.7.5
minimongo@1.9.0
minimongo@1.9.3
mixmax:smart-disconnect@0.0.5
mobile-experience@1.1.0
mobile-status-bar@1.1.0
modern-browsers@0.1.9
modules@0.19.0
modules-runtime@0.13.1
mongo@1.16.1
mongo@1.16.6
mongo-decimal@0.1.3
mongo-dev-server@1.1.0
mongo-id@1.0.8
mongo-livedata@1.0.12
npm-mongo@4.11.0
oauth@2.1.2
oauth2@1.3.1
npm-mongo@4.16.0
oauth@2.2.0
oauth2@1.3.2
ordered-dict@1.1.0
ostrio:cookies@2.7.2
ostrio:files@2.3.2
ostrio:files@2.3.3
patreon-oauth@0.1.0
peerlibrary:assert@0.3.0
peerlibrary:check-extension@0.7.0
@@ -94,11 +92,11 @@ peerlibrary:reactive-publish@0.10.0
peerlibrary:server-autorun@0.8.0
peerlibrary:subscription-data@0.8.0
percolate:migrations@1.1.0
promise@0.12.1
promise@0.12.2
raix:eventemitter@1.0.0
random@1.2.1
rate-limit@1.0.9
react-fast-refresh@0.2.3
rate-limit@1.1.1
react-fast-refresh@0.2.7
reactive-dict@1.3.1
reactive-var@1.0.12
reload@1.3.1
@@ -114,16 +112,17 @@ simple:rest@1.2.1
simple:rest-bearer-token-parser@1.1.1
simple:rest-json-error-handler@1.1.1
simple:rest-method-mixin@1.1.0
socket-stream-client@0.5.0
socket-stream-client@0.5.1
spacebars-compiler@1.3.1
standard-minifier-js@2.8.1
static-html@1.3.2
templating-tools@1.2.2
tmeasday:check-npm-versions@1.0.2
tracker@1.2.1
typescript@4.5.4
underscore@1.0.11
tracker@1.3.2
typescript@4.9.4
underscore@1.0.13
url@1.3.2
webapp@1.13.2
webapp@1.13.5
webapp-hashing@1.1.1
zegenie:redis-oplog@2.0.16
zer0th:meteor-vuetify-loader@0.1.41

View File

@@ -0,0 +1,27 @@
import SimpleSchema from 'simpl-schema';
// Actions are creature actions that have been partially executed and not yet resolved
// They require some user input to progress
let Actions = new Mongo.Collection('actions');
let CreaturePropertySchema = new SimpleSchema({
_id: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
// Which creature is taking the action
_creatureId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
// The user who began taking the action
user: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
// The property that is about to be applied
property: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
});

View File

@@ -13,12 +13,12 @@ import { incrementFileStorageUsed } from '/imports/api/users/methods/updateFileS
import verifyArchiveSafety from '/imports/api/creature/archive/methods/verifyArchiveSafety.js';
let migrateArchive;
if (Meteor.isServer){
migrateArchive = require('/imports/migrations/server/migrateArchive.js').default;
if (Meteor.isServer) {
migrateArchive = require('/imports/migrations/archive/migrateArchive.js').default;
}
function restoreCreature(archive, userId){
if (SCHEMA_VERSION < archive.meta.schemaVersion){
function restoreCreature(archive, userId) {
if (SCHEMA_VERSION < archive.meta.schemaVersion) {
throw new Meteor.Error('Incompatible',
'The archive file is from a newer version. Update required to read.')
}
@@ -35,7 +35,7 @@ function restoreCreature(archive, userId){
});
if (existingCreature) throw new Meteor.Error('Already exists',
'The creature you are trying to restore already exists.')
// Ensure the user owns the restored creature
archive.creature.owner = userId;
@@ -44,13 +44,13 @@ function restoreCreature(archive, userId){
Creatures.insert(archive.creature);
try {
// Add all the properties
if (archive.properties && archive.properties.length){
if (archive.properties && archive.properties.length) {
CreatureProperties.batchInsert(archive.properties);
}
if (archive.experiences && archive.experiences.length){
if (archive.experiences && archive.experiences.length) {
Experiences.batchInsert(archive.experiences);
}
if (archive.logs && archive.logs.length){
if (archive.logs && archive.logs.length) {
CreatureLogs.batchInsert(archive.logs);
}
} catch (e) {
@@ -73,23 +73,23 @@ const restoreCreaturefromFile = new ValidatedMethod({
numRequests: 10,
timeInterval: 5000,
},
async run({fileId}) {
async run({ fileId }) {
// fetch the file
const file = ArchiveCreatureFiles.findOne({_id: fileId}).get();
if (!file){
const file = ArchiveCreatureFiles.findOne({ _id: fileId }).get();
if (!file) {
throw new Meteor.Error('File not found',
'The requested creature archive does not exist');
'The requested creature archive does not exist');
}
// Assert ownership
const userId = file?.userId;
if (!userId || userId !== this.userId){
if (!userId || userId !== this.userId) {
throw new Meteor.Error('Permission denied',
'You can only restore creatures you own');
'You can only restore creatures you own');
}
assertHasCharactersSlots(this.userId);
if (Meteor.isServer){
if (Meteor.isServer) {
// Read the file data
const archive = await ArchiveCreatureFiles.readJSONFile(file);
restoreCreature(archive, this.userId);

View File

@@ -46,6 +46,12 @@ let CreaturePropertySchema = new SimpleSchema({
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
// Fill more than one quantity in a slot, like feats and ability score
// improvements, filtered out of UI if there isn't space in quantityExpected
slotQuantityFilled: {
type: SimpleSchema.Integer,
optional: true, // Undefined implies 1
},
});
const DenormalisedOnlyCreaturePropertySchema = new SimpleSchema({
@@ -82,6 +88,12 @@ const DenormalisedOnlyCreaturePropertySchema = new SimpleSchema({
index: 1,
removeBeforeCompute: true,
},
deactivatingToggleId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
removeBeforeCompute: true,
},
// When this is true on any property, the creature needs to be recomputed
dirty: {
type: Boolean,

View File

@@ -0,0 +1,189 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import { RefSchema } from '/imports/api/parenting/ChildSchema.js';
import {
assertEditPermission,
assertDocEditPermission,
assertCopyPermission
} from '/imports/api/sharing/sharingPermissions.js';
import {
setLineageOfDocs,
getAncestry,
renewDocIds
} from '/imports/api/parenting/parenting.js';
import { reorderDocs } from '/imports/api/parenting/order.js';
import { setDocToLastOrder } from '/imports/api/parenting/order.js';
import Libraries from '/imports/api/library/Libraries.js';
const DUPLICATE_CHILDREN_LIMIT = 500;
const copyPropertyToLibrary = new ValidatedMethod({
name: 'creatureProperties.copyPropertyToLibrary',
validate: new SimpleSchema({
propId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
parentRef: {
type: RefSchema,
},
order: {
type: Number,
optional: true,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 1,
timeInterval: 5000,
},
run({ propId, parentRef, order }) {
// get the new ancestry for the properties
let { parentDoc, ancestors } = getAncestry({ parentRef });
// Check permission to edit the destination
let rootLibrary;
if (parentRef.collection === 'libraries') {
rootLibrary = parentDoc;
} else if (parentRef.collection === 'libraryNodes') {
rootLibrary = Libraries.findOne(parentDoc.ancestors[0].id)
} else {
throw `${parentRef.collection} is not a valid parent collection`
}
assertEditPermission(rootLibrary, this.userId);
const insertedRootNode = insertNodeFromProperty(propId, ancestors, order, this);
// Tree structure changed by inserts, reorder the tree
reorderDocs({
collection: LibraryNodes,
ancestorId: rootLibrary._id,
});
// Return the docId of the inserted root property
return insertedRootNode?._id;
},
});
function insertNodeFromProperty(propId, ancestors, order, method) {
// Fetch the property and its descendants, provided they have not been
// removed
let prop = CreatureProperties.findOne({
_id: propId,
removed: { $ne: true },
});
if (!prop) {
if (Meteor.isClient) return;
else {
throw new Meteor.Error(
'Insert property from library failed',
`No property with id '${propId}' was found`
);
}
}
// Make sure we can edit this property
assertDocEditPermission(prop, method.userId);
let oldParent = prop.parent;
const propCursor = CreatureProperties.find({
'ancestors.id': propId,
removed: { $ne: true },
});
// Make sure there aren't too many descendants
if (propCursor.count() > DUPLICATE_CHILDREN_LIMIT) {
throw new Meteor.Error('Copy children limit',
`The property has over ${DUPLICATE_CHILDREN_LIMIT} descendants and cannot be copied`);
}
let props = propCursor.fetch();
// The root prop is first in the array of props
// It must get the first generated ID to prevent flickering
props = [prop, ...props];
// If the docs came from a library, that library must consent to this user copying their
// properties
assertSourceLibraryCopyPermission(props, method);
// re-map all the ancestors
setLineageOfDocs({
docArray: props,
newAncestry: ancestors,
oldParent,
});
// Give the docs new IDs without breaking internal references
renewDocIds({
docArray: props,
collectionMap: { 'creatureProperties': 'libraryNodes' }
});
// Order the root node
if (order === undefined) {
setDocToLastOrder({
collection: LibraryNodes,
doc: prop,
});
} else {
prop.order = order;
}
// Insert the props as library nodes
LibraryNodes.batchInsert(props);
return prop;
}
/**
*
* @param {[Property]} props The properties to check
* @param {String} userId The userId trying to copy these properties to a library
* Checks that every property can be copied out of the library that originated it by this user
*/
function assertSourceLibraryCopyPermission(props, method) {
// Skip on the client
if (method.isSimulation) return;
// Get all the library node ids that are sources for these properties
const libraryNodeIds = [];
props.forEach(prop => {
if (prop.libraryNodeId) libraryNodeIds.push(prop.libraryNodeId);
});
if (!libraryNodeIds.length) return;
// Get the actual library Ids that each of these source nodes came from
const sourceLibIds = new Set();
LibraryNodes.find({
_id: { $in: libraryNodeIds }
}, {
fields: { ancestors: 1 }
}).forEach(node => {
sourceLibIds.add(node.ancestors?.[0]?.id);
});
// Assert copy permission on each of those libraries
Libraries.find({
_id: { $in: Array.from(sourceLibIds) }
}, {
fields: {
name: 1,
owner: 1,
readers: 1,
writers: 1,
public: 1,
readersCanCopy: 1,
}
}).forEach(lib => {
try {
assertCopyPermission(lib, method.userId);
} catch (e) {
throw new Meteor.Error('Copy permission denied',
`One of the properties you are copying comes from ${lib.name}, which you do not have permission to copy from`);
}
});
}
export default copyPropertyToLibrary;

View File

@@ -64,12 +64,12 @@ export function damagePropertyWork({ prop, operation, value, actionContext, logF
// Save the value to the scope before applying the before triggers
if (operation === 'increment') {
if (value >= 0) {
actionContext.scope['$damage'] = value;
actionContext.scope['~damage'] = { value };
} else {
actionContext.scope['$healing'] = -value;
actionContext.scope['~healing'] = { value: -value };
}
} else {
actionContext.scope['$set'] = value;
actionContext.scope['~set'] = { value };
}
applyTriggers(actionContext.triggers?.damageProperty?.before, prop, actionContext);
@@ -77,12 +77,12 @@ export function damagePropertyWork({ prop, operation, value, actionContext, logF
// fetch the value from the scope after the before triggers, in case they changed them
if (operation === 'increment') {
if (value >= 0) {
value = actionContext.scope['$damage'];
value = actionContext.scope['~damage']?.value;
} else {
value = -actionContext.scope['$healing'];
value = -actionContext.scope['~healing']?.value;
}
} else {
value = actionContext.scope['$set'];
value = actionContext.scope['~set']?.value;
}
let damage, newValue, increment;

View File

@@ -42,6 +42,11 @@ const duplicateProperty = new ValidatedMethod({
let propertyId = randomSrc.id();
property._id = propertyId;
// Change the variableName so it isn't immediately overridden
if (property.variableName) {
property.variableName += 'Copy'
}
// Get all the descendants
let nodes = CreatureProperties.find({
'ancestors.id': _id,

View File

@@ -1,17 +1,19 @@
export default function getSlotFillFilter({slot, libraryIds}){
export default function getSlotFillFilter({ slot, libraryIds }) {
if (!slot) throw 'Slot is required for getSlotFillFilter';
if (!libraryIds) throw 'LibraryIds is required for getSlotFillFilter';
let filter = {
removed: {$ne: true},
fillSlots: true,
removed: { $ne: true },
$and: []
};
if (libraryIds){
filter['ancestors.id'] = {$in: libraryIds};
}
if (slot.slotType){
filter['ancestors.id'] = { $in: libraryIds };
if (slot.slotType) {
filter.$and.push({
$or: [{
type: slot.slotType
},{
type: 'slotFiller',
}, {
slotFillerType: slot.slotType,
}]
});
@@ -19,44 +21,43 @@ export default function getSlotFillFilter({slot, libraryIds}){
filter.$and.push({
$or: [{
type: 'classLevel',
},{
type: 'slotFiller',
}, {
slotFillerType: 'classLevel',
}]
});
if (slot.variableName) {
if (slot.variableName) {
filter.variableName = slot.variableName;
}
// Only search for levels the class needs
if (slot.missingLevels && slot.missingLevels.length) {
filter.level = {$in: slot.missingLevels};
filter.level = { $in: slot.missingLevels };
} else {
filter.level = (slot.level || 0) + 1;
filter.level = { $gt: slot.level || 0 };
}
}
let tagsOr = [];
let tagsNin = [];
if (slot.slotTags && slot.slotTags.length){
tagsOr.push({tags: {$all: slot.slotTags}});
if (slot.slotTags && slot.slotTags.length) {
tagsOr.push({ libraryTags: { $all: slot.slotTags } });
}
if (slot.extraTags && slot.extraTags.length){
if (slot.extraTags && slot.extraTags.length) {
slot.extraTags.forEach(extra => {
if (!extra.tags || !extra.tags.length) return;
if (extra.operation === 'OR'){
tagsOr.push({tags: {$all: extra.tags}});
} else if (extra.operation === 'NOT'){
if (extra.operation === 'OR') {
tagsOr.push({ libraryTags: { $all: extra.tags } });
} else if (extra.operation === 'NOT') {
tagsNin.push(...extra.tags);
}
});
}
if (tagsOr.length){
if (tagsOr.length) {
filter.$or = tagsOr;
}
if (tagsNin.length){
filter.$and.push({tags: {$nin: tagsNin}});
if (tagsNin.length) {
filter.$and.push({ libraryTags: { $nin: tagsNin } });
}
if (!filter.$and.length){
if (!filter.$and.length) {
delete filter.$and;
}
return filter;

View File

@@ -0,0 +1,85 @@
import { assert } from 'chai';
import getSlotFillFilter from '/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js';
describe('Slot fill filter', function () {
it('Gives error if arguments aren\'t provided', function () {
assert.throws(
() => getSlotFillFilter(undefined),
null, null, 'Passing undefined should give an error'
);
assert.throws(
() => getSlotFillFilter({
slot: { slotTags: ['tag1'] },
}),
null, null, 'Passing no libraryIds should give an error'
);
assert.throws(
() => getSlotFillFilter({
libraryIds: ['libraryId1'],
}),
null, null, 'Passing no slot should give an error'
);
});
it('filters using basic slot tags', function () {
const filter = getSlotFillFilter({
slot: {
slotTags: ['tag1', 'tag2']
},
libraryIds: ['libraryId1', 'libraryId2'],
});
assert.deepStrictEqual(filter, {
$or: [{
libraryTags: { $all: ['tag1', 'tag2'] }
}],
'ancestors.id': { $in: ['libraryId1', 'libraryId2'] },
removed: { $ne: true },
fillSlots: true,
});
});
it('filters using slot type', function () {
const filter = getSlotFillFilter({
slot: {
slotTags: ['tag1', 'tag2'],
slotType: 'feature',
},
libraryIds: ['libraryId1', 'libraryId2']
});
assert.deepStrictEqual(filter.$and, [{
$or: [{
type: 'feature'
}, {
slotFillerType: 'feature',
}],
}]);
});
it('filters using extra tags', function () {
const filter = getSlotFillFilter({
slot: {
slotTags: ['tag1', 'tag2'],
extraTags: [
{ operation: 'OR', tags: ['tag3', 'tag4'] },
{ operation: 'NOT', tags: ['tag5', 'tag6'] },
{ operation: 'NOT', tags: ['tag7', 'tag8'] },
],
},
libraryIds: ['libraryId1', 'libraryId2'],
});
assert.deepStrictEqual(filter, {
$or: [
{ libraryTags: { $all: ['tag1', 'tag2'] } },
{ libraryTags: { $all: ['tag3', 'tag4'] } },
],
$and: [
{ libraryTags: { $nin: ['tag5', 'tag6', 'tag7', 'tag8'] } },
],
'ancestors.id': { $in: ['libraryId1', 'libraryId2'] },
removed: { $ne: true },
fillSlots: true,
});
});
});

View File

@@ -1,4 +1,5 @@
import '/imports/api/creature/creatureProperties/methods/adjustQuantity.js';
import '/imports/api/creature/creatureProperties/methods/copyPropertyToLibrary.js';
import '/imports/api/creature/creatureProperties/methods/damageProperty.js';
import '/imports/api/creature/creatureProperties/methods/duplicateProperty.js';
import '/imports/api/creature/creatureProperties/methods/equipItem.js';

View File

@@ -98,13 +98,14 @@ function insertPropertyFromNode(nodeId, ancestors, order) {
removed: { $ne: true },
}).fetch();
// Convert all references into actual nodes
nodes = reifyNodeReferences(nodes);
// The root node is first in the array of nodes
// It must get the first generated ID to prevent flickering
nodes = [node, ...nodes];
// Convert all references into actual nodes
nodes = reifyNodeReferences(nodes);
// set libraryNodeIds
storeLibraryNodeReferences(nodes);

View File

@@ -7,9 +7,10 @@ import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
import { parse, prettifyParseError } from '/imports/parser/parser.js';
import resolve, { toString } from '/imports/parser/resolve.js';
const PER_CREATURE_LOG_LIMIT = 100;
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const PER_CREATURE_LOG_LIMIT = 100;
if (Meteor.isServer) {
var sendWebhookAsCreature = require('/imports/server/discord/sendWebhook.js').sendWebhookAsCreature;
}
@@ -70,10 +71,21 @@ function logToMessageData(log) {
let embed = {
fields: [],
};
log.content.forEach(field => {
log.content.forEach((field, index) => {
// Empty character for blank names
if (!field.name) field.name = '\u200b';
if (!field.value) field.value = '\u200b';
embed.fields.push(field);
// Enforce Discord field character limits
if (field.name?.length > 256) {
field.name = field.name.substring(0, 255);
}
if (field.value?.length > 1024) {
field.value = field.value.substring(0, 1024 - 3) + '...';
}
// Enforce Discord 25 field limit
if (index < 25) {
embed.fields.push(field);
}
});
return { embeds: [embed] };
}
@@ -122,7 +134,15 @@ export function insertCreatureLogWork({ log, creature, method }) {
log = { content: [{ value: log }] };
}
if (!log.content?.length) return;
// Truncate the string lengths to fit the log content schema
log.content.forEach((logItem) => {
if (logItem.value?.length > STORAGE_LIMITS.summary) {
logItem.value = logItem.value.substring(0, STORAGE_LIMITS.summary - 3) + '...';
}
});
log.date = new Date();
// Insert it
let id = CreatureLogs.insert(log);
if (Meteor.isServer) {

View File

@@ -8,6 +8,8 @@ import { damagePropertyWork } from '/imports/api/creature/creatureProperties/met
import numberToSignedString from '/imports/api/utility/numberToSignedString.js';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js';
import { resetProperties } from '/imports/api/creature/creatures/methods/restCreature.js';
import { getPropertyDecendants } from '/imports/api/engine/loadCreatures.js';
import { nodeArrayToTree } from '/imports/api/parenting/nodesToTree.js';
export default function applyAction(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
@@ -51,11 +53,11 @@ export default function applyAction(node, actionContext) {
}
function applyAttackWithoutTarget({ attack, actionContext }) {
delete actionContext.scope['$attackHit'];
delete actionContext.scope['$attackMiss'];
delete actionContext.scope['$criticalHit'];
delete actionContext.scope['$criticalMiss'];
delete actionContext.scope['$attackRoll'];
delete actionContext.scope['~attackHit'];
delete actionContext.scope['~attackMiss'];
delete actionContext.scope['~criticalHit'];
delete actionContext.scope['~criticalMiss'];
delete actionContext.scope['~attackRoll'];
recalculateCalculation(attack, actionContext);
const scope = actionContext.scope;
@@ -66,16 +68,16 @@ function applyAttackWithoutTarget({ attack, actionContext }) {
criticalMiss,
} = rollAttack(attack, scope);
let name = criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit';
if (scope['$attackAdvantage'] === 1) {
if (scope['~attackAdvantage']?.value === 1) {
name += ' (Advantage)';
} else if (scope['$attackAdvantage'] === -1) {
} else if (scope['~attackAdvantage']?.value === -1) {
name += ' (Disadvantage)';
}
if (!criticalMiss) {
scope['$attackHit'] = { value: true }
scope['~attackHit'] = { value: true }
}
if (!criticalHit) {
scope['$attackMiss'] = { value: true };
scope['~attackMiss'] = { value: true };
}
actionContext.addLog({
@@ -87,12 +89,12 @@ function applyAttackWithoutTarget({ attack, actionContext }) {
function applyAttackToTarget({ attack, target, actionContext }) {
const scope = actionContext.scope;
delete scope['$attackHit'];
delete scope['$attackMiss'];
delete scope['$criticalHit'];
delete scope['$criticalMiss'];
delete scope['$attackDiceRoll'];
delete scope['$attackRoll'];
delete scope['~attackHit'];
delete scope['~attackMiss'];
delete scope['~criticalHit'];
delete scope['~criticalMiss'];
delete scope['~attackDiceRoll'];
delete scope['~attackRoll'];
recalculateCalculation(attack, actionContext);
@@ -109,9 +111,9 @@ function applyAttackToTarget({ attack, target, actionContext }) {
let name = criticalHit ? 'Critical Hit!' :
criticalMiss ? 'Critical Miss!' :
result > armor ? 'Hit!' : 'Miss!';
if (scope['$attackAdvantage'] === 1) {
if (scope['~attackAdvantage']?.value === 1) {
name += ' (Advantage)';
} else if (scope['$attackAdvantage'] === -1) {
} else if (scope['~attackAdvantage']?.value === -1) {
name += ' (Disadvantage)';
}
@@ -121,9 +123,9 @@ function applyAttackToTarget({ attack, target, actionContext }) {
inline: true,
});
if (criticalMiss || result < armor) {
scope['$attackMiss'] = { value: true };
scope['~attackMiss'] = { value: true };
} else {
scope['$attackHit'] = { value: true };
scope['~attackHit'] = { value: true };
}
} else {
actionContext.addLog({
@@ -141,7 +143,7 @@ function applyAttackToTarget({ attack, target, actionContext }) {
function rollAttack(attack, scope) {
const rollModifierText = numberToSignedString(attack.value, true);
let value, resultPrefix;
if (scope['$attackAdvantage'] === 1) {
if (scope['~attackAdvantage']?.value === 1) {
const [a, b] = rollDice(2, 20);
if (a >= b) {
value = a;
@@ -150,7 +152,7 @@ function rollAttack(attack, scope) {
value = b;
resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`;
}
} else if (scope['$attackAdvantage'] === -1) {
} else if (scope['~attackAdvantage']?.value === -1) {
const [a, b] = rollDice(2, 20);
if (a <= b) {
value = a;
@@ -163,23 +165,23 @@ function rollAttack(attack, scope) {
value = rollDice(1, 20)[0];
resultPrefix = `1d20 [${value}] ${rollModifierText}`
}
scope['$attackDiceRoll'] = { value };
scope['~attackDiceRoll'] = { value };
const result = value + attack.value;
scope['$attackRoll'] = { value: result };
scope['~attackRoll'] = { value: result };
const { criticalHit, criticalMiss } = applyCrits(value, scope);
return { resultPrefix, result, value, criticalHit, criticalMiss };
}
function applyCrits(value, scope) {
let criticalHitTarget = scope.criticalHitTarget?.value || 20;
const criticalHitTarget = scope['~criticalHitTarget']?.value || 20;
let criticalHit = value >= criticalHitTarget;
let criticalMiss;
if (criticalHit) {
scope['$criticalHit'] = { value: true };
scope['~criticalHit'] = { value: true };
} else {
criticalMiss = value === 1;
if (criticalMiss) {
scope['$criticalMiss'] = { value: true };
scope['~criticalMiss'] = { value: true };
}
}
return { criticalHit, criticalMiss };
@@ -211,6 +213,7 @@ function spendResources(prop, actionContext) {
let itemQuantityAdjustments = [];
let spendLog = [];
let gainLog = [];
let ammoChildren = [];
try {
prop.resources.itemsConsumed.forEach(itemConsumed => {
recalculateCalculation(itemConsumed.quantity, actionContext);
@@ -221,9 +224,6 @@ function spendResources(prop, actionContext) {
if (!item || item.ancestors[0].id !== prop.ancestors[0].id) {
throw 'The prop\'s ammo was not found on the creature';
}
if (!item.equipped) {
throw 'The selected ammo is not equipped';
}
if (
!itemConsumed.quantity.value ||
!isFinite(itemConsumed.quantity.value)
@@ -242,6 +242,7 @@ function spendResources(prop, actionContext) {
} else if (itemConsumed.quantity.value < 0) {
gainLog.push(logName + ': ' + -itemConsumed.quantity.value);
}
ammoChildren.push(...getItemChildren(item, actionContext, prop));
});
} catch (e) {
actionContext.addLog({
@@ -303,4 +304,36 @@ function spendResources(prop, actionContext) {
value: spendLog.join('\n'),
inline: true,
});
// Apply the ammo children
ammoChildren.forEach(prop => {
applyProperty(prop, actionContext);
});
}
function getItemChildren(item, actionContext, prop) {
// Skip if the prop or the item are ancestors of one another, otherwise infinite loop
if (hasAncestorRelationship(item, prop)) return [];
// Get the item children
const itemProperties = getPropertyDecendants(actionContext.creature._id, item._id);
// Tree them up
const propertyForest = nodeArrayToTree(itemProperties);
return propertyForest
}
function hasAncestorRelationship(a, b) {
let top, bottom;
if (a.ancestors.length === b.ancestors.length) {
// Can't be ancestors of one another if they have the same number of ancestors
return false;
} else if (a.ancestors.length > b.ancestors.length) {
// longer ancestor list goes on the bottom
top = b;
bottom = a;
} else {
top = a;
bottom = b;
}
const expectedAncestorPosition = top.ancestors.length;
return bottom.ancestors[expectedAncestorPosition]?.id === top._id;
}

View File

@@ -3,22 +3,22 @@ import recalculateCalculation from './shared/recalculateCalculation.js';
import rollDice from '/imports/parser/rollDice.js';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js';
export default function applyBranch(node, actionContext){
export default function applyBranch(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
const applyChildren = function(){
const applyChildren = function () {
applyNodeTriggers(node, 'after', actionContext);
node.children.forEach(child => applyProperty(child, actionContext));
};
const scope = actionContext.scope;
const targets = actionContext.targets;
const prop = node.node;
switch(prop.branchType){
switch (prop.branchType) {
case 'if':
recalculateCalculation(prop.condition, actionContext);
if (prop.condition?.value) applyChildren();
break;
case 'index':
if (node.children.length){
if (node.children.length) {
recalculateCalculation(prop.condition, actionContext);
if (!isFinite(prop.condition?.value)) {
actionContext.addLog({
@@ -35,31 +35,31 @@ export default function applyBranch(node, actionContext){
}
break;
case 'hit':
if (scope['$attackHit']?.value){
if (!targets.length && !prop.silent) actionContext.addLog({value: '**On hit**'});
if (scope['~attackHit']?.value) {
if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On hit**' });
applyChildren();
}
break;
case 'miss':
if (scope['$attackMiss']?.value){
if (!targets.length && !prop.silent) actionContext.addLog({value: '**On miss**'});
if (scope['~attackMiss']?.value) {
if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On miss**' });
applyChildren();
}
break;
case 'failedSave':
if (scope['$saveFailed']?.value){
if (!targets.length && !prop.silent) actionContext.addLog({value: '**On failed save**'});
if (scope['~saveFailed']?.value) {
if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On failed save**' });
applyChildren();
}
break;
case 'successfulSave':
if (scope['$saveSucceeded']?.value){
if (!targets.length && !prop.silent) actionContext.addLog({value: '**On save**',});
if (scope['~saveSucceeded']?.value) {
if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On save**', });
applyChildren();
}
break;
case 'random':
if (node.children.length){
if (node.children.length) {
let index = rollDice(1, node.children.length)[0] - 1;
applyNodeTriggers(node, 'after', actionContext);
applyProperty(node.children[index], actionContext);

View File

@@ -21,7 +21,10 @@ export default function applyBuff(node, actionContext) {
const prop = node.node;
let buffTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets;
// Then copy the decendants of the buff to the targets
// Mark the buff as dirty for recalculation
prop.dirty = true;
// Then copy the descendants of the buff to the targets
let propList = [prop];
function addChildrenToPropList(children, { skipCrystalize } = {}) {
children.forEach(child => {

View File

@@ -27,7 +27,7 @@ export default function applyDamage(node, actionContext) {
// Choose target
let damageTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets;
// Determine if the hit is critical
let criticalHit = scope['$criticalHit']?.value &&
let criticalHit = scope['~criticalHit']?.value &&
prop.damageType !== 'healing' // Can't critically heal
;
// Double the damage rolls if the hit is critical
@@ -73,12 +73,12 @@ export default function applyDamage(node, actionContext) {
damage = Math.floor(damage);
// Convert extra damage into the stored type
if (prop.damageType === 'extra' && scope['$lastDamageType']) {
prop.damageType = scope['$lastDamageType'];
if (prop.damageType === 'extra' && scope['~lastDamageType']?.value) {
prop.damageType = scope['~lastDamageType']?.value;
}
// Store current damage type
if (prop.damageType !== 'healing') {
scope['$lastDamageType'] = prop.damageType;
scope['~lastDamageType'] = { value: prop.damageType };
}
// Memoise the damage suffix for the log
@@ -194,14 +194,18 @@ function dealDamage({ target, damageType, amount, actionContext }) {
let healthBars = getPropertiesOfType(target._id, 'attribute');
// Keep only the healthbars that can take damage/healing
remove(healthBars, (bar) =>
bar.attributeType !== 'healthBar' ||
bar.inactive ||
bar.removed ||
bar.overridden ||
(amount >= 0 && bar.healthBarNoDamage) ||
(amount < 0 && bar.healthBarNoHealing)
);
healthBars = healthBars.filter((bar) => {
if (bar.attributeType !== 'healthBar' || bar.inactive || bar.removed || bar.overridden) {
return false;
}
if (damageType === 'healing' && bar.healthBarNoHealing) {
return false;
}
if (damageType !== 'healing' && amount >= 0 && bar.healthBarNoDamage) {
return false;
}
return true;
});
// Sort healthbars by damage/healing order or tree order as a fallback
healthBars.sort((a, b) => {

View File

@@ -4,22 +4,22 @@ import applyEffectsToCalculationParseNode from '/imports/api/engine/actions/appl
import resolve, { toString } from '/imports/parser/resolve.js';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js';
export default function applyRoll(node, actionContext){
export default function applyRoll(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
const prop = node.node;
const applyChildren = function(){
const applyChildren = function () {
applyNodeTriggers(node, 'after', actionContext);
node.children.forEach(child => applyProperty(child, actionContext));
};
if (prop.roll?.calculation){
if (prop.roll?.calculation) {
const logValue = [];
// roll the dice only and store that string
applyEffectsToCalculationParseNode(prop.roll, actionContext);
const {result: rolled, context} = resolve('roll', prop.roll.parseNode, actionContext.scope);
if (rolled.parseType !== 'constant'){
const { result: rolled, context } = resolve('roll', prop.roll.parseNode, actionContext.scope);
if (rolled.parseType !== 'constant') {
logValue.push(toString(rolled));
}
logErrors(context.errors, actionContext);
@@ -28,28 +28,28 @@ export default function applyRoll(node, actionContext){
context.errors = [];
// Resolve the roll to a final value
const {result: reduced} = resolve('reduce', rolled, actionContext.scope, context);
const { result: reduced } = resolve('reduce', rolled, actionContext.scope, context);
logErrors(context.errors, actionContext);
// Store the result
if (reduced.parseType === 'constant'){
if (reduced.parseType === 'constant') {
prop.roll.value = reduced.value;
} else if (reduced.parseType === 'error'){
} else if (reduced.parseType === 'error') {
prop.roll.value = null;
} else {
prop.roll.value = toString(reduced);
}
// If we didn't end up with a constant of finite amount, give up
if (reduced?.parseType !== 'constant' || !isFinite(reduced.value)){
// If we didn't end up with a constant or a number of finite value, give up
if (reduced?.parseType !== 'constant' || (reduced.valueType === 'number' && !isFinite(reduced.value))) {
return applyChildren();
}
const value = reduced.value;
actionContext.scope[prop.variableName] = value;
actionContext.scope[prop.variableName] = { value };
logValue.push(`**${value}**`);
if (!prop.silent){
if (!prop.silent) {
actionContext.addLog({
name: prop.name,
value: logValue.join('\n'),

View File

@@ -8,6 +8,7 @@ import { applyUnresolvedEffects } from '/imports/api/engine/actions/doCheck.js';
export default function applySavingThrow(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
const prop = node.node;
const originalTargets = actionContext.targets;
let saveTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets;
@@ -31,22 +32,22 @@ export default function applySavingThrow(node, actionContext) {
// If there are no save targets, apply all children as if the save both
// succeeeded and failed
if (!saveTargets?.length) {
scope['$saveFailed'] = { value: true };
scope['$saveSucceeded'] = { value: true };
scope['~saveFailed'] = { value: true };
scope['~saveSucceeded'] = { value: true };
applyNodeTriggers(node, 'after', actionContext);
return node.children.forEach(child => applyProperty(child, actionContext));
}
// Each target makes the saving throw
saveTargets.forEach(target => {
delete scope['$saveFailed'];
delete scope['$saveSucceeded'];
delete scope['$saveDiceRoll'];
delete scope['$saveRoll'];
delete scope['~saveFailed'];
delete scope['~saveSucceeded'];
delete scope['~saveDiceRoll'];
delete scope['~saveRoll'];
const applyChildren = function () {
applyNodeTriggers(node, 'after', actionContext);
actionContext.targets = [target]
applyNodeTriggers(node, 'after', actionContext);
node.children.forEach(child => applyProperty(child, actionContext));
};
@@ -90,14 +91,14 @@ export default function applySavingThrow(node, actionContext) {
value = values[0];
resultPrefix = `1d20 [ ${value} ] ${rollModifierText}`
}
scope['$saveDiceRoll'] = { value };
scope['~saveDiceRoll'] = { value };
const result = value + rollModifier || 0;
scope['$saveRoll'] = { value: result };
scope['~saveRoll'] = { value: result };
const saveSuccess = result >= dc;
if (saveSuccess) {
scope['$saveSucceeded'] = { value: true };
scope['~saveSucceeded'] = { value: true };
} else {
scope['$saveFailed'] = { value: true };
scope['~saveFailed'] = { value: true };
}
if (!prop.silent) actionContext.addLog({
name: saveSuccess ? 'Successful save' : 'Failed save',
@@ -106,4 +107,6 @@ export default function applySavingThrow(node, actionContext) {
});
return applyChildren();
});
// reset the targets after the save to each child
actionContext.targets = originalTargets;
}

View File

@@ -35,7 +35,7 @@ export function applyTrigger(trigger, prop, actionContext) {
if (trigger.inactive) {
return;
}
// Prevent triggers from firing if their condition is false
if (trigger.condition?.parseNode) {
recalculateCalculation(trigger.condition, actionContext);
@@ -61,11 +61,11 @@ export function applyTrigger(trigger, prop, actionContext) {
value: trigger.description,
inline: false,
}
if (trigger.description?.text){
if (trigger.description?.text) {
recalculateInlineCalculations(trigger.description, actionContext);
content.value = trigger.description.value;
}
if(!trigger.silent) actionContext.addLog(content);
if (!trigger.silent) actionContext.addLog(content);
// Get all the trigger's properties and apply them
const properties = getPropertyDecendants(actionContext.creature._id, trigger._id);
@@ -78,7 +78,7 @@ export function applyTrigger(trigger, prop, actionContext) {
trigger.firing = false;
}
function triggerMatchTags(trigger, prop) {
export function triggerMatchTags(trigger, prop) {
let matched = false;
const propTags = getEffectivePropTags(prop);
// Check the target tags
@@ -89,23 +89,26 @@ function triggerMatchTags(trigger, prop) {
matched = true;
}
// Check the extra tags
trigger.extraTags?.forEach(extra => {
if (extra.operation === 'OR') {
if (matched) return;
if (
!extra.tags.length ||
difference(extra.tags, propTags).length === 0
) {
matched = true;
}
} else if (extra.operation === 'NOT') {
if (
extra.tags.length &&
intersection(extra.tags, propTags)
) {
return false;
if (trigger.extraTags) {
for (const extra of trigger.extraTags) {
if (extra.operation === 'OR') {
if (matched) break;
if (
!extra.tags.length ||
difference(extra.tags, propTags).length === 0
) {
matched = true;
}
} else if (extra.operation === 'NOT') {
if (
extra.tags.length &&
intersection(extra.tags, propTags).length > 0
) {
matched = false;
break;
}
}
}
});
}
return matched;
}

View File

@@ -0,0 +1,67 @@
import { triggerMatchTags } from '/imports/api/engine/actions/applyTriggers.js';
import clean from '/imports/api/engine/computation/utility/cleanProp.testFn.js';
import { assert } from 'chai';
export default function () {
const prop = clean({
id: 'propWithTags',
type: 'action',
tags: ['yes1', 'notUsed', 'no1', 'yes2', 'no2', 'or1', 'or2'],
});
const positiveProp = clean({
id: 'propWithTags',
type: 'action',
tags: ['yes1', 'notUsed', 'yes2', 'or1', 'or2'],
});
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
targetTags: ['yes1'],
}), prop),
'Trigger matches on a single target tag'
);
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
targetTags: ['yes1', 'yes2'],
}), prop),
'Trigger matches on a multiple target tags'
);
assert.isFalse(
triggerMatchTags(clean({
type: 'trigger',
targetTags: ['yes1'],
extraTags: [{ operation: 'NOT', tags: ['no1'] }]
}), prop),
'Trigger correctly fails to match when not tags are present'
);
assert.isFalse(
triggerMatchTags(clean({
type: 'trigger',
extraTags: [{ operation: 'NOT', tags: ['no1'] }]
}), prop),
'Trigger correctly fails to match when only not tags are present'
);
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
extraTags: [{ operation: 'NOT', tags: ['no1'] }]
}), positiveProp),
'Trigger matches when only not tags are present'
);
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
extraTags: [{ operation: 'OR', tags: ['or1'] }]
}), positiveProp),
'Trigger matches when OR tags are present'
);
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
targetTags: ['missing1'],
extraTags: [{ operation: 'OR', tags: ['or1'] }]
}), positiveProp),
'Trigger matches when only OR tags are present'
);
}

View File

@@ -1,16 +1,17 @@
import '/imports/api/simpleSchemaConfig.js';
//import testTypes from './testTypes/index.js';
import applyTriggers from '/imports/api/engine/actions/applyTriggers.testFn.js';
import { doActionWork } from './doAction.js';
import { CreatureLogSchema } from '/imports/api/creature/log/CreatureLogs.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
function cleanProp(prop){
function cleanProp(prop) {
let schema = CreatureProperties.simpleSchema(prop);
return schema.clean(prop);
}
function cleanCreature(creature){
function cleanCreature(creature) {
let schema = Creatures.simpleSchema(creature);
return schema.clean(creature);
}
@@ -28,7 +29,7 @@ const testActionContext = {
}),
scope: {},
addLog(content) {
if (content.name || content.value){
if (content.name || content.value) {
this.log.content.push(content);
}
},
@@ -40,8 +41,8 @@ const action = cleanProp({
});
const actionAncestors = [];
describe('Do Action', function(){
it('Does an empty action', function(){
describe('Do Action', function () {
it('Does an empty action', function () {
doActionWork({
properties: [action],
ancestors: actionAncestors,
@@ -51,3 +52,7 @@ describe('Do Action', function(){
});
//testTypes.forEach(test => it(test.text, test.fn));
});
describe('Action utility functions', function () {
it('Triggers match tags', applyTriggers);
})

View File

@@ -117,7 +117,8 @@ const doAction = new ValidatedMethod({
}
}
actionContext.scope['slotLevel'] = slotLevel;
actionContext.scope['slotLevel'] = { value: slotLevel };
actionContext.scope['~slotLevel'] = { value: slotLevel };
// Do the action
doActionWork({

View File

@@ -81,7 +81,7 @@ function rollCheck(prop, actionContext) {
rollModifier += effectBonus;
let value, values, resultPrefix;
if (scope['$checkAdvantage'] === 1) {
if (scope['~checkAdvantage']?.value === 1) {
logName += ' (Advantage)';
const [a, b] = rollDice(2, 20);
if (a >= b) {
@@ -91,7 +91,7 @@ function rollCheck(prop, actionContext) {
value = b;
resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText} = `;
}
} else if (scope['$checkAdvantage'] === -1) {
} else if (scope['~checkAdvantage']?.value === -1) {
logName += ' (Disadvantage)';
const [a, b] = rollDice(2, 20);
if (a <= b) {
@@ -107,9 +107,9 @@ function rollCheck(prop, actionContext) {
resultPrefix = `1d20 [ ${value} ] ${rollModifierText} = `
}
const result = (value + rollModifier) || 0;
scope['$checkDiceRoll'] = value;
scope['$checkRoll'] = result;
scope['$checkModifier'] = rollModifier;
scope['~checkDiceRoll'] = { value };
scope['~checkRoll'] = { value: result };
scope['~checkModifier'] = { value: rollModifier };
actionContext.addLog({
name: logName,
value: `${resultPrefix} **${result}**`,

View File

@@ -2,7 +2,7 @@
* Only computes `totalFilled`, need to compute `quantityExpected.value`
* before `spacesLeft` can be computed
*/
export default function computeSlotQuantityFilled(node, dependencyGraph){
export default function computeSlotQuantityFilled(node, dependencyGraph) {
let slot = node.node;
if (slot.type !== 'propertySlot') return;
slot.totalFilled = 0;
@@ -10,9 +10,8 @@ export default function computeSlotQuantityFilled(node, dependencyGraph){
let childProp = child.node;
dependencyGraph.addLink(slot._id, childProp._id, 'slotFill');
if (
childProp.type === 'slotFiller' &&
Number.isFinite(childProp.slotQuantityFilled)
){
) {
slot.totalFilled += childProp.slotQuantityFilled;
} else {
slot.totalFilled++;

View File

@@ -1,16 +1,31 @@
import walkDown from '/imports/api/engine/computation/utility/walkdown.js';
import { getEffectTagTargets } from '/imports/api/engine/computation/buildComputation/linkTypeDependencies.js';
export default function computeToggleDependencies(node, dependencyGraph){
export default function computeToggleDependencies(node, dependencyGraph, computation, forest) {
const prop = node.node;
// Only for toggles that aren't inactive and aren't set to enabled or disabled
if (
prop.inactive ||
prop.type !== 'toggle' ||
prop.disabled ||
prop.enabled
) return;
// Only for toggles
if (prop.type !== 'toggle') return;
if (prop.targetByTags) {
// Find all the props targeted by tags, and disable them and their children
getEffectTagTargets(prop, computation).forEach(targetId => {
const target = forest.nodeIndex[targetId];
if (!target) return;
target.node._computationDetails.toggleAncestors.push(prop);
dependencyGraph.addLink(target.node._id, prop._id, 'toggle');
walkDown(target.children, child => {
// The child nodes depend on the toggle
child.node._computationDetails.toggleAncestors.push(prop);
dependencyGraph.addLink(child.node._id, prop._id, 'toggle');
});
});
}
// We don't need to link direct children of static toggles, it's already done
if (prop.disabled || prop.enabled) return;
walkDown(node.children, child => {
// The child nodes depend on the toggle condition compuation
// The child nodes depend on the toggle
child.node._computationDetails.toggleAncestors.push(prop);
dependencyGraph.addLink(child.node._id, prop._id, 'toggle');
});

View File

@@ -164,7 +164,7 @@ function linkEffects(dependencyGraph, prop, computation) {
}
// Returns an array of IDs of the properties the effect targets
function getEffectTagTargets(effect, computation) {
export function getEffectTagTargets(effect, computation) {
let targets = getTargetListFromTags(effect.targetTags, computation);
let notIds = [];
if (effect.extraTags) {
@@ -218,7 +218,6 @@ function getDefaultCalculationField(prop) {
case 'roll': return 'roll';
case 'savingThrow': return 'dc';
case 'skill': return 'baseValue';
case 'slotFiller': return null;
case 'slot': return 'quantityExpected';
case 'spellList': return 'attackRollBonus';
case 'spell': return null;
@@ -268,20 +267,45 @@ function linkPointBuy(dependencyGraph, prop) {
if (prop.inactive) return;
}
function linkProficiencies(dependencyGraph, prop) {
function linkProficiencies(dependencyGraph, prop, computation) {
// The stats depend on the proficiency
if (prop.inactive) return;
prop.stats.forEach(statName => {
if (!statName) return;
dependencyGraph.addLink(statName, prop._id, prop.type);
});
if (prop.targetByTags) {
// Tag targeted proficiencies depend on the creature's proficiencyBonus,
// since they add it directly to the targeted field
dependencyGraph.addLink(prop._id, 'proficiencyBonus', 'skillProficiencyBonus');
getEffectTagTargets(prop, computation).forEach(targetId => {
const targetProp = computation.propsById[targetId];
if (
(targetProp.type === 'attribute' || targetProp.type === 'skill')
&& targetProp.variableName
&& !prop.targetField
) {
// If the field wasn't specified and we're targeting an attribute or
// skill, just treat it like a normal proficiency on its variable name
dependencyGraph.addLink(targetProp.variableName, prop._id, 'proficiency');
} else {
// Otherwise target a field on that property
const key = prop.targetField || getDefaultCalculationField(targetProp);
const calcObj = get(targetProp, key);
if (calcObj && calcObj.calculation) {
dependencyGraph.addLink(`${targetProp._id}.${key}`, prop._id, 'proficiency');
}
}
});
} else {
prop.stats.forEach(statName => {
if (!statName) return;
dependencyGraph.addLink(statName, prop._id, 'proficiency');
});
}
}
function linkSavingThrow(dependencyGraph, prop) {
dependOnCalc({ dependencyGraph, prop, key: 'dc' });
}
function linkSkill(dependencyGraph, prop) {
function linkSkill(dependencyGraph, prop, computation) {
// Depends on base value
dependOnCalc({ dependencyGraph, prop, key: 'baseValue' });
// Link dependents
@@ -293,6 +317,20 @@ function linkSkill(dependencyGraph, prop) {
}
// Skills depend on the creature's proficiencyBonus
dependencyGraph.addLink(prop._id, 'proficiencyBonus', 'skillProficiencyBonus');
// Skills can apply their value as a proficiency bonus to calculations based on tag
if (prop.targetByTags) {
getEffectTagTargets(prop, computation).forEach(targetId => {
const targetProp = computation.propsById[targetId];
// Always target a field on the target property, applying a skill to an attribute or
// other skill isn't supported
const key = prop.targetField || getDefaultCalculationField(targetProp);
const calcObj = get(targetProp, key);
if (calcObj && calcObj.calculation) {
dependencyGraph.addLink(`${targetProp._id}.${key}`, prop._id, 'proficiency');
}
});
}
}
function linkSlot(dependencyGraph, prop) {

View File

@@ -5,14 +5,14 @@ import { get, unset } from 'lodash';
import errorNode from '/imports/parser/parseTree/error.js';
import cyrb53 from '/imports/api/engine/computation/utility/cyrb53.js';
export default function parseCalculationFields(prop, schemas){
export default function parseCalculationFields(prop, schemas) {
discoverInlineCalculationFields(prop, schemas);
parseAllCalculationFields(prop, schemas);
}
function discoverInlineCalculationFields(prop, schemas){
function discoverInlineCalculationFields(prop, schemas) {
// For each key in the schema
schemas[prop.type].inlineCalculationFields().forEach( calcKey => {
schemas[prop.type]?.inlineCalculationFields?.()?.forEach(calcKey => {
// That ends in .inlineCalculations
applyFnToKey(prop, calcKey, (prop, key) => {
const inlineCalcObj = get(prop, key);
@@ -22,7 +22,7 @@ function discoverInlineCalculationFields(prop, schemas){
// Extract the calculations and store them on the property
let string = inlineCalcObj.text;
// If there is no text, delete the whole field
if (!string){
if (!string) {
unset(prop, calcKey);
return;
}
@@ -32,7 +32,7 @@ function discoverInlineCalculationFields(prop, schemas){
// Has the text, if it matches the existing hash, stop
const inlineCalcHash = cyrb53(inlineCalcObj.text);
if (inlineCalcHash === inlineCalcObj.hash){
if (inlineCalcHash === inlineCalcObj.hash) {
return;
}
inlineCalcObj.hash = inlineCalcHash;
@@ -41,7 +41,7 @@ function discoverInlineCalculationFields(prop, schemas){
// It will be re set including the embedded calculation at the end of
// the computation
let matches = string.matchAll(INLINE_CALCULATION_REGEX);
for (let match of matches){
for (let match of matches) {
let calculation = match[1];
inlineCalcObj.inlineCalculations.push({
calculation,
@@ -51,9 +51,9 @@ function discoverInlineCalculationFields(prop, schemas){
});
}
function parseAllCalculationFields(prop, schemas){
function parseAllCalculationFields(prop, schemas) {
// For each computed key in the schema
schemas[prop.type].computedFields().forEach( calcKey => {
schemas[prop.type]?.computedFields?.()?.forEach(calcKey => {
// Determine the level the calculation should compute down to
let parseLevel = schemas[prop.type].getDefinition(calcKey).parseLevel || 'reduce';
@@ -66,7 +66,7 @@ function parseAllCalculationFields(prop, schemas){
const calcObj = get(prop, key);
if (!calcObj) return;
// Delete the whole calculation object if the calculation string isn't set
if (!calcObj.calculation){
if (!calcObj.calculation) {
unset(prop, calcKey);
return;
}
@@ -84,10 +84,10 @@ function parseAllCalculationFields(prop, schemas){
});
}
function parseCalculation(calcObj){
function parseCalculation(calcObj) {
const calcHash = cyrb53(calcObj.calculation);
// If the cached parse calculation is equal to the calculation, skip
if (calcHash === calcObj.hash){
if (calcHash === calcObj.hash) {
return;
}
calcObj.hash = calcHash;
@@ -100,6 +100,6 @@ function parseCalculation(calcObj){
message: prettifyParseError(e),
};
calcObj.parseError = error;
calcObj.parseNode = errorNode.create({error});
calcObj.parseNode = errorNode.create({ error });
}
}

View File

@@ -1,9 +1,9 @@
import applyFnToKey from '../utility/applyFnToKey.js';
import { unset } from 'lodash';
export default function removeSchemaFields(schemas, prop){
export default function removeSchemaFields(schemas, prop) {
schemas.forEach(schema => {
schema.removeBeforeComputeFields().forEach(
schema?.removeBeforeComputeFields?.().forEach(
key => applyFnToKey(prop, key, unset)
);
});

View File

@@ -2,7 +2,7 @@ import { buildComputationFromProps } from '/imports/api/engine/computation/build
import { assert } from 'chai';
import clean from '../../utility/cleanProp.testFn.js';
export default function(){
export default function () {
const computation = buildComputationFromProps(testProperties);
const totalFilled = computation.propsById['slotId'].totalFilled;
assert.equal(totalFilled, 4);
@@ -13,24 +13,24 @@ var testProperties = [
clean({
_id: 'slotId',
type: 'propertySlot',
ancestors: [{id: 'charId'}],
ancestors: [{ id: 'charId' }],
}),
// Children
clean({
_id: 'slotFillerId',
type: 'slotFiller',
type: 'folder',
slotQuantityFilled: 3,
slotFillerType: 'item',
ancestors: [{id: 'charId'}, {id: 'slotId'}],
ancestors: [{ id: 'charId' }, { id: 'slotId' }],
}),
clean({
_id: 'slotChildId',
type: 'item',
ancestors: [{id: 'charId'}, {id: 'slotId'}],
ancestors: [{ id: 'charId' }, { id: 'slotId' }],
}),
clean({
_id: 'slotGrandchildId',
type: 'effect',
ancestors: [{id: 'charId'}, {id: 'slotId'}, {id: 'slotChildId'}],
ancestors: [{ id: 'charId' }, { id: 'slotId' }, { id: 'slotChildId' }],
}),
];

View File

@@ -29,7 +29,7 @@ import removeSchemaFields from './buildComputation/removeSchemaFields.js';
* computed toggles
*/
export default function buildCreatureComputation(creatureId){
export default function buildCreatureComputation(creatureId) {
const creature = getCreature(creatureId);
const variables = getVariables(creatureId);
const properties = getProperties(creatureId);
@@ -37,7 +37,7 @@ export default function buildCreatureComputation(creatureId){
return computation;
}
export function buildComputationFromProps(properties, creature, variables){
export function buildComputationFromProps(properties, creature, variables) {
const computation = new CreatureComputation(properties, creature, variables);
// Dependency graph where edge(a, b) means a depends on b
@@ -49,14 +49,14 @@ export function buildComputationFromProps(properties, creature, variables){
const dependencyGraph = computation.dependencyGraph;
// Link the denormalizedStats from the creature
if (creature && creature.denormalizedStats){
if (creature.denormalizedStats.xp){
if (creature && creature.denormalizedStats) {
if (creature.denormalizedStats.xp) {
dependencyGraph.addNode('xp', {
baseValue: creature.denormalizedStats.xp,
type: '_variable'
});
}
if (creature.denormalizedStats.milestoneLevels){
if (creature.denormalizedStats.milestoneLevels) {
dependencyGraph.addNode('milestoneLevels', {
baseValue: creature.denormalizedStats.milestoneLevels,
type: '_variable'
@@ -93,7 +93,7 @@ export function buildComputationFromProps(properties, creature, variables){
// Inactive status must be complete for the whole tree before toggle deps
// are calculated
walkDown(forest, node => {
computeToggleDependencies(node, dependencyGraph);
computeToggleDependencies(node, dependencyGraph, computation, forest);
computeSlotQuantityFilled(node, dependencyGraph);
});

View File

@@ -6,6 +6,7 @@ import pointBuy from './computeByType/computePointBuy.js';
import propertySlot from './computeByType/computeSlot.js';
import container from './computeByType/computeContainer.js';
import spellList from './computeByType/computeSpellList.js';
import toggle from './computeByType/computeToggle.js';
import _calculation from './computeByType/computeCalculation.js';
export default Object.freeze({
@@ -19,4 +20,5 @@ export default Object.freeze({
propertySlot,
spell: action,
spellList,
toggle,
});

View File

@@ -1,12 +1,17 @@
import evaluateCalculation from '../../utility/evaluateCalculation.js';
import { getPropertyName } from '/imports/constants/PROPERTIES.js';
export default function computeCalculation(computation, node){
export default function computeCalculation(computation, node) {
const calcObj = node.data;
evaluateCalculation(calcObj, computation.scope);
if (calcObj.effects || calcObj.proficiencies) {
calcObj.baseValue = calcObj.value;
}
aggregateCalculationEffects(node, computation);
aggregateCalculationProficiencies(node, computation);
}
export function aggregateCalculationEffects(node, computation){
function aggregateCalculationEffects(node, computation) {
const calcObj = node.data;
delete calcObj.effects;
computation.dependencyGraph.forEachLinkedNode(
@@ -34,15 +39,77 @@ export function aggregateCalculationEffects(node, computation){
},
true // enumerate only outbound links
);
if (calcObj.effects && typeof calcObj.value === 'number'){
calcObj.baseValue = calcObj.value;
if (calcObj.effects && typeof calcObj.value === 'number') {
calcObj.effects.forEach(effect => {
if (
effect.operation === 'add' &&
effect.amount && typeof effect.amount.value === 'number'
){
) {
calcObj.value += effect.amount.value
}
});
}
}
function aggregateCalculationProficiencies(node, computation) {
const calcObj = node.data;
delete calcObj.proficiencies;
delete calcObj.proficiency;
let profBonus = computation.scope['proficiencyBonus']?.value || 0;
// Go through all the links and collect them on the calculation
computation.dependencyGraph.forEachLinkedNode(
node.id,
(linkedNode, link) => {
// Only proficiency links
if (link.data !== 'proficiency') return;
// That have data
if (!linkedNode.data) return;
// Ignoring inactive props
if (linkedNode.data.inactive) return;
// Compute the proficiency and value
let proficiency, value;
if (linkedNode.data.type === 'proficiency') {
proficiency = linkedNode.data.value || 0;
// Multiply the proficiency bonus by the actual proficiency
if (proficiency === 0.49) {
// Round down proficiency bonus in the special case
value = Math.floor(profBonus * 0.5);
} else {
value = Math.ceil(profBonus * proficiency);
}
} else if (linkedNode.data.type === 'skill') {
value = linkedNode.data.value || 0;
proficiency = linkedNode.data.proficiency || 0;
}
// Collate proficiencies
calcObj.proficiencies = calcObj.proficiencies || [];
calcObj.proficiencies.push({
_id: linkedNode.data._id,
name: linkedNode.data.name,
type: linkedNode.data.type,
proficiency,
value,
});
},
true // enumerate only outbound links
);
// Apply the highest proficiency, marking all others as overridden
if (calcObj.proficiencies && typeof calcObj.value === 'number') {
calcObj.proficiency = 0;
calcObj.proficiencyBonus = 0;
let currentProf;
calcObj.proficiencies.forEach(prof => {
if (prof.value > calcObj.proficiencyBonus) {
if (currentProf) currentProf.overridden = true;
calcObj.proficiencyBonus = prof.value;
calcObj.proficiency = prof.proficiency;
currentProf = prof;
} else {
prof.overridden = true;
}
});
calcObj.value += calcObj.proficiencyBonus;
}
}

View File

@@ -1,6 +1,6 @@
export default function computSlot(computation, node){
export default function computeSlot(computation, node) {
const prop = node.data;
if (prop.quantityExpected && prop.quantityExpected.value){
if (prop.quantityExpected && prop.quantityExpected.value) {
prop.spaceLeft = prop.quantityExpected.value - prop.totalFilled;
}
}

View File

@@ -0,0 +1,7 @@
export default function computeToggle(computation, node) {
const prop = node.data;
if (!prop.enabled && !prop.disabled && prop.condition && !prop.condition.value) {
prop.inactive = true;
prop.deactivatedBySelf = true;
}
}

View File

@@ -1,16 +1,40 @@
import getAggregatorResult from './getAggregatorResult.js';
export default function computeVariableAsAttribute(computation, node, prop){
export default function computeVariableAsAttribute(computation, node, prop) {
let result = getAggregatorResult(node) || 0;
prop.total = result;
// Apply damage in a way that respects the damage rules, modifying damage if need be
// Bound the damage
if (!prop.ignoreLowerLimit && prop.damage > prop.total) {
console.log(`reducing damage from ${prop.damage} to ${prop.total}`);
prop.damage = prop.total;
}
if (!prop.ignoreUpperLimit && prop.damage < 0) {
console.log(`increasing damage from ${prop.damage} to 0`);
prop.damage = 0;
}
// Apply damage
prop.value = prop.total - (prop.damage || 0);
// Proficiency
prop.proficiency = node.data.proficiency;
// Advantage/disadvantage
const aggregator = node.data.effectAggregator;
if (aggregator) {
if (aggregator.advantage && !aggregator.disadvantage) {
prop.advantage = 1;
} else if (aggregator.disadvantage && !aggregator.advantage) {
prop.advantage = -1;
} else {
prop.advantage = 0;
}
}
// Ability scores get modifiers
if (prop.attributeType === 'ability'){
if (prop.attributeType === 'ability') {
prop.modifier = Math.floor((prop.value - 10) / 2);
}

View File

@@ -1,12 +1,12 @@
import aggregate from './aggregate/index.js';
export default function computeVariableAsSkill(computation, node, prop){
export default function computeVariableAsSkill(computation, node, prop) {
// Skills are based on some ability Modifier
let ability = computation.scope[prop.ability];
prop.abilityMod = ability?.modifier || 0;
// Inherit the ability's skill effects and proficiencies if skill is not a save
if (prop.skillType !== 'save' && ability){
if (prop.skillType !== 'save' && ability) {
aggregateAbilityEffects({
computation,
skillNode: node,
@@ -21,7 +21,7 @@ export default function computeVariableAsSkill(computation, node, prop){
let profBonus = computation.scope['proficiencyBonus']?.value || 0;
// Multiply the proficiency bonus by the actual proficiency
if(prop.proficiency === 0.49){
if (prop.proficiency === 0.49) {
// Round down proficiency bonus in the special case
profBonus = Math.floor(profBonus * 0.5);
} else {
@@ -37,7 +37,7 @@ export default function computeVariableAsSkill(computation, node, prop){
prop.effects = node.data.effects;
// If there is no aggregator, determine if the prop can hide, then exit
if (!aggregator){
if (!aggregator) {
prop.hide = statBase === undefined &&
prop.proficiency == 0 ||
undefined;
@@ -52,20 +52,32 @@ export default function computeVariableAsSkill(computation, node, prop){
if (aggregator.set !== undefined) {
result = aggregator.set;
}
if (Number.isFinite(result)){
if (Number.isFinite(result)) {
result = Math.floor(result);
}
prop.value = result;
// Advantage/disadvantage
if (aggregator.advantage && !aggregator.disadvantage){
if (aggregator.advantage && !aggregator.disadvantage) {
prop.advantage = 1;
} else if (aggregator.disadvantage && !aggregator.advantage){
} else if (aggregator.disadvantage && !aggregator.advantage) {
prop.advantage = -1;
} else {
prop.advantage = 0;
}
// Passive bonus
prop.passiveBonus = aggregator.passiveAdd;
// +/- 5 to passive bonus if the skill has advantage/disadvantage
if (
prop.advantage === 1
&& Number.isFinite(prop.passiveBonus)
) {
prop.passiveBonus += 5;
} else if (
prop.advantage === -1
&& Number.isFinite(prop.passiveBonus)
) {
prop.bassiveBonus -= 5;
}
// conditional benefits
prop.conditionalBenefits = aggregator.conditional;
// Roll bonuses
@@ -76,7 +88,8 @@ export default function computeVariableAsSkill(computation, node, prop){
prop.rollBonuses = aggregator.rollBonus;
}
function aggregateAbilityEffects({computation, skillNode, abilityNode}){
function aggregateAbilityEffects({ computation, skillNode, abilityNode }) {
if (!abilityNode?.id) return;
computation.dependencyGraph.forEachLinkedNode(
abilityNode.id,
(linkedNode, link) => {
@@ -85,15 +98,15 @@ function aggregateAbilityEffects({computation, skillNode, abilityNode}){
if (linkedNode.data.inactive) return;
// Check that the link is a valid effect/proficiency to pass on
// to a skill from its ability
if (link.data === 'effect'){
if (link.data === 'effect') {
if (![
'advantage', 'disadvantage', 'passiveAdd', 'fail', 'conditional'
].includes(linkedNode.data.operation)){
].includes(linkedNode.data.operation)) {
return;
}
}
// Apply the aggregations
let arg = {node: skillNode, linkedNode, link};
let arg = { node: skillNode, linkedNode, link };
aggregate.effect(arg);
aggregate.proficiency(arg);
},

View File

@@ -1,13 +1,16 @@
export default function evaluateToggles(computation, node){
export default function evaluateToggles(computation, node) {
let prop = node.data;
if (!prop) return;
let toggles = prop._computationDetails?.toggleAncestors;
if (!toggles) return;
toggles.forEach(toggle => {
if (!toggle.condition) return;
if (!toggle.condition.value){
if (
(!toggle.enabled && !toggle.disabled && toggle.condition && !toggle.condition.value)
|| (toggle.disabled)
) {
prop.inactive = true;
prop.deactivatedByToggle = true;
prop.deactivatingToggleId = toggle._id;
}
});
}

View File

@@ -0,0 +1,64 @@
import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
import { assert } from 'chai';
import computeCreatureComputation from '../../computeCreatureComputation.js';
import clean from '../../utility/cleanProp.testFn.js';
export default function () {
const computation = buildComputationFromProps(testProperties);
computeCreatureComputation(computation);
const prop = id => computation.propsById[id];
assert.equal(
prop('strengthId').value, 8,
'The proficiency bonus should not change the strength score'
);
assert.equal(
prop('strengthId').modifier, -1,
'The proficiency bonus should not change the strength modifier'
);
assert.exists(prop('actionId').attackRoll.proficiencies, 'The proficiency aggregator should be here')
assert.exists(prop('actionId').attackRoll.proficiencies[0], 'The proficiency should be here')
// attack roll = strength.mod + proficiencyBonus/2 rounded down
// = -1 + 13/2 = -1 + 6 = 5
assert.equal(
prop('actionId').attackRoll.value, 5,
'The proficiency should apply correctly to modify the attack roll'
);
}
var testProperties = [
clean({
_id: 'strengthId',
variableName: 'strength',
type: 'attribute',
attributeType: 'ability',
baseValue: {
calculation: '8'
},
}),
clean({
_id: 'actionId',
type: 'action',
ancestors: [{ id: 'charId' }],
attackRoll: {
calculation: 'strength.modifier',
},
tags: ['rapier', 'martial weapon', 'weapon', 'attack']
}),
clean({
_id: 'profBonusId',
type: 'attribute',
variableName: 'proficiencyBonus',
ancestors: [{ id: 'charId' }],
baseValue: {
calculation: '13'
},
}),
clean({
_id: 'tagTargetedProficiency',
type: 'proficiency',
stats: ['strength'], // Should be ignored, we are targeting by tags
value: 0.49,
targetByTags: true,
targetTags: ['martial weapon']
}),
];

View File

@@ -6,29 +6,33 @@ import computeInventory from './computeInventory.testFn.js';
import computeDamageMultipliers from './computeDamageMultipliers.testFn.js';
import computeEffects from './computeEffects.testFn.js';
import computeSkills from './computeSkills.testFn.js';
import computeProficiencies from './computeProficiencies.testFn.js';
export default [{
text: 'Computes actions',
fn: computeAction,
},{
}, {
text: 'Computes attributes',
fn: computeAttribute,
},{
}, {
text: 'Computes classes',
fn: computeClasses,
},{
}, {
text: 'Computes constants',
fn: computeConstants,
},{
}, {
text: 'Computes inventory',
fn: computeInventory,
},{
}, {
text: 'Computes damage multipliers',
fn: computeDamageMultipliers,
},{
}, {
text: 'Computes effects',
fn: computeEffects,
},{
}, {
text: 'Computes skills',
fn: computeSkills,
}, {
text: 'Computes proficiencies',
fn: computeProficiencies,
}];

View File

@@ -3,12 +3,12 @@ import { EJSON } from 'meteor/ejson';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import propertySchemasIndex from '/imports/api/properties/computedOnlyPropertySchemasIndex.js';
export default function writeAlteredProperties(computation){
export default function writeAlteredProperties(computation) {
let bulkWriteOperations = [];
// Loop through all properties on the memo
computation.props.forEach(changed => {
let schema = propertySchemasIndex[changed.type];
if (!schema){
if (!schema) {
console.warn('No schema for ' + changed.type);
return;
}
@@ -20,12 +20,13 @@ export default function writeAlteredProperties(computation){
'deactivatedBySelf',
'deactivatedByAncestor',
'deactivatedByToggle',
'deactivatingToggleId',
'damage',
'dirty',
...schema.objectKeys(),
];
op = addChangedKeysToOp(op, keys, original, changed);
if (op){
if (op) {
bulkWriteOperations.push(op);
}
});
@@ -37,10 +38,10 @@ function addChangedKeysToOp(op, keys, original, changed) {
// Loop through all keys that can be changed by computation
// and compile an operation that sets all those keys
for (let key of keys) {
if (!EJSON.equals(original[key], changed[key])){
if (!EJSON.equals(original[key], changed[key])) {
if (!op) op = newOperation(original._id, changed.type);
let value = changed[key];
if (value === undefined){
if (value === undefined) {
// Unset values that become undefined
addUnsetOp(op, key);
} else {
@@ -52,32 +53,32 @@ function addChangedKeysToOp(op, keys, original, changed) {
return op;
}
function newOperation(_id, type){
function newOperation(_id, type) {
let newOp = {
updateOne: {
filter: {_id},
filter: { _id },
update: {},
}
};
if (Meteor.isClient){
if (Meteor.isClient) {
newOp.type = type;
}
return newOp;
}
function addSetOp(op, key, value){
if (op.updateOne.update.$set){
function addSetOp(op, key, value) {
if (op.updateOne.update.$set) {
op.updateOne.update.$set[key] = value;
} else {
op.updateOne.update.$set = {[key]: value};
op.updateOne.update.$set = { [key]: value };
}
}
function addUnsetOp(op, key){
if (op.updateOne.update.$unset){
function addUnsetOp(op, key) {
if (op.updateOne.update.$unset) {
op.updateOne.update.$unset[key] = 1;
} else {
op.updateOne.update.$unset = {[key]: 1};
op.updateOne.update.$unset = { [key]: 1 };
}
}
@@ -100,14 +101,14 @@ function writePropertiesSequentially(bulkWriteOps) {
// in the UI because of incompatibility with latency compensation. If the
// duplicate redraws can be fixed, this is a strictly better way of processing
// writes
function bulkWriteProperties(bulkWriteOps){
function bulkWriteProperties(bulkWriteOps) {
if (!bulkWriteOps.length) return;
// bulkWrite is only available on the server
if (Meteor.isServer) {
CreatureProperties.rawCollection().bulkWrite(
bulkWriteOps,
{ordered : false},
function(e){
{ ordered: false },
function (e) {
if (e) {
console.error('Bulk write failed: ');
console.error(e);

View File

@@ -15,18 +15,21 @@ export default function writeScope(creatureId, computation) {
let $set, $unset;
for (const key in scope){
for (const key in scope) {
// Mongo can't handle keys that start with a dollar sign
if (key[0] === '$' || key[0] === '_') continue;
// Remove large properties that aren't likely to be accessed
delete scope[key].parent;
delete scope[key].ancestors;
// Remove empty keys
for (const subKey in scope[key]) {
if (scope[key][subKey] === undefined) {
delete scope[key][subKey]
}
}
// Only update changed fields
if (!EJSON.equals(variables[key], scope[key])) {
if (!$set) $set = {};
@@ -53,9 +56,19 @@ export default function writeScope(creatureId, computation) {
const update = {};
if ($set) update.$set = $set;
if ($unset) update.$unset = $unset;
CreatureVariables.update({_creatureId: creatureId}, update);
CreatureVariables.update({ _creatureId: creatureId }, update);
}
if (computation.creature?.dirty) {
Creatures.update({_id: creatureId}, {$unset: { dirty: 1 }});
Creatures.update({ _id: creatureId }, { $unset: { dirty: 1 } });
}
}
/*
function calculateSize(computation) {
const sizeEstimator = {
creature: computation.creature,
variables: computation.variables,
props: computation.originalPropsById,
};
return MongoInternals.NpmModule.BSON.calculateObjectSize(sizeEstimator, { checkKeys: false })
}
*/

View File

@@ -4,7 +4,7 @@ import writeAlteredProperties from './computation/writeComputation/writeAlteredP
import writeScope from './computation/writeComputation/writeScope.js';
import writeErrors from './computation/writeComputation/writeErrors.js';
export default function computeCreature(creatureId){
export default function computeCreature(creatureId) {
if (Meteor.isClient) return;
// console.log('compute ' + creatureId);
const computation = buildCreatureComputation(creatureId);
@@ -16,7 +16,7 @@ function computeComputation(computation, creatureId) {
computeCreatureComputation(computation);
writeAlteredProperties(computation);
writeScope(creatureId, computation);
} catch (e){
} catch (e) {
const errorText = e.reason || e.message || e.toString();
computation.errors.push({
type: 'crash',
@@ -32,6 +32,19 @@ function computeComputation(computation, creatureId) {
console.error(logError);
throw e;
} finally {
checkPropertyCount(computation)
writeErrors(creatureId, computation.errors);
}
}
const MAX_PROPS = 1000;
function checkPropertyCount(computation) {
const count = computation.props.length;
if (count <= MAX_PROPS) return;
computation.errors.push({
type: 'warning',
details: {
error: `This character sheet has too many properties and may perform poorly ( ${count} / ${MAX_PROPS} )`
},
});
}

View File

@@ -29,6 +29,16 @@ let LibrarySchema = new SimpleSchema({
optional: true,
max: STORAGE_LIMITS.summary,
},
showInMarket: {
index: 1,
type: Boolean,
optional: true,
},
subscriberCount: {
index: 1,
type: Number,
optional: true,
},
});
LibrarySchema.extend(SharingSchema);
@@ -104,6 +114,29 @@ const updateLibraryDescription = new ValidatedMethod({
},
});
const updateLibraryShowInMarket = new ValidatedMethod({
name: 'libraries.updateShowInMarket',
validate: new SimpleSchema({
_id: {
type: String,
regEx: SimpleSchema.RegEx.id
},
value: {
type: Boolean,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({ _id, value }) {
let library = Libraries.findOne(_id);
assertEditPermission(library, this.userId);
Libraries.update(_id, { $set: { showInMarket: value } });
},
});
const removeLibrary = new ValidatedMethod({
name: 'libraries.remove',
validate: new SimpleSchema({
@@ -130,4 +163,4 @@ export function removeLibaryWork(libraryId) {
LibraryNodes.remove({ 'ancestors.id': libraryId });
}
export { LibrarySchema, insertLibrary, updateLibraryName, updateLibraryDescription, removeLibrary };
export { LibrarySchema, insertLibrary, updateLibraryName, updateLibraryDescription, updateLibraryShowInMarket, removeLibrary };

View File

@@ -32,6 +32,16 @@ const LibraryCollectionSchema = new SimpleSchema({
type: String,
regEx: SimpleSchema.RegEx.Id,
},
showInMarket: {
index: 1,
type: Boolean,
optional: true,
},
subscriberCount: {
index: 1,
type: Number,
optional: true,
},
});
LibraryCollectionSchema.extend(SharingSchema);
@@ -48,12 +58,12 @@ const insertLibraryCollection = new ValidatedMethod({
run(libraryCollection) {
if (!this.userId) {
throw new Meteor.Error('LibraryCollections.methods.insert.denied',
'You need to be logged in to insert a library');
'You need to be logged in to insert a library');
}
let tier = getUserTier(this.userId);
if (!tier.paidBenefits){
if (!tier.paidBenefits) {
throw new Meteor.Error('LibraryCollections.methods.insert.denied',
`The ${tier.name} tier does not allow you to insert a library collection`);
`The ${tier.name} tier does not allow you to insert a library collection`);
}
libraryCollection.owner = this.userId;
return LibraryCollections.insert(libraryCollection);
@@ -72,7 +82,7 @@ const updateLibraryCollection = new ValidatedMethod({
},
update: {
type: LibraryCollectionSchema
.pick('name', 'description', 'libraries')
.pick('name', 'description', 'libraries', 'showInMarket')
.extend({ //make libraries optional
libraries: {
optional: true,
@@ -85,7 +95,7 @@ const updateLibraryCollection = new ValidatedMethod({
numRequests: 5,
timeInterval: 5000,
},
run({_id, update}){
run({ _id, update }) {
const libraryCollection = LibraryCollections.findOne(_id, {
fields: {
owner: 1,
@@ -93,7 +103,7 @@ const updateLibraryCollection = new ValidatedMethod({
}
});
assertEditPermission(libraryCollection, this.userId);
return LibraryCollections.update(_id, {$set: update});
return LibraryCollections.update(_id, { $set: update });
},
});
@@ -110,7 +120,7 @@ const removeLibraryCollection = new ValidatedMethod({
numRequests: 5,
timeInterval: 5000,
},
run({_id}){
run({ _id }) {
const libraryCollection = LibraryCollections.findOne(_id, {
fields: {
owner: 1,

View File

@@ -4,7 +4,7 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import SimpleSchema from 'simpl-schema';
import ColorSchema from '/imports/api/properties/subSchemas/ColorSchema.js';
import ChildSchema from '/imports/api/parenting/ChildSchema.js';
import ChildSchema, { RefSchema } from '/imports/api/parenting/ChildSchema.js';
import propertySchemasIndex from '/imports/api/properties/propertySchemasIndex.js';
import Libraries from '/imports/api/library/Libraries.js';
import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js';
@@ -15,6 +15,8 @@ import '/imports/api/library/methods/index.js';
import { updateReferenceNodeWork } from '/imports/api/library/methods/updateReferenceNode.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
import { restore } from '/imports/api/parenting/softRemove.js';
import { getAncestry } from '/imports/api/parenting/parenting.js';
import { reorderDocs } from '/imports/api/parenting/order.js';
let LibraryNodes = new Mongo.Collection('libraryNodes');
@@ -36,20 +38,66 @@ let LibraryNodeSchema = new SimpleSchema({
type: String,
max: STORAGE_LIMITS.tagLength,
},
icon: {
type: storedIconsSchema,
optional: true,
max: STORAGE_LIMITS.icon,
},
// Library-specific properties, these can be stripped from the resulting
// creature properties
// Will this property show up in the slot-fill dialog
fillSlots: {
type: Boolean,
optional: true,
index: 1,
},
// Will this property show up in the insert-from-library dialog
searchable: {
type: Boolean,
optional: true,
index: 1,
},
libraryTags: {
type: Array,
defaultValue: [],
optional: true,
maxCount: STORAGE_LIMITS.tagCount,
},
'libraryTags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
icon: {
type: storedIconsSchema,
// Overrides the type when searching for properties
slotFillerType: {
type: String,
optional: true,
max: STORAGE_LIMITS.icon,
}
max: STORAGE_LIMITS.variableName,
},
// Image to display when filling the slot
slotFillImage: {
type: String,
optional: true,
max: STORAGE_LIMITS.url,
},
// Fill more than one quantity in a slot, like feats and ability score
// improvements, filtered out of UI if there isn't space in quantityExpected
slotQuantityFilled: {
type: SimpleSchema.Integer,
optional: true, // Undefined implies 1
},
// Filters out of UI if condition isn't met, but isn't otherwise enforced
slotFillerCondition: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
// Text to display if slot filler condition fails
slotFillerConditionNote: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
});
// Set up server side search index
@@ -86,20 +134,56 @@ function assertNodeEditPermission(node, userId) {
const insertNode = new ValidatedMethod({
name: 'libraryNodes.insert',
validate: null,
validate: new SimpleSchema({
libraryNode: {
type: Object,
blackbox: true,
},
parentRef: RefSchema,
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run(libraryNode) {
run({ libraryNode, parentRef }) {
// get the new ancestry
let { parentDoc, ancestors } = getAncestry({ parentRef });
// Check permission to edit
let root;
if (parentRef.collection === 'libraries') {
root = parentDoc;
} else if (parentRef.collection === 'libraryNodes') {
root = Libraries.findOne(parentDoc.ancestors[0].id);
} else {
throw `${parentRef.collection} is not a valid parent collection`
}
assertEditPermission(root, this.userId);
// Set the ancestry of the library node
libraryNode.parent = parentRef;
libraryNode.ancestors = ancestors;
// Remove its ID if it came with one to force a random one to be generated
// server-side
delete libraryNode._id;
assertNodeEditPermission(libraryNode, this.userId);
let nodeId = LibraryNodes.insert(libraryNode);
// Insert the node
const nodeId = LibraryNodes.insert(libraryNode);
// Update the node if it was a reference node
if (libraryNode.type == 'reference') {
libraryNode._id = nodeId;
updateReferenceNodeWork(libraryNode, this.userId);
}
// Tree structure changed by insert, reorder the tree
reorderDocs({
collection: LibraryNodes,
ancestorId: root._id,
});
// Return the id of the inserted node
return nodeId;
},
});
@@ -119,7 +203,7 @@ const updateLibraryNode = new ValidatedMethod({
},
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
numRequests: 15,
timeInterval: 5000,
},
run({ _id, path, value }) {

View File

@@ -4,6 +4,7 @@ import getUserLibraryIds from './getUserLibraryIds';
import { intersection, union } from 'lodash';
export default function getCreatureLibraryIds(creature, userId) {
if (!userId) console.log('no userId, returning empty array');
if (!userId) return [];
// Get the ids of libraries the user is permitted to view
@@ -17,14 +18,14 @@ export default function getCreatureLibraryIds(creature, userId) {
allowedLibraryCollections: 1,
}
});
if (!creature) return [];
if (!creature) return userLibIds;
}
// If the creature does not restrict the libraries, let it use them all
if (!creature.allowedLibraryCollections && !creature.allowedLibraries) {
return userLibIds;
}
// Get the ids of the libraries that the creature allows
const allowedCollections = creature.allowedLibraryCollections || [];
let creatureLibIds = creature.allowedLibraries || [];

View File

@@ -0,0 +1,18 @@
export default function getDefaultSlotFiller(slot) {
if (typeof slot !== 'object') throw 'getDefaultSlotFiller requires a slot';
if (slot.type !== 'propertySlot') throw 'provided slot must be a propertySlot';
let slotType = slot.slotType;
if (!slotType || slot.slotType === 'slotFiller') {
slotType = 'folder';
}
const filler = {
type: slotType,
libraryTags: slot.slotTags || [],
name: 'Custom ' + slot.name || 'slot filler',
parent: { collection: 'creatureProperties', id: slot._id },
ancestors: [...slot.ancestors, { collection: 'creatureProperties', id: slot._id }],
};
return filler;
}

View File

@@ -21,7 +21,7 @@ const updateReferenceNode = new ValidatedMethod({
numRequests: 5,
timeInterval: 5000,
},
run({_id}) {
run({ _id }) {
let userId = this.userId;
let node = LibraryNodes.findOne(_id);
assertDocEditPermission(node, userId);
@@ -29,15 +29,15 @@ const updateReferenceNode = new ValidatedMethod({
},
});
function writeCache(_id, cache){
LibraryNodes.update(_id, {$set: {cache}}, {
selector: {type: 'reference'},
function writeCache(_id, cache) {
LibraryNodes.update(_id, { $set: { cache } }, {
selector: { type: 'reference' },
});
}
function updateReferenceNodeWork(node, userId){
function updateReferenceNodeWork(node, userId) {
let cache = {}
if (!node.ref){
if (!node.ref?.collection || !node.ref?.id) {
writeCache(node._id, cache);
return;
}
@@ -45,20 +45,23 @@ function updateReferenceNodeWork(node, userId){
try {
doc = fetchDocByRef(node.ref);
if (doc.removed) throw 'Property has been deleted';
if (doc.ancestors[0].id !== node.ancestors[0].id){
if (doc.ancestors[0].id !== node.ancestors[0].id) {
library = fetchDocByRef(doc.ancestors[0]);
assertViewPermission(library, userId)
}
} catch(e){
cache = {error: e.reason || e.message || e.toString()}
} catch (e) {
cache = { error: e.reason || e.message || e.toString() }
writeCache(node._id, cache);
return;
}
cache = {
node: doc,
};
if (library){
cache.library = {name: library.name};
if (library) {
cache.library = {
id: library._id,
name: library.name,
};
}
writeCache(node._id, cache);
}

View File

@@ -26,6 +26,7 @@ export function nodeArrayToTree(nodes) {
forest.push(treeNode);
}
});
forest.nodeIndex = nodeIndex;
return forest;
}

View File

@@ -23,13 +23,20 @@ const organizeDoc = new ValidatedMethod({
type: Boolean,
optional: true,
},
skipClient: {
type: Boolean,
optional: true,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({docRef, parentRef, order, skipRecompute}) {
run({ docRef, parentRef, order, skipRecompute, skipClient }) {
if (skipClient && this.isSimulation) {
return;
}
let doc = fetchDocByRef(docRef);
let collection = getCollectionByName(docRef.collection);
// The user must be able to edit both the doc and its parent to move it
@@ -39,23 +46,23 @@ const organizeDoc = new ValidatedMethod({
assertDocEditPermission(parent, this.userId);
// Change the doc's parent
updateParent({docRef, parentRef});
updateParent({ docRef, parentRef });
// Change the doc's order to be a half step ahead of its target location
collection.update(doc._id, {$set: {order}}, {selector: {type: 'any'}});
collection.update(doc._id, { $set: { order } }, { selector: { type: 'any' } });
// Reorder both ancestors' documents
let oldAncestorId = doc.ancestors[0].id;
reorderDocs({collection, ancestorId: oldAncestorId});
reorderDocs({ collection, ancestorId: oldAncestorId });
let newAncestorId = getRootId(parent);
if (newAncestorId !== oldAncestorId){
reorderDocs({collection, ancestorId: newAncestorId});
if (newAncestorId !== oldAncestorId) {
reorderDocs({ collection, ancestorId: newAncestorId });
}
// Figure out which creatures need to be recalculated after this move
let docCreatures = getCreatureAncestors(doc);
let parentCreatures = getCreatureAncestors(parent);
if (!skipRecompute){
if (!skipRecompute) {
let creaturesToRecompute = union(docCreatures, parentCreatures);
// Mark the creatures for recompute
Creatures.update({
@@ -81,10 +88,10 @@ const reorderDoc = new ValidatedMethod({
numRequests: 5,
timeInterval: 5000,
},
run({docRef, order}) {
run({ docRef, order }) {
let doc = fetchDocByRef(docRef);
assertDocEditPermission(doc, this.userId);
safeUpdateDocOrder({docRef, order});
safeUpdateDocOrder({ docRef, order });
// Recompute the affected creatures
const ancestors = getCreatureAncestors(doc);
if (ancestors.length) {
@@ -97,22 +104,22 @@ const reorderDoc = new ValidatedMethod({
},
});
function getRootId(doc){
if (doc.ancestors && doc.ancestors.length && doc.ancestors[0]){
function getRootId(doc) {
if (doc.ancestors && doc.ancestors.length && doc.ancestors[0]) {
return doc.ancestors[0].id;
} else {
return doc._id;
}
}
function getCreatureAncestors(doc){
function getCreatureAncestors(doc) {
let ids = [];
if(doc.type === 'pc' || doc.type === 'npc' || doc.type === 'monster'){
if (doc.type === 'pc' || doc.type === 'npc' || doc.type === 'monster') {
ids.push(doc._id);
}
if (doc.ancestors){
if (doc.ancestors) {
doc.ancestors.forEach(ancestorRef => {
if (ancestorRef.collection === 'creatures'){
if (ancestorRef.collection === 'creatures') {
ids.push(ancestorRef.id);
}
});

View File

@@ -50,7 +50,6 @@ let ActionSchema = createPropertySchema({
attackRoll: {
type: 'fieldToCompute',
optional: true,
defaultValue: 'strength.modifier + proficiencyBonus',
},
// Calculation of how many times this action can be used
uses: {

View File

@@ -173,6 +173,13 @@ let ComputedOnlyAttributeSchema = createPropertySchema({
optional: true,
removeBeforeCompute: true,
},
// Attributes with advantage grant it to all skills based on the attribute
advantage: {
type: SimpleSchema.Integer,
optional: true,
allowedValues: [-1, 0, 1],
removeBeforeCompute: true,
},
// The computed creature constitution modifier for hit dice
constitutionMod: {
type: Number,

View File

@@ -26,12 +26,6 @@ const ClassLevelSchema = createPropertySchema({
defaultValue: 1,
max: STORAGE_LIMITS.levelMax,
},
// Filters out of UI if condition isn't met, but isn't otherwise enforced
slotFillerCondition: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
});
const ComputedOnlyClassLevelSchema = createPropertySchema({

View File

@@ -1,6 +1,7 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
import TagTargetingSchema from '/imports/api/properties/subSchemas/TagTargetingSchema.js';
/*
* Effects are reason-value attached to skills and abilities
@@ -50,57 +51,7 @@ let EffectSchema = createPropertySchema({
type: String,
max: STORAGE_LIMITS.variableName,
},
// True when targeting by tags instead of stats
targetByTags: {
type: Boolean,
optional: true,
},
// If targeting by tags, the field which will be targeted
targetField: {
type: String,
optional: true,
max: STORAGE_LIMITS.variableName,
},
// Which tags the effect is applied to
targetTags: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.tagCount,
},
'targetTags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
extraTags: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.extraTagsCount,
},
'extraTags.$': {
type: Object,
},
'extraTags.$._id': {
type: String,
regEx: SimpleSchema.RegEx.Id,
autoValue(){
if (!this.isSet) return Random.id();
}
},
'extraTags.$.operation': {
type: String,
allowedValues: ['OR', 'NOT'],
defaultValue: 'OR',
},
'extraTags.$.tags': {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.tagCount,
},
'extraTags.$.tags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
});
}).extend(TagTargetingSchema);
const ComputedOnlyEffectSchema = createPropertySchema({
amount: {

View File

@@ -1,14 +1,19 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
// Folders organize a character sheet into a tree, particularly to group things
// like 'race' and 'background'
let FolderSchema = new createPropertySchema({
let FolderSchema = createPropertySchema({
name: {
type: String,
max: STORAGE_LIMITS.name,
optional: true,
},
description: {
type: 'inlineCalculationFieldToCompute',
optional: true,
},
groupStats: {
type: Boolean,
optional: true,
@@ -33,6 +38,15 @@ let FolderSchema = new createPropertySchema({
},
});
const ComputedOnlyFolderSchema = new createPropertySchema({});
const ComputedOnlyFolderSchema = createPropertySchema({
description: {
type: 'computedOnlyInlineCalculationField',
optional: true,
},
});
export { FolderSchema, ComputedOnlyFolderSchema };
const ComputedFolderSchema = new SimpleSchema()
.extend(FolderSchema)
.extend(ComputedOnlyFolderSchema);
export { FolderSchema, ComputedFolderSchema, ComputedOnlyFolderSchema };

View File

@@ -1,5 +1,6 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
import TagTargetingSchema from '/imports/api/properties/subSchemas/TagTargetingSchema.js';
let ProficiencySchema = new SimpleSchema({
name: {
@@ -24,7 +25,7 @@ let ProficiencySchema = new SimpleSchema({
allowedValues: [0.49, 0.5, 1, 2],
defaultValue: 1,
},
});
}).extend(TagTargetingSchema);
const ComputedOnlyProficiencySchema = new SimpleSchema({});

View File

@@ -51,6 +51,10 @@ let ReferenceSchema = new SimpleSchema({
type: Object,
optional: true,
},
'cache.library.id': {
type: String,
optional: true,
},
'cache.library.name': {
type: String,
optional: true,

View File

@@ -2,6 +2,7 @@ import SimpleSchema from 'simpl-schema';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
import TagTargetingSchema from '/imports/api/properties/subSchemas/TagTargetingSchema.js';
/*
* Skills are anything that results in a modifier to be added to a D20
@@ -59,7 +60,8 @@ let SkillSchema = createPropertySchema({
type: 'inlineCalculationFieldToCompute',
optional: true,
},
});
// Skills can apply their value to other calculations as a proficiency using tag targeting
}).extend(TagTargetingSchema);
let ComputedOnlySkillSchema = createPropertySchema({
// Computed value of skill to be added to skill rolls

View File

@@ -1,44 +0,0 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
// SlotFiller fillers specifically fill a slot with a bit more control than
// other properties
let SlotFillerSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
picture: {
type: String,
optional: true,
max: STORAGE_LIMITS.url,
},
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
// Overrides the type when searching for properties
slotFillerType: {
type: String,
optional: true,
max: STORAGE_LIMITS.variableName,
},
// Fill more than one quantity in a slot, like feats and ability score
// improvements, filtered out of UI if there isn't space in quantityExpected
slotQuantityFilled: {
type: SimpleSchema.Integer,
defaultValue: 1,
},
// Filters out of UI if condition isn't met, but isn't otherwise enforced
slotFillerCondition: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
});
const ComputedOnlySlotFillerSchema = new SimpleSchema({});
export { SlotFillerSchema, ComputedOnlySlotFillerSchema };

View File

@@ -1,6 +1,7 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
import TagTargetingSchema from '/imports/api/properties/subSchemas/TagTargetingSchema.js';
const ToggleSchema = createPropertySchema({
name: {
@@ -31,7 +32,7 @@ const ToggleSchema = createPropertySchema({
type: 'fieldToCompute',
optional: true,
},
});
}).extend(TagTargetingSchema);
const ComputedOnlyToggleSchema = createPropertySchema({
condition: {

View File

@@ -23,7 +23,6 @@ import { ComputedOnlyRollSchema } from '/imports/api/properties/Rolls.js';
import { ComputedOnlySavingThrowSchema } from '/imports/api/properties/SavingThrows.js';
import { ComputedOnlySkillSchema } from '/imports/api/properties/Skills.js';
import { ComputedOnlySlotSchema } from '/imports/api/properties/Slots.js';
import { ComputedOnlySlotFillerSchema } from '/imports/api/properties/SlotFillers.js';
import { ComputedOnlySpellSchema } from '/imports/api/properties/Spells.js';
import { ComputedOnlySpellListSchema } from '/imports/api/properties/SpellLists.js';
import { ComputedOnlyToggleSchema } from '/imports/api/properties/Toggles.js';
@@ -54,7 +53,6 @@ const propertySchemasIndex = {
roll: ComputedOnlyRollSchema,
savingThrow: ComputedOnlySavingThrowSchema,
skill: ComputedOnlySkillSchema,
slotFiller: ComputedOnlySlotFillerSchema,
spellList: ComputedOnlySpellListSchema,
spell: ComputedOnlySpellSchema,
toggle: ComputedOnlyToggleSchema,

View File

@@ -13,7 +13,7 @@ import { ComputedDamageSchema } from '/imports/api/properties/Damages.js';
import { DamageMultiplierSchema } from '/imports/api/properties/DamageMultipliers.js';
import { ComputedEffectSchema } from '/imports/api/properties/Effects.js';
import { ComputedFeatureSchema } from '/imports/api/properties/Features.js';
import { FolderSchema } from '/imports/api/properties/Folders.js';
import { ComputedFolderSchema } from '/imports/api/properties/Folders.js';
import { ComputedItemSchema } from '/imports/api/properties/Items.js';
import { ComputedNoteSchema } from '/imports/api/properties/Notes.js';
import { ComputedPointBuySchema } from '/imports/api/properties/PointBuys.js';
@@ -23,7 +23,6 @@ import { ComputedRollSchema } from '/imports/api/properties/Rolls.js';
import { ComputedSavingThrowSchema } from '/imports/api/properties/SavingThrows.js';
import { ComputedSkillSchema } from '/imports/api/properties/Skills.js';
import { ComputedSlotSchema } from '/imports/api/properties/Slots.js';
import { SlotFillerSchema } from '/imports/api/properties/SlotFillers.js';
import { ComputedSpellSchema } from '/imports/api/properties/Spells.js';
import { ComputedSpellListSchema } from '/imports/api/properties/SpellLists.js';
import { ComputedToggleSchema } from '/imports/api/properties/Toggles.js';
@@ -43,7 +42,7 @@ const propertySchemasIndex = {
damageMultiplier: DamageMultiplierSchema,
effect: ComputedEffectSchema,
feature: ComputedFeatureSchema,
folder: FolderSchema,
folder: ComputedFolderSchema,
note: ComputedNoteSchema,
pointBuy: ComputedPointBuySchema,
proficiency: ProficiencySchema,
@@ -52,7 +51,6 @@ const propertySchemasIndex = {
roll: ComputedRollSchema,
savingThrow: ComputedSavingThrowSchema,
skill: ComputedSkillSchema,
slotFiller: SlotFillerSchema,
spellList: ComputedSpellListSchema,
spell: ComputedSpellSchema,
toggle: ComputedToggleSchema,

View File

@@ -21,7 +21,6 @@ import { RollSchema } from '/imports/api/properties/Rolls.js';
import { SavingThrowSchema } from '/imports/api/properties/SavingThrows.js';
import { SkillSchema } from '/imports/api/properties/Skills.js';
import { SlotSchema } from '/imports/api/properties/Slots.js';
import { SlotFillerSchema } from '/imports/api/properties/SlotFillers.js';
import { SpellListSchema } from '/imports/api/properties/SpellLists.js';
import { SpellSchema } from '/imports/api/properties/Spells.js';
import { ToggleSchema } from '/imports/api/properties/Toggles.js';
@@ -52,7 +51,6 @@ const propertySchemasIndex = {
roll: RollSchema,
savingThrow: SavingThrowSchema,
skill: SkillSchema,
slotFiller: SlotFillerSchema,
spellList: SpellListSchema,
spell: SpellSchema,
toggle: ToggleSchema,

View File

@@ -0,0 +1,57 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const TagTargetingSchema = new SimpleSchema({
// True when targeting by tags instead of stats
targetByTags: {
type: Boolean,
optional: true,
},
// If targeting by tags, the field which will be targeted
targetField: {
type: String,
optional: true,
max: STORAGE_LIMITS.variableName,
},
// Which tags the effect is applied to
targetTags: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.tagCount,
},
'targetTags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
extraTags: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.extraTagsCount,
},
'extraTags.$': {
type: Object,
},
'extraTags.$._id': {
type: String,
regEx: SimpleSchema.RegEx.Id,
autoValue() {
if (!this.isSet) return Random.id();
}
},
'extraTags.$.operation': {
type: String,
allowedValues: ['OR', 'NOT'],
defaultValue: 'OR',
},
'extraTags.$.tags': {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.tagCount,
},
'extraTags.$.tags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
});
export default TagTargetingSchema;

View File

@@ -1,11 +1,12 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import Libraries from '/imports/api/library/Libraries.js';
import LibraryCollections from '/imports/api/library/LibraryCollections.js';
import '/imports/api/users/methods/deleteMyAccount.js';
import '/imports/api/users/methods/addEmail.js';
import '/imports/api/users/methods/removeEmail.js';
import '/imports/api/users/methods/updateFileStorageUsed.js';
import { some } from 'lodash';
const defaultLibraries = process.env.DEFAULT_LIBRARIES && process.env.DEFAULT_LIBRARIES.split(',') || [];
const defaultLibraryCollections = process.env.DEFAULT_LIBRARY_COLLECTIONS && process.env.DEFAULT_LIBRARY_COLLECTIONS.split(',') || [];
@@ -142,12 +143,12 @@ Meteor.users.generateApiKey = new ValidatedMethod({
Meteor.users.setDarkMode = new ValidatedMethod({
name: 'users.setDarkMode',
validate: new SimpleSchema({
darkMode: { type: Boolean },
darkMode: { type: Boolean, optional: true },
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
timeInterval: 2000,
},
run({ darkMode }) {
if (!this.userId) return;
@@ -250,6 +251,29 @@ Meteor.users.setPreference = new ValidatedMethod({
},
});
if (Meteor.isServer) {
Accounts.onCreateUser(() => {
if (defaultLibraries?.length) {
Libraries.update({
_id: { $in: defaultLibraries }
}, {
$inc: { subscriberCount: 1 }
}, {
multi: true,
}, () => {/**/ });
}
if (defaultLibraryCollections?.length) {
LibraryCollections.update({
_id: { $in: defaultLibraryCollections }
}, {
$inc: { subscriberCount: 1 }
}, {
multi: true,
}, () => {/**/ });
}
});
}
Meteor.users.subscribeToLibrary = new ValidatedMethod({
name: 'users.subscribeToLibrary',
validate: new SimpleSchema({
@@ -264,15 +288,17 @@ Meteor.users.subscribeToLibrary = new ValidatedMethod({
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
timeInterval: 2000,
},
run({ libraryId, subscribe }) {
if (!this.userId) throw 'Can only subscribe if logged in';
if (subscribe) {
Libraries.update({ _id: libraryId }, { $inc: { subscriberCount: 1 } }, () => {/**/ });
return Meteor.users.update(this.userId, {
$addToSet: { subscribedLibraries: libraryId },
});
} else {
Libraries.update({ _id: libraryId }, { $inc: { subscriberCount: -1 } }, () => {/**/ });
return Meteor.users.update(this.userId, {
$pullAll: { subscribedLibraries: libraryId },
});
@@ -299,10 +325,12 @@ Meteor.users.subscribeToLibraryCollection = new ValidatedMethod({
run({ libraryCollectionId, subscribe }) {
if (!this.userId) throw 'Can only subscribe if logged in';
if (subscribe) {
LibraryCollections.update({ _id: libraryCollectionId }, { $inc: { subscriberCount: 1 } }, () => {/**/ });
return Meteor.users.update(this.userId, {
$addToSet: { subscribedLibraryCollections: libraryCollectionId },
});
} else {
LibraryCollections.update({ _id: libraryCollectionId }, { $inc: { subscriberCount: -1 } }, () => {/**/ });
return Meteor.users.update(this.userId, {
$pullAll: { subscribedLibraryCollections: libraryCollectionId },
});

View File

@@ -0,0 +1,3 @@
export default function escapeRegex(string) {
return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '');
}

View File

@@ -1,10 +1,11 @@
export default function numberToSignedString(number, spaced){
export default function numberToSignedString(number, spaced) {
if (typeof number !== 'number') return number;
if (number === 0){
if (number === 0) {
return spaced ? '+ 0' : '+0';
} else if (number > 0){
} else if (number > 0) {
return spaced ? `+ ${number}` : `+${number}`;
} else {
return spaced ? `- ${Math.abs(number) || number}` : `${number}`;
// Uses the unicode minus sign '' instead of a dash '-' to help line up numbers nicely
return spaced ? ` ${Math.abs(number) || number}` : `${Math.abs(number) || number}`;
}
}

View File

@@ -7,16 +7,18 @@
>
<template #activator="{ on }">
<v-btn
:outlined="!!label"
:icon="!label"
:tile="!label"
:min-width="label && 108"
:height="height"
:width="width"
:disabled="context.editPermission === false"
v-on="on"
>
{{ label }}
<v-icon
:right="!!label"
:color="label && value"
:color="value"
>
mdi-format-paint
</v-icon>
@@ -137,7 +139,15 @@
label: {
type: String,
default: undefined,
}
},
height: {
type: Number,
default: undefined,
},
width: {
type: Number,
default: undefined,
},
},
data(){ return {
colors: [

View File

@@ -16,6 +16,14 @@ export default {
wideColumns: Boolean,
},
};
/*
Removed to improve chrome layout performance, put it back if there are rendering errors
.column-layout>span>div {
display: table;
table-layout: fixed;
}
*/
</script>
<style lang="css">
@@ -39,14 +47,9 @@ export default {
.column-layout>div,
.column-layout>span>div {
display: table;
table-layout: fixed;
width: 100%;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
transform: translateX(0);
-webkit-transform: translateX(0);
-webkit-column-break-inside: avoid;
page-break-inside: avoid;
break-inside: avoid;
padding: 4px;

View File

@@ -19,7 +19,10 @@
</v-btn>
</template>
<v-sheet class="d-flex flex-column align-center justify-center">
<v-btn-toggle v-model="dataAdvantage">
<v-btn-toggle
v-model="dataAdvantage"
color="accent"
>
<v-btn :value="-1">
Disadvantage
</v-btn>

View File

@@ -13,7 +13,7 @@
<v-divider vertical />
<div
class="flex layout column"
style="background-color: inherit; overflow: hidden;"
style="background-color: inherit; overflow: hidden; min-height: 100%;"
data-id="selected-node-card"
>
<slot name="detail" />

View File

@@ -5,26 +5,32 @@
transition="slide-y-transition"
min-width="290px"
style="overflow-y: auto;"
left
>
<template #activator="{ on }">
<v-btn
:loading="loading"
outlined
:min-width="108"
v-bind="$attrs"
:outlined="!!label"
:icon="!label"
:tile="!label"
:min-width="label && 108"
:height="height"
:width="width"
:style="buttonStyle"
:disabled="context.editPermission === false"
v-bind="$attrs"
v-on="on"
>
{{ label }}
<svg-icon
v-if="safeValue && safeValue.shape"
right
class="ml-2"
:class="{'ml-2': !!label}"
:shape="safeValue.shape"
/>
<v-icon
v-else
right
:right="!!label"
>
mdi-select-search
</v-icon>
@@ -87,15 +93,26 @@ export default {
SvgIcon,
},
mixins: [SmartInput],
inject: {
context: { default: {} }
},
props: {
label: {
type: String,
default: 'Icon',
default: undefined,
},
buttonStyle: {
type: String,
default: undefined,
},
height: {
type: Number,
default: undefined,
},
width: {
type: Number,
default: undefined,
},
},
data() {
return {

View File

@@ -3,7 +3,7 @@
v-bind="$attrs"
:disabled="isDisabled"
:loading="loading"
@click="click"
@click.stop.prevent="click"
>
<slot />
</v-btn>
@@ -41,7 +41,7 @@ export default {
} else if (Number.isFinite(this.context.debounceTime)){
return this.context.debounceTime;
} else {
return 750;
return 400;
}
},
},
@@ -62,11 +62,12 @@ export default {
this.$emit('click', this.acknowledgeChange);
},
clicks() {
if (!this.$listeners?.clicks) return;
this.loading = true;
this.$emit('clicks', this.timesClicked, this.acknowledgeChange);
this.timesClicked = 0;
},
acknowledgeChange(error){
acknowledgeChange(error) {
this.loading = false;
if (error) {
console.error(error)

View File

@@ -0,0 +1,76 @@
<template lang="html">
<outlined-input
:name="label"
class="mb-6 pt-1"
>
<v-btn-toggle
v-bind="$attrs"
mandatory
tile
group
:value="safeValue"
color="accent"
style="flex-wrap: wrap;"
>
<v-btn
v-for="(option, i) in options"
:key="`toggle-option-${i}`"
:value="option.value"
:disabled="isDisabled || (clickedValue != option.value && loading)"
:plain="clickedValue != option.value && loading"
:loading="clickedValue == option.value && loading"
height="42"
v-on="(value == option.value) ? {} : { click() { click(option.value) } }"
>
<v-icon
v-if="option.icon"
left
>
{{ option.icon }}
</v-icon>
{{ option.name }}
</v-btn>
</v-btn-toggle>
<v-expand-transition>
<div
v-if="errors.length"
class="pa-2 error--text"
>
{{ errors.join('\n\n') }}
</div>
</v-expand-transition>
</outlined-input>
</template>
<script lang="js">
import SmartInput from '/imports/client/ui/components/global/SmartInputMixin.js';
import OutlinedInput from '/imports/client/ui/properties/viewers/shared/OutlinedInput.vue';
export default {
components: {
OutlinedInput,
},
mixins: [SmartInput],
props: {
label: {
type: String,
default: '',
},
options: {
type: Array,
default: () => [],
}
},
data() {
return {
clickedValue: undefined,
};
},
methods: {
click(val) {
this.clickedValue = val;
this.change(val);
},
}
};
</script>

View File

@@ -16,6 +16,9 @@
<template #append>
<slot name="value" />
</template>
<template #prepend>
<slot name="prepend" />
</template>
</v-text-field>
</template>

View File

@@ -10,6 +10,7 @@ import SmartBtn from '/imports/client/ui/components/global/SmartBtn.vue';
import SmartCombobox from '/imports/client/ui/components/global/SmartCombobox.vue';
import SmartCheckbox from '/imports/client/ui/components/global/SmartCheckbox.vue';
import SmartSwitch from '/imports/client/ui/components/global/SmartSwitch.vue';
import SmartToggle from '/imports/client/ui/components/global/SmartToggle.vue';
import SvgIcon from '/imports/client/ui/components/global/SvgIcon.vue';
import SmartSlider from '/imports/client/ui/components/global/SmartSlider.vue';
@@ -24,4 +25,5 @@ Vue.component('SmartCombobox', SmartCombobox);
Vue.component('SmartCheckbox', SmartCheckbox);
Vue.component('SmartSlider', SmartSlider);
Vue.component('SmartSwitch', SmartSwitch);
Vue.component('SmartToggle', SmartToggle);
Vue.component('SvgIcon', SvgIcon);

View File

@@ -29,11 +29,6 @@
style="flex-shrink: 0;"
>
<v-spacer />
<color-picker
v-if="$listeners && $listeners['color-changed']"
:value="model.color"
@input="colorChanged"
/>
<v-menu
v-if="$listeners && (
$listeners.move ||
@@ -109,6 +104,20 @@
<v-icon>mdi-send</v-icon>
</v-list-item-action>
</v-list-item>
<v-list-item
v-if="$listeners && $listeners['copy-to-library'] && userPaid"
:disabled="context.editPermission === false"
@click="$emit('copy-to-library')"
>
<v-list-item-content>
<v-list-item-title>
Copy to library
</v-list-item-title>
</v-list-item-content>
<v-list-item-action>
<v-icon>mdi-content-duplicate</v-icon>
</v-list-item-action>
</v-list-item>
<v-list-item
v-if="$listeners && $listeners.remove"
:disabled="context.editPermission === false"
@@ -165,14 +174,13 @@
import isDarkColor from '/imports/client/ui/utility/isDarkColor.js';
import PropertyIcon from '/imports/client/ui/properties/shared/PropertyIcon.vue';
import { getPropertyName } from '/imports/constants/PROPERTIES.js';
import ColorPicker from '/imports/client/ui/components/ColorPicker.vue';
import getThemeColor from '/imports/client/ui/utility/getThemeColor.js';
import PROPERTIES from '/imports/constants/PROPERTIES.js';
import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers.js';
export default {
components: {
PropertyIcon,
ColorPicker,
},
inject: {
context: { default: {} }
@@ -211,6 +219,16 @@ export default {
return propDef && propDef.docsPath;
},
},
meteor: {
userPaid() {
try {
assertUserHasPaidBenefits(Meteor.user())
return true;
} catch (e) {
return false;
}
},
},
methods: {
colorChanged(value){
this.$emit('color-changed', value);

View File

@@ -2,6 +2,8 @@
<v-snackbar
bottom
left
outlined
color="accent"
v-bind="$attrs"
:value="isShown"
:timeout="timeout"
@@ -50,7 +52,7 @@ export default {
props: {
timeout: {
type: Number,
default: 6000,
default: 6000000,
},
pause: {
type: Number,

View File

@@ -2,18 +2,21 @@
<div class="creature-form">
<text-field
label="Name"
:disabled="!editPermission"
:value="model.name"
:error-messages="errors.name"
@change="(value, ack) => $emit('change', {path: ['name'], value, ack})"
/>
<text-field
label="Alignment"
:disabled="!editPermission"
:value="model.alignment"
:error-messages="errors.alignment"
@change="(value, ack) => $emit('change', {path: ['alignment'], value, ack})"
/>
<text-field
label="Gender"
:disabled="!editPermission"
:value="model.gender"
:error-messages="errors.gender"
@change="(value, ack) => $emit('change', {path: ['gender'], value, ack})"
@@ -21,6 +24,7 @@
<text-field
label="Picture URL"
hint="A link to a high resolution image"
:disabled="!editPermission"
:value="model.picture"
:error-messages="errors.picture"
@change="(value, ack) => $emit('change', {path: ['picture'], value, ack})"
@@ -28,6 +32,7 @@
<text-field
label="Avatar picture URL"
hint="A link to a smaller, square image to use as an avatar"
:disabled="!editPermission"
:value="model.avatarPicture"
:error-messages="errors.avatarPicture"
@change="(value, ack) => $emit('change', {path: ['avatarPicture'], value, ack})"
@@ -36,21 +41,25 @@
<form-section name="Settings">
<v-switch
label="Hide redundant stats"
:disabled="!editPermission"
:input-value="model.settings.hideUnusedStats"
@change="value => $emit('change', {path: ['settings','hideUnusedStats'], value: !!value})"
/>
<v-switch
label="Hide rest buttons"
:disabled="!editPermission"
:input-value="model.settings.hideRestButtons"
@change="value => $emit('change', {path: ['settings','hideRestButtons'], value: !!value})"
/>
<v-switch
label="Show spells tab"
:disabled="!editPermission"
:input-value="!model.settings.hideSpellsTab"
@change="changeHideSpellsTab"
/>
<v-switch
label="Show tree tab"
:disabled="!editPermission"
:input-value="model.settings.showTreeTab"
@change="changeShowTreeTab"
/>
@@ -62,6 +71,7 @@
min="0"
max="1"
step="0.1"
:disabled="!editPermission"
:value="model.settings.hitDiceResetMultiplier"
@change="(value, ack) => $emit('change', {path: ['settings','hitDiceResetMultiplier'], value, ack})"
/>
@@ -69,6 +79,7 @@
label="Discord Webhook URL"
hint="This creature's logs will be posted to the discord channel"
placeholder="https://discordapp.com/api/webhooks/<id>/<token>"
:disabled="!editPermission"
:value="model.settings.discordWebhook"
@change="(value, ack) => $emit('change', {path: ['settings','discordWebhook'], value, ack})"
/>
@@ -96,12 +107,13 @@
<form-section name="Libraries">
<smart-switch
label="All user libraries"
:disabled="!editPermission"
:value="allUserLibraries"
@change="allUserLibrariesChange"
/>
<library-list
selection
:disabled="!model.allowedLibraries && !model.allowedLibraryCollections"
:disabled="!editPermission || (!model.allowedLibraries && !model.allowedLibraryCollections)"
:libraries-selected="model.allowedLibraries"
:library-collections-selected="model.allowedLibraryCollections"
:libraries-selected-by-collections="librariesSelectedByCollections"
@@ -120,6 +132,18 @@
{{ libraryWriteError }}
</p>
</form-section>
<form-section name="Debug">
<v-btn
data-id="dependency-graph-button"
text
@click="showDependencyGraph"
>
<v-icon left>
mdi-graph
</v-icon>
Dependency Graph
</v-btn>
</form-section>
</form-sections>
</div>
</template>
@@ -130,6 +154,7 @@ import FormSection, { FormSections } from '/imports/client/ui/properties/forms/s
import LibraryList from '/imports/client/ui/library/LibraryList.vue';
import LibraryCollections from '/imports/api/library/LibraryCollections.js';
import { changeAllowedLibraries, toggleAllUserLibraries } from '/imports/api/creature/creatures/methods/changeAllowedLibraries.js';
import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
export default {
components: {
@@ -215,6 +240,14 @@ export default {
});
return ids;
},
editPermission() {
try {
assertEditPermission(this.model, Meteor.userId());
return true;
} catch (e) {
return false;
}
},
},
methods: {
changeShowTreeTab(value) {
@@ -267,6 +300,15 @@ export default {
this.dirty = true;
this.updateAllowedLibraryCollections();
},
showDependencyGraph() {
this.$store.commit('pushDialogStack', {
component: 'dependency-graph-dialog',
elementId: 'dependency-graph-button',
data: {
creatureId: this.model._id,
},
});
},
},
};
</script>

View File

@@ -43,7 +43,7 @@
{{ node.name }}
</span>
<fill-slot-button
v-if="(node.quantityExpected && node.quantityExpected.value === 1) && node.spaceLeft === 1"
v-if="canFillWithOne"
:model="node"
/>
</div>
@@ -93,6 +93,7 @@
v-if="showExpanded"
:children="computedChildren"
:parent-slot-id="computedSlotId"
:depth="depth"
@selected="e => $emit('selected', e)"
/>
<div v-else>
@@ -141,6 +142,10 @@ export default {
context: { default: {} }
},
props: {
depth: {
type: Number,
default: 0,
},
node: {
type: Object,
required: true,
@@ -155,7 +160,7 @@ export default {
},
},
data(){return {
expanded: false,
expanded: this.depth <= 2,
/* expand if there's a slot needing attention:
this.node._descendantCanFill || (
this.node.type === 'propertySlot' &&
@@ -170,7 +175,8 @@ export default {
this.children.length === 1 &&
this.children[0].node.type !== 'propertySlot' &&
this.node.quantityExpected &&
this.node.quantityExpected.value === 1;
this.node.quantityExpected.value === 1 &&
!this.canFill;
},
isSlot(){
return this.node.type === 'propertySlot';
@@ -180,15 +186,18 @@ export default {
},
canFillWithOne(){
return this.isSlot &&
this.node.quantityExpected &&
this.canFill &&
this.node.quantityExpected &&
this.node.quantityExpected.value === 1 &&
this.node.spaceLeft === 1
this.node.spaceLeft === 1 &&
!this.children?.length;
},
canFillWithMany(){
return this.isSlot && (
return this.isSlot && this.canFill && (
!this.node.quantityExpected ||
this.node.quantityExpected.value === 0 ||
(this.node.quantityExpected.value > 1 && this.node.spaceLeft > 0)
(this.node.quantityExpected.value > 1 && this.node.spaceLeft > 0) ||
(this.node.quantityExpected.value === 1 && this.children?.length)
);
},
hasChildren(){

View File

@@ -6,6 +6,7 @@
:node="child.node"
:children="child.children"
:parent-slot-id="parentSlotId"
:depth="depth + 1"
@selected="e => $emit('selected', e)"
/>
</div>
@@ -27,6 +28,10 @@ export default {
type: String,
default: undefined,
},
depth: {
type: Number,
default: 0,
},
},
data() {
return {

View File

@@ -153,12 +153,13 @@
}
this.$store.commit('pushDialogStack', {
component: 'add-creature-property-dialog',
component: 'insert-property-dialog',
elementId: 'insert-creature-property-type-' + forcedType,
data: {
parentDoc: forcedType ? undefined : parent,
forcedType,
creatureId: this.creatureId,
noBackdropClose: true,
},
callback(result){
if (!result){

View File

@@ -29,10 +29,10 @@
bottom
left
transition="slide-y-transition"
data-id="creature-menu"
>
<template #activator="{ on }">
<v-btn
data-id="creature-menu"
icon
v-on="on"
>
@@ -40,6 +40,35 @@
</v-btn>
</template>
<v-list>
<v-list-item
v-if="!isOwner && ownerName"
two-line
disabled
>
<v-list-item-avatar>
<v-icon>
mdi-account
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>
{{ ownerName }}
</v-list-item-title>
<v-list-item-subtitle>
Sheet owner
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item
v-if="!isOwner"
@click="unshareWithMe"
>
<v-list-item-title>
<v-icon left>
mdi-cancel
</v-icon> Unshare with me
</v-list-item-title>
</v-list-item>
<v-list-item :to="printUrl">
<v-list-item-title>
<v-icon left>
@@ -47,37 +76,31 @@
</v-icon> Print
</v-list-item-title>
</v-list-item>
<template v-if="editPermission">
<v-list-item @click="deleteCharacter">
<v-list-item-title>
<v-icon left>
mdi-delete
</v-icon> Delete
</v-list-item-title>
</v-list-item>
<v-list-item @click="showCharacterForm">
<v-list-item-title>
<v-icon left>
mdi-pencil
</v-icon> Edit details
</v-list-item-title>
</v-list-item>
<v-list-item @click="showShareDialog">
<v-list-item-title>
<v-icon left>
mdi-share-variant
</v-icon> Sharing
</v-list-item-title>
</v-list-item>
</template>
<v-list-item @click="showCharacterForm">
<v-list-item-title>
<v-icon left>
mdi-pencil
</v-icon> Edit details
</v-list-item-title>
</v-list-item>
<v-list-item
v-else
@click="unshareWithMe"
:disabled="!isOwner"
@click="showShareDialog"
>
<v-list-item-title>
<v-icon left>
mdi-share-variant
</v-icon> Sharing
</v-list-item-title>
</v-list-item>
<v-list-item
:disabled="!isOwner"
@click="deleteCharacter"
>
<v-list-item-title>
<v-icon left>
mdi-delete
</v-icon> Unshare with me
</v-icon> Delete
</v-list-item-title>
</v-list-item>
</v-list>
@@ -266,6 +289,15 @@ export default {
return false;
}
},
isOwner() {
if (!this.creature) return;
return Meteor.userId() === this.creature.owner;
},
ownerName() {
if (!this.creature) return;
const username = Meteor.users.findOne(this.creature.owner)?.username;
return username;
},
},
}
</script>

View File

@@ -150,11 +150,12 @@ export default {
addProperty(){
let parentPropertyId = this._id;
this.$store.commit('pushDialogStack', {
component: 'add-creature-property-dialog',
component: 'insert-property-dialog',
elementId: 'insert-creature-property-btn',
data: {
parentDoc: this.creature,
creatureId: this._id,
noBackdropClose: true,
},
callback(result){
if (!result) return;

View File

@@ -70,6 +70,8 @@ export default {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
},
'parent.id': {
$nin: folderIds,
},
type: 'action',

View File

@@ -68,6 +68,8 @@ export default {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
},
'parent.id': {
$nin: folderIds,
},
type: 'feature',

View File

@@ -155,6 +155,8 @@ export default {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
},
'parent.id': {
$nin: this.folderIds,
},
type: 'container',
@@ -179,7 +181,10 @@ export default {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
$nin: [...this.containerIds, ...this.folderIds],
$nin: this.containerIds,
},
'parent.id': {
$nin: this.folderIds,
},
type: 'container',
removed: { $ne: true },
@@ -192,12 +197,16 @@ export default {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
$nin: [...this.containerIds, ...this.folderIds],
$nin: this.containerIds,
},
'parent.id': {
$nin: this.folderIds,
},
type: 'item',
equipped: { $ne: true },
removed: { $ne: true },
deactivatedByAncestor: { $ne: true },
deactivatedByToggle: { $ne: true },
}, {
sort: { order: 1 },
});

View File

@@ -72,6 +72,8 @@ export default {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
},
'parent.id': {
$nin: folderIds,
},
type: 'note',

View File

@@ -102,6 +102,8 @@ export default {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
},
'parent.id': {
$nin: this.folderIds,
},
inactive: { $ne: true },
@@ -121,6 +123,8 @@ export default {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
},
'parent.id': {
$nin: this.folderIds,
},
type: 'spellList',
@@ -142,7 +146,10 @@ export default {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
$nin: [...this.spellListIds, ...this.folderIds],
$nin: this.spellListIds,
},
'parent.id': {
$nin: this.folderIds,
},
type: 'spell',
removed: { $ne: true },
@@ -159,7 +166,10 @@ export default {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
$nin: [...this.spellListIds, ...this.folderIds],
$nin: this.spellListIds,
},
'parent.id': {
$nin: this.folderIds,
},
type: 'spellList',
removed: { $ne: true },

View File

@@ -425,15 +425,11 @@ function walkDown(forest, callback){
const propertyHandlers = {
folder(prop) {
let skipChildren;
let propPath = null;
if (prop.groupStats && prop.hideStatsGroup) {
skipChildren = true;
}
if (prop.groupStats && prop.tab === 'stats') {
propPath = ['folder', prop.location]
}
return { skipChildren, propPath }
return { propPath }
},
attribute(prop) {
if (
@@ -456,7 +452,7 @@ const propertyHandlers = {
},
toggle(prop) {
if (
prop.deactivatedByAncestor || !prop.showUI
prop.deactivatedByToggle || prop.deactivatedByAncestor || !prop.showUI
) return { propPath: null };
return { propPath: 'toggle' };
},
@@ -524,12 +520,25 @@ export default {
properties() {
const creature = this.creature;
if (!creature) return;
const folderIds = CreatureProperties.find({
'ancestors.id': this.creatureId,
type: 'folder',
groupStats: true,
hideStatsGroup: true,
removed: { $ne: true },
inactive: { $ne: true },
}, { fields: { _id: 1 } }).map(folder => folder._id);
const filter = {
'ancestors.id': this.creatureId,
'parent.id': {
$nin: folderIds,
},
$or: [
{ inactive: { $ne: true } },
{ type: 'toggle' },
],
overridden: {$ne: true},
removed: { $ne: true },
type: {
$in: [
@@ -579,6 +588,7 @@ export default {
'ancestors.id': this.creatureId,
removed: { $ne: true },
deactivatedByAncestor: { $ne: true },
deactivatedByToggle: { $ne: true },
showUI: true,
}, {
sort: { order: 1 }
@@ -600,13 +610,15 @@ export default {
data: { _id },
});
},
incrementChange(_id, { type, value }) {
incrementChange(_id, { type, value, ack }) {
damageProperty.call({
_id,
operation: type,
value: -value
}, error => {
if (error) {
if (ack) {
ack(error);
} else if (error) {
snackbar({ text: error.reason || error.message || error.toString() });
console.error(error);
}

View File

@@ -46,6 +46,7 @@
:_id="selectedNodeId"
@removed="selectedNodeId = undefined"
@duplicated="id => selectedNodeId = id"
@select-sub-property="clickNode"
/>
</template>
</tree-detail-layout>

View File

@@ -34,6 +34,16 @@
:key="index + 'dependencyLoopError'"
:model="error"
/>
<v-alert
v-else-if="error.type === 'warning'"
:key="index + 'otherError'"
border="bottom"
colored-border
elevation="2"
type="warning"
>
{{ error.details.error }}
</v-alert>
<v-alert
v-else
:key="index + 'otherError'"

View File

@@ -152,6 +152,7 @@ export default {
removed: { $ne: true },
equipped: { $ne: true },
deactivatedByAncestor: { $ne: true },
deactivatedByToggle: { $ne: true },
}, {
sort: { order: 1 },
}).fetch();
@@ -168,6 +169,7 @@ export default {
equipped: { $ne: true },
removed: { $ne: true },
deactivatedByAncestor: { $ne: true },
deactivatedByToggle: { $ne: true },
}, {
sort: { order: 1 },
});

View File

@@ -450,6 +450,7 @@ export default {
type: 'toggle',
removed: { $ne: true },
deactivatedByAncestor: { $ne: true },
deactivatedByToggle: { $ne: true },
showUI: true,
}, {
sort: { order: 1 }

View File

@@ -2,59 +2,78 @@
lang="html"
functional
>
<v-list-item
v-bind="$attrs"
:class="isSelected && 'primary--text v-list-item--active'"
:dense="dense"
v-on="selection ? { click() {$emit('click')} } : {}"
<draggable
v-model="dataItems"
:group="'item-list'"
:animation="200"
:sort="false"
ghost-class="item-to-creature-ghost"
draggable=".no-real-items"
style="position: relative;"
@change="dropItem"
>
<v-list-item-avatar
:color="isSelected ? 'red darken-1' : model.color || 'grey'"
:size="dense ? 30 : undefined"
class="white--text"
style="transition: background 0.3s;"
<v-list-item
slot="header"
v-bind="$attrs"
:class="{
'primary--text v-list-item--active': isSelected,
'item-to-creature-drag-over': dragover,
}"
:dense="dense"
v-on="selection ? { click() {$emit('click')} } : {}"
>
<v-fade-transition leave-absolute>
<v-icon v-if="isSelected">
mdi-check
</v-icon>
<img
v-else-if="model.avatarPicture"
:src="model.avatarPicture"
:alt="model.name"
>
<template v-else>
<span>
{{ model.initial }}
</span>
</template>
</v-fade-transition>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>
{{ model.name }}
</v-list-item-title>
<v-list-item-subtitle v-if="!dense">
{{ model.alignment }} {{ model.gender }} {{ model.race }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action v-if="!dense">
<shared-icon :model="model" />
</v-list-item-action>
<v-list-item-action v-if="!selection && !dense">
<drag-handle
style="height: 100%; width: 40px;"
/>
</v-list-item-action>
</v-list-item>
<v-list-item-avatar
:color="isSelected ? 'red darken-1' : model.color || 'grey'"
:size="dense ? 30 : undefined"
class="white--text"
style="transition: background 0.3s;"
>
<v-fade-transition leave-absolute>
<v-icon v-if="isSelected">
mdi-check
</v-icon>
<img
v-else-if="model.avatarPicture"
:src="model.avatarPicture"
:alt="model.name"
>
<template v-else>
<span>
{{ model.initial }}
</span>
</template>
</v-fade-transition>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>
{{ model.name }}
</v-list-item-title>
<v-list-item-subtitle v-if="!dense">
{{ model.alignment }} {{ model.gender }} {{ model.race }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action v-if="!dense">
<shared-icon :model="model" />
</v-list-item-action>
<v-list-item-action v-if="!selection && !dense">
<drag-handle
style="height: 100%; width: 40px;"
/>
</v-list-item-action>
</v-list-item>
</draggable>
</template>
<script lang="js">
import SharedIcon from '/imports/client/ui/components/SharedIcon.vue';
import draggable from 'vuedraggable';
import { organizeDoc } from '/imports/api/parenting/organizeMethods.js';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue.js';
export default {
components: {
SharedIcon,
draggable,
},
props: {
model: {
@@ -64,6 +83,65 @@ export default {
selection: Boolean,
isSelected: Boolean,
dense: Boolean,
},
data() {
return {
dataItems: [],
dragover: false,
};
},
methods: {
dropItem({ added }) {
const item = added?.element;
if (!item?._id) return;
const docRef = { collection: 'creatureProperties', id: item._id };
// Create the undo function
const oldOrder = item.order;
const oldParent = item.parent;
const undo = () => organizeDoc.call({
docRef,
parentRef: oldParent,
order: (oldOrder || 0) - 0.5,
skipClient: true, // The client no longer has the doc subscribed, so we can't simulate
}, (error) => {
if (error) {
console.error(error);
snackbar({ text: error.reason || error.message || error.toString() });
}
});
// Move the doc
organizeDoc.call({
docRef,
parentRef: { collection: 'creatures', id: this.model._id },
order: -0.5,
}, (error) => {
if (error) {
console.error(error);
snackbar({ text: error.reason || error.message || error.toString() });
} else {
snackbar({
text: `\u{1F36A} Moved ${item.name || 'item'} to ${this.model.name || 'another character'}`,
callbackName: 'undo',
callback: undo,
});
}
});
},
}
}
</script>
<style lang="css">
.item-to-creature-ghost {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
right: 0;
}
.item-to-creature-ghost .v-btn {
display: none;
}
</style>

Some files were not shown because too many files have changed in this diff Show More