foundryvtt-wh4-lang-fr-fr/modules/fr-actor-wfrp4e.js

3610 lines
152 KiB
JavaScript

/**
* 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: `<p>${game.i18n.localize("ACTOR.BasicSkillsPrompt")}</p>`,
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 `<b>Error</b>: ${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 = `<b>${game.i18n.localize("CHAT.DamageApplied")}</b><span class = 'hide-option'>: @TOTAL`;
if (damageType != game.wfrp4e.config.DAMAGE_TYPE.IGNORE_ALL)
updateMsg += " ("
let weaponProperties
// If armor at hitloc has impenetrable value or not
let impenetrable = false;
// If weapon is undamaging
let undamaging = false;
// If weapon has Hack
let hack = false;
// If weapon has Impale
let impale = false;
// If weapon has Penetrating
let penetrating = false;
// if weapon has pummel - only used for audio
let pummel = false
// Reduce damage by TB
if (applyTB) {
totalWoundLoss -= actor.data.data.characteristics.t.bonus
updateMsg += actor.data.data.characteristics.t.bonus + " TB"
}
// If the actor has the Robust talent, reduce damage by times taken
totalWoundLoss -= actor.data.flags.robust || 0;
if (actor.data.flags.robust)
updateMsg += ` + ${actor.data.flags.robust} Robust`
if (applyAP) {
AP = actor.prepareItems().AP[opposeData.hitloc.value]
AP.ignored = 0;
if (opposeData.attackerTestResult.weapon) // If the attacker is using a weapon
{
// Determine its qualities/flaws to be used for damage calculation
weaponProperties = opposeData.attackerTestResult.weapon.properties;
penetrating = weaponProperties.qualities.includes(game.i18n.localize("PROPERTY.Penetrating"))
undamaging = weaponProperties.flaws.includes(game.i18n.localize("PROPERTY.Undamaging"))
hack = weaponProperties.qualities.includes(game.i18n.localize("PROPERTY.Hack"))
impale = weaponProperties.qualities.includes(game.i18n.localize("PROPERTY.Impale"))
pummel = weaponProperties.qualities.includes(game.i18n.localize("PROPERTY.Pummel"))
}
// see if armor flaws should be triggered
let ignorePartial = opposeData.attackerTestResult.roll % 2 == 0 || opposeData.attackerTestResult.extra.critical
let ignoreWeakpoints = opposeData.attackerTestResult.extra.critical && impale
// Mitigate damage with armor one layer at a time
for (let layer of AP.layers) {
if (ignoreWeakpoints && layer.weakpoints) {
AP.ignored += layer.value
}
else if (ignorePartial && layer.partial) {
AP.ignored += layer.value;
}
else if (penetrating) // If penetrating - ignore 1 or all armor depending on material
{
AP.ignored += layer.metal ? 1 : layer.value
}
if (opposeData.attackerTestResult.roll % 2 != 0 && layer.impenetrable) {
impenetrable = true;
soundContext.outcome = "impenetrable"
}
// Prioritize plate over chain over leather for sound
if (layer.value) {
if (layer.armourType == "plate")
soundContext.item.armourType = layer.armourType
else if (!soundContext.item.armourType || (soundContext.item.armourType && (soundContext.item.armourType.includes("leather")) && layer.armourType == "mail")) // set to chain if there isn't an armour type set yet, or the current armor type is leather
soundContext.item.armourType = layer.armourType
else if (!soundContext.item.armourType)
soundContext.item.armourType = "leather"
}
}
// AP.used is the actual amount of AP considered
AP.used = AP.value - AP.ignored
AP.used = AP.used < 0 ? 0 : AP.used; // AP minimum 0
AP.used = undamaging ? AP.used * 2 : AP.used; // Double AP if undamaging
// show the AP usage in the updated message
if (AP.ignored)
updateMsg += ` + ${AP.used}/${AP.value} ${game.i18n.localize("AP")}`
else
updateMsg += ` + ${AP.used} ${game.i18n.localize("AP")}`
// If using a shield, add that AP as well
let shieldAP = 0;
if (opposeData.defenderTestResult.weapon) {
if (opposeData.defenderTestResult.weapon.properties.qualities.find(q => q.toLowerCase().includes(game.i18n.localize("PROPERTY.Shield").toLowerCase())))
shieldAP = Number(opposeData.defenderTestResult.weapon.properties.qualities.find(q => q.toLowerCase().includes(game.i18n.localize("PROPERTY.Shield").toLowerCase())).split(" ")[1]);
}
if (shieldAP)
updateMsg += ` + ${shieldAP} ${game.i18n.localize("CHAT.DamageShield")})`
else
updateMsg += ")"
// Reduce damage done by AP
totalWoundLoss -= (AP.used + shieldAP)
// Minimum 1 wound if not undamaging
if (!undamaging)
totalWoundLoss = totalWoundLoss <= 0 ? 1 : totalWoundLoss
else
totalWoundLoss = totalWoundLoss <= 0 ? 0 : totalWoundLoss
try {
if (opposeData.attackerTestResult.weapon.attackType == "melee") {
if ((weaponProperties.qualities.concat(weaponProperties.flaws)).every(p => [game.i18n.localize("PROPERTY.Pummel"), game.i18n.localize("PROPERTY.Slow"), game.i18n.localize("PROPERTY.Damaging")].includes(p)))
soundContext.outcome = "warhammer" // special sound for warhammer :^)
else if (AP.used) {
soundContext.item.type = "armour"
if (applyAP && totalWoundLoss <= 1)
soundContext.outcome = "blocked"
else if (applyAP)
soundContext.outcome = "normal"
if (impenetrable)
soundContext.outcome = "impenetrable"
if (hack)
soundContext.outcome = "hack"
}
else {
soundContext.item.type = "hit"
soundContext.outcome = "normal"
if (impale || penetrating) {
soundContext.outcome = "normal_slash"
}
}
}
}
catch (e) { console.log("wfrp4e | Sound Context Error: " + e) } // Ignore sound errors
}
else updateMsg += ")"
newWounds -= totalWoundLoss
game.wfrp4e.audio.PlayContextAudio(soundContext)
// If damage taken reduces wounds to 0, show Critical
if (newWounds <= 0 && !impenetrable) {
//WFRP_Audio.PlayContextAudio(opposeData.attackerTestResult.weapon, {"type": "hit", "equip": "crit"})
let critAmnt = game.settings.get("wfrp4e", "dangerousCritsMod")
if (game.settings.get("wfrp4e", "dangerousCrits") && critAmnt && (Math.abs(newWounds) - actor.data.data.characteristics.t.bonus) > 0) {
let critModifier = (Math.abs(newWounds) - actor.data.data.characteristics.t.bonus) * critAmnt;
updateMsg += `<br><a class ="table-click critical-roll" data-modifier=${critModifier} data-table = "crit${opposeData.hitloc.value}" ><i class='fas fa-list'></i> ${game.i18n.localize("Critical")} +${critModifier}</a>`
}
else if (Math.abs(newWounds) < actor.data.data.characteristics.t.bonus)
updateMsg += `<br><a class ="table-click critical-roll" data-modifier="-20" data-table = "crit${opposeData.hitloc.value}" ><i class='fas fa-list'></i> ${game.i18n.localize("Critical")} (-20)</a>`
else
updateMsg += `<br><a class ="table-click critical-roll" data-table = "crit${opposeData.hitloc.value}" ><i class='fas fa-list'></i> ${game.i18n.localize("Critical")}</a>`
}
else if (impenetrable)
updateMsg += `<br>${game.i18n.localize("PROPERTY.Impenetrable")} - ${game.i18n.localize("CHAT.CriticalsNullified")}`
if (hack)
updateMsg += `<br>${game.i18n.localize("CHAT.DamageAP")} ${game.wfrp4e.config.locations[opposeData.hitloc.value]}`
if (newWounds <= 0)
newWounds = 0; // Do not go below 0 wounds
updateMsg += "</span>"
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 "<talent1>, <talent2>", each talent being a choice. Finally,
* the last element of the talent list is a number denoting the number of random talents. This function uses
* the advanceTalent() helper defined below.
*/
async _advanceSpeciesTalents() {
// A species may not be entered in the actor, so use some error handling.
let talentList
try {
talentList = game.wfrp4e.config.speciesTalents[this.data.data.details.species.value];
if (!talentList) {
// 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)
talentList = game.wfrp4e.config.speciesTalents[game.wfrp4e.utility.findKey(this.data.data.details.species.value, game.wfrp4e.config.species)]
if (!talentList)
throw game.i18n.localize("Error.SpeciesTalents") + " " + 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
}
let talentSelector;
for (let talent of talentList) {
if (!isNaN(talent)) // If is a number, roll on random talents
{
for (let i = 0; i < talent; i++) {
let result = game.wfrp4e.tables.rollTable("talents")
await this._advanceTalent(result.name);
}
continue
}
// If there is a comma, talent.split() will yield an array of length > 1
let talentOptions = talent.split(',').map(function (item) {
return item.trim();
});
// Randomly choose a talent option and advance it.
if (talentOptions.length > 1) {
talentSelector = new Roll(`1d${talentOptions.length} - 1`)
await this._advanceTalent(talentOptions[talentSelector.roll().total])
}
else // If no option, simply advance the talent.
{
await this._advanceTalent(talent)
}
}
}
/**
* Adds (if needed) and advances a skill by the specified amount.
*
* As the name suggests, this function advances any given skill, if
* the actor does not currently have that skill, it will be added
* from the compendium and advanced. Note that this function is neither
* used by manually advancing skills nor when clicking on advancement
* indicators. This will simply add the advancement value with no
* other processing.
*
* @param {String} skillName Name of the skill to advance/add
* @param {Number} advances Advances to add to the skill
*/
async _advanceSkill(skillName, advances) {
// Look through items and determine if the actor has the skill
let existingSkill = this.data.items.find(i => i.name.trim() == skillName && i.type == "skill")
// If so, simply update the skill with the new advancement value.
if (existingSkill) {
existingSkill = duplicate(existingSkill);
// If the existing skill has a greater amount of advances, use the greater value instead (make no change) - ??? Is this needed? I'm not sure why I did this. TODO: Evaluate.
existingSkill.data.advances.value = (existingSkill.data.advances.value < advances) ? advances : existingSkill.data.advances.value;
await this.updateEmbeddedEntity("OwnedItem", existingSkill);
return;
}
// If the actor does not already own skill, search through compendium and add it
try {
// See findSkill() for a detailed explanation of how it works
// Advanced find function, returns the skill the user expects it to return, even with skills not included in the compendium (Lore (whatever))
let skillToAdd = await game.wfrp4e.utility.findSkill(skillName)
skillToAdd.data.data.advances.value = advances;
await this.createEmbeddedEntity("OwnedItem", skillToAdd.data);
}
catch (error) {
console.error("Something went wrong when adding skill " + skillName + ": " + error);
ui.notifications.error("Something went wrong when adding skill " + skillName + ": " + error);
}
}
/**
* Adds the given talent to the actor
*
* In my implementation, adding a talent is the same as advancing a talent. See
* prepareTalent() and you'll see that the total number of any given talent is the
* advencement value.
*
* @param {String} talentName Name of the talent to add/advance.
*/
async _advanceTalent(talentName) {
try {
// See findTalent() for a detailed explanation of how it works
// Advanced find function, returns the Talent the user expects it to return, even with Talents not included in the compendium (Etiquette (whatever))
let talent = await game.wfrp4e.utility.findTalent(talentName);
await this.createEmbeddedEntity("OwnedItem", talent.data);
}
catch (error) {
console.error("Something went wrong when adding talent " + talentName + ": " + error);
ui.notifications.error("Something went wrong when adding talent " + talentName + ": " + error);
}
}
/**
* Advance NPC based on given career
*
* A specialized function used by NPC type Actors that triggers when you click on a
* career to be "complete". This takes all the career data and uses it (and the helpers
* defined above) to advance the actor accordingly. It adds all skills (advanced to the
* correct amount to be considered complete), advances all characteristics similarly, and
* adds all talents.
*
* Note: This adds *all* skills and talents, which is not necessary to be considered complete.
* However, I find deleting the ones you don't want to be much easier than trying to pick and
* choose the ones you do want.
*
* @param {Object} careerData Career type Item to be used for advancement.
*
* TODO Refactor for embedded entity along with the helper functions
*/
async _advanceNPC(careerData) {
let updateObj = {};
let advancesNeeded = careerData.level.value * 5; // Tier 1 needs 5, 2 needs 10, 3 needs 15, 4 needs 20 in all characteristics and skills
// Update all necessary characteristics to the advancesNeeded
for (let advChar of careerData.characteristics)
if (this.data.data.characteristics[advChar].advances < 5 * careerData.level.value)
updateObj[`data.characteristics.${advChar}.advances`] = 5 * careerData.level.value;
// Advance all skills in the career
for (let skill of careerData.skills)
await this._advanceSkill(skill, advancesNeeded);
// Add all talents in the career
for (let talent of careerData.talents)
await this._advanceTalent(talent);
this.update(updateObj);
}
_replaceData(formula) {
let dataRgx = new RegExp(/@([a-z.0-9]+)/gi);
return formula.replace(dataRgx, (match, term) => {
let value = getProperty(this.data, term);
return value ? String(value).trim() : "0";
});
}
/**
* Use a fortune point from the actor to reroll or add sl to a roll
* @param {Object} message
* @param {String} type (reroll, addSL)
*/
useFortuneOnRoll(message, type) {
if (this.data.data.status.fortune.value > 0) {
message.data.flags.data.preData.roll = undefined;
let data = message.data.flags.data;
let html = `<h3 class="center"><b>${game.i18n.localize("FORTUNE.Use")}</b></h3>`;
//First we send a message to the chat
if (type == "reroll")
html += `${game.i18n.format("FORTUNE.UsageRerollText", { character: '<b>' + this.name + '</b>' })}<br>`;
else
html += `${game.i18n.format("FORTUNE.UsageAddSLText", { character: '<b>' + this.name + '</b>' })}<br>`;
html += `<b>${game.i18n.localize("FORTUNE.PointsRemaining")} </b>${this.data.data.status.fortune.value - 1}`;
ChatMessage.create(game.wfrp4e.utility.chatDataSetup(html));
let cardOptions = this.preparePostRollAction(message);
//Then we do the actual fortune action
if (type == "reroll") {
cardOptions.fortuneUsedReroll = true;
cardOptions.hasBeenCalculated = false;
cardOptions.calculatedMessage = [];
//It was an unopposed targeted test who failed
if (data.originalTargets && data.originalTargets.size > 0) {
game.user.targets = data.originalTargets;
//Foundry has a circular reference to the user in its targets set so we do it too
game.user.targets.user = game.user;
}
//It it is an ongoing opposed test, we transfer the list of the startMessages to update them
if (!data.defenderMessage && data.startMessagesList) {
cardOptions.startMessagesList = data.startMessagesList;
}
delete data.preData.roll;
delete data.preData.SL;
new ActorWfrp4e_fr(data.postData.actor)[`${data.postData.postFunction}`]({testData : data.preData, cardOptions});
//We also set fortuneUsedAddSL to force the player to use it on the new roll
message.update({
"flags.data.fortuneUsedReroll": true,
"flags.data.fortuneUsedAddSL": true
});
}
else //addSL
{
let newTestData = data.preData;
newTestData.SL = Math.trunc(data.postData.SL) + 1;
newTestData.slBonus = 0;
newTestData.successBonus = 0;
newTestData.roll = Math.trunc(data.postData.roll);
newTestData.hitloc = data.preData.hitloc;
//We deselect the token,
//2020-04-25 : Currently the foundry function is bugged so we do it ourself
//game.user.updateTokenTargets([]);
game.user.targets.forEach(t => t.setTarget(false, { user: game.user, releaseOthers: false, groupSelection: true }));
cardOptions.fortuneUsedAddSL = true;
new ActorWfrp4e_fr(data.postData.actor)[`${data.postData.postFunction}`]({testData : newTestData, cardOptions}, {rerenderMessage : message});
message.update({
"flags.data.fortuneUsedAddSL": true
});
}
this.update({ "data.status.fortune.value": this.data.data.status.fortune.value - 1 });
}
}
/**
* Take a Dark Deal to reroll for +1 Corruption
* @param {Object} message
*/
useDarkDeal(message) {
let html = `<h3 class="center"><b>${game.i18n.localize("DARKDEAL.Use")}</b></h3>`;
html += `${game.i18n.format("DARKDEAL.UsageText", { character: '<b>' + this.name + '</b>' })}<br>`;
let corruption = Math.trunc(this.data.data.status.corruption.value) + 1;
html += `<b>${game.i18n.localize("Corruption")}: </b>${corruption}/${this.data.data.status.corruption.max}`;
ChatMessage.create(game.wfrp4e.utility.chatDataSetup(html));
this.update({ "data.status.corruption.value": corruption }).then(() => {
this.checkCorruption();
});
message.data.flags.data.preData.roll = undefined;
let cardOptions = this.preparePostRollAction(message);
let data = message.data.flags.data;
cardOptions.fortuneUsedReroll = data.fortuneUsedReroll;
cardOptions.fortuneUsedAddSL = data.fortuneUsedAddSL;
cardOptions.hasBeenCalculated = false;
cardOptions.calculatedMessage = [];
//It was an unopposed targeted test who failed
if (data.originalTargets && data.originalTargets.size > 0) {
game.user.targets = data.originalTargets;
//Foundry has a circular reference to the user in its targets set so we do it too
game.user.targets.user = game.user;
}
//It it is an ongoing opposed test, we transfer the list of the startMessages to update them
if (!data.defenderMessage && data.startMessagesList) {
cardOptions.startMessagesList = data.startMessagesList;
}
delete message.data.flags.data.preData.roll;
delete message.data.flags.data.preData.SL;
new ActorWfrp4e_fr(data.postData.actor)[`${data.postData.postFunction}`]({testData : data.preData, cardOptions});
}
/**
* This helper can be used to prepare cardOptions to reroll/edit a test card
* It uses the informations of the roll located in the message entry
* from game.messages
* @param {Object} message
* @returns {Object} cardOptions
*/
preparePostRollAction(message) {
//recreate the initial (virgin) cardOptions object
//add a flag for reroll limit
let data = message.data.flags.data;
let cardOptions = {
flags: { img: message.data.flags.img },
rollMode: data.rollMode,
sound: message.data.sound,
speaker: message.data.speaker,
template: data.template,
title: data.title.replace(` - ${game.i18n.localize("Opposed")}`, ""),
user: message.data.user
};
if (data.attackerMessage)
cardOptions.attackerMessage = data.attackerMessage;
if (data.defenderMessage)
cardOptions.defenderMessage = data.defenderMessage;
if (data.unopposedStartMessage)
cardOptions.unopposedStartMessage = data.unopposedStartMessage;
return cardOptions;
}
async corruptionDialog(strength) {
new Dialog({
title: "Corrupting Influence",
content: `<p>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(`<b>${this.name}</b> 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:<br>" + game.wfrp4e.config.corruptionTables.map(t => `@Table[${t}]<br>`).join("")
ChatMessage.create(game.wfrp4e.utility.chatDataSetup(`
<h3>Dissolution of Body and Mind</h3>
<p>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.</p>
<p><b>${this.name}</b> loses ${wpb} Corruption.
<p>${tableText}</p>`,
"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("<br>" + "<b>Completed</b>")
}
testResult.other.push(displayString)
if (test)
this.updateEmbeddedEntity("OwnedItem", test);
}
}