//set up the collection for characters Characters = new Mongo.Collection("characters"); Schemas.Character = new SimpleSchema({ //strings name: { type: String, defaultValue: "", trim: false, optional: true, max: 128 }, urlName: { type: String, defaultValue: "-", trim: false, optional: true, max: 128 }, alignment: { type: String, defaultValue: "", trim: false, optional: true, max: 64 }, gender: { type: String, defaultValue: "", trim: false, optional: true, max: 64 }, race: { type: String, defaultValue: "", trim: false, optional: true, max: 64 }, picture: { type: String, defaultValue: "", trim: true, optional: true, max: 256 }, description: { type: String, defaultValue: "", trim: false, optional: true, max: 5000 }, personality: { type: String, defaultValue: "", trim: false, optional: true, max: 5000 }, ideals: { type: String, defaultValue: "", trim: false, optional: true, max: 5000 }, bonds: { type: String, defaultValue: "", trim: false, optional: true, max: 5000 }, flaws: { type: String, defaultValue: "", trim: false, optional: true, max: 5000 }, backstory: { type: String, defaultValue: "", trim: false, optional: true, max: 5000 }, //attributes //ability scores strength: { type: Schemas.Attribute }, dexterity: { type: Schemas.Attribute }, constitution: { type: Schemas.Attribute }, intelligence: { type: Schemas.Attribute }, wisdom: { type: Schemas.Attribute }, charisma: { type: Schemas.Attribute }, //stats hitPoints: { type: Schemas.Attribute }, tempHP: { type: Schemas.Attribute }, experience: { type: Schemas.Attribute }, proficiencyBonus: { type: Schemas.Attribute }, speed: { type: Schemas.Attribute }, weight: { type: Schemas.Attribute }, age: { type: Schemas.Attribute }, ageRate: { type: Schemas.Attribute }, armor: { type: Schemas.Attribute }, carryMultiplier: { type: Schemas.Attribute }, //resources level1SpellSlots: { type: Schemas.Attribute }, level2SpellSlots: { type: Schemas.Attribute }, level3SpellSlots: { type: Schemas.Attribute }, level4SpellSlots: { type: Schemas.Attribute }, level5SpellSlots: { type: Schemas.Attribute }, level6SpellSlots: { type: Schemas.Attribute }, level7SpellSlots: { type: Schemas.Attribute }, level8SpellSlots: { type: Schemas.Attribute }, level9SpellSlots: { type: Schemas.Attribute }, ki: { type: Schemas.Attribute }, sorceryPoints: { type: Schemas.Attribute }, rages: { type: Schemas.Attribute }, superiorityDice: { type: Schemas.Attribute }, expertiseDice: { type: Schemas.Attribute }, //specific features rageDamage: { type: Schemas.Attribute }, //hit dice d6HitDice: { type: Schemas.Attribute }, d8HitDice: { type: Schemas.Attribute }, d10HitDice: { type: Schemas.Attribute }, d12HitDice: { type: Schemas.Attribute }, //vulnerabilities acidMultiplier: { type: Schemas.Attribute }, bludgeoningMultiplier: { type: Schemas.Attribute }, coldMultiplier: { type: Schemas.Attribute }, fireMultiplier: { type: Schemas.Attribute }, forceMultiplier: { type: Schemas.Attribute }, lightningMultiplier: { type: Schemas.Attribute }, necroticMultiplier: { type: Schemas.Attribute }, piercingMultiplier: { type: Schemas.Attribute }, poisonMultiplier: { type: Schemas.Attribute }, psychicMultiplier: { type: Schemas.Attribute }, radiantMultiplier: { type: Schemas.Attribute }, slashingMultiplier: { type: Schemas.Attribute }, thunderMultiplier: { type: Schemas.Attribute }, //skills //saves strengthSave: { type: Schemas.Skill }, "strengthSave.ability": { type: String, defaultValue: "strength" }, dexteritySave: { type: Schemas.Skill }, "dexteritySave.ability": { type: String, defaultValue: "dexterity" }, constitutionSave: { type: Schemas.Skill }, "constitutionSave.ability": { type: String, defaultValue: "constitution" }, intelligenceSave: { type: Schemas.Skill }, "intelligenceSave.ability": { type: String, defaultValue: "intelligence" }, wisdomSave: { type: Schemas.Skill }, "wisdomSave.ability": { type: String, defaultValue: "wisdom" }, charismaSave: { type: Schemas.Skill }, "charismaSave.ability": { type: String, defaultValue: "charisma" }, //skill skills acrobatics: { type: Schemas.Skill }, "acrobatics.ability": { type: String, defaultValue: "dexterity" }, animalHandling: { type: Schemas.Skill }, "animalHandling.ability": { type: String, defaultValue: "wisdom" }, arcana: { type: Schemas.Skill }, "arcana.ability": { type: String, defaultValue: "intelligence" }, athletics: { type: Schemas.Skill }, "athletics.ability": { type: String, defaultValue: "strength" }, deception: { type: Schemas.Skill }, "deception.ability": { type: String, defaultValue: "charisma" }, history: { type: Schemas.Skill }, "history.ability": { type: String, defaultValue: "intelligence" }, insight: { type: Schemas.Skill }, "insight.ability": { type: String, defaultValue: "wisdom" }, intimidation: { type: Schemas.Skill }, "intimidation.ability": { type: String, defaultValue: "charisma" }, investigation: { type: Schemas.Skill }, "investigation.ability": { type: String, defaultValue: "intelligence" }, medicine: { type: Schemas.Skill }, "medicine.ability": { type: String, defaultValue: "wisdom" }, nature: { type: Schemas.Skill }, "nature.ability": { type: String, defaultValue: "intelligence" }, perception: { type: Schemas.Skill }, "perception.ability": { type: String, defaultValue: "wisdom" }, performance: { type: Schemas.Skill }, "performance.ability": { type: String, defaultValue: "charisma" }, persuasion: { type: Schemas.Skill }, "persuasion.ability": { type: String, defaultValue: "charisma" }, religion: { type: Schemas.Skill }, "religion.ability": { type: String, defaultValue: "intelligence" }, sleightOfHand: { type: Schemas.Skill }, "sleightOfHand.ability": { type: String, defaultValue: "dexterity" }, stealth: { type: Schemas.Skill }, "stealth.ability": { type: String, defaultValue: "dexterity" }, survival: { type: Schemas.Skill }, "survival.ability": { type: String, defaultValue: "wisdom" }, //Mechanical Skills initiative: { type: Schemas.Skill }, "initiative.ability": { type: String, defaultValue: "dexterity" }, dexterityArmor: { type: Schemas.Skill }, "dexterityArmor.ability": { type: String, defaultValue: "dexterity" }, //mechanics deathSave: { type: Schemas.DeathSave }, //permissions party: { type: String, regEx: SimpleSchema.RegEx.Id, optional: true }, owner: { type: String, regEx: SimpleSchema.RegEx.Id, index: 1 }, readers: { type: [String], regEx: SimpleSchema.RegEx.Id, defaultValue: [], index: 1 }, writers: { type: [String], regEx: SimpleSchema.RegEx.Id, defaultValue: [], index: 1 }, color: { type: String, allowedValues: _.pluck(colorOptions, "key"), defaultValue: "q", }, //TODO add per-character settings //how many experiences to load at a time in XP table "settings.experiencesInc": { type: Number, defaultValue: 20 }, //slowed down by carrying too much? "settings.useVariantEncumbrance": { type: Boolean, defaultValue: false }, "settings.useStandardEncumbrance": { type: Boolean, defaultValue: true }, //hide spellcasting "settings.hideSpellcasting": { type: Boolean, defaultValue: false }, //show to anyone with link "settings.viewPermission": { type: String, defaultValue: "whitelist", allowedValues: ["whitelist", "public"], index: 1, }, "settings.swapStatAndModifier": { type: Boolean, defaultValue: false }, "settings.exportFeatures": { type: Boolean, defaultValue: true }, "settings.exportAttacks": { type: Boolean, defaultValue: true }, "settings.exportDescription": { type: Boolean, defaultValue: true }, "settings.newUserExperience": { type: Boolean, optional: true }, }); Characters.attachSchema(Schemas.Character); var attributeBase = preventLoop(function (charId, statName) { check(statName, String); //if it's a damage multiplier, we treat it specially if (_.contains(DAMAGE_MULTIPLIERS, statName)) { var invulnerabilityCount = Effects.find({ charId: charId, stat: statName, enabled: true, operation: "mul", value: 0, }).count(); if (invulnerabilityCount) return 0; var resistCount = Effects.find({ charId: charId, stat: statName, enabled: true, operation: "mul", value: 0.5, }).count(); var vulnCount = Effects.find({ charId: charId, stat: statName, enabled: true, operation: "mul", value: 2, }).count(); if (!resistCount && !vulnCount) { return 1; } else if (resistCount && !vulnCount) { return 0.5; } else if (!resistCount && vulnCount) { return 2; } else { return 1; } } var value; var base = 0; var add = 0; var mul = 1; var min = Number.NEGATIVE_INFINITY; var max = Number.POSITIVE_INFINITY; Effects.find({ charId: charId, stat: statName, enabled: true, operation: { $in: ["base", "add", "mul", "min", "max"] }, }).forEach(function (effect) { value = evaluateEffect(charId, effect); if (effect.operation === "base") { if (value > base) base = value; } else if (effect.operation === "add") { add += value; } else if (effect.operation === "mul") { mul *= value; } else if (effect.operation === "min") { if (value > min) min = value; } else if (effect.operation === "max") { if (value < max) max = value; } }); var result = (base + add) * mul; if (result < min) result = min; if (result > max) result = max; // Don't round carry multiplier if (statName === "carryMultiplier") { return result; } return Math.floor(result); }); if (Meteor.isClient) { Template.registerHelper("characterCalculate", function (func, charId, input) { try { return Characters.calculate[func](charId, input); } catch (e) { if (!Characters.calculate[func]) { throw new Error(func + "is not a function name"); } else { throw e; } } }); } //create a local memoize with a argument concatenating hash function var memoize = function (f) { if (Meteor.isServer) return f; return Tracker.memoize(f, function () { return _.reduce(arguments, function (memo, arg) { return memo + arg; }, ""); }); }; //memoize funcitons that have finds and slow loops Characters.calculate = { getField: function (charId, fieldName) { var fieldSelector = {}; fieldSelector[fieldName] = 1; var char = Characters.findOne(charId, { fields: fieldSelector }); if (!char) return; var field = char[fieldName]; if (field === undefined) { throw new Meteor.Error( "getField failed", "getField could not find field " + fieldName + " in character " + char._id ); } return field; }, fieldValue: function (charId, fieldName) { if (!Schemas.Character.schema(fieldName)) { throw new Meteor.Error( "Field not found", "Character's schema does not contain a field called: " + fieldName ); } //duck typing to get the right value function //.ability implies skill if (Schemas.Character.schema(fieldName + ".ability")) { return Characters.calculate.skillMod(charId, fieldName); } //adjustment implies attribute if (Schemas.Character.schema(fieldName + ".adjustment")) { return Characters.calculate.attributeValue(charId, fieldName); } //fall back to just returning the field itself return Characters.calculate.getField(charId, fieldName); }, attributeValue: memoize(function (charId, attributeName) { var attribute = Characters.calculate.getField(charId, attributeName); if (!attribute) return; //base value var value = Characters.calculate.attributeBase(charId, attributeName); //plus adjustment value += attribute.adjustment; return value; }), attributeBase: memoize(function (charId, attributeName) { return attributeBase(charId, attributeName); }), skillMod: memoize(preventLoop(function (charId, skillName) { var skill = Characters.calculate.getField(charId, skillName); if (!skill) return; //get the final value of the ability score var ability = Characters.calculate.attributeValue(charId, skill.ability); //base modifier var mod = +getMod(ability); //multiply proficiency bonus by largest value in proficiency array var prof = Characters.calculate.proficiency(charId, skillName); //add multiplied proficiency bonus to modifier mod += prof * Characters.calculate.attributeValue(charId, "proficiencyBonus"); //apply all effects var value; var add = 0; var mul = 1; var min = Number.NEGATIVE_INFINITY; var max = Number.POSITIVE_INFINITY; Effects.find({ charId: charId, stat: skillName, enabled: true, operation: { $in: ["base", "add", "mul", "min", "max"] }, }).forEach(function (effect) { value = evaluateEffect(charId, effect); if (effect.operation === "add") { add += value; } else if (effect.operation === "mul") { mul *= value; } else if (effect.operation === "min") { if (value > min) min = value; } else if (effect.operation === "max") { if (value < max) max = value; } }); var result = (mod + add) * mul; if (result < min) result = min; if (result > max) result = max; return Math.floor(result); })), proficiency: memoize(function (charId, skillName) { //return largest value in proficiency array var prof = Proficiencies.findOne( { charId: charId, name: skillName, enabled: true }, { sort: { value: -1 } } ); return prof && prof.value || 0; }), passiveSkill: memoize(function (charId, skillName) { var mod = +Characters.calculate.skillMod(charId, skillName); var value = 10 + mod; Effects.find( { charId: charId, stat: skillName, enabled: true, operation: "passiveAdd" } ).forEach(function (effect) { value += evaluateEffect(charId, effect); }); var advantage = Characters.calculate.advantage(charId, skillName); value += 5 * advantage; return Math.floor(value); }), advantage: memoize(function (charId, skillName) { var advantage = Effects.find( { charId: charId, stat: skillName, enabled: true, operation: "advantage" } ).count(); var disadvantage = Effects.find( { charId: charId, stat: skillName, enabled: true, operation: "disadvantage" } ).count(); if (advantage && !disadvantage) return 1; if (disadvantage && !advantage) return -1; return 0; }), abilityMod: function (charId, attribute) { return getMod( Characters.calculate.attributeValue(charId, attribute) ); }, passiveAbility: function (charId, attribute) { var mod = +getMod(Characters.calculate.attributeValue(charId, attribute)); return 10 + mod; }, xpLevel: function (charId) { var xp = Characters.calculate.experience(charId); for (var i = 0; i < 19; i++) { if (xp < XP_TABLE[i]) { return i; } } if (xp > 355000) return 20; return 0; }, level: memoize(function (charId) { var level = 0; Classes.find({ charId: charId }).forEach(function (cls) { level += cls.level; }); return level; }), experience: memoize(function (charId) { var xp = 0; Experiences.find( { charId: charId }, { fields: { value: 1 } } ).forEach(function (e) { xp += e.value; }); return xp; }), }; var deprecated = function () { //var err = new Error("this function has been deprecated"); var name = ""; if (Template.instance()) { name = Template.instance().view.name; } var logString = "this function has been deprecated \n"; if (name) { logString += "View: " + name + "\n\n"; } //logString += err.stack + "\n\n---------------------\n\n"; console.log(logString); }; //functions and calculated values. //These functions can only rely on this._id since no other //field is likely to be attached to all returned characters Characters.helpers({ //returns the value stored in the field requested //will set up dependencies on just that field getField: function (fieldName) { deprecated(); return Characters.calculate.getField(this._id, fieldName); }, //returns the value of a field fieldValue: function (fieldName) { deprecated(); return Characters.calculate.fieldValue(this._id, fieldName); }, attributeValue: function (attributeName) { deprecated(); return Characters.calculate.attributeValue(this._id, attributeName); }, attributeBase: function (attributeName) { deprecated(); return Characters.calculate.attributeBase(this._id, attributeName); }, skillMod: function (skillName) { deprecated(); return Characters.calculate.skillMod(this._id, skillName); }, proficiency: function (skillName) { deprecated(); return Characters.calculate.proficiency(this._id, skillName); }, passiveSkill: function (skillName) { deprecated(); return Characters.calculate.passiveSkill(this._id, skillName); }, advantage: function (skillName) { deprecated(); return Characters.calculate.advantage(this._id, skillName); }, abilityMod: function (attribute) { deprecated(); return Characters.calculate.abilityMod(this._id, attribute); }, passiveAbility: function (attribute) { deprecated(); return Characters.calculate.passiveAbility(this._id, attribute); }, xpLevel: function () { deprecated(); return Characters.calculate.xpLevel(this._id); }, level: function () { deprecated(); return Characters.calculate.level(this._id); }, experience: function () { deprecated(); return Characters.calculate.experience(this._id); }, }); //clean up all data related to that character before removing it if (Meteor.isServer) { Characters.after.remove(function (userId, character) { Actions.remove({ charId: character._id }); Attacks.remove({ charId: character._id }); Buffs.remove({ charId: character._id }); Classes.remove({ charId: character._id }); CustomBuffs.remove({ charId: character._id }); Effects.remove({ charId: character._id }); Experiences.remove({ charId: character._id }); Features.remove({ charId: character._id }); Notes.remove({ charId: character._id }); Proficiencies.remove({ charId: character._id }); SpellLists.remove({ charId: character._id }); Items.remove({ charId: character._id }); Containers.remove({ charId: character._id }); }); Characters.after.update(function (userId, doc, fieldNames, modifier, options) { if (_.contains(fieldNames, "name")) { var urlName = getSlug(doc.name, { maintainCase: true }) || "-"; Characters.update(doc._id, { $set: { urlName } }); } }); Characters.before.insert(function (userId, doc) { doc.urlName = getSlug(doc.name, { maintainCase: true }) || "-"; // The first character a user creates should have the new user experience if (!Characters.find({ owner: userId }).count()) { doc.settings.newUserExperience = true; } }); } Characters.allow({ insert: function (userId, doc) { // the user must be logged in, and the document must be owned by the user return (userId && doc.owner === userId); }, update: function (userId, doc, fields, modifier) { // can only change documents you have write access to return doc.owner === userId || _.contains(doc.writers, userId); }, remove: function (userId, doc) { // can only remove your own documents return doc.owner === userId; }, fetch: ["owner", "writers"], }); Characters.deny({ update: function (userId, doc, fields, modifier) { // can't change owners unless you are the current owner return _.contains(fields, "owner") && doc.owner !== userId; } });