|
const MODULE_ID = 'pf2e-modifiers-matter'
|
|
// TODO - currently impossible, but in the future may be possible to react to effects that change embedded DCs in Note rule elements.
|
|
// See: https://github.com/foundryvtt/pf2e/issues/9824
|
|
// for example, the Monk's Stunning Fist uses a Class DC but this module won't recognize modifiers to that DC in this situation.
|
|
|
|
// Helpful for testing - replace random dice roller with 1,2,3,4....19,20 by putting this in the console:
|
|
/*
|
|
NEXT_RND_ROLLS_D20 = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]
|
|
rndIndex = -1
|
|
CONFIG.Dice.randomUniform = () => {rndIndex = (rndIndex + 1) % NEXT_RND_ROLLS_D20.length; return NEXT_RND_ROLLS_D20[rndIndex] / 20 - 0.001}
|
|
*/
|
|
|
|
// this file has a ton of math (mostly simple).
|
|
// I did my best to make it all easily understandable math, but there are limits to what I can do.
|
|
|
|
/**
|
|
* ESSENTIAL (strong green) - This modifier was necessary to achieve this degree of success (DoS). Others were
|
|
* potentially also necessary. You should thank the character who caused this modifier!
|
|
*
|
|
* HELPFUL (weak green) - This modifier was not necessary to achieve this DoS, but degree of success did change due to
|
|
* modifiers in this direction, and at least one of the helpful modifiers was needed. For example, if you rolled a 14,
|
|
* had +1 & +2, and needed a 15, both the +1 and +2 are weak green because neither is necessary on its own, but they
|
|
* were necessary together. If you had rolled a 13 in this case, the +2 would be strong green but the +1 would still be
|
|
* weak green, simply because it's difficult to come up with an algorithm that would solve complex cases.
|
|
* Note, by the way, that in case of multiple non-stacking modifiers, PF2e hides some of them from the chat card.
|
|
*
|
|
* NONE - This modifier did not affect the DoS at all, this time.
|
|
*
|
|
* HARMFUL (orange) - Like HELPFUL but in the opposite direction. Without all the harmful modifiers you had (but
|
|
* not without any one of them), you would've gotten a better DoS.
|
|
*
|
|
* DETRIMENTAL (red) - Like ESSENTIAL but in the opposite direction. Without this, you would've gotten a better DoS.
|
|
*/
|
|
const SIGNIFICANCE = Object.freeze({
|
|
ESSENTIAL: 'ESSENTIAL',
|
|
HELPFUL: 'HELPFUL',
|
|
NONE: 'NONE',
|
|
HARMFUL: 'HARMFUL',
|
|
DETRIMENTAL: 'DETRIMENTAL',
|
|
})
|
|
const COLOR_BY_SIGNIFICANCE = Object.freeze({
|
|
ESSENTIAL: '#008000',
|
|
HELPFUL: '#91a82a',
|
|
NONE: '#000000',
|
|
HARMFUL: '#ff0000',
|
|
DETRIMENTAL: '#ff852f',
|
|
})
|
|
let IGNORED_MODIFIER_LABELS = []
|
|
let IGNORED_MODIFIER_LABELS_FOR_AC_ONLY = []
|
|
|
|
let warnedAboutLocalization = false
|
|
const tryLocalize = (key, defaultValue) => {
|
|
const localized = game.i18n.localize(key)
|
|
if (localized === key) {
|
|
if (!warnedAboutLocalization) {
|
|
console.warn(`${MODULE_ID}: failed to localize ${key}`)
|
|
warnedAboutLocalization = true
|
|
}
|
|
return defaultValue
|
|
}
|
|
return localized
|
|
}
|
|
|
|
const initializeIgnoredModifiers = () => {
|
|
const IGNORED_MODIFIERS_I18N = [
|
|
'PF2E.BaseModifier',
|
|
'PF2E.ModifierTitle',
|
|
'PF2E.MultipleAttackPenalty',
|
|
'PF2E.ProficiencyLevel0',
|
|
'PF2E.ProficiencyLevel1',
|
|
'PF2E.ProficiencyLevel2',
|
|
'PF2E.ProficiencyLevel3',
|
|
'PF2E.ProficiencyLevel4',
|
|
'PF2E.AbilityStr',
|
|
'PF2E.AbilityCon',
|
|
'PF2E.AbilityDex',
|
|
'PF2E.AbilityInt',
|
|
'PF2E.AbilityWis',
|
|
'PF2E.AbilityCha',
|
|
'PF2E.PotencyRuneLabel',
|
|
'PF2E.RuleElement.WeaponPotency',
|
|
'PF2E.AutomaticBonusProgression.attackPotency',
|
|
'PF2E.AutomaticBonusProgression.defensePotency',
|
|
'PF2E.AutomaticBonusProgression.savePotency',
|
|
'PF2E.AutomaticBonusProgression.perceptionPotency',
|
|
'PF2E.NPC.Adjustment.EliteLabel',
|
|
'PF2E.NPC.Adjustment.WeakLabel',
|
|
'PF2E.MasterSavingThrow.fortitude',
|
|
'PF2E.MasterSavingThrow.reflex',
|
|
'PF2E.MasterSavingThrow.will',
|
|
`${MODULE_ID}.IgnoredModifiers.DeviseAStratagem`, // Investigator
|
|
`${MODULE_ID}.IgnoredModifiers.HuntersEdgeFlurry1`, // Ranger, replaces multiple attack penalty
|
|
`${MODULE_ID}.IgnoredModifiers.HuntersEdgeFlurry2`, // same
|
|
`${MODULE_ID}.IgnoredModifiers.HuntersEdgeFlurry3`, // same, Ranger's companion
|
|
// NOTE: all spells that end in "form" are also ignored for the attack bonus; e.g. Ooze Form
|
|
// also some battle form spells with different names:
|
|
`${MODULE_ID}.IgnoredModifiers.BattleForm1`, // battle form
|
|
`${MODULE_ID}.IgnoredModifiers.BattleForm2`, // battle form
|
|
`${MODULE_ID}.IgnoredModifiers.BattleForm3`, // battle form
|
|
`${MODULE_ID}.IgnoredModifiers.BattleForm4`, // battle form
|
|
// yes I'm gonna add my houserules to my module, you can't stop me.
|
|
// https://discord.com/channels/880968862240239708/880969943724728391/1082678343234760704
|
|
`${MODULE_ID}.IgnoredModifiers.SpellAttackHouserule`,
|
|
`${MODULE_ID}.IgnoredModifiers.SpellPotency1`,
|
|
`${MODULE_ID}.IgnoredModifiers.SpellPotency2`,
|
|
`${MODULE_ID}.IgnoredModifiers.SkillPotency1`,
|
|
`${MODULE_ID}.IgnoredModifiers.SkillPotency2`,
|
|
// compatibility with a module, pf2e-flatten, which adds modifiers to match the PWoL variants.
|
|
// https://github.com/League-of-Foundry-Developers/pf2e-flatten/blob/main/bundle.js#L41
|
|
`${MODULE_ID}.IgnoredModifiers3p.pf2e-flatten_pwol`,
|
|
`${MODULE_ID}.IgnoredModifiers3p.pf2e-flatten_pwol_half`,
|
|
]
|
|
IGNORED_MODIFIER_LABELS = IGNORED_MODIFIERS_I18N.map(str => tryLocalize(str, str)).
|
|
concat(getSetting('additional-ignored-labels').split(';'))
|
|
IGNORED_MODIFIER_LABELS_FOR_AC_ONLY = [
|
|
// effect that replaces your AC item bonus and dex cap - super hard to calculate its "true" bonus so I just ignore.
|
|
// however, this effect also has other modifiers which I don't want to ignore.
|
|
`${MODULE_ID}.IgnoredModifiers.DrakeheartMutagen`,
|
|
].map(str => tryLocalize(str, str))
|
|
}
|
|
|
|
const sumMods = (modsList) => modsList.reduce((accumulator, curr) => accumulator + curr.modifier, 0)
|
|
const modifierPositive = m => m.modifier > 0
|
|
const modifierNegative = m => m.modifier < 0
|
|
const getOffGuardAcMod = () => {
|
|
const offGuardSlug = isNewerVersion(game.version, '5.3') ? 'off-guard' : 'flat-footed'
|
|
const systemOffGuardCondition = game.pf2e.ConditionManager.getCondition(offGuardSlug)
|
|
return {
|
|
label: systemOffGuardCondition.name,
|
|
modifier: -2,
|
|
type: 'circumstance',
|
|
}
|
|
}
|
|
const dcModsOfStatistic = (dcStatistic, actorWithDc) => {
|
|
return dcStatistic.modifiers
|
|
// remove if not enabled, or ignored
|
|
.filter(m => m.enabled && !m.ignored)
|
|
// remove everything that should be ignored (including user-defined)
|
|
.filter(m => !IGNORED_MODIFIER_LABELS.includes(m.label))
|
|
// ignore item bonuses that come from armor, they're Resilient runes
|
|
.filter(m => !(
|
|
m.type === 'item'
|
|
// comparing the modifier label to the names of the actor's Armor items
|
|
&& actorWithDc?.attributes.ac.modifiers.some(m2 => m2.label === m.label)
|
|
))
|
|
}
|
|
const rollModsFromChatMessage = (modifiersFromChatMessage, rollingActor, dcType) => {
|
|
return modifiersFromChatMessage
|
|
// enabled is false for one of the conditions if it can't stack with others
|
|
.filter(m => m.enabled && !m.ignored)
|
|
// ignoring standard things from list (including user-defined)
|
|
.filter(m => !IGNORED_MODIFIER_LABELS.includes(m.label))
|
|
// for attacks, ignore all "form" spells that replace your attack bonus
|
|
.filter(m => !(dcType === 'armor' && m.slug.endsWith('-form')))
|
|
// for attacks/skills, ignore Doubling Rings which are basically a permanent item bonus
|
|
.filter(m => !m.slug.startsWith('doubling-rings'))
|
|
// TODO - ignore item bonuses that are permanent (mostly skill items)
|
|
|
|
// TODO - can next thing be removed?
|
|
// for saving throws, ignore item bonuses that come from armor, they're Resilient runes
|
|
.filter(m => !(
|
|
m.type === 'item'
|
|
// comparing the modifier label to the name of the rolling actor's Armor item
|
|
&& rollingActor?.attributes.ac.modifiers.some(m2 => m2.label === m.label)
|
|
))
|
|
}
|
|
|
|
const DEGREES = Object.freeze({
|
|
CRIT_SUCC: 'CRIT_SUCC',
|
|
SUCCESS: 'SUCCESS',
|
|
FAILURE: 'FAILURE',
|
|
CRIT_FAIL: 'CRIT_FAIL',
|
|
})
|
|
|
|
// REMEMBER: in Pf2e, delta 0-9 means SUCCESS, delta 10+ means CRIT SUCCESS, delta -1-9 is FAIL, delta -10- is CRIT FAIL
|
|
const calcDegreeOfSuccess = (deltaFromDc) => {
|
|
switch (true) {
|
|
case deltaFromDc >= 10:
|
|
return DEGREES.CRIT_SUCC
|
|
case deltaFromDc <= -10:
|
|
return DEGREES.CRIT_FAIL
|
|
case deltaFromDc >= 1:
|
|
return DEGREES.SUCCESS
|
|
case deltaFromDc <= -1:
|
|
return DEGREES.FAILURE
|
|
case deltaFromDc === 0:
|
|
return DEGREES.SUCCESS
|
|
}
|
|
// impossible
|
|
console.error(`${MODULE_ID} | calcDegreeOfSuccess got wrong number: ${deltaFromDc}`)
|
|
return DEGREES.CRIT_FAIL
|
|
}
|
|
const calcDegreePlusRoll = (deltaFromDc, dieRoll) => {
|
|
const degree = calcDegreeOfSuccess(deltaFromDc)
|
|
// handle natural 20 and natural 1
|
|
if (dieRoll === 20) {
|
|
switch (degree) {
|
|
case 'CRIT_SUCC':
|
|
return DEGREES.CRIT_SUCC
|
|
case 'SUCCESS':
|
|
return DEGREES.CRIT_SUCC
|
|
case 'FAILURE':
|
|
return DEGREES.SUCCESS
|
|
case 'CRIT_FAIL':
|
|
return DEGREES.FAILURE
|
|
}
|
|
} else if (dieRoll === 1) {
|
|
switch (degree) {
|
|
case 'CRIT_SUCC':
|
|
return DEGREES.SUCCESS
|
|
case 'SUCCESS':
|
|
return DEGREES.FAILURE
|
|
case 'FAILURE':
|
|
return DEGREES.CRIT_FAIL
|
|
case 'CRIT_FAIL':
|
|
return DEGREES.CRIT_FAIL
|
|
}
|
|
} else return degree
|
|
}
|
|
|
|
const shouldIgnoreStrikeCritFailToFail = (oldDOS, newDOS, isStrike) => {
|
|
// only ignore in this somewhat common edge case:
|
|
return (
|
|
// fail changed to crit fail, or vice versa
|
|
((oldDOS === DEGREES.FAILURE && newDOS === DEGREES.CRIT_FAIL)
|
|
|| (oldDOS === DEGREES.CRIT_FAIL && newDOS === DEGREES.FAILURE))
|
|
// and this game setting is enabled
|
|
&& getSetting('ignore-crit-fail-over-fail-on-attacks')
|
|
// and it was a Strike attack
|
|
&& isStrike
|
|
)
|
|
}
|
|
|
|
/**
|
|
* dcFlavorSuffix will be e.g. 'Off-Guard -2, Frightened -1'
|
|
*/
|
|
const insertDcFlavorSuffix = ($flavorText, dcFlavorSuffix, dcActorType) => {
|
|
const showDefenseHighlightsToEveryone = getSetting('show-defense-highlights-to-everyone')
|
|
const dataVisibility = showDefenseHighlightsToEveryone ? 'all' : 'gm'
|
|
const messageKey = dcActorType === 'target' ? `${MODULE_ID}.Message.TargetHas`
|
|
: dcActorType === 'caster' ? `${MODULE_ID}.Message.CasterHas`
|
|
: `${MODULE_ID}.Message.ActorHas`
|
|
$flavorText.find('div.degree-of-success').before(
|
|
`<div data-visibility="${dataVisibility}">
|
|
${tryLocalize(messageKey, 'Target has:')} <b>${dcFlavorSuffix}</b>
|
|
</div>`)
|
|
}
|
|
|
|
const hook_preCreateChatMessage = async (chatMessage, data) => {
|
|
// continue only if message is a PF2e roll message with a rolling actor
|
|
if (
|
|
!chatMessage.flags
|
|
|| !chatMessage.flags.pf2e
|
|
|| !chatMessage.flags.pf2e.modifiers
|
|
|| !chatMessage.flags.pf2e.context.dc
|
|
|| !chatMessage.flags.pf2e.context.actor
|
|
) return true
|
|
|
|
const rollingActor = game.actors.get(chatMessage.flags.pf2e.context.actor)
|
|
// here I assume the PF2E system always includes the d20 roll as the first roll! and as the first term of that roll!
|
|
const roll = chatMessage.rolls[0]
|
|
const rollTotal = roll?.total !== undefined ? roll.total : parseInt(chatMessage.content)
|
|
// dc.value is usually defined, but apparently not when Escaping vs an enemy's Athletics DC
|
|
const rollDc = chatMessage.flags.pf2e.context.dc.value ?? chatMessage.flags.pf2e.context.dc.parent?.dc?.value
|
|
const deltaFromDc = rollTotal - rollDc
|
|
// using roll.terms[0].total will work when rolling 1d20+9, or 2d20kh+9 (RollTwice RE), or 10+9 (SubstituteRoll RE)
|
|
const dieRoll = roll.terms[0].total
|
|
const currentDegreeOfSuccess = calcDegreePlusRoll(deltaFromDc, dieRoll)
|
|
const dcSlug = chatMessage.flags.pf2e.context.dc.slug ?? chatMessage.flags.pf2e.context.dc.parent?.slug
|
|
const isStrike = dcSlug === 'armor'
|
|
const isSpell = chatMessage.flags.pf2e.origin?.type === 'spell'
|
|
const targetedTokenUuid = chatMessage.flags.pf2e.context.target?.token
|
|
const targetedActorUuid = chatMessage.flags.pf2e.context.target?.actor
|
|
const targetedToken = targetedTokenUuid ? fromUuidSync(targetedTokenUuid) : undefined
|
|
// targetedActorUuid will return the TOKEN uuid if it's an unlinked token! so, we're probably going to ignore it
|
|
const targetedActor = targetedToken?.actor ? targetedToken.actor
|
|
: targetedActorUuid ? fromUuidSync(targetedActorUuid)
|
|
: undefined
|
|
const originUuid = chatMessage.flags.pf2e.origin?.uuid
|
|
const originItem = originUuid ? fromUuidSync(originUuid) : undefined
|
|
const allModifiersInChatMessage = chatMessage.flags.pf2e.modifiers
|
|
/*
|
|
NOTE - from this point on, I use the term "modifier" or "mod" to refer to conditions/effects/feats that have granted
|
|
a bonus or penalty to the roll or to the DC the roll was against. I will filter rollMods and dcMods to only include
|
|
relevant non-ignored modifiers, and then calculate which modifiers actually made a significant impact on the outcome.
|
|
|
|
The "modifier" objects in these lists are generally ModifierPf2e class objects, which have a "label", a "type", and
|
|
a "modifier" field (their signed numerical value).
|
|
*/
|
|
const rollMods = rollModsFromChatMessage(allModifiersInChatMessage, rollingActor, dcSlug)
|
|
let dcMods
|
|
let actorWithDc
|
|
if (isStrike && targetedActor) {
|
|
actorWithDc = targetedActor
|
|
dcMods = dcModsOfStatistic(targetedActor.system.attributes.ac, actorWithDc)
|
|
const offGuardMod = getOffGuardAcMod()
|
|
const isTargetEphemerallyOffGuard = chatMessage.flags.pf2e.context.options.includes(
|
|
'target:condition:off-guard')
|
|
if (isTargetEphemerallyOffGuard && !dcMods.some(m => m.label === offGuardMod.label)) {
|
|
const messageFlavorHtml = $(`<div>${chatMessage.flavor}</div>`)
|
|
const dcTooltipsStr = messageFlavorHtml.find('div.target-dc > span > span.adjusted').attr('data-tooltip')
|
|
if (dcTooltipsStr === undefined) {
|
|
// TODO - find when it happens and fix it
|
|
console.error(`${MODULE_ID} | failed to find target DC tooltips in message flavor:`, chatMessage.flavor)
|
|
console.error(`${MODULE_ID} | message flavor as string: ${chatMessage.flavor}`)
|
|
console.error(
|
|
`${MODULE_ID} | please show these three error messages to shemetz on Discord and include a bit of context to explain what happened! 🙏`)
|
|
offGuardMod.label = 'Off-Guard'
|
|
dcMods.push(offGuardMod)
|
|
} else {
|
|
const dcTooltips = dcTooltipsStr.split('\n').map(s => s.replace('<div>', '').replace('</div>', ''))
|
|
const offGuardTooltip = dcTooltips.find(t => t.includes(game.i18n.localize('PF2E.condition.off-guard.name')))
|
|
offGuardMod.label = offGuardTooltip.split(':')[0]
|
|
dcMods.push(offGuardMod)
|
|
}
|
|
}
|
|
dcMods = dcMods.filter(m => !IGNORED_MODIFIER_LABELS_FOR_AC_ONLY.includes(m.label))
|
|
} else if (isSpell && !!originItem) {
|
|
// (note: originItem will be undefined in the rare case of a message created through a module like Quick Send To Chat)
|
|
// if saving against spell, DC is the Spellcasting DC which means it's affected by stuff like Frightened and Stupefied
|
|
actorWithDc = originItem.actor
|
|
dcMods = dcModsOfStatistic(originItem.spellcasting.statistic.dc, actorWithDc)
|
|
} else if (originItem?.category === 'class') {
|
|
// if saving against a class feat/feature, DC is the Class DC which means it's affected by stuff like Frightened and Enfeebled/Drained/etc, depending
|
|
// NOTE: this will not work for embedded Check buttons that come from Note REs. see https://github.com/foundryvtt/pf2e/issues/9824
|
|
actorWithDc = originItem.actor
|
|
dcMods = dcModsOfStatistic(originItem.parent.classDC, actorWithDc)
|
|
} else if (targetedActor && dcSlug) {
|
|
// if there's a target, but it's not an attack, then it's probably a skill check against one of the target's
|
|
// save DCs or perception DC or possibly a skill DC
|
|
actorWithDc = targetedActor
|
|
const dcStatistic = targetedActor.saves[dcSlug] || targetedActor.skills[dcSlug] || targetedActor[dcSlug]
|
|
// dcStatistic should always be defined. (otherwise it means I didn't account for all cases here!)
|
|
dcMods = dcModsOfStatistic(dcStatistic.dc, actorWithDc)
|
|
} else {
|
|
// happens if e.g. rolling from a @Check style button
|
|
dcMods = []
|
|
}
|
|
|
|
/**
|
|
* wouldChangeOutcome(x) returns true if a bonus of x ("penalty" if x is negative) changes the degree of success
|
|
*/
|
|
const wouldChangeOutcome = (extra) => {
|
|
const newDegreeOfSuccess = calcDegreePlusRoll(deltaFromDc + extra, dieRoll)
|
|
return newDegreeOfSuccess !== currentDegreeOfSuccess &&
|
|
!shouldIgnoreStrikeCritFailToFail(currentDegreeOfSuccess, newDegreeOfSuccess, isStrike)
|
|
}
|
|
|
|
const positiveRollMods = rollMods.filter(modifierPositive)
|
|
const negativeRollMods = rollMods.filter(modifierNegative)
|
|
const positiveDcMods = dcMods.filter(modifierPositive)
|
|
const negativeDcMods = dcMods.filter(modifierNegative)
|
|
const necessaryPositiveRollMods = positiveRollMods.filter(m => wouldChangeOutcome(-m.modifier))
|
|
const necessaryNegativeRollMods = negativeRollMods.filter(m => wouldChangeOutcome(-m.modifier))
|
|
const necessaryPositiveDcMods = positiveDcMods.filter(m => wouldChangeOutcome(m.modifier))
|
|
const necessaryNegativeDcMods = negativeDcMods.filter(m => wouldChangeOutcome(m.modifier))
|
|
const rollModsPositiveTotal = sumMods(positiveRollMods) - sumMods(negativeDcMods)
|
|
const rollModsNegativeTotal = sumMods(negativeRollMods) - sumMods(positiveDcMods)
|
|
// sum of modifiers that were necessary to reach the current outcome - these are the biggest bonuses/penalties.
|
|
const rollModsNecessaryPositiveTotal = sumMods(necessaryPositiveRollMods) - sumMods(necessaryPositiveDcMods)
|
|
const rollModsNecessaryNegativeTotal = sumMods(necessaryNegativeRollMods) - sumMods(necessaryNegativeDcMods)
|
|
// sum of all other modifiers. if this sum's changing does not affect the outcome it means modifiers were unnecessary
|
|
const rollModsRemainingPositiveTotal = rollModsPositiveTotal - rollModsNecessaryPositiveTotal
|
|
const rollModsRemainingNegativeTotal = rollModsNegativeTotal - rollModsNecessaryNegativeTotal
|
|
// based on the above sums and the following booleans, we can determine which modifiers were significant and how much
|
|
const didPositiveModifiersChangeOutcome = wouldChangeOutcome(-rollModsPositiveTotal)
|
|
const didNegativeModifiersChangeOutcome = wouldChangeOutcome(-rollModsNegativeTotal)
|
|
const didRemainingPositivesChangeOutcome = wouldChangeOutcome(-rollModsRemainingPositiveTotal)
|
|
const didRemainingNegativesChangeOutcome = wouldChangeOutcome(-rollModsRemainingNegativeTotal)
|
|
|
|
const calcSignificance = (modifierValue) => {
|
|
const isNegativeMod = modifierValue < 0
|
|
const isPositiveMod = modifierValue > 0
|
|
const changedOutcome = wouldChangeOutcome(-modifierValue)
|
|
if (isPositiveMod && changedOutcome)
|
|
return SIGNIFICANCE.ESSENTIAL
|
|
if (isPositiveMod && !changedOutcome && didPositiveModifiersChangeOutcome && didRemainingPositivesChangeOutcome)
|
|
return SIGNIFICANCE.HELPFUL
|
|
if (isNegativeMod && changedOutcome)
|
|
return SIGNIFICANCE.HARMFUL
|
|
if (isNegativeMod && !changedOutcome && didNegativeModifiersChangeOutcome && didRemainingNegativesChangeOutcome)
|
|
return SIGNIFICANCE.DETRIMENTAL
|
|
return SIGNIFICANCE.NONE
|
|
}
|
|
const significantModifiers = []
|
|
rollMods.forEach(m => {
|
|
const modVal = m.modifier
|
|
const significance = calcSignificance(modVal)
|
|
if (significance === SIGNIFICANCE.NONE) return
|
|
significantModifiers.push({
|
|
appliedTo: 'roll',
|
|
name: m.label,
|
|
value: modVal,
|
|
significance: significance,
|
|
})
|
|
})
|
|
dcMods.forEach(m => {
|
|
const modVal = m.modifier
|
|
const significance = calcSignificance(-modVal)
|
|
significantModifiers.push({
|
|
appliedTo: 'dc',
|
|
name: m.label,
|
|
value: modVal,
|
|
significance: significance,
|
|
})
|
|
})
|
|
|
|
const oldFlavor = chatMessage.flavor
|
|
// adding an artificial div to have a single parent element, enabling nicer editing of html
|
|
const $editedFlavor = $(`<div>${oldFlavor}</div>`)
|
|
// remove old highlights, in case of a reroll within the same message
|
|
$editedFlavor.find('.pf2emm-highlight').
|
|
removeClass('pf2emm-highlight').
|
|
removeClass(`pf2emm-is-${SIGNIFICANCE.HARMFUL}`).
|
|
removeClass(`pf2emm-is-${SIGNIFICANCE.HELPFUL}`).
|
|
removeClass(`pf2emm-is-${SIGNIFICANCE.ESSENTIAL}`).
|
|
removeClass(`pf2emm-is-${SIGNIFICANCE.DETRIMENTAL}`)
|
|
significantModifiers.filter(m => m.appliedTo === 'roll').forEach(m => {
|
|
const modVal = m.value
|
|
const modName = m.name
|
|
const modSignificance = m.significance
|
|
if (modSignificance === SIGNIFICANCE.NONE) return
|
|
const modValStr = (modVal < 0 ? '' : '+') + modVal
|
|
$editedFlavor.find(`span.tag:contains(${modName} ${modValStr})`).
|
|
addClass('pf2emm-highlight').
|
|
addClass(`pf2emm-is-${m.significance}`)
|
|
})
|
|
const dcFlavorSuffixHtmls = []
|
|
significantModifiers.filter(m => m.appliedTo === 'dc').forEach(m => {
|
|
const modVal = m.value
|
|
const modName = m.name
|
|
const modSignificance = m.significance
|
|
if (modSignificance === SIGNIFICANCE.NONE)
|
|
if (!(isStrike && getSetting('always-show-defense-conditions', false)))
|
|
return
|
|
// remove number from end of name, because it's better to see "Frightened (-3)" than "Frightened 3 (-3)"
|
|
const modNameNoNum = modName.match(/.* \d+/) ? modName.substring(0, modName.lastIndexOf(' ')) : modName
|
|
const modValStr = (modVal < 0 ? '' : '+') + modVal
|
|
dcFlavorSuffixHtmls.push(
|
|
`<span class="pf2emm-suffix pf2emm-is-${m.significance}">${modNameNoNum} ${modValStr}</span>`)
|
|
})
|
|
const dcFlavorSuffix = dcFlavorSuffixHtmls.join(', ')
|
|
$editedFlavor.find('.pf2emm-suffix').remove()
|
|
if (dcFlavorSuffix) {
|
|
// dcActorType is only used to make the string slightly more fitting
|
|
const dcActorType = targetedActor ? 'target' : isSpell ? 'caster' : 'actor'
|
|
insertDcFlavorSuffix($editedFlavor, dcFlavorSuffix, dcActorType)
|
|
}
|
|
// newFlavor will be the inner HTML without the artificial div
|
|
const newFlavor = $editedFlavor.html()
|
|
if (newFlavor !== oldFlavor) {
|
|
data.flavor = newFlavor // just in case other hooks rely on it
|
|
await chatMessage.updateSource({ 'flavor': newFlavor })
|
|
}
|
|
|
|
// hook call - to allow other modules/macros to trigger based on MM
|
|
if (significantModifiers.length > 0) {
|
|
Hooks.callAll('modifiersMatter', {
|
|
rollingActor,
|
|
actorWithDc, // can be undefined
|
|
targetedToken, // can be undefined
|
|
significantModifiers, // list of: {name: string, value: number, significance: string}
|
|
chatMessage,
|
|
})
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
const exampleHookInspireCourage = () => {
|
|
// this hook call is an example!
|
|
// it will play a nice chime sound each time an Inspire Courage effect turns a miss into a hit (or hit to crit)
|
|
Hooks.on('modifiersMatter', ({ rollingActor, significantModifiers }) => {
|
|
console.log(`${rollingActor} was helped!`)
|
|
significantModifiers.forEach(({ name, significance }) => {
|
|
if (name.includes('Inspire Courage') && significance === 'ESSENTIAL') {
|
|
AudioHelper.play({
|
|
src: 'https://cdn.pixabay.com/audio/2022/01/18/audio_8db1f1b5a5.mp3',
|
|
volume: 1.0,
|
|
autoplay: true,
|
|
loop: false,
|
|
}, true)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
const getSetting = (settingName) => game.settings.get(MODULE_ID, settingName)
|
|
|
|
Hooks.on('init', function () {
|
|
game.settings.register(MODULE_ID, 'show-defense-highlights-to-everyone', {
|
|
name: `${MODULE_ID}.Settings.show-defense-highlights-to-everyone.name`,
|
|
hint: `${MODULE_ID}.Settings.show-defense-highlights-to-everyone.hint`,
|
|
scope: 'world',
|
|
config: true,
|
|
default: true,
|
|
type: Boolean,
|
|
})
|
|
game.settings.register(MODULE_ID, 'ignore-crit-fail-over-fail-on-attacks', {
|
|
name: `${MODULE_ID}.Settings.ignore-crit-fail-over-fail-on-attacks.name`,
|
|
hint: `${MODULE_ID}.Settings.ignore-crit-fail-over-fail-on-attacks.hint`,
|
|
scope: 'client',
|
|
config: true,
|
|
default: false,
|
|
type: Boolean,
|
|
})
|
|
game.settings.register(MODULE_ID, 'additional-ignored-labels', {
|
|
name: `${MODULE_ID}.Settings.additional-ignored-labels.name`,
|
|
hint: `${MODULE_ID}.Settings.additional-ignored-labels.hint`,
|
|
scope: 'world',
|
|
config: true,
|
|
default: 'Example;Skill Potency',
|
|
type: String,
|
|
onChange: initializeIgnoredModifiers,
|
|
})
|
|
game.settings.register(MODULE_ID, 'always-show-defense-conditions', {
|
|
name: `${MODULE_ID}.Settings.always-show-defense-conditions.name`,
|
|
hint: `${MODULE_ID}.Settings.always-show-defense-conditions.hint`,
|
|
scope: 'world',
|
|
config: true,
|
|
default: false,
|
|
type: Boolean,
|
|
})
|
|
})
|
|
|
|
Hooks.once('setup', function () {
|
|
Hooks.on('preCreateChatMessage', hook_preCreateChatMessage)
|
|
initializeIgnoredModifiers()
|
|
console.info(`${MODULE_ID} | initialized`)
|
|
})
|
|
|