/** * Provides the main Actor data computation and organization. * * ActorWfrp4e contains all the preparation data and methods used for preparing an actor: * going through each Owned Item, preparing them for display based on characteristics. * Additionally, it handles all the different types of roll requests, setting up the * test dialog, how each test is displayed, etc. * * * @see ActorSheetWfrp4e - Base sheet class * @see ActorSheetWfrp4eCharacter - Character sheet class * @see ActorSheetWfrp4eNPC - NPC sheet class * @see ActorSheetWfrp4eCreature - Creature sheet class * @see DiceWFRP4e - Sends test data to roll tests. */ export default class ActorWfrp4e_fr extends Actor { /** * Override the create() function to provide additional WFRP4e functionality. * * This overrided create() function adds initial items and flags to an actor * upon creation. Namely: Basic skills, the 3 default coin values (brass * pennies, silver shillings, gold crowns) at a quantity of 0, and setting * up the default Automatic Calculation flags to be true. We still want to * use the upstream create method, so super.create() is called at the end. * Additionally - See the preCreateActor hook for more initial settings * upon creation * * @param {Object} data Barebones actor data which this function adds onto. * @param {Object} options (Unused) Additional options which customize the creation workflow. * */ static async create(data, options) { // If the created actor has items (only applicable to duplicated actors) bypass the new actor creation logic if (data.items) { return super.create(data, options); } // Initialize empty items data.items = []; // Default auto calculation to true data.flags = { autoCalcRun: true, autoCalcWalk: true, autoCalcWounds: true, autoCalcCritW: true, autoCalcCorruption: true, autoCalcEnc: true, autoCalcSize: true, } let basicSkills = await game.wfrp4e.utility.allBasicSkills() || []; let moneyItems = await game.wfrp4e.utility.allMoneyItems() || []; moneyItems = moneyItems.sort((a, b) => (a.data.coinValue.value > b.data.coinValue.value) ? -1 : 1); // If character, automatically add basic skills and money items if (data.type == "character") { data.items = data.items.concat(basicSkills); // Set all money items to 0, add to actor data.items = data.items.concat(moneyItems.map(m => { m.data.quantity.value = 0 return m })) super.create(data, options); // Follow through the the rest of the Actor creation process upstream } // If not a character, ask the user whether they want to add basic skills / money else if (data.type == "npc" || data.type == "creature") { new Dialog({ title: game.i18n.localize("ACTOR.BasicSkillsTitle"), content: `
${game.i18n.localize("ACTOR.BasicSkillsPrompt")}
`, buttons: { yes: { label: game.i18n.localize("Yes"), callback: async dlg => { data.items = data.items.concat(basicSkills); // Set all money items to 0, add to actor data.items = data.items.concat(moneyItems.map(m => { m.data.quantity.value = 0 return m })) super.create(data, options); // Follow through the the rest of the Actor creation process upstream } }, no: { label: game.i18n.localize("No"), callback: async dlg => { super.create(data, options); // Do not add new items, continue with the rest of the Actor creation process upstream } }, }, default: 'yes' }).render(true); } } prepareBaseData() { // For each characteristic, calculate the total and bonus value for (let ch of Object.values(this.data.data.characteristics)) { ch.value = ch.initial + ch.advances + (ch.modifier || 0); ch.bonus = Math.floor(ch.value / 10) ch.cost = game.wfrp4e.utility._calculateAdvCost(ch.advances, "characteristic") } } /** * Calculates simple dynamic data when actor is updated. * * prepareData() is called when actor data is updated to recalculate values such as Characteristic totals, bonus (e.g. * this is how Strength total and Strength Bonus gets updated whenever the user changes the Strength characteristic), * movement values, and encumbrance. Some of these may or may not actually be calculated, depending on the user choosing * not to have them autocalculated. These values are relatively simple, more complicated calculations that require items * can be found in the sheet's getData() function. * * NOTE: NOT TO BE CONFUSED WITH prepare() - that function is called upon rendering to organize and process actor data * * @see ActorSheetWfrp4e.getData() */ prepareData() { try { super.prepareData(); const data = this.data; if (this.data.type == "character") this.prepareCharacter(); if (this.data.type == "creature") this.prepareCreature(); // Only characters have experience if (data.type === "character") data.data.details.experience.current = data.data.details.experience.total - data.data.details.experience.spent; // Auto calculation values - only calculate if user has not opted to enter ther own values if (data.flags.autoCalcWalk) data.data.details.move.walk = parseInt(data.data.details.move.value) * 2; if (data.flags.autoCalcRun) data.data.details.move.run = parseInt(data.data.details.move.value) * 4; if (data.flags.autoCalcEnc) data.data.status.encumbrance.max = data.data.characteristics.t.bonus + data.data.characteristics.s.bonus; if (game.settings.get("wfrp4e", "capAdvantageIB")) data.data.status.advantage.max = data.data.characteristics.i.bonus else data.data.status.advantage.max = 10; if (!hasProperty(this, "data.flags.autoCalcSize")) data.flags.autoCalcSize = true; // Find size based on Traits/Talents let size; let trait = data.items.find(t => t.type == "trait" && t.name.toLowerCase().includes(game.i18n.localize("NAME.Size").toLowerCase())); if (this.data.type == "creature") { trait = data.items.find(t => t.type == "trait" && t.included && t.name.toLowerCase().includes(game.i18n.localize("NAME.Size").toLowerCase())) } if (trait) size = trait.data.specification.value; else { size = data.items.find(x => x.type == "talent" && x.name.toLowerCase() == game.i18n.localize("NAME.Small").toLowerCase()); if (size) size = size.name; else size = game.i18n.localize("SPEC.Average") } // If the size has been changed since the last known value, update the value data.data.details.size.value = game.wfrp4e.utility.findKey(size, game.wfrp4e.config.actorSizes) || "avg" // Now that we have size, calculate wounds and token size if (data.flags.autoCalcWounds) { let wounds = this._calculateWounds() if (data.data.status.wounds.max != wounds) // If change detected, reassign max and current wounds { data.data.status.wounds.max = wounds; data.data.status.wounds.value = wounds; } } if (data.flags.autoCalcSize) { //let tokenSize = WFRP4E.tokenSizes[data.data.details.size.value] let tokenSize = game.wfrp4e.config.tokenSizes[data.data.details.size.value] if (this.isToken) { this.token.update({"height" : tokenSize, "width" : tokenSize }); } data.token.height = tokenSize; data.token.width = tokenSize; } // Auto calculation flags - if the user hasn't disabled various autocalculated values, calculate them if (data.flags.autoCalcRun) { // This is specifically for the Stride trait if (data.items.find(t => t.type == "trait" && t.name.toLowerCase() == game.i18n.localize("NAME.Stride").toLowerCase())) data.data.details.move.run += data.data.details.move.walk; } let talents = data.items.filter(t => t.type == "talent") // talentTests is used to easily reference talent bonuses (e.g. in setupTest function and dialog) // instead of iterating through every item again to find talents when rolling data.flags.talentTests = []; for (let talent of talents) // For each talent, if it has a Tests value, push it to the talentTests array if (talent.data.tests.value) data.flags.talentTests.push({ talentName: talent.name, test: talent.data.tests.value, SL: talent.data.advances.value }); // ------------------------ Talent Modifications ------------------------ // These consist of Strike Mighty Blow, Accurate Shot, and Robust. Each determines // how many advances there are according to preparedData, then modifies the flag value // if there's any difference. // Strike Mighty Blow Talent let smb = talents.filter(t => t.name.toLowerCase() == game.i18n.localize("NAME.SMB").toLowerCase()).reduce((advances, talent) => advances + talent.data.advances.value, 0) if (smb) data.flags.meleeDamageIncrease = smb else if (!smb) data.flags.meleeDamageIncrease = 0 // Accurate Shot Talent let accshot = talents.filter(t => t.name.toLowerCase() == game.i18n.localize("NAME.AS").toLowerCase()).reduce((advances, talent) => advances + talent.data.advances.value, 0) if (accshot) data.flags.rangedDamageIncrease = accshot; else if (!accshot) data.flags.rangedDamageIncrease = 0 // Robust Talent let robust = talents.filter(t => t.name.toLowerCase() == game.i18n.localize("NAME.Robust").toLowerCase()).reduce((advances, talent) => advances + talent.data.advances.value, 0) if (robust) data.flags.robust = robust; else data.flags.robust = 0 let ambi = talents.filter(t => t.name.toLowerCase() == game.i18n.localize("NAME.Ambi").toLowerCase()).reduce((advances, talent) => advances + talent.data.advances.value, 0) data.flags.ambi = ambi; } catch (error) { console.error("Something went wrong with preparing actor data: " + error) ui.notifications.error(game.i18n.localize("ACTOR.PreparationError") + error) } } /** * Augments actor preparation with additional calculations for Characters. * * Characters have more features and so require more calculation. Specifically, * this will add pure soul talent advances to max corruption, as well as display * current career values (details, advancement indicatiors, etc.). * * Note that this functions requires actorData to be prepared, by this.prepare(). * * @param {Object} actorData prepared actor data to augment */ prepareCharacter() { if (this.data.type != "character") return; let tb = this.data.data.characteristics.t.bonus; let wpb = this.data.data.characteristics.wp.bonus; // If the user has not opted out of auto calculation of corruption, add pure soul value if (this.data.flags.autoCalcCorruption) { this.data.data.status.corruption.max = tb + wpb; let pureSoulTalent = this.data.items.find(x => x.type == "talent" && x.name.toLowerCase() == (game.i18n.localize("NAME.PS")).toLowerCase()) if (pureSoulTalent) this.data.data.status.corruption.max += pureSoulTalent.data.advances.value; } } prepareNPC() { } /** * Augments actor preparation with additional calculations for Creatures. * * preparing for Creatures mainly involves excluding traits that were marked to be excluded, * then replacing the traits array with only the included traits (which is used by prepare()). * * Note that this functions requires actorData to be prepared, by this.prepare(). * * @param {Object} actorData prepared actor data to augment */ prepareCreature() { if (this.data.type != "creature") return; // mark each trait as included or not for (let trait of this.data.items.filter(i => i.type == "trait")) { if (this.data.data.excludedTraits.includes(trait._id)) trait.included = false; else trait.included = true; } } /* --------------------------------------------------------------------------------------------------------- */ /* Setting up Rolls /* /* All "setup______" functions gather the data needed to roll a certain test. These are in 3 main objects. /* These 3 objects are then given to DiceWFRP.setupDialog() to show the dialog, see that function for its usage. /* /* The 3 Main objects: /* testData - Data associated with modifications to rolling the test itself, or results of the test. /* Examples of this are whether hit locations are found, Weapon qualities that may cause criticals/fumbles more often or ingredients for spells that cancel miscasts. dialogOptions - Data for rendering the dialog that's important for a specific test type. Example: when casting or channelling, there should be an option for Malignant Influences, but only for those tests. cardOptions - Which card to use, the title of the card, the name of the actor, etc. /* --------------------------------------------------------------------------------------------------------- */ /** * Setup a Characteristic Test. * * Characteristics tests are the simplest test, all that needs considering is the target number of the * characteristic being tested, and any modifiers the user enters. * * @param {String} characteristicId The characteristic id (e.g. "ws") - id's can be found in config.js * */ setupCharacteristic(characteristicId, options = {}) { let char = this.data.data.characteristics[characteristicId]; let title = game.i18n.localize(char.label) + " " + game.i18n.localize("Test"); let testData = { target: char.value, hitLocation: false, extra: { size : this.data.data.details.size.value, actor : this.data, options: options } }; if (options.rest) testData.extra.options["tb"] = char.bonus; // Default a WS or BS test to have hit location checked if (characteristicId == "ws" || characteristicId == "bs") testData.hitLocation = true; // Setup dialog data: title, template, buttons, prefilled data let dialogOptions = { title: title, template: "/systems/wfrp4e/templates/dialog/characteristic-dialog.html", // Prefilled dialog data data: { hitLocation: testData.hitLocation, talents: this.data.flags.talentTests, advantage: this.data.data.status.advantage.value || 0, rollMode: options.rollMode }, callback: (html) => { // When dialog confirmed, fill testData dialog information // Note that this does not execute until DiceWFRP.setupDialog() has finished and the user confirms the dialog cardOptions.rollMode = html.find('[name="rollMode"]').val(); testData.testModifier = Number(html.find('[name="testModifier"]').val()); testData.testDifficulty = game.wfrp4e.config.difficultyModifiers[html.find('[name="testDifficulty"]').val()]; testData.successBonus = Number(html.find('[name="successBonus"]').val()); testData.slBonus = Number(html.find('[name="slBonus"]').val()); // Target value is the final value being tested against, after all modifiers and bonuses are added testData.target = testData.target + testData.testModifier + testData.testDifficulty; testData.hitLocation = html.find('[name="hitLocation"]').is(':checked'); let talentBonuses = html.find('[name = "talentBonuses"]').val(); // Combine all Talent Bonus values (their times taken) into one sum testData.successBonus += talentBonuses.reduce(function (prev, cur) { return prev + Number(cur) }, 0) return { testData, cardOptions }; } }; if (options.corruption) { title = `Corrupting Influence - ${game.i18n.localize(char.label)} Test` dialogOptions.title = title; dialogOptions.data.testDifficulty = "challenging" } if (options.mutate) { title = `Dissolution of Body and Mind - ${game.i18n.localize(char.label)} Test` dialogOptions.title = title; dialogOptions.data.testDifficulty = "challenging" } if (options.rest) { dialogOptions.data.testDifficulty = "average" } // Call the universal cardOptions helper let cardOptions = this._setupCardOptions("systems/wfrp4e/templates/chat/roll/characteristic-card.html", title) // Provide these 3 objects to setupDialog() to create the dialog and assign the roll function return game.wfrp4e.dice.setupDialog({ dialogOptions: dialogOptions, testData: testData, cardOptions: cardOptions }); } /** * Setup a Skill Test. * * Skill tests are much like Characteristic Tests in their simplicity, just with another layer of modifiers (skill advances). * However, there is more complication if the skill is instead for an Income test, which adds computation after the roll is * completed. * * @param {Object} skill The skill item being tested. Skill items contain the advancements and the base characteristic, see template.json for more information. * @param {bool} income Whether or not the skill is being tested to determine Income. */ setupSkill(skill, options = {}) { let title = skill.name + " " + game.i18n.localize("Test"); let testData = { hitLocation: false, income: options.income, target: this.data.data.characteristics[skill.data.characteristic.value].value + skill.data.advances.value, extra: { size: this.data.data.details.size.value, actor : this.data, options: options, skill: skill } }; // Default a WS, BS, Melee, or Ranged to have hit location checked if (skill.data.characteristic.value == "ws" || skill.data.characteristic.value == "bs" || skill.name.includes(game.i18n.localize("NAME.Melee")) || skill.name.includes(game.i18n.localize("NAME.Ranged"))) { testData.hitLocation = true; } // Setup dialog data: title, template, buttons, prefilled data let dialogOptions = { title: title, template: "/systems/wfrp4e/templates/dialog/skill-dialog.html", // Prefilled dialog data data: { hitLocation: testData.hitLocation, talents: this.data.flags.talentTests, characteristicList: game.wfrp4e.config.characteristics, characteristicToUse: skill.data.characteristic.value, advantage: this.data.data.status.advantage.value || 0, rollMode: options.rollMode }, callback: (html) => { // When dialog confirmed, fill testData dialog information // Note that this does not execute until DiceWFRP.setupDialog() has finished and the user confirms the dialog cardOptions.rollMode = html.find('[name="rollMode"]').val(); testData.testModifier = Number(html.find('[name="testModifier"]').val()); testData.testDifficulty = game.wfrp4e.config.difficultyModifiers[html.find('[name="testDifficulty"]').val()]; testData.successBonus = Number(html.find('[name="successBonus"]').val()); testData.slBonus = Number(html.find('[name="slBonus"]').val()); let characteristicToUse = html.find('[name="characteristicToUse"]').val(); // Target value is the final value being tested against, after all modifiers and bonuses are added testData.target = this.data.data.characteristics[characteristicToUse].value + testData.testModifier + testData.testDifficulty + skill.data.advances.value + skill.data.modifier.value testData.hitLocation = html.find('[name="hitLocation"]').is(':checked'); let talentBonuses = html.find('[name = "talentBonuses"]').val(); // Combine all Talent Bonus values (their times taken) into one sum testData.successBonus += talentBonuses.reduce(function (prev, cur) { return prev + Number(cur) }, 0) return { testData, cardOptions }; } }; // If Income, use the specialized income roll handler and set testDifficulty to average if (testData.income) { dialogOptions.data.testDifficulty = "average"; } if (options.corruption) { title = `Corrupting Influence - ${skill.name} Test` dialogOptions.title = title; dialogOptions.data.testDifficulty = "challenging" } if (options.mutate) { title = `Dissolution of Body and Mind - ${skill.name} Test` dialogOptions.title = title; dialogOptions.data.testDifficulty = "challenging" } // If Rest & Recover, set testDifficulty to average if (options.rest) { dialogOptions.data.testDifficulty = "average"; } // Call the universal cardOptions helper let cardOptions = this._setupCardOptions("systems/wfrp4e/templates/chat/roll/skill-card.html", title) if (options.corruption) cardOptions.rollMode = "gmroll" // Provide these 3 objects to setupDialog() to create the dialog and assign the roll function return game.wfrp4e.dice.setupDialog({ dialogOptions: dialogOptions, testData: testData, cardOptions: cardOptions }); } /** * Setup a Weapon Test. * * Probably the most complicated type of Test, weapon tests' complexity comes from all the different * factors and variables of the different weapons available and how they might affect test results, * as well as ammo usage, the effects of using different skills etc. * * @param {Object} weapon The weapon Item being used. * @param {bool} event The event that called this Test, used to determine if attack is melee or ranged. */ setupWeapon(weapon, options = {}) { let skillCharList = []; // This array is for the different options available to roll the test (Skills and characteristics) let slBonus = 0 // Used when wielding Defensive weapons let modifier = 0; // Used when attacking with Accurate weapons let successBonus = 0; let title = game.i18n.localize("WeaponTest") + " - " + weapon.name; // Prepare the weapon to have the complete data object, including qualities/flaws, damage value, etc. let wep = this.prepareWeaponCombat(duplicate(weapon)); let testData = { target: 0, hitLocation: true, extra: { // Store this extra weapon/ammo data for later use weapon: wep, size: this.data.data.details.size.value, actor : this.data, champion: !!this.items.find(i => i.data.name.toLowerCase() == game.i18n.localize("NAME.Champion").toLowerCase() && i.type == "trait"), riposte: !!this.items.find(i => i.data.name.toLowerCase() == game.i18n.localize("NAME.Riposte").toLowerCase() && i.type == "talent"), options: options } }; if (wep.attackType == "melee") skillCharList.push(game.i18n.localize("Weapon Skill")) else if (wep.attackType == "ranged") { // If Ranged, default to Ballistic Skill, but check to see if the actor has the specific skill for the weapon skillCharList.push(game.i18n.localize("Ballistic Skill")) if (weapon.data.weaponGroup.value != "throwing" && weapon.data.weaponGroup.value != "explosives" && weapon.data.weaponGroup.value != "entangling") { // Check to see if they have ammo if appropriate testData.extra.ammo = duplicate(this.getEmbeddedEntity("OwnedItem", weapon.data.currentAmmo.value)) if (!testData.extra.ammo || weapon.data.currentAmmo.value == 0 || testData.extra.ammo.data.quantity.value == 0) { AudioHelper.play({ src: "systems/wfrp4e/sounds/no.wav" }, false) ui.notifications.error(game.i18n.localize("Error.NoAmmo")) return } } else if (weapon.data.weaponGroup.value != "entangling" && weapon.data.quantity.value == 0) { // If this executes, it means it uses its own quantity for ammo (e.g. throwing), which it has none of AudioHelper.play({ src: "systems/wfrp4e/sounds/no.wav" }, false) ui.notifications.error(game.i18n.localize("Error.NoAmmo")) return; } else { // If this executes, it means it uses its own quantity for ammo (e.g. throwing) testData.extra.ammo = weapon; } } let defaultSelection // The default skill/characteristic being used if (wep.skillToUse) { // If the actor has the appropriate skill, default to that. skillCharList.push(wep.skillToUse.name) defaultSelection = skillCharList.indexOf(wep.skillToUse.name) testData.target = this.data.data.characteristics[wep.skillToUse.data.characteristic.value].value + wep.skillToUse.data.advances.value; } // Bypass macro default values if (!testData.target) testData.target = wep.attackType == "melee" ? this.data.data.characteristics["ws"].value : this.data.data.characteristics["bs"].value // ***** Automatic Test Data Fill Options ****** // If offhand and should apply offhand penalty (should apply offhand penalty = not parry, not defensive, and not twohanded) if (getProperty(wep, "data.offhand.value") && !wep.data.twohanded.value && !(weapon.data.weaponGroup.value == "parry" && wep.properties.qualities.includes(game.i18n.localize("PROPERTY.Defensive")))) { modifier = -20 modifier += Math.min(20, this.data.flags.ambi * 10) } // Try to automatically fill the dialog with values based on context // If the auto-fill setting is true, and there is combat.... if (game.settings.get("wfrp4e", "testAutoFill") && (game.combat && game.combat.data.round != 0 && game.combat.turns)) { try { let currentTurn = game.combat.turns[game.combat.current.turn] // If actor is a token if (this.data.token.actorLink) { // If it is NOT the actor's turn if (currentTurn && this.data.token != currentTurn.token) slBonus = this.data.flags.defensive; // Prefill Defensive values (see prepareItems() for how defensive flags are assigned) else // If it is the actor's turn { // Prefill dialog according to qualities/flaws if (wep.properties.qualities.includes(game.i18n.localize("PROPERTY.Accurate"))) modifier += 10; if (wep.properties.qualities.includes(game.i18n.localize("PROPERTY.Precise"))) successBonus += 1; if (wep.properties.flaws.includes(game.i18n.localize("PROPERTY.Imprecise"))) slBonus -= 1; } } else // If the actor is not a token { // If it is NOT the actor's turn if (currentTurn && currentTurn.tokenId != this.token.data._id) slBonus = this.data.flags.defensive; else // If it is the actor's turn { // Prefill dialog according to qualities/flaws if (wep.properties.qualities.includes(game.i18n.localize("PROPERTY.Accurate"))) modifier += 10; if (wep.properties.qualities.includes(game.i18n.localize("PROPERTY.Precise"))) successBonus += 1; if (wep.properties.flaws.includes(game.i18n.localize("PROPERTY.Imprecise"))) slBonus -= 1; } } } catch // If something went wrong, default to 0 for all prefilled data { slBonus = 0; successBonus = 0; modifier = 0; } } // Setup dialog data: title, template, buttons, prefilled data let dialogOptions = { title: title, template: "/systems/wfrp4e/templates/dialog/weapon-dialog.html", // Prefilled dialog data data: { hitLocation: testData.hitLocation, talents: this.data.flags.talentTests, skillCharList: skillCharList, slBonus: slBonus || 0, successBonus: successBonus || 0, testDifficulty: options.difficulty, modifier: modifier || 0, defaultSelection: defaultSelection, advantage: this.data.data.status.advantage.value || 0, rollMode: options.rollMode }, callback: (html) => { // When dialog confirmed, fill testData dialog information // Note that this does not execute until DiceWFRP.setupDialog() has finished and the user confirms the dialog cardOptions.rollMode = html.find('[name="rollMode"]').val(); testData.testModifier = Number(html.find('[name="testModifier"]').val()); testData.testDifficulty = game.wfrp4e.config.difficultyModifiers[html.find('[name="testDifficulty"]').val()]; testData.successBonus = Number(html.find('[name="successBonus"]').val()); testData.slBonus = Number(html.find('[name="slBonus"]').val()); let skillSelected = skillCharList[Number(html.find('[name="skillSelected"]').val())]; // Determine final target if a characteristic was selected if (skillSelected == game.i18n.localize("CHAR.WS") || skillSelected == game.i18n.localize("CHAR.BS")) { if (skillSelected == game.i18n.localize("CHAR.WS")) testData.target = this.data.data.characteristics.ws.value else if (skillSelected == game.i18n.localize("CHAR.BS")) testData.target = this.data.data.characteristics.bs.value testData.target += testData.testModifier + testData.testDifficulty; } else // If a skill was selected { // If using the appropriate skill, set the target number to characteristic value + advances + modifiers // Target value is the final value being tested against, after all modifiers and bonuses are added let skillUsed = testData.extra.weapon.skillToUse; testData.target = this.data.data.characteristics[skillUsed.data.characteristic.value].value + testData.testModifier + testData.testDifficulty + skillUsed.data.advances.value + skillUsed.data.modifier.value } testData.hitLocation = html.find('[name="hitLocation"]').is(':checked'); let talentBonuses = html.find('[name = "talentBonuses"]').val(); // Combine all Talent Bonus values (their times taken) into one sum testData.successBonus += talentBonuses.reduce(function (prev, cur) { return prev + Number(cur) }, 0) return { testData, cardOptions }; } }; // Call the universal cardOptions helper let cardOptions = this._setupCardOptions("systems/wfrp4e/templates/chat/roll/weapon-card.html", title) // Provide these 3 objects to setupDialog() to create the dialog and assign the roll function return game.wfrp4e.dice.setupDialog({ dialogOptions: dialogOptions, testData: testData, cardOptions: cardOptions }); } /** * Setup a Casting Test. * * Casting tests are more complicated due to the nature of spell miscasts, ingredients, etc. Whatever ingredient * is selected will automatically be used and negate one miscast. For the spell rolling logic, see DiceWFRP.rollCastTest * where all this data is passed to in order to calculate the roll result. * * @param {Object} spell The spell Item being Casted. The spell item has information like CN, lore, and current ingredient ID * */ setupCast(spell, options = {}) { let title = game.i18n.localize("CastingTest") + " - " + spell.name; // castSkill array holds the available skills/characteristics to cast with - Casting: Intelligence let castSkills = [{ key: "int", name: game.i18n.localize("CHAR.Int") }] // if the actor has Language (Magick), add it to the array. castSkills = castSkills.concat(this.items.filter(i => i.name.toLowerCase() == `${game.i18n.localize("Language")} (${game.i18n.localize("Magick")})`.toLowerCase() && i.type == "skill")) // Default to Language Magick if it exists let defaultSelection = castSkills.findIndex(i => i.name.toLowerCase() == `${game.i18n.localize("Language")} (${game.i18n.localize("Magick")})`.toLowerCase()) // Whether the actor has Instinctive Diction is important in the test rolling logic let instinctiveDiction = (this.data.flags.talentTests.findIndex(x => x.talentName.toLowerCase() == game.i18n.localize("NAME.ID").toLowerCase()) > -1) // instinctive diction boolean // Prepare the spell to have the complete data object, including damage values, range values, CN, etc. let preparedSpell = this.prepareSpellOrPrayer(spell); let testData = { target: 0, extra: { // Store this data to be used by the test logic spell: preparedSpell, malignantInfluence: false, ingredient: false, ID: instinctiveDiction, size: this.data.data.details.size.value, actor : this.data, options: options } }; // If the spell does damage, default the hit location to checked if (preparedSpell.damage) testData.hitLocation = true; // Setup dialog data: title, template, buttons, prefilled data let dialogOptions = { title: title, template: "/systems/wfrp4e/templates/dialog/spell-dialog.html", // Prefilled dialog data data: { hitLocation: testData.hitLocation, malignantInfluence: testData.malignantInfluence, talents: this.data.flags.talentTests, advantage: this.data.data.status.advantage.value || 0, defaultSelection: defaultSelection, castSkills: castSkills, rollMode: options.rollMode }, callback: (html) => { // When dialog confirmed, fill testData dialog information // Note that this does not execute until DiceWFRP.setupDialog() has finished and the user confirms the dialog cardOptions.rollMode = html.find('[name="rollMode"]').val(); testData.testModifier = Number(html.find('[name="testModifier"]').val()); testData.testDifficulty = game.wfrp4e.config.difficultyModifiers[html.find('[name="testDifficulty"]').val()]; testData.successBonus = Number(html.find('[name="successBonus"]').val()); testData.slBonus = Number(html.find('[name="slBonus"]').val()); let skillSelected = castSkills[Number(html.find('[name="skillSelected"]').val())]; // If an actual skill (Language Magick) was selected, use that skill to calculate the target number if (skillSelected.key != "int") { testData.target = this.data.data.characteristics[skillSelected.data.data.characteristic.value].value + skillSelected.data.data.advances.value + skillSelected.data.data.modifier.value + testData.testDifficulty + testData.testModifier; } else // if a characteristic was selected, use just the characteristic { testData.target = this.data.data.characteristics.int.value + testData.testDifficulty + testData.testModifier; } testData.hitLocation = html.find('[name="hitLocation"]').is(':checked'); testData.extra.malignantInfluence = html.find('[name="malignantInfluence"]').is(':checked'); let talentBonuses = html.find('[name = "talentBonuses"]').val(); // Combine all Talent Bonus values (their times taken) into one sum testData.successBonus += talentBonuses.reduce(function (prev, cur) { return prev + Number(cur) }, 0) return { testData, cardOptions }; } }; // Call the universal cardOptions helper let cardOptions = this._setupCardOptions("systems/wfrp4e/templates/chat/roll/spell-card.html", title) // Provide these 3 objects to setupDialog() to create the dialog and assign the roll function return game.wfrp4e.dice.setupDialog({ dialogOptions: dialogOptions, testData: testData, cardOptions: cardOptions }); } /** * Setup a Channelling Test. * * Channelling tests are more complicated due to the nature of spell miscasts, ingredients, etc. Whatever ingredient * is selected will automatically be used and mitigate miscasts. For the spell rolling logic, see DiceWFRP.rollChannellTest * where all this data is passed to in order to calculate the roll result. * * @param {Object} spell The spell Item being Channelled. The spell item has information like CN, lore, and current ingredient ID * This spell SL will then be updated accordingly. * */ setupChannell(spell, options = {}) { let title = game.i18n.localize("ChannellingTest") + " - " + spell.name; // channellSkills array holds the available skills/characteristics to with - Channelling: Willpower let channellSkills = [{ key: "wp", name: game.i18n.localize("CHAR.WP") }] // if the actor has any channel skills, add them to the array. channellSkills = channellSkills.concat(this.items.filter(i => i.name.toLowerCase().includes(game.i18n.localize("NAME.Channelling").toLowerCase()) && i.type == "skill")) // Find the spell lore, and use that to determine the default channelling selection let spellLore = spell.data.lore.value; let defaultSelection if (spell.data.wind && spell.data.wind.value) { defaultSelection = channellSkills.indexOf(channellSkills.find(x => x.name.includes(spell.data.wind.value))) if (defaultSelection == -1) { let customChannellSkill = this.items.find(i => i.name.toLowerCase().includes(spell.data.wind.value.toLowerCase()) && i.type == "skill"); if (customChannellSkill) { channellSkills.push(customChannellSkill) defaultSelection = channellSkills.length - 1 } } } else { defaultSelection = channellSkills.indexOf(channellSkills.find(x => x.name.includes(game.wfrp4e.config.magicWind[spellLore]))); } if (spellLore == "witchcraft") defaultSelection = channellSkills.indexOf(channellSkills.find(x => x.name.toLowerCase().includes(game.i18n.localize("NAME.Channelling").toLowerCase()))) // Whether the actor has Aethyric Attunement is important in the test rolling logic let aethyricAttunement = (this.data.flags.talentTests.findIndex(x => x.talentName.toLowerCase() == game.i18n.localize("NAME.AA").toLowerCase()) > -1) // aethyric attunement boolean let testData = { target: 0, extra: { // Store data to be used by the test logic spell: this.prepareSpellOrPrayer(spell), malignantInfluence: false, actor : this.data, ingredient: false, AA: aethyricAttunement, size: this.data.data.details.size.value, options: options } }; // Setup dialog data: title, template, buttons, prefilled data let dialogOptions = { title: title, template: "/systems/wfrp4e/templates/dialog/channel-dialog.html", // Prefilled dialog data data: { malignantInfluence: testData.malignantInfluence, channellSkills: channellSkills, defaultSelection: defaultSelection, talents: this.data.flags.talentTests, advantage: "N/A", rollMode: options.rollMode }, callback: (html) => { // When dialog confirmed, fill testData dialog information // Note that this does not execute until DiceWFRP.setupDialog() has finished and the user confirms the dialog cardOptions.rollMode = html.find('[name="rollMode"]').val(); testData.testModifier = Number(html.find('[name="testModifier"]').val()); testData.testDifficulty = game.wfrp4e.config.difficultyModifiers[html.find('[name="testDifficulty"]').val()]; testData.successBonus = Number(html.find('[name="successBonus"]').val()); testData.slBonus = Number(html.find('[name="slBonus"]').val()); testData.extra.malignantInfluence = html.find('[name="malignantInfluence"]').is(':checked'); let skillSelected = channellSkills[Number(html.find('[name="skillSelected"]').val())]; // If an actual Channelling skill was selected, use that skill to calculate the target number if (skillSelected.key != "wp") { testData.target = testData.testModifier + testData.testDifficulty + this.data.data.characteristics[skillSelected.data.data.characteristic.value].value + skillSelected.data.data.advances.value + skillSelected.data.data.modifier.value testData.extra.channellSkill = skillSelected.data } else // if the ccharacteristic was selected, use just the characteristic testData.target = testData.testModifier + testData.testDifficulty + this.data.data.characteristics.wp.value let talentBonuses = html.find('[name = "talentBonuses"]').val(); // Combine all Talent Bonus values (their times taken) into one sum testData.successBonus += talentBonuses.reduce(function (prev, cur) { return prev + Number(cur) }, 0) return { testData, cardOptions }; } }; // Call the universal cardOptions helper let cardOptions = this._setupCardOptions("systems/wfrp4e/templates/chat/roll/channel-card.html", title) // Provide these 3 objects to setupDialog() to create the dialog and assign the roll function return game.wfrp4e.dice.setupDialog({ dialogOptions: dialogOptions, testData: testData, cardOptions: cardOptions }); } /** * Setup a Prayer Test. * * Prayer tests are fairly simple, with the main complexity coming from sin and wrath of the gods, * the logic of which can be found in DiceWFRP.rollPrayerTest, where all this data here is passed * to in order to calculate the roll result. * * @param {Object} prayer The prayer Item being used, compared to spells, not much information * from the prayer itself is needed. */ setupPrayer(prayer, options = {}) { let title = game.i18n.localize("PrayerTest") + " - " + prayer.name; // ppraySkills array holds the available skills/characteristics to pray with - Prayers: Fellowship let praySkills = [{ key: "fel", name: game.i18n.localize("CHAR.Fel") }] // if the actor has the Pray skill, add it to the array. praySkills = praySkills.concat(this.items.filter(i => i.name.toLowerCase() == game.i18n.localize("NAME.Pray").toLowerCase() && i.type == "skill")); // Default to Pray skill if available let defaultSelection = praySkills.findIndex(i => i.name.toLowerCase() == game.i18n.localize("NAME.Pray").toLowerCase()) // Prepare the prayer to have the complete data object, including damage values, range values, etc. let preparedPrayer = this.prepareSpellOrPrayer(prayer); let testData = { // Store this data to be used in the test logic target: 0, hitLocation: false, target: defaultSelection != -1 ? this.data.data.characteristics[praySkills[defaultSelection].data.data.characteristic.value].value + praySkills[defaultSelection].data.data.advances.value : this.data.data.characteristics.fel.value, extra: { prayer: preparedPrayer, size: this.data.data.details.size.value, actor : this.data, sin: this.data.data.status.sin.value, options: options, rollMode: options.rollMode } }; // If the spell does damage, default the hit location to checked if (preparedPrayer.damage) testData.hitLocation = true; // Setup dialog data: title, template, buttons, prefilled data let dialogOptions = { title: title, template: "/systems/wfrp4e/templates/dialog/prayer-dialog.html", // Prefilled dialog data data: { hitLocation: testData.hitLocation, talents: this.data.flags.talentTests, advantage: this.data.data.status.advantage.value || 0, praySkills: praySkills, defaultSelection: defaultSelection }, callback: (html) => { // When dialog confirmed, fill testData dialog information // Note that this does not execute until DiceWFRP.setupDialog() has finished and the user confirms the dialog cardOptions.rollMode = html.find('[name="rollMode"]').val(); testData.testModifier = Number(html.find('[name="testModifier"]').val()); testData.testDifficulty = game.wfrp4e.config.difficultyModifiers[html.find('[name="testDifficulty"]').val()]; testData.successBonus = Number(html.find('[name="successBonus"]').val()); testData.slBonus = Number(html.find('[name="slBonus"]').val()); let skillSelected = praySkills[Number(html.find('[name="skillSelected"]').val())]; // If an actual skill (Pray) was selected, use that skill to calculate the target number if (skillSelected.key != "fel") { testData.target = this.data.data.characteristics[skillSelected.data.data.characteristic.value].value + skillSelected.data.data.advances.value + testData.testDifficulty + testData.testModifier; + skillSelected.data.data.modifier.value } else // if a characteristic was selected, use just the characteristic { testData.target = this.data.data.characteristics.fel.value + testData.testDifficulty + testData.testModifier; } testData.hitLocation = html.find('[name="hitLocation"]').is(':checked'); let talentBonuses = html.find('[name = "talentBonuses"]').val(); // Combine all Talent Bonus values (their times taken) into one sum testData.successBonus += talentBonuses.reduce(function (prev, cur) { return prev + Number(cur) }, 0) return { testData, cardOptions }; } }; // Call the universal cardOptions helper let cardOptions = this._setupCardOptions("systems/wfrp4e/templates/chat/roll/prayer-card.html", title) // Provide these 3 objects to setupDialog() to create the dialog and assign the roll function return game.wfrp4e.dice.setupDialog({ dialogOptions: dialogOptions, testData: testData, cardOptions: cardOptions }); } /** * Setup a Trait Test. * * Some traits are rollable, and so are assigned a rollable characteristic, this is where * rolling those characteristics is setup. Additonally, sometimes these traits have a * "Bonus characteristic" which in most all cases means what characteristic bonus to add * to determine damage. See the logic in traitTest. * * @param {Object} trait The trait Item being used, containing which characteristic/bonus characteristic to use */ setupTrait(trait, options = {}) { if (!trait.data.rollable.value) return; let title = game.wfrp4e.config.characteristics[trait.data.rollable.rollCharacteristic] + ` ${game.i18n.localize("Test")} - ` + trait.name; let testData = { hitLocation: false, target: this.data.data.characteristics[trait.data.rollable.rollCharacteristic].value, extra: { // Store this trait data for later use trait: trait, size: this.data.data.details.size.value, actor : this.data, champion: !!this.items.find(i => i.data.name.toLowerCase() == game.i18n.localize("NAME.Champion").toLowerCase()), options: options, rollMode: options.rollMode } }; // Default hit location checked if the rollable trait's characteristic is WS or BS if (trait.data.rollable.rollCharacteristic == "ws" || trait.data.rollable.rollCharacteristic == "bs") testData.hitLocation = true; // Setup dialog data: title, template, buttons, prefilled data let dialogOptions = { title: title, template: "/systems/wfrp4e/templates/dialog/skill-dialog.html", // Reuse skill dialog // Prefilled dialog data data: { hitLocation: testData.hitLocation, talents: this.data.flags.talentTests, characteristicList: game.wfrp4e.config.characteristics, characteristicToUse: trait.data.rollable.rollCharacteristic, advantage: this.data.data.status.advantage.value || 0, testDifficulty: trait.data.rollable.defaultDifficulty }, callback: (html) => { // When dialog confirmed, fill testData dialog information // Note that this does not execute until DiceWFRP.setupDialog() has finished and the user confirms the dialog cardOptions.rollMode = html.find('[name="rollMode"]').val(); testData.testModifier = Number(html.find('[name="testModifier"]').val()); testData.testDifficulty = game.wfrp4e.config.difficultyModifiers[html.find('[name="testDifficulty"]').val()]; testData.successBonus = Number(html.find('[name="successBonus"]').val()); testData.slBonus = Number(html.find('[name="slBonus"]').val()); let characteristicToUse = html.find('[name="characteristicToUse"]').val(); // Target value is the final value being tested against, after all modifiers and bonuses are added testData.target = this.data.data.characteristics[characteristicToUse].value + testData.testModifier + testData.testDifficulty testData.hitLocation = html.find('[name="hitLocation"]').is(':checked'); let talentBonuses = html.find('[name = "talentBonuses"]').val(); // Combine all Talent Bonus values (their times taken) into one sum testData.successBonus += talentBonuses.reduce(function (prev, cur) { return prev + Number(cur) }, 0) return { testData, cardOptions }; } }; // Call the universal cardOptions helper let cardOptions = this._setupCardOptions("systems/wfrp4e/templates/chat/roll/skill-card.html", title) // Provide these 3 objects to setupDialog() to create the dialog and assign the roll function return game.wfrp4e.dice.setupDialog({ dialogOptions: dialogOptions, testData: testData, cardOptions: cardOptions }); } /** * Universal card options for setup functions. * * The setup_____() functions all use the same cardOptions, just different templates. So this is * a standardized helper function to maintain DRY code. * * @param {string} template Fileptah to the template being used * @param {string} title Title of the Test to be displayed on the dialog and card */ _setupCardOptions(template, title) { let cardOptions = { speaker: { alias: this.data.token.name, actor: this.data._id, }, title: title, template: template, flags: { img: this.data.token.randomImg ? this.data.img : this.data.token.img } // img to be displayed next to the name on the test card - if it's a wildcard img, use the actor image } // If the test is coming from a token sheet if (this.token) { cardOptions.speaker.alias = this.token.data.name; // Use the token name instead of the actor name cardOptions.speaker.token = this.token.data._id; cardOptions.speaker.scene = canvas.scene._id cardOptions.flags.img = this.token.data.img; // Use the token image instead of the actor image } else // If a linked actor - use the currently selected token's data if the actor id matches { let speaker = ChatMessage.getSpeaker() if (speaker.actor == this.data._id) { cardOptions.speaker.alias = speaker.alias cardOptions.speaker.token = speaker.token cardOptions.speaker.scene = speaker.scene cardOptions.flags.img = speaker.token ? canvas.tokens.get(speaker.token).data.img : cardOptions.flags.img } } return cardOptions } /* --------------------------------------------------------------------------------------------------------- */ /* --------------------------------------------- Roll Overides --------------------------------------------- */ /* --------------------------------------------------------------------------------------------------------- */ /** * Roll overrides are specialized functions for different types of rolls. In each override, DiceWFRP is called * to perform the test logic, which has its own specialized functions for different types of tests. For exapmle, * weaponTest() calls DiceWFRP.rollWeaponTest(). Additionally, any post-roll logic that needs to be performed * is done here. For example, Income tests use incomeTest, which determines how much money is made after the * roll is completed. A normal Skill Test does not go through this process, instead using basicTest override, * however both overrides just use the standard DiceWFRP.rollTest(). * /* --------------------------------------------------------------------------------------------------------- */ /** * Default Roll override, the standard rolling method for general tests. * * basicTest is the default roll override (see DiceWFRP.setupDialog() for where it's assigned). This follows * the basic steps. Call DiceWFRP.rollTest for standard test logic, send the result and display data to * if(!options.suppressMessage) DiceWFRP.renderRollCard() as well as handleOpposedTarget(). * * @param {Object} testData All the data needed to evaluate test results - see setupSkill/Characteristic * @param {Object} cardOptions Data for the card display, title, template, etc. * @param {Object} rerenderMessage The message to be updated (used if editing the chat card) */ async basicTest({ testData, cardOptions }, options = {}) { testData = await game.wfrp4e.dice.rollDices(testData, cardOptions); let result = game.wfrp4e.dice.rollTest(testData); result.postFunction = "basicTest"; if (testData.extra) mergeObject(result, testData.extra); if (result.options.corruption) { this.handleCorruptionResult(result); } if (result.options.mutate) { this.handleMutationResult(result) } if (result.options.extended) { this.handleExtendedTest(result) } try { let contextAudio = await game.wfrp4e.audio.MatchContextAudio(game.wfrp4e.audio.FindContext(result)) cardOptions.sound = contextAudio.file || cardOptions.sound } catch { } Hooks.call("wfrp4e:rollTest", result, cardOptions) if (game.user.targets.size) { cardOptions.title += ` - ${game.i18n.localize("Opposed")}`; cardOptions.isOpposedTest = true } if (!options.suppressMessage) if (!options.suppressMessage) game.wfrp4e.dice.renderRollCard(cardOptions, result, options.rerenderMessage).then(msg => { game.wfrp4e.opposed.handleOpposedTarget(msg) // Send to handleOpposed to determine opposed status, if any. }) return {result, cardOptions}; } /** * incomeTest is used to add income calculation to Skill tests. * * Normal skill Tests just use basicTest() override, however, when testing Income, this override is used instead * because it adds 'post processing' in the form of determining how much money was earned. See this.setupSkill() * for how this override is assigned. * * @param {Object} testData All the data needed to evaluate test results - see setupSkill() * @param {Object} cardOptions Data for the card display, title, template, etc. * @param {Object} rerenderMessage The message to be updated (used if editing the chat card) */ async incomeTest({ testData, cardOptions }, options = {}) { testData = await game.wfrp4e.dice.rollDices(testData, cardOptions); let result = game.wfrp4e.dice.rollTest(testData); result.postFunction = "incomeTest" Hooks.call("wfrp4e:rollIncomeTest", result, cardOptions) if (game.user.targets.size) { cardOptions.title += ` - ${game.i18n.localize("Opposed")}`, cardOptions.isOpposedTest = true } let status = testData.income.value.split(' ') let dieAmount = game.wfrp4e.config.earningValues[game.wfrp4e.utility.findKey(status[0], game.wfrp4e.config.statusTiers)][0] // b, s, or g maps to 2d10, 1d10, or 1 respectively (takes the first letter) dieAmount = Number(dieAmount) * status[1]; // Multilpy that first letter by your standing (Brass 4 = 8d10 pennies) let moneyEarned; if (game.wfrp4e.utility.findKey(status[0], game.wfrp4e.config.statusTiers) != "g") // Don't roll for gold, just use standing value { dieAmount = dieAmount + "d10"; moneyEarned = new Roll(dieAmount).roll().total; } else moneyEarned = dieAmount; // After rolling, determined how much, if any, was actually earned if (result.description.includes("Success")) { result.incomeResult = game.i18n.localize("INCOME.YouEarn") + " " + moneyEarned; switch (game.wfrp4e.utility.findKey(status[0], game.wfrp4e.config.statusTiers)) { case "b": result.incomeResult += ` ${game.i18n.localize("NAME.BPPlural").toLowerCase()}.` break; case "s": result.incomeResult += ` ${game.i18n.localize("NAME.SSPlural").toLowerCase()}.` break; case "g": if (moneyEarned > 1) result.incomeResult += ` ${game.i18n.localize("NAME.GC").toLowerCase()}.` else result.incomeResult += ` ${game.i18n.localize("NAME.GCPlural").toLowerCase()}.` break; } } else if (Number(result.SL) > -6) { moneyEarned /= 2; result.incomeResult = game.i18n.localize("INCOME.YouEarn") + " " + moneyEarned; switch (game.wfrp4e.utility.findKey(status[0], game.wfrp4e.config.statusTiers)) { case "b": result.incomeResult += ` ${game.i18n.localize("NAME.BPPlural").toLowerCase()}.` break; case "s": result.incomeResult += ` ${game.i18n.localize("NAME.SSPlural").toLowerCase()}.` break; case "g": if (moneyEarned > 1) result.incomeResult += ` ${game.i18n.localize("NAME.GC").toLowerCase()}.` else result.incomeResult += ` ${game.i18n.localize("NAME.GCPlural").toLowerCase()}.` break; } } else { result.incomeResult = game.i18n.localize("INCOME.Failure") moneyEarned = 0; } // let contextAudio = await WFRP_Audio.MatchContextAudio(WFRP_Audio.FindContext(result)) // cardOptions.sound = contextAudio.file || cardOptions.sound result.moneyEarned = moneyEarned + game.wfrp4e.utility.findKey(status[0], game.wfrp4e.config.statusTiers); if (!options.suppressMessage) game.wfrp4e.dice.renderRollCard(cardOptions, result, options.rerenderMessage).then(msg => { game.wfrp4e.opposed.handleOpposedTarget(msg) }) return {result, cardOptions}; } /** * weaponTest is used for weapon tests, see setupWeapon for how it's assigned. * * weaponTest doesn't add any special functionality, it's main purpose being to call * DiceWFRP.rollWeaponTest() instead of the generic DiceWFRP.rollTest() * * @param {Object} testData All the data needed to evaluate test results - see setupWeapon() * @param {Object} cardOptions Data for the card display, title, template, etc. * @param {Object} rerenderMessage The message to be updated (used if editing the chat card) */ async weaponTest({ testData, cardOptions }, options = {}) { if (game.user.targets.size) { cardOptions.title += ` - ${game.i18n.localize("Opposed")}`, cardOptions.isOpposedTest = true } testData = await game.wfrp4e.dice.rollDices(testData, cardOptions); let result = game.wfrp4e.dice.rollWeaponTest(testData); result.postFunction = "weaponTest"; // Reduce ammo if necessary if (result.ammo && result.weapon.data.weaponGroup.value != game.i18n.localize("SPEC.Entangling").toLowerCase()) { result.ammo.data.quantity.value--; this.updateEmbeddedEntity("OwnedItem", { _id: result.ammo._id, "data.quantity.value": result.ammo.data.quantity.value }); } try { let contextAudio = await game.wfrp4e.audio.MatchContextAudio(game.wfrp4e.audio.FindContext(result)) cardOptions.sound = contextAudio.file || cardOptions.sound } catch { } Hooks.call("wfrp4e:rollWeaponTest", result, cardOptions) if (!options.suppressMessage) game.wfrp4e.dice.renderRollCard(cardOptions, result, options.rerenderMessage).then(msg => { game.wfrp4e.opposed.handleOpposedTarget(msg) // Send to handleOpposed to determine opposed status, if any. }) return {result, cardOptions}; } /** * castTest is used for casting tests, see setupCast for how it's assigned. * * The only special functionality castTest adds is reseting spell SL channelled back to 0, other than that, * it's main purpose is to call DiceWFRP.rollCastTest() instead of the generic DiceWFRP.rollTest(). * * @param {Object} testData All the data needed to evaluate test results - see setupCast() * @param {Object} cardOptions Data for the card display, title, template, etc. * @param {Object} rerenderMessage The message to be updated (used if editing the chat card) */ async castTest({ testData, cardOptions }, options = {}) { if (game.user.targets.size) { cardOptions.title += ` - ${game.i18n.localize("Opposed")}`, cardOptions.isOpposedTest = true } testData = await game.wfrp4e.dice.rollDices(testData, cardOptions); let result = game.wfrp4e.dice.rollCastTest(testData); result.postFunction = "castTest"; // Find ingredient being used, if any let ing = duplicate(this.getEmbeddedEntity("OwnedItem", testData.extra.spell.data.currentIng.value)) if (ing) { // Decrease ingredient quantity testData.extra.ingredient = true; ing.data.quantity.value--; this.updateEmbeddedEntity("OwnedItem", ing); } // If quantity of ingredient is 0, disregard the ingredient else if (!ing || ing.data.data.quantity.value <= 0) testData.extra.ingredient = false; try { let contextAudio = await game.wfrp4e.audio.MatchContextAudio(game.wfrp4e.audio.FindContext(result)) cardOptions.sound = contextAudio.file || cardOptions.sound } catch { } Hooks.call("wfrp4e:rollCastTest", result, cardOptions) // Update spell to reflect SL from channelling resetting to 0 game.wfrp4e.utility.getSpeaker(cardOptions.speaker).updateEmbeddedEntity("OwnedItem", { _id: testData.extra.spell._id, 'data.cn.SL': 0 }); if (!options.suppressMessage) game.wfrp4e.dice.renderRollCard(cardOptions, result, options.rerenderMessage).then(msg => { game.wfrp4e.opposed.handleOpposedTarget(msg) // Send to handleOpposed to determine opposed status, if any. }) return {result, cardOptions}; } /** * channelTest is used for casting tests, see setupCast for how it's assigned. * * channellOveride doesn't add any special functionality, it's main purpose being to call * DiceWFRP.rollChannellTest() instead of the generic DiceWFRP.rollTest() * * @param {Object} testData All the data needed to evaluate test results - see setupChannell() * @param {Object} cardOptions Data for the card display, title, template, etc. * @param {Object} rerenderMessage The message to be updated (used if editing the chat card) */ async channelTest({ testData, cardOptions }, options = {}) { if (game.user.targets.size) { cardOptions.title += ` - ${game.i18n.localize("Opposed")}`, cardOptions.isOpposedTest = true } testData = await game.wfrp4e.dice.rollDices(testData, cardOptions); let result = game.wfrp4e.dice.rollChannellTest(testData, game.wfrp4e.utility.getSpeaker(cardOptions.speaker)); result.postFunction = "channelTest"; // Find ingredient being used, if any let ing = duplicate(this.getEmbeddedEntity("OwnedItem", testData.extra.spell.data.currentIng.value)) if (ing) { // Decrease ingredient quantity testData.extra.ingredient = true; ing.data.quantity.value--; this.updateEmbeddedEntity("OwnedItem", ing); } // If quantity of ingredient is 0, disregard the ingredient else if (!ing || ing.data.data.quantity.value <= 0) testData.extra.ingredient = false; try { let contextAudio = await game.wfrp4e.audio.MatchContextAudio(game.wfrp4e.audio.FindContext(result)) cardOptions.sound = contextAudio.file || cardOptions.sound } catch { } Hooks.call("wfrp4e:rollChannelTest", result, cardOptions) if (!options.suppressMessage) DiceWFRP.renderRollCard(cardOptions, result, options.rerenderMessage).then(msg => { game.wfrp4e.opposed.handleOpposedTarget(msg) // Send to handleOpposed to determine opposed status, if any. }) return {result, cardOptions}; } /** * prayerTest is used for casting tests, see setupCast for how it's assigned. * * prayerTest doesn't add any special functionality, it's main purpose being to call * DiceWFRP.rollPrayerTest() instead of the generic DiceWFRP.rollTest() * * @param {Object} testData All the data needed to evaluate test results - see setupPrayer() * @param {Object} cardOptions Data for the card display, title, template, etc. * @param {Object} rerenderMessage The message to be updated (used if editing the chat card) */ async prayerTest({ testData, cardOptions }, options = {}) { if (game.user.targets.size) { cardOptions.title += ` - ${game.i18n.localize("Opposed")}`, cardOptions.isOpposedTest = true } testData = await game.wfrp4e.dice.rollDices(testData, cardOptions); let result = game.wfrp4e.dice.rollPrayTest(testData, game.wfrp4e.utility.getSpeaker(cardOptions.speaker)); result.postFunction = "prayerTest"; try { let contextAudio = await game.wfrp4e.audio.MatchContextAudio(game.wfrp4e.audio.FindContext(result)) cardOptions.sound = contextAudio.file || cardOptions.sound } catch { } Hooks.call("wfrp4e:rollPrayerTest", result, cardOptions) if (!options.suppressMessage) game.wfrp4e.dice.renderRollCard(cardOptions, result, options.rerenderMessage).then(msg => { game.wfrp4e.opposed.handleOpposedTarget(msg) // Send to handleOpposed to determine opposed status, if any. }) return {result, cardOptions}; } /** * traitTest is used for Trait tests, see setupTrait for how it's assigned. * * Since traitTest calls the generic DiceWFRP.rollTest(), which does not consider damage, * some post processing must be done to calculate damage values. * * @param {Object} testData All the data needed to evaluate test results - see setupTrait() * @param {Object} cardOptions Data for the card display, title, template, etc. * @param {Object} rerenderMessage The message to be updated (used if editing the chat card) */ async traitTest({ testData, cardOptions }, options = {}) { if (game.user.targets.size) { cardOptions.title += ` - ${game.i18n.localize("Opposed")}`, cardOptions.isOpposedTest = true } testData = await game.wfrp4e.dice.rollDices(testData, cardOptions); let result = game.wfrp4e.dice.rollTest(testData); result.postFunction = "traitTest"; try { // If the specification of a trait is a number, it's probably damage. (Animosity (Elves) - not a number specification: no damage) if (!isNaN(testData.extra.trait.data.specification.value) || testData.extra.trait.data.rollable.rollCharacteristic == "ws" || testData.extra.trait.data.rollabble.rollCharacteristic == "bs") // (Bite 7 - is a number specification, do damage) { testData.extra.damage = Number(result.SL) // Start damage off with SL testData.extra.damage += Number(testData.extra.trait.data.specification.value) || 0 if (testData.extra.trait.data.rollable.bonusCharacteristic) // Add the bonus characteristic (probably strength) testData.extra.damage += Number(this.data.data.characteristics[testData.extra.trait.data.rollable.bonusCharacteristic].bonus) || 0; } } catch (error) { ui.notifications.error(game.i18n.localize("CHAT.DamageError") + " " + error) } // If something went wrong calculating damage, do nothing and still render the card if (testData.extra) mergeObject(result, testData.extra); try { let contextAudio = await game.wfrp4e.audio.MatchContextAudio(game.wfrp4e.audio.FindContext(result)) cardOptions.sound = contextAudio.file || cardOptions.sound } catch { } Hooks.call("wfrp4e:rollTraitTest", result, cardOptions) if (!options.suppressMessage) game.wfrp4e.dice.renderRollCard(cardOptions, result, options.rerenderMessage).then(msg => { game.wfrp4e.opposed.handleOpposedTarget(msg) // Send to handleOpposed to determine opposed status, if any. }) return {result, cardOptions}; } /* --------------------------------------------------------------------------------------------------------- */ /* --------------------------------- Preparation & Calculation Functions ----------------------------------- */ /* --------------------------------------------------------------------------------------------------------- */ /** * Preparation function takes raw item data and processes it with actor data, typically using the calculate * functions to do so. For example, A weapon passed into prepareWeaponCombat will turn the weapon's damage * from "SB + 4" to the actual damage value by using the actor's strength bonus. See the specific functions * below for more details on what exactly is processed. These functions are used when rolling a test * (determining a weapon's base damage) or setting up the actor sheet to be displayed (displaying the damage * in the combat tab). * /* --------------------------------------------------------------------------------------------------------- */ /** * Prepares actor data for display and other features. * * prepare() is the principal function behind taking every aspect of an actor and processing them * for display (getData() - see ActorSheetWfrp4e) and other needs. This is where all items (call to * prepareItems()) are prepared and organized, then used to calculate different Actor features, * such as the Size trait influencing wounds and token size, or how talents might affect damage. * In many areas here, these talents/traits that affect some calculation are updated only if a * difference is detected to avoid infinite loops, I would like an alternative but I'm not sure * where to go instead. * * NOTE: THIS FUNCTION IS NOT TO BE CONFUSED WITH prepareData(). That function is called upon updating * an actor. This function is called whenever the sheet is rendered. */ prepare() { let preparedData = duplicate(this.data) // Call prepareItems first to organize and process OwnedItems mergeObject(preparedData, this.prepareItems()) // Add speciality functions for each Actor type if (preparedData.type == "character") this.prepareCharacter(preparedData) if (preparedData.type == "npc") this.prepareNPC(preparedData) if (preparedData.type == "creature") this.prepareCreature(preparedData) return preparedData; } /** * Iterates through the Owned Items, processes them and organizes them into containers. * * This behemoth of a function goes through all Owned Items, separating them into individual arrays * that the html templates use. Before adding them into the array, they are typically processed with * the actor data, which can either be a large function itself (see prepareWeaponCombat) or not, such * as career items which have minimal processing. These items, as well as some auxiliary data (e.g. * encumbrance, AP) are bundled into an return object * */ prepareItems() { let actorData = duplicate(this.data) // These containers are for the various different tabs const careers = []; const basicSkills = []; const advancedOrGroupedSkills = []; const talents = []; const traits = []; const weapons = []; const armour = []; const injuries = []; const grimoire = []; const petty = []; const blessings = []; const miracles = []; const psychology = []; const mutations = []; const diseases = []; const criticals = []; const extendedTests = []; let penalties = { [game.i18n.localize("Armour")]: { value: "" }, [game.i18n.localize("Injury")]: { value: "" }, [game.i18n.localize("Mutation")]: { value: "" }, [game.i18n.localize("Criticals")]: { value: "" }, }; const AP = { head: { value: 0, layers: [], label : game.i18n.localize("Head"), show: true, }, body: { value: 0, layers: [], label : game.i18n.localize("Body"), show: true }, rArm: { value: 0, layers: [], label : game.i18n.localize("Left Arm"), show: true }, lArm: { value: 0, layers: [], label : game.i18n.localize("Right Arm"), show: true }, rLeg: { value: 0, layers: [], label : game.i18n.localize("Right Leg"), show: true }, lLeg: { value: 0, layers: [], label : game.i18n.localize("Left Leg"), show: true }, shield: 0 } for(let loc in AP) { if (loc == "shield") continue let row = game.wfrp4e.tables[actorData.data.details.hitLocationTable.value].rows.find(r => r.result == loc) if (row) AP[loc].label = game.i18n.localize(row.description) else AP[loc].show = false; } // Inventory object is for the Trappings tab - each sub object is for an individual inventory section const inventory = { weapons: { label: game.i18n.localize("WFRP4E.TrappingType.Weapon"), // Label - what is displayed in the inventory section header items: [], // Array of items in the section toggle: true, // Is there a toggle in the section? (Equipped, worn, etc.) toggleName: game.i18n.localize("Equipped"), // What is the name of the toggle in the header show: false, // Should this section be shown (if an item exists in this list, it is set to true) dataType: "weapon" // What type of FVTT Item is in this section (used by the + button to add an item of this type) }, armor: { label: game.i18n.localize("WFRP4E.TrappingType.Armour"), items: [], toggle: true, toggleName: game.i18n.localize("Worn"), show: false, dataType: "armour" }, ammunition: { label: game.i18n.localize("WFRP4E.TrappingType.Ammunition"), items: [], show: false, dataType: "ammunition" }, clothingAccessories: { label: game.i18n.localize("WFRP4E.TrappingType.ClothingAccessories"), items: [], toggle: true, toggleName: game.i18n.localize("Worn"), show: false, dataType: "trapping" }, booksAndDocuments: { label: game.i18n.localize("WFRP4E.TrappingType.BooksDocuments"), items: [], show: false, dataType: "trapping" }, toolsAndKits: { label: game.i18n.localize("WFRP4E.TrappingType.ToolsKits"), items: [], show: false, dataType: "trapping" }, foodAndDrink: { label: game.i18n.localize("WFRP4E.TrappingType.FoodDrink"), items: [], show: false, dataType: "trapping" }, drugsPoisonsHerbsDraughts: { label: game.i18n.localize("WFRP4E.TrappingType.DrugsPoisonsHerbsDraughts"), items: [], show: false, dataType: "trapping" }, misc: { label: game.i18n.localize("WFRP4E.TrappingType.Misc"), items: [], show: true, dataType: "trapping" } }; // Money and ingredients are not in inventory object because they need more customization - note in actor-inventory.html that they do not exist in the main inventory loop const ingredients = { label: game.i18n.localize("WFRP4E.TrappingType.Ingredient"), items: [], show: false, dataType: "trapping" }; const money = { coins: [], total: 0, // Total coinage value show: true }; const containers = { items: [], show: false }; const inContainers = []; // inContainers is the temporary storage for items within a container let totalEnc = 0; // Total encumbrance of items let hasSpells = false; // if the actor has atleast a single spell - used to display magic tab let hasPrayers = false; // if the actor has atleast a single prayer - used to display religion tab let showOffhand = true; // Show offhand checkboxes if no offhand equipped let defensiveCounter = 0; // Counter for weapons with the defensive quality actorData.items = actorData.items.sort((a, b) => (a.sort || 0) - (b.sort || 0)) // Iterate through items, allocating to containers // Items that need more intense processing are sent to a specialized function (see preparation functions below) // Physical items are also placed into containers instead of the inventory object if their 'location' is not 0 // A location of 0 means not in a container, otherwise, the location corresponds to the id of the container the item is in for (let i of actorData.items) { try { i.img = i.img || DEFAULT_TOKEN; // *********** TALENTS *********** if (i.type === "talent") { this.prepareTalent(i, talents); } // *********** Skills *********** else if (i.type === "skill") { this.prepareSkill(i); if (i.data.grouped.value == "isSpec" || i.data.advanced.value == "adv") advancedOrGroupedSkills.push(i) else basicSkills.push(i); } // *********** Ammunition *********** else if (i.type === "ammunition") { i.encumbrance = (i.data.encumbrance.value * i.data.quantity.value).toFixed(2); if (i.data.location.value == 0) { inventory.ammunition.items.push(i); inventory.ammunition.show = true totalEnc += Number(i.encumbrance); } else { inContainers.push(i); } } // *********** Weapons *********** // Weapons are "processed" at the end for efficency else if (i.type === "weapon") { i.encumbrance = Math.floor(i.data.encumbrance.value * i.data.quantity.value); if (i.data.location.value == 0) { i.toggleValue = i.data.equipped || false; inventory.weapons.items.push(i); inventory.weapons.show = true; totalEnc += i.encumbrance; } else { inContainers.push(i); } } // *********** Armour *********** // Armour is prepared only if it is worn, otherwise, it is just pushed to inventory and encumbrance is calculated else if (i.type === "armour") { i.encumbrance = Math.floor(i.data.encumbrance.value * i.data.quantity.value); if (i.data.location.value == 0) { i.toggleValue = i.data.worn.value || false; if (i.data.worn.value) { i.encumbrance = i.encumbrance - 1; i.encumbrance = i.encumbrance < 0 ? 0 : i.encumbrance; } inventory.armor.items.push(i); inventory.armor.show = true; totalEnc += i.encumbrance; } else { inContainers.push(i); } if (i.data.worn.value) armour.push(this.prepareArmorCombat(i, AP)); } // *********** Injuries *********** else if (i.type == "injury") { injuries.push(i); penalties[game.i18n.localize("Injury")].value += i.data.penalty.value; } // *********** Criticals *********** else if (i.type == "critical") { criticals.push(i); penalties[game.i18n.localize("Criticals")].value += i.data.modifier.value; } // *********** Containers *********** // Items within containers are organized at the end else if (i.type === "container") { i.encumbrance = i.data.encumbrance.value; if (i.data.location.value == 0) { if (i.data.worn.value) { i.encumbrance = i.encumbrance - 1; i.encumbrance = i.encumbrance < 0 ? 0 : i.encumbrance; } totalEnc += i.encumbrance; } else { inContainers.push(i); } containers.items.push(i); containers.show = true; } // *********** Trappings *********** // Trappings have several sub-categories, most notably Ingredients // The trappings tab does not have a "Trappings" section, but sections for each type of trapping instead else if (i.type === "trapping") { i.encumbrance = i.data.encumbrance.value * i.data.quantity.value; if (i.data.location.value == 0) { // Push ingredients to a speciality array for futher customization in the trappings tab if (i.data.trappingType.value == "ingredient") { ingredients.items.push(i) } // The trapping will fall into one of these if statements and set the array accordingly else if (i.data.trappingType.value == "clothingAccessories") { i.toggleValue = i.data.worn || false; inventory[i.data.trappingType.value].items.push(i); inventory[i.data.trappingType.value].show = true; if (i.data.worn) { i.encumbrance = i.encumbrance - 1; // Since some trappings are worn, they need special treatment i.encumbrance = i.encumbrance < 0 ? 0 : i.encumbrance; // This if statement is specific to worn clothing Trappings } } else if (i.data.trappingType.value == "tradeTools") { inventory["toolsAndKits"].items.push(i) // I decided not to separate "Trade Tools" and "Tools and Kits" inventory["toolsAndKits"].show = true; // Instead, merging them both into "Tools and Kits" } else if (i.data.trappingType.value) { inventory[i.data.trappingType.value].items.push(i); // Generic - add anything else to their appropriate array inventory[i.data.trappingType.value].show = true; } else { inventory.misc.items.push(i); // If somehow it didn't fall into the other categories (it should) inventory.misc.show = true; // Just push it to miscellaneous } totalEnc += i.encumbrance; } else { inContainers.push(i); } } // *********** Spells *********** // See this.prepareSpellOrPrayer() for how these items are processed else if (i.type === "spell") { hasSpells = true; if (i.data.lore.value == "petty") petty.push(this.prepareSpellOrPrayer(i)); else grimoire.push(this.prepareSpellOrPrayer(i)); } // *********** Prayers *********** // See this.prepareSpellOrPrayer() for how these items are processed else if (i.type === "prayer") { hasPrayers = true; if (i.data.type.value == "blessing") blessings.push(this.prepareSpellOrPrayer(i)); else miracles.push(this.prepareSpellOrPrayer(i)); } // *********** Careers *********** else if (i.type === "career") { careers.push(i); } // *********** Trait *********** // Display Traits as Trait-Name (Specification) // Such as Animosity (Elves) else if (i.type === "trait") { if (i.data.specification.value) { if (i.data.rollable.bonusCharacteristic) // Bonus characteristic adds to the specification (Weapon +X includes SB for example) { i.data.specification.value = parseInt(i.data.specification.value) || 0 i.data.specification.value += actorData.data.characteristics[i.data.rollable.bonusCharacteristic].bonus; } i.name = i.name + " (" + i.data.specification.value + ")"; } traits.push(i); } // *********** Psychologies *********** else if (i.type === "psychology") { psychology.push(i); } // *********** Diseases *********** // .roll is the roll result. If it doesn't exist, show the formula instead else if (i.type === "disease") { i.data.incubation.roll = i.data.incubation.roll || i.data.incubation.value; i.data.duration.roll = i.data.duration.roll || i.data.duration.value; diseases.push(i); } // *********** Mutations *********** // Some mutations have modifiers - see the penalties section below else if (i.type === "mutation") { mutations.push(i); if (i.data.modifiesSkills.value) penalties[game.i18n.localize("Mutation")].value += i.data.modifier.value; } // *********** Money *********** // Keep a running total of the coin value the actor has outside of containers else if (i.type === "money") { i.encumbrance = (i.data.encumbrance.value * i.data.quantity.value).toFixed(2); if (i.data.location.value == 0) { money.coins.push(i); totalEnc += Number(i.encumbrance); } else { inContainers.push(i); } money.total += i.data.quantity.value * i.data.coinValue.value; } else if (i.type === "extendedTest") { i.pct = 0; if (i.data.SL.target > 0) i.pct = i.data.SL.current / i.data.SL.target * 100 if (i.pct > 100) i.pct = 100 if (i.pct < 0) i.pct = 0; extendedTests.push(i); } } catch (error) { console.error("Something went wrong with preparing item " + i.name + ": " + error) ui.notifications.error("Something went wrong with preparing item " + i.name + ": " + error) // ui.notifications.error("Deleting " + i.name); // this.deleteEmbeddedEntity("OwnedItem", i._id); } } // END ITEM SORTING // Prepare weapons for combat after items passthrough for efficiency - weapons need to know the ammo possessed, so instead of iterating through // all items to find, iterate through the inventory.ammo array we just made let totalShieldDamage = 0; // Used for damage tooltip let eqpPoints = 0 // Weapon equipment value, only 2 one handed weapons or 1 two handed weapon for (let wep of inventory.weapons.items) { // We're only preparing equipped items here - this is for displaying weapons in the combat tab after all if (wep.data.equipped) { if (getProperty(wep, "data.offhand.value")) showOffhand = false; // Don't show offhand checkboxes if a weapon is offhanded // Process weapon taking into account actor data, skills, and ammo weapons.push(this.prepareWeaponCombat(wep, inventory.ammo, basicSkills.concat(advancedOrGroupedSkills))); // Add shield AP to AP object let shieldProperty = wep.properties.qualities.find(q => q.toLowerCase().includes(game.i18n.localize("PROPERTY.Shield").toLowerCase())) if (shieldProperty) { let shieldDamage = wep.data.APdamage || 0; AP.shield += (parseInt(shieldProperty.split(" ")[1]) - shieldDamage); totalShieldDamage += shieldDamage; } // Keep a running total of defensive weapons equipped if (wep.properties.qualities.find(q => q.toLowerCase().includes(game.i18n.localize("PROPERTY.Defensive").toLowerCase()))) { defensiveCounter++; } eqpPoints += wep.data.twohanded.value ? 2 : 1 } } this.data.flags.eqpPoints = eqpPoints // If you have no spells, just put all ingredients in the miscellaneous section, otherwise, setup the ingredients to be available if (grimoire.length > 0 && ingredients.items.length > 0) { ingredients.show = true; // For each spell, set available ingredients to ingredients that have been assigned to that spell for (let s of grimoire) s.data.ingredients = ingredients.items.filter(i => i.data.spellIngredient.value == s._id && i.data.quantity.value > 0) } else inventory.misc.items = inventory.misc.items.concat(ingredients.items); // ******************************** Container Setup *********************************** // containerMissing is an array of items whose container does not exist (needed for when a container is deleted) var containerMissing = inContainers.filter(i => !containers.items.find(c => c._id == i.data.location.value)); for (var itemNoContainer of containerMissing) // Reset all items without container references (items that were removed from a contanier) itemNoContainer.data.location.value = 0; // If there were missing containers, reset the items that are orphaned if (containerMissing.length) this.updateEmbeddedEntity("OwnedItem", containerMissing) for (var cont of containers.items) // For each container { // All items referencing (inside) that container var itemsInside = inContainers.filter(i => i.data.location.value == cont._id); itemsInside.map(function (item) { // Add category of item to be displayed if (item.type == "trapping") item.type = game.wfrp4e.config.trappingCategories[item.data.trappingType.value]; else item.type = game.wfrp4e.config.trappingCategories[item.type]; }) cont["carrying"] = itemsInside.filter(i => i.type != "Container"); // cont.carrying -> items the container is carrying cont["packsInside"] = itemsInside.filter(i => i.type == "Container"); // cont.packsInside -> containers the container is carrying cont["holding"] = itemsInside.reduce(function (prev, cur) { // cont.holding -> total encumbrance the container is holding return Number(prev) + Number(cur.encumbrance); }, 0); cont.holding = Math.floor(cont.holding) } containers.items = containers.items.filter(c => c.data.location.value == 0); // Do not show containers inside other containers as top level (a location value of 0 means not inside a container) // ******************************** Penalties Setup *********************************** // Penalties box setup // If too much text, divide the penalties into groups let penaltyOverflow = false; penalties[game.i18n.localize("Armour")].value += this.calculateArmorPenalties(armour); if ((penalties[game.i18n.localize("Armour")].value + penalties[game.i18n.localize("Mutation")].value + penalties[game.i18n.localize("Injury")].value + penalties[game.i18n.localize("Criticals")].value).length > 50) // ~50 characters is when the text box overflows { // When that happens, break it up into categories penaltyOverflow = true; for (let penaltyType in penalties) { if (penalties[penaltyType].value) penalties[penaltyType].show = true; else penalties[penaltyType].show = false; // Don't show categories without any penalties } } // Penalties flag is teh string that shows when the actor's turn in combat starts let penaltiesFlag = penalties[game.i18n.localize("Armour")].value + " " + penalties[game.i18n.localize("Mutation")].value + " " + penalties[game.i18n.localize("Injury")].value + " " + penalties[game.i18n.localize("Criticals")].value + " " + this.data.data.status.penalties.value penaltiesFlag = penaltiesFlag.trim(); // This is for the penalty string in flags, for combat turn message if (this.data.flags.modifier != penaltiesFlag) this.update({ "flags.modifier": penaltiesFlag }) // Add armor trait to AP object let armorTrait = traits.find(t => t.name.toLowerCase().includes(game.i18n.localize("NAME.Armour").toLowerCase())) if (armorTrait && (!this.data.data.excludedTraits || !this.data.data.excludedTraits.includes(armorTrait._id))) { for (let loc in AP) { try { let traitDamage = 0; if (armorTrait.APdamage) traitDamage = armorTrait.APdamage[loc] || 0; if (loc != "shield") AP[loc].value += (parseInt(armorTrait.data.specification.value) || 0) - traitDamage; } catch {//ignore armor traits with invalid values } } } // keep defensive counter in flags to use for test auto fill (see setupWeapon()) this.data.flags.defensive = defensiveCounter; // Encumbrance is initially calculated in prepareItems() - this area augments it based on talents if (actorData.flags.autoCalcEnc) { let strongBackTalent = talents.find(t => t.name.toLowerCase() == game.i18n.localize("NAME.StrongBack").toLowerCase()) let sturdyTalent = talents.find(t => t.name.toLowerCase() == game.i18n.localize("NAME.Sturdy").toLowerCase()) if (strongBackTalent) actorData.data.status.encumbrance.max += strongBackTalent.data.advances.value; if (sturdyTalent) actorData.data.status.encumbrance.max += sturdyTalent.data.advances.value * 2; } // enc used for encumbrance bar in trappings tab let enc; totalEnc = Math.floor(totalEnc); enc = { max: actorData.data.status.encumbrance.max, value: Math.round(totalEnc * 10) / 10, }; // percentage of the bar filled enc.pct = Math.min(enc.value * 100 / enc.max, 100); enc.state = enc.value / enc.max; // state is how many times over you are max encumbrance if (enc.state > 3) { enc["maxEncumbered"] = true enc.penalty = game.wfrp4e.config.encumbrancePenalties["maxEncumbered"]; } else if (enc.state > 2) { enc["veryEncumbered"] = true enc.penalty = game.wfrp4e.config.encumbrancePenalties["veryEncumbered"]; } else if (enc.state > 1) { enc["encumbered"] = true enc.penalty = game.wfrp4e.config.encumbrancePenalties["encumbered"]; } else enc["notEncumbered"] = true; // Return all processed objects return { inventory, containers, basicSkills: basicSkills.sort(game.wfrp4e.utility.nameSorter), advancedOrGroupedSkills: advancedOrGroupedSkills.sort(game.wfrp4e.utility.nameSorter), talents, traits, weapons, diseases, mutations, armour, penalties, penaltyOverflow, AP, injuries, grimoire, petty, careers: careers.reverse(), blessings, miracles, money, psychology, criticals, criticalCount: criticals.length, encumbrance: enc, ingredients, totalShieldDamage, extendedTests, hasSpells, hasPrayers, showOffhand } } /** * Prepares a skill Item. * * Preparation of a skill is simply determining the `total` value, which is the base characteristic + advances. * * @param {Object} skill 'skill' type Item * @return {Object} skill Processed skill, with total value calculated */ prepareSkill(skill) { let actorData = this.data skill.data.characteristic.num = actorData.data.characteristics[skill.data.characteristic.value].value; if (skill.data.modifier) { if (skill.data.modifier.value > 0) skill.modified = "positive"; else if (skill.data.modifier.value < 0) skill.modified = "negative" } skill.data.characteristic.abrev = game.wfrp4e.config.characteristicsAbbrev[skill.data.characteristic.value]; skill.data.cost = game.wfrp4e.utility._calculateAdvCost(skill.data.advances.value, "skill", skill.data.advances.costModifier) return skill } /** * * Prepares a talent Item. * * Prepares a talent with actor data and other talents. Two different ways to prepare a talent: * * 1. If a talent with the same name is already prepared, don't prepare this talent and instead * add to the advancements of the existing talent. * * 2. If the talent does not exist yet, turn its "Max" value into "numMax", in other words, turn * "Max: Initiative Bonus" into an actual number value. * * @param {Object} talent 'talent' type Item. * @param {Array} talentList List of talents prepared so far. Prepared talent is pushed here instead of returning. */ prepareTalent(talent, talentList) { let actorData = this.data // Find an existing prepared talent with the same name let existingTalent = talentList.find(t => t.name == talent.name) if (existingTalent) // If it exists { if (!existingTalent.numMax) // If for some reason, it does not have a numMax, assign it one talent["numMax"] = actorData.data.characteristics[talent.data.max.value].bonus; // Add an advancement to the existing talent existingTalent.data.advances.value++; existingTalent.cost = (existingTalent.data.advances.value + 1) * 100 } else // If a talent of the same name does not exist { switch (talent.data.max.value) // Turn its max value into "numMax", which is an actual numeric value { case '1': talent["numMax"] = 1; break; case '2': talent["numMax"] = 2; break; case 'none': talent["numMax"] = "-"; break; default: talent["numMax"] = actorData.data.characteristics[talent.data.max.value].bonus; } talent.cost = 200; talentList.push(talent); // Add the prepared talent to the talent list } } /** * Prepares a weapon Item. * * Prepares a weapon using actor data, ammunition, properties, and flags. The weapon's raw * data is turned into more user friendly / meaningful data with either config values or * calculations. Also turns all qualities/flaws into a more structured object. * * @param {Object} weapon 'weapon' type Item * @param {Array} ammoList array of 'ammo' type Items * @param {Array} skills array of 'skill' type Items * * @return {Object} weapon processed weapon */ prepareWeaponCombat(weapon, ammoList, skills) { let actorData = this.data if (!skills) // If a skill list isn't provided, filter all items to find skills skills = actorData.items.filter(i => i.type == "skill"); weapon.attackType = game.wfrp4e.config.groupToType[weapon.data.weaponGroup.value] weapon.data.reach.value = game.wfrp4e.config.weaponReaches[weapon.data.reach.value]; weapon.data.weaponGroup.value = game.wfrp4e.config.weaponGroups[weapon.data.weaponGroup.value] || "basic"; // Attach the available skills to use to the weapon. weapon.skillToUse = skills.find(x => x.name.toLowerCase().includes(`(${weapon.data.weaponGroup.value.toLowerCase()})`)) // prepareQualitiesFlaws turns the comma separated qualities/flaws string into a string array // Does not include qualities if no skill could be found above weapon["properties"] = game.wfrp4e.utility._prepareQualitiesFlaws(weapon, !!weapon.skillToUse); // Special flail rule - if no skill could be found, add the Dangerous property if (weapon.data.weaponGroup.value == game.i18n.localize("SPEC.Flail") && !weapon.skillToUse && !weapon.properties.includes(game.i18n.localize("PROPERTY.Dangerous"))) weapon.properties.push(game.i18n.localize("PROPERTY.Dangerous")); // Turn range into a numeric value (important for ranges including SB, see the function for details) weapon.data.range.value = this.calculateRangeOrDamage(weapon.data.range.value); // Melee Damage calculation if (weapon.attackType == "melee") { weapon["meleeWeaponType"] = true; // Turn melee damage formula into a numeric value (SB + 4 into a number) Melee damage increase flag comes from Strike Mighty Blow talent weapon.data.damage.value = this.calculateRangeOrDamage(weapon.data.damage.value) + (actorData.flags.meleeDamageIncrease || 0); // Very poor wording, but if the weapon has suffered damage (weaponDamage), subtract that amount from meleeValue (melee damage the weapon deals) if (weapon.data.weaponDamage) weapon.data.damage.value -= weapon.data.weaponDamage else weapon.data["weaponDamage"] = 0; } // Ranged Damage calculation else { weapon["rangedWeaponType"] = true; // Turn ranged damage formula into numeric value, same as melee Ranged damage increase flag comes from Accurate Shot weapon.data.damage.value = this.calculateRangeOrDamage(weapon.data.damage.value) + (actorData.flags.rangedDamageIncrease || 0) // Very poor wording, but if the weapon has suffered damage (weaponDamage), subtract that amount from rangedValue (ranged damage the weapon deals) if (weapon.data.weaponDamage) weapon.data.damage.value -= weapon.data.weaponDamage else weapon.data["weaponDamage"] = 0; } // If the weapon uses ammo... if (weapon.data.ammunitionGroup.value != "none") { weapon["ammo"] = []; // If a list of ammo has been provided, filter it by ammo that is compatible with the weapon type if (ammoList) { weapon.ammo = ammoList.filter(a => a.data.ammunitionType.value == weapon.data.ammunitionGroup.value) } else // If no ammo has been provided, filter through all items and find ammo that is compaptible for (let a of actorData.items) if (a.type == "ammunition" && a.data.ammunitionType.value == weapon.data.ammunitionGroup.value) // If is ammo and the correct type of ammo weapon.ammo.push(a); // Send to prepareWeaponWithAmmo for further calculation (Damage/range modifications based on ammo) this.prepareWeaponWithAmmo(weapon); } // If throwing or explosive weapon, its ammo is its own quantity else if (weapon.data.weaponGroup.value == game.i18n.localize("SPEC.Throwing") || weapon.data.weaponGroup.value == game.i18n.localize("SPEC.Explosives")) { weapon.data.ammunitionGroup.value = ""; } // If entangling, it has no ammo else if (weapon.data.weaponGroup.value == game.i18n.localize("SPEC.Entangling")) { weapon.data.ammunitionGroup.value = ""; } // Separate qualities and flaws into their own arrays: weapon.properties.qualities/flaws weapon.properties = game.wfrp4e.utility._separateQualitiesFlaws(weapon.properties); if (weapon.properties.spec) { for(let prop of weapon.properties.spec) { let spec if (prop == game.i18n.localize("Special")) weapon.properties.special = weapon.data.special.value; if (prop == game.i18n.localize("Special Ammo")) weapon.properties.specialammo = weapon.ammo.find(a => a._id == weapon.data.currentAmmo.value).data.special.value } } return weapon; } /** * Prepares an armour Item. * * Takes a an armour item, along with a persistent AP object to process the armour * into a useable format. Adding AP values and qualities to the AP object to be used * in display and opposed tests. * * @param {Object} armor 'armour' type item * @param {Object} AP Object consisting of numeric AP value for each location and a layer array to represent each armour layer * @return {Object} armor processed armor item */ prepareArmorCombat(armor, AP) { // Turn comma separated qualites/flaws into a more structured 'properties.qualities/flaws` string array armor.properties = game.wfrp4e.utility._separateQualitiesFlaws(game.wfrp4e.utility._prepareQualitiesFlaws(armor)); // Iterate through armor locations covered for (let apLoc in armor.data.currentAP) { // -1 is what all newly created armor's currentAP is initialized to, so if -1: currentAP = maxAP (undamaged) if (armor.data.currentAP[apLoc] == -1) { armor.data.currentAP[apLoc] = armor.data.maxAP[apLoc]; } } // If the armor protects a certain location, add the AP value of the armor to the AP object's location value // Then pass it to addLayer to parse out important information about the armor layer, namely qualities/flaws if (armor.data.maxAP.head > 0) { armor["protectsHead"] = true; AP.head.value += armor.data.currentAP.head; game.wfrp4e.utility.addLayer(AP, armor, "head") } if (armor.data.maxAP.body > 0) { armor["protectsBody"] = true; AP.body.value += armor.data.currentAP.body; game.wfrp4e.utility.addLayer(AP, armor, "body") } if (armor.data.maxAP.lArm > 0) { armor["protectslArm"] = true; AP.lArm.value += armor.data.currentAP.lArm; game.wfrp4e.utility.addLayer(AP, armor, "lArm") } if (armor.data.maxAP.rArm > 0) { armor["protectsrArm"] = true; AP.rArm.value += armor.data.currentAP.rArm; game.wfrp4e.utility.addLayer(AP, armor, "rArm") } if (armor.data.maxAP.lLeg > 0) { armor["protectslLeg"] = true; AP.lLeg.value += armor.data.currentAP.lLeg; game.wfrp4e.utility.addLayer(AP, armor, "lLeg") } if (armor.data.maxAP.rLeg > 0) { armor["protectsrLeg"] = true AP.rLeg.value += armor.data.currentAP.rLeg; game.wfrp4e.utility.addLayer(AP, armor, "rLeg") } return armor; } /** * Augments a prepared weapon based on its equipped ammo. * * Ammo can provide bonuses or penalties to the weapon using it, this function * takes a weapon, finds its current ammo, and applies those modifiers to the * weapon stats. For instance, if ammo that halves weapon range is equipped, * this is where it modifies the range of the weapon * * @param {Object} weapon A *prepared* weapon item * @return {Object} weapon Augmented weapon item */ prepareWeaponWithAmmo(weapon) { // Find the current ammo equipped to the weapon, if none, return let ammo = weapon.ammo.find(a => a._id == weapon.data.currentAmmo.value); if (!ammo) return; let ammoProperties = game.wfrp4e.utility._prepareQualitiesFlaws(ammo); // If ammo properties include a "special" value, rename the property as "Special Ammo" to not overlap // with the weapon's "Special" property let specialPropInd = ammoProperties.indexOf(ammoProperties.find(p => p && p.toLowerCase() == game.i18n.localize("Special").toLowerCase())); if (specialPropInd != -1) ammoProperties[specialPropInd] = game.i18n.localize("Special Ammo") let ammoRange = ammo.data.range.value || "0"; let ammoDamage = ammo.data.damage.value || "0"; // If range modification was handwritten, process it if (ammoRange.toLowerCase() == "as weapon") { } // Do nothing to weapon's range else if (ammoRange.toLowerCase() == "half weapon") weapon.data.range.value /= 2; else if (ammoRange.toLowerCase() == "third weapon") weapon.data.range.value /= 3; else if (ammoRange.toLowerCase() == "quarter weapon") weapon.data.range.value /= 4; else if (ammoRange.toLowerCase() == "twice weapon") weapon.data.range.value *= 2; else // If the range modification is a formula (supports +X -X /X *X) { try // Works for + and - { ammoRange = eval(ammoRange); weapon.data.range.value = Math.floor(eval(weapon.data.range.value + ammoRange)); } catch // if *X and /X { // eval (50 + "/5") = eval(50/5) = 10 weapon.data.range.value = Math.floor(eval(weapon.data.range.value + ammoRange)); } } try // Works for + and - { ammoDamage = eval(ammoDamage); weapon.data.damage.value = Math.floor(eval(weapon.data.damage.value + ammoDamage)); } catch // if *X and /X { // eval (5 + "*2") = eval(5*2) = 10 weapon.data.damage.value = Math.floor(eval(weapon.data.damage.value + ammoDamage)); // Eval throws exception for "/2" for example. } // The following code finds qualities or flaws of the ammo that add to the weapon's qualities // Example: Blast +1 should turn a weapon's Blast 4 into Blast 5 ammoProperties = ammoProperties.filter(p => p != undefined); let propertyChange = ammoProperties.filter(p => p.includes("+") || p.includes("-")); // Properties that increase or decrease another (Blast +1, Blast -1) // Normal properties (Impale, Penetrating) from ammo that need to be added to the equipped weapon let propertiesToAdd = ammoProperties.filter(p => !(p.includes("+") || p.includes("-"))); for (let change of propertyChange) { // Using the example of "Blast +1" to a weapon with "Blast 3" let index = change.indexOf(" "); let property = change.substring(0, index).trim(); // "Blast" let value = change.substring(index, change.length); // "+1" if (weapon.properties.find(p => p.includes(property))) // Find the "Blast" quality in the main weapon { let basePropertyIndex = weapon.properties.findIndex(p => p.includes(property)) let baseValue = weapon.properties[basePropertyIndex].split(" ")[1]; // Find the Blast value of the weapon (3) let newValue = eval(baseValue + value) // Assign the new value of Blast 4 weapon.properties[basePropertyIndex] = `${property} ${newValue}`; // Replace old Blast } else // If the weapon does not have the Blast quality to begin with { propertiesToAdd.push(property + " " + Number(value)); // Add blast as a new quality (Blast 1) } } // Add the new Blast property to the rest of the qualities the ammo adds to the weapon weapon.properties = weapon.properties.concat(propertiesToAdd); } /** * Prepares a 'spell' or 'prayer' Item type. * * Calculates many aspects of spells/prayers defined by characteristics - range, duration, damage, aoe, etc. * See the calculation function used for specific on how it processes these attributes. * * @param {Object} item 'spell' or 'prayer' Item * @return {Object} item Processed spell/prayer */ prepareSpellOrPrayer(item) { // Turns targets and duration into a number - (e.g. Willpower Bonus allies -> 4 allies, Willpower Bonus Rounds -> 4 rounds, Willpower Yards -> 46 yards) item['target'] = this.calculateSpellAttributes(item.data.target.value, item.data.target.aoe); item['duration'] = this.calculateSpellAttributes(item.data.duration.value); item['range'] = this.calculateSpellAttributes(item.data.range.value); item.overcasts = { available: 0, range: undefined, duration: undefined, target: undefined, } if (parseInt(item.target)) { item.overcasts.target = { label: "Target", count: 0, AoE: false, initial: parseInt(item.target) || item.target, current: parseInt(item.target) || item.target, unit: "" } } else if (item.target.includes("AoE")) { let aoeValue = item.target.substring(item.target.indexOf("(") + 1, item.target.length - 1) item.overcasts.target = { label: "AoE", count: 0, AoE: true, initial: parseInt(aoeValue) || aoeValue, current: parseInt(aoeValue) || aoeValue, unit: aoeValue.split(" ")[1] } } if (parseInt(item.duration)) { item.overcasts.duration = { label: "Duration", count: 0, initial: parseInt(item.duration) || item.duration, current: parseInt(item.duration) || item.duration, unit: item.duration.split(" ")[1] } } if (parseInt(item.range)) { item.overcasts.range = { label: "Range", count: 0, initial: parseInt(item.range) || aoeValue, current: parseInt(item.range) || aoeValue, unit: item.range.split(" ")[1] } } // Add the + to the duration if it's extendable if (item.data.duration.extendable) item.duration += "+"; // Calculate the damage different if it's a Magic Misile spell versus a prayer if (item.type == "spell") item['damage'] = this.calculateSpellDamage(item.data.damage.value, item.data.magicMissile.value); else item['damage'] = this.calculateSpellDamage(item.data.damage.value, false); // If it's a spell, augment the description (see _spellDescription() and CN based on memorization) if (item.type == "spell") { item.data.description.value = game.wfrp4e.utility._spellDescription(item); if (!item.data.memorized.value) item.data.cn.value *= 2; } return item; } /** * Turns a formula into a processed string for display * * Turns a spell attribute such as "Willpower Bonus Rounds" into a more user friendly, processed value * such as "4 Rounds". If the aoe is checked, it wraps the result in AoE (Result). * * @param {String} formula Formula to process - "Willpower Bonus Rounds" * @param {boolean} aoe Whether or not it's calculating AoE (changes string return) * @returns {String} formula processed formula */ calculateSpellAttributes(formula, aoe=false) { console.log("Compute FR") let actorData = this.data formula = formula.toLowerCase(); // Do not process these special values if (formula != game.i18n.localize("You").toLowerCase() && formula != game.i18n.localize("Special").toLowerCase() && formula != game.i18n.localize("Instant").toLowerCase()) { // Specific case, to avoid wrong matching with "Force" if (formula.includes("force mentale")) { // Determine if it's looking for the bonus or the value if (formula.includes('bonus')) { formula = formula.replace( "bonus de force mentale", actorData.data.characteristics["wp"].bonus); formula = formula.replace( "force mentale bonus", actorData.data.characteristics["wp"].bonus); } else formula = formula.replace("force mentale", actorData.data.characteristics["wp"].value); } if (formula.includes("yard") ) formula = formula.replace('yard', "mètre" ); if (formula.includes("yds") ) formula = formula.replace('yds', "m." ); // Iterate through remaining characteristics for(let ch in actorData.data.characteristics) { // If formula includes characteristic name //console.log("Testing :", ch, WFRP4E.characteristics[ch].toLowerCase()); if (formula.includes(game.wfrp4e.config.characteristics[ch].toLowerCase())) { // Determine if it's looking for the bonus or the value if (formula.includes('bonus')) { formula = formula.replace("bonus de " + game.wfrp4e.config.characteristics[ch].toLowerCase(), actorData.data.characteristics[ch].bonus); formula = formula.replace(game.wfrp4e.config.characteristics[ch].toLowerCase() + " bonus", actorData.data.characteristics[ch].bonus); } else formula = formula.replace(game.wfrp4e.config.characteristics[ch].toLowerCase(), actorData.data.characteristics[ch].value); } } } // If AoE - wrap with AoE ( ) if (aoe) formula = "AoE (" + formula.capitalize() + ")"; //console.log("calculateSpellAttributes -> " + formula ); return formula.capitalize(); } /** * Turns a formula into a processed string for display * * Processes damage formula based - same as calculateSpellAttributes, but with additional * consideration to whether its a magic missile or not * * @param {String} formula Formula to process - "Willpower Bonus + 4" * @param {boolean} isMagicMissile Whether or not it's a magic missile - used in calculating additional damage * @returns {String} Processed formula */ calculateSpellDamage(formula, isMagicMissile) { console.log("Compute FR") let actorData = this.data formula = formula.toLowerCase(); if (isMagicMissile) // If it's a magic missile, damage includes willpower bonus { formula += "+ " + actorData.data.characteristics["wp"].bonus } // Specific case, to avoid wrong matching with "Force" if (formula.includes("force mentale")) { // Determine if it's looking for the bonus or the value if (formula.includes('bonus')) { formula = formula.replace( "bonus de force mentale", actorData.data.characteristics["wp"].bonus); formula = formula.replace( "force mentale bonus", actorData.data.characteristics["wp"].bonus); } else formula = formula.replace("force mentale", actorData.data.characteristics["wp"].value); } // Iterate through characteristics for(let ch in actorData.data.characteristics) { // If formula includes characteristic name while (formula.includes(actorData.data.characteristics[ch].label.toLowerCase())) { // Determine if it's looking for the bonus or the value if (formula.includes('bonus')) { formula = formula.replace("bonus de " + game.wfrp4e.config.characteristics[ch].toLowerCase(), actorData.data.characteristics[ch].bonus); formula = formula.replace(game.wfrp4e.config.characteristics[ch].toLowerCase() + " bonus", actorData.data.characteristics[ch].bonus); } else formula = formula.replace(game.wfrp4e.config.characteristics[ch].toLowerCase(), actorData.data.characteristics[ch].value); } } //console.log("calculateSpellDamage -> " + formula ); return eval(formula); } /** * Construct armor penalty string based on armors equipped. * * For each armor, compile penalties and concatenate them into one string. * Does not stack armor *type* penalties. * * @param {Array} armorList array of processed armor items * @return {string} Penalty string */ calculateArmorPenalties(armorList) { let armorPenaltiesString = ""; // Armor type penalties do not stack, only apply if you wear any of that type let wearingMail = false; let wearingPlate = false; for (let a of armorList) { // For each armor, apply its specific penalty value, as well as marking down whether // it qualifies for armor type penalties (wearingMail/Plate) armorPenaltiesString += a.data.penalty.value + " "; if (a.data.armorType.value == "mail") wearingMail = true; if (a.data.armorType.value == "plate") wearingPlate = true; } // Apply armor type penalties at the end if (wearingMail || wearingPlate) { let stealthPenaltyValue = 0; if (wearingMail) stealthPenaltyValue += -10; if (wearingPlate) stealthPenaltyValue += -10; // Add the penalties together to reduce redundancy armorPenaltiesString += (stealthPenaltyValue + ` ${game.i18n.localize("NAME.Stealth")}`); } return armorPenaltiesString; } /** * Calculates a weapon's range or damage formula. * * Takes a weapon formula for Damage or Range (SB + 4 or SBx3) and converts to a numeric value. * * @param {String} formula formula to be processed (SBx3 => 9). * * @return {Number} Numeric formula evaluation */ calculateRangeOrDamage(formula) { console.log("FR function calculateRangeOrDamage !", formula); let actorData = this.data try { formula = formula.toLowerCase(); // Iterate through characteristics for(let ch in actorData.data.characteristics) { // Determine if the formula includes the characteristic's abbreviation + B (SB, WPB, etc.) if (formula.includes(ch.concat('b'))) { // Replace that abbreviation with the Bonus value formula = formula.replace(ch.concat('b'), actorData.data.characteristics[ch].bonus.toString()); } } if (formula.includes("yard") ) formula = formula.replace('yard', "mètre" ); if (formula.includes("yds") ) formula = formula.replace('yds', "m." ); // To evaluate multiplication, replace x with * formula = formula.replace('x', '*'); return eval(formula); } catch { return formula } } /** * Adds all missing basic skills to the Actor. * * This function will add all mising basic skills, used when an Actor is created (see create()) * as well as from the right click menu from the Actor directory. * */ async addBasicSkills() { let allItems = duplicate(this.data.items) let ownedBasicSkills = allItems.filter(i => i.type == "skill" && i.data.advanced.value == "bsc"); let allBasicSkills = await game.wfrp4e.utility.allBasicSkills() // Filter allBasicSkills with ownedBasicSkills, resulting in all the missing skills let skillsToAdd = allBasicSkills.filter(s => !ownedBasicSkills.find(ownedSkill => ownedSkill.name == s.name)) // Add those missing basic skills this.createEmbeddedEntity("OwnedItem", skillsToAdd); } /** * Calculates the wounds of an actor based on prepared items * * Once all the item preparation is done (prepareItems()), we have a list of traits/talents to use that will * factor into Wonuds calculation. Namely: Hardy and Size traits. If we find these, they must be considered * in Wound calculation. * * @returns {Number} Max wound value calculated */ _calculateWounds() { let hardies = this.data.items.filter(t => (t.type == "trait" || t.type == "talent") && t.name.toLowerCase().includes(game.i18n.localize("NAME.Hardy").toLowerCase())) let traits = this.data.items.filter(t => t.type == "trait") let tbMultiplier = hardies.length tbMultiplier += hardies.filter(h => h.type == "talent").reduce((extra, talent) => extra + talent.data.advances.value - 1, 0) // Add extra advances if some of the talents had multiple advances (rare, usually there are multiple talent items, not advances) // Easy to reference bonuses let sb = this.data.data.characteristics.s.bonus; let tb = this.data.data.characteristics.t.bonus; let wpb = this.data.data.characteristics.wp.bonus; if (this.data.flags.autoCalcCritW) this.data.data.status.criticalWounds.max = tb; let wounds = this.data.data.status.wounds.max; if (this.data.flags.autoCalcWounds) { // Construct trait means you use SB instead of WPB if (traits.find(t => t.name.toLowerCase().includes(game.i18n.localize("NAME.Construct").toLowerCase()) || traits.find(t => t.name.toLowerCase().includes(game.i18n.localize("NAME.Mindless").toLowerCase())))) wpb = sb; switch (this.data.data.details.size.value) // Use the size to get the correct formula (size determined in prepare()) { case "tiny": wounds = 1 + tb * tbMultiplier; break; case "ltl": wounds = tb + tb * tbMultiplier; break; case "sml": wounds = 2 * tb + wpb + tb * tbMultiplier; break; case "avg": wounds = sb + 2 * tb + wpb + tb * tbMultiplier; break; case "lrg": wounds = 2 * (sb + 2 * tb + wpb + tb * tbMultiplier); break; case "enor": wounds = 4 * (sb + 2 * tb + wpb + tb * tbMultiplier); break; case "mnst": wounds = 8 * (sb + 2 * tb + wpb + tb * tbMultiplier); break; } } let swarmTrait = traits.find(t => t.name.toLowerCase().includes(game.i18n.localize("NAME.Swarm").toLowerCase())) if (swarmTrait) wounds *= 5; return wounds } /** * Apply damage to an actor, taking into account armor, size, and weapons. * * applyDamage() is typically called at the end of an oppposed tests, where you can * right click the chat message and apply damage. This function goes through the * process of calculating and reducing damage if needede based on armor, toughness, * size, armor qualities/flaws, and weapon qualities/flaws * * @param {Object} victim id of actor taking damage * @param {Object} opposedData Test results, all the information needed to calculate damage * @param {var} damageType enum for what the damage ignores, see config.js */ static applyDamage(victim, opposeData, damageType = game.wfrp4e.config.DAMAGE_TYPE.NORMAL) { if (!opposeData.damage) return `Error: ${game.i18n.localize("CHAT.DamageAppliedError")}` // If no damage value, don't attempt anything if (!opposeData.damage.value) return game.i18n.localize("CHAT.DamageAppliedErrorTiring"); // Get actor/tokens for those in the opposed test let actor = game.wfrp4e.utility.getSpeaker(victim); let attacker = game.wfrp4e.utility.getSpeaker(opposeData.speakerAttack) let soundContext = { item: {}, action: "hit" }; // Start wound loss at the damage value let totalWoundLoss = opposeData.damage.value let newWounds = actor.data.data.status.wounds.value; let applyAP = (damageType == game.wfrp4e.config.DAMAGE_TYPE.IGNORE_TB || damageType == game.wfrp4e.config.DAMAGE_TYPE.NORMAL) let applyTB = (damageType == game.wfrp4e.config.DAMAGE_TYPE.IGNORE_AP || damageType == game.wfrp4e.config.DAMAGE_TYPE.NORMAL) let AP = {}; // Start message update string let updateMsg = `${game.i18n.localize("CHAT.DamageApplied")} " updateMsg = updateMsg.replace("@TOTAL", totalWoundLoss) // Update actor wound value actor.update({ "data.status.wounds.value": newWounds }) return updateMsg; } /* --------------------------------------------------------------------------------------------------------- */ /* -------------------------------------- Auto-Advancement Functions --------------------------------------- */ /* --------------------------------------------------------------------------------------------------------- */ /** * These functions are primarily for NPCs and Creatures and their automatic advancement capabilities. * /* --------------------------------------------------------------------------------------------------------- */ /** * Advances an actor's skills based on their species and character creation rules * * Per character creation, 3 skills from your species list are advanced by 5, and 3 more are advanced by 3. * This functions uses the Foundry Roll class to randomly select skills from the list (defined in config.js) * and advance the first 3 selected by 5, and the second 3 selected by 3. This function uses the advanceSkill() * helper defined below. */ async _advanceSpeciesSkills() { let skillList // A species may not be entered in the actor, so use some error handling. try { skillList = game.wfrp4e.config.speciesSkills[this.data.data.details.species.value]; if (!skillList) { // findKey() will do an inverse lookup of the species key in the species object defined in config.js, and use that if // user-entered species value does not work (which it probably will not) skillList = game.wfrp4e.config.speciesSkills[game.wfrp4e.utility.findKey(this.data.data.details.species.value, game.wfrp4e.config.species)] if (!skillList) { throw game.i18n.localize("Error.SpeciesSkills") + " " + this.data.data.details.species.value; } } } catch (error) { ui.notifications.info("Could not find species " + this.data.data.details.species.value) console.log("wfrp4e | Could not find species " + this.data.data.details.species.value + ": " + error); throw error } // The Roll class used to randomly select skills let skillSelector = new Roll(`1d${skillList.length}- 1`); skillSelector.roll().total; // Store selected skills let skillsSelected = []; while (skillsSelected.length < 6) { skillSelector = skillSelector.reroll() if (!skillsSelected.includes(skillSelector.total)) // Do not push duplicates skillsSelected.push(skillSelector.total); } // Advance the first 3 by 5, advance the second 3 by 3. for (let skillIndex = 0; skillIndex < skillsSelected.length; skillIndex++) { if (skillIndex <= 2) await this._advanceSkill(skillList[skillsSelected[skillIndex]], 5) else await this._advanceSkill(skillList[skillsSelected[skillIndex]], 3) } } /** * Advances an actor's talents based on their species and character creation rules * * Character creation rules for talents state that you get all talents in your species, but there * are a few where you must choose between two instead. See config.js for how the species talent * object is set up for support in this. Basically species talents are an array of strings, however * ones that offer a choice is formatted as "How does ${this.name} resist this corruption?`,
buttons: {
endurance: {
label: game.i18n.localize("NAME.Endurance"),
callback: () => {
let skill = this.items.find(i => i.name == game.i18n.localize("NAME.Endurance") && i.type == "skill")
if (skill) {
this.setupSkill(skill.data, { corruption: strength }).then(setupData => this.basicTest(setupData))
}
else {
this.setupCharacteristic("t", { corruption: strength }).then(setupData => this.basicTest(setupData))
}
}
},
cool: {
label: game.i18n.localize("NAME.Cool"),
callback: () => {
let skill = this.items.find(i => i.name == game.i18n.localize("NAME.Cool") && i.type == "skill")
if (skill) {
this.setupSkill(skill.data, { corruption: strength }).then(setupData => this.basicTest(setupData))
}
else {
this.setupCharacteristic("wp", { corruption: strength }).then(setupData => this.basicTest(setupData))
}
}
}
}
}).render(true)
}
async handleCorruptionResult(testResult) {
let strength = testResult.options.corruption;
let failed = testResult.target < testResult.roll;
let corruption = 0 // Corruption GAINED
switch (strength) {
case "minor":
if (failed)
corruption++;
break;
case "moderate":
if (failed)
corruption += 2
else if (testResult.SL < 2)
corruption += 1
break;
case "major":
if (failed)
corruption += 3
else if (testResult.SL < 2)
corruption += 2
else if (testResult.SL < 4)
corruption += 1
break;
}
let newCorruption = Number(this.data.data.status.corruption.value) + corruption
ChatMessage.create(game.wfrp4e.utility.chatDataSetup(`${this.name} gains ${corruption} Corruption.`, "gmroll", false))
await this.update({ "data.status.corruption.value": newCorruption })
if (corruption > 0)
this.checkCorruption();
}
async checkCorruption() {
if (this.data.data.status.corruption.value > this.data.data.status.corruption.max) {
let skill = this.items.find(i => i.name == game.i18n.localize("NAME.Endurance") && i.type == "skill")
if (skill) {
this.setupSkill(skill.data, { mutate: true }).then(setupData => {
this.basicTest(setupData)
});
}
else {
this.setupCharacteristic("t", { mutate: true }).then(setupData => {
this.basicTest(setupData)
});
}
}
}
async handleMutationResult(testResult) {
let failed = testResult.target < testResult.roll;
if (failed) {
let wpb = this.data.data.characteristics.wp.bonus;
let tableText = "Roll on a Corruption Table:
" + game.wfrp4e.config.corruptionTables.map(t => `@Table[${t}]
`).join("")
ChatMessage.create(game.wfrp4e.utility.chatDataSetup(`
As corruption ravages your soul, the warping breath of Chaos whispers within, either fanning your flesh into a fresh, new form, or fracturing your psyche with exquisite knowledge it can never unlearn.
${this.name} loses ${wpb} Corruption.
${tableText}
`, "gmroll", false)) this.update({ "data.status.corruption.value": Number(this.data.data.status.corruption.value) - wpb }) } else ChatMessage.create(game.wfrp4e.utility.chatDataSetup(`You have managed to hold off your corruption. For now.`, "gmroll", false)) } async handleExtendedTest(testResult) { let test = duplicate(this.getEmbeddedEntity("OwnedItem", testResult.options.extended)); if(game.settings.get("wfrp4e", "extendedTests") && testResult.SL == 0) testResult.SL = testResult.roll <= testResult.target ? 1 : -1 if (test.data.failingDecreases.value) { test.data.SL.current += Number(testResult.SL) if (!test.data.negativePossible.value && test.data.SL.current < 0) test.data.SL.current = 0; } else if(testResult.SL > 0) test.data.SL.current += Number(testResult.SL) let displayString = `${test.name} ${test.data.SL.current} / ${test.data.SL.target} SL` if (test.data.SL.current >= test.data.SL.target) { if (test.data.completion.value == "reset") test.data.SL.current = 0; else if (test.data.completion.value == "remove") { this.deleteEmbeddedEntity("OwnedItem", test._id) test = undefined } displayString = displayString.concat("