All user data for FoundryVTT. Includes worlds, systems, modules, and any asset in the "foundryuserdata" directory. Does NOT include the FoundryVTT installation itself.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

531 lines
24 KiB

1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
  1. const MODULE_ID = 'pf2e-modifiers-matter'
  2. // TODO - currently impossible, but in the future may be possible to react to effects that change embedded DCs in Note rule elements.
  3. // See: https://github.com/foundryvtt/pf2e/issues/9824
  4. // for example, the Monk's Stunning Fist uses a Class DC but this module won't recognize modifiers to that DC in this situation.
  5. // Helpful for testing - replace random dice roller with 1,2,3,4....19,20 by putting this in the console:
  6. /*
  7. NEXT_RND_ROLLS_D20 = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]
  8. rndIndex = -1
  9. CONFIG.Dice.randomUniform = () => {rndIndex = (rndIndex + 1) % NEXT_RND_ROLLS_D20.length; return NEXT_RND_ROLLS_D20[rndIndex] / 20 - 0.001}
  10. */
  11. // this file has a ton of math (mostly simple).
  12. // I did my best to make it all easily understandable math, but there are limits to what I can do.
  13. /**
  14. * ESSENTIAL (strong green) - This modifier was necessary to achieve this degree of success (DoS). Others were
  15. * potentially also necessary. You should thank the character who caused this modifier!
  16. *
  17. * HELPFUL (weak green) - This modifier was not necessary to achieve this DoS, but degree of success did change due to
  18. * modifiers in this direction, and at least one of the helpful modifiers was needed. For example, if you rolled a 14,
  19. * had +1 & +2, and needed a 15, both the +1 and +2 are weak green because neither is necessary on its own, but they
  20. * were necessary together. If you had rolled a 13 in this case, the +2 would be strong green but the +1 would still be
  21. * weak green, simply because it's difficult to come up with an algorithm that would solve complex cases.
  22. * Note, by the way, that in case of multiple non-stacking modifiers, PF2e hides some of them from the chat card.
  23. *
  24. * NONE - This modifier did not affect the DoS at all, this time.
  25. *
  26. * HARMFUL (orange) - Like HELPFUL but in the opposite direction. Without all the harmful modifiers you had (but
  27. * not without any one of them), you would've gotten a better DoS.
  28. *
  29. * DETRIMENTAL (red) - Like ESSENTIAL but in the opposite direction. Without this, you would've gotten a better DoS.
  30. */
  31. const SIGNIFICANCE = Object.freeze({
  32. ESSENTIAL: 'ESSENTIAL',
  33. HELPFUL: 'HELPFUL',
  34. NONE: 'NONE',
  35. HARMFUL: 'HARMFUL',
  36. DETRIMENTAL: 'DETRIMENTAL',
  37. })
  38. const COLOR_BY_SIGNIFICANCE = Object.freeze({
  39. ESSENTIAL: '#008000',
  40. HELPFUL: '#91a82a',
  41. NONE: '#000000',
  42. HARMFUL: '#ff0000',
  43. DETRIMENTAL: '#ff852f',
  44. })
  45. let IGNORED_MODIFIER_LABELS = []
  46. let IGNORED_MODIFIER_LABELS_FOR_AC_ONLY = []
  47. let warnedAboutLocalization = false
  48. const tryLocalize = (key, defaultValue) => {
  49. const localized = game.i18n.localize(key)
  50. if (localized === key) {
  51. if (!warnedAboutLocalization) {
  52. console.warn(`${MODULE_ID}: failed to localize ${key}`)
  53. warnedAboutLocalization = true
  54. }
  55. return defaultValue
  56. }
  57. return localized
  58. }
  59. const initializeIgnoredModifiers = () => {
  60. const IGNORED_MODIFIERS_I18N = [
  61. 'PF2E.BaseModifier',
  62. 'PF2E.ModifierTitle',
  63. 'PF2E.MultipleAttackPenalty',
  64. 'PF2E.ProficiencyLevel0',
  65. 'PF2E.ProficiencyLevel1',
  66. 'PF2E.ProficiencyLevel2',
  67. 'PF2E.ProficiencyLevel3',
  68. 'PF2E.ProficiencyLevel4',
  69. 'PF2E.AbilityStr',
  70. 'PF2E.AbilityCon',
  71. 'PF2E.AbilityDex',
  72. 'PF2E.AbilityInt',
  73. 'PF2E.AbilityWis',
  74. 'PF2E.AbilityCha',
  75. 'PF2E.PotencyRuneLabel',
  76. 'PF2E.RuleElement.WeaponPotency',
  77. 'PF2E.AutomaticBonusProgression.attackPotency',
  78. 'PF2E.AutomaticBonusProgression.defensePotency',
  79. 'PF2E.AutomaticBonusProgression.savePotency',
  80. 'PF2E.AutomaticBonusProgression.perceptionPotency',
  81. 'PF2E.NPC.Adjustment.EliteLabel',
  82. 'PF2E.NPC.Adjustment.WeakLabel',
  83. 'PF2E.MasterSavingThrow.fortitude',
  84. 'PF2E.MasterSavingThrow.reflex',
  85. 'PF2E.MasterSavingThrow.will',
  86. `${MODULE_ID}.IgnoredModifiers.DeviseAStratagem`, // Investigator
  87. `${MODULE_ID}.IgnoredModifiers.HuntersEdgeFlurry1`, // Ranger, replaces multiple attack penalty
  88. `${MODULE_ID}.IgnoredModifiers.HuntersEdgeFlurry2`, // same
  89. `${MODULE_ID}.IgnoredModifiers.HuntersEdgeFlurry3`, // same, Ranger's companion
  90. // NOTE: all spells that end in "form" are also ignored for the attack bonus; e.g. Ooze Form
  91. // also some battle form spells with different names:
  92. `${MODULE_ID}.IgnoredModifiers.BattleForm1`, // battle form
  93. `${MODULE_ID}.IgnoredModifiers.BattleForm2`, // battle form
  94. `${MODULE_ID}.IgnoredModifiers.BattleForm3`, // battle form
  95. `${MODULE_ID}.IgnoredModifiers.BattleForm4`, // battle form
  96. // yes I'm gonna add my houserules to my module, you can't stop me.
  97. // https://discord.com/channels/880968862240239708/880969943724728391/1082678343234760704
  98. `${MODULE_ID}.IgnoredModifiers.SpellAttackHouserule`,
  99. `${MODULE_ID}.IgnoredModifiers.SpellPotency1`,
  100. `${MODULE_ID}.IgnoredModifiers.SpellPotency2`,
  101. `${MODULE_ID}.IgnoredModifiers.SkillPotency1`,
  102. `${MODULE_ID}.IgnoredModifiers.SkillPotency2`,
  103. // compatibility with a module, pf2e-flatten, which adds modifiers to match the PWoL variants.
  104. // https://github.com/League-of-Foundry-Developers/pf2e-flatten/blob/main/bundle.js#L41
  105. `${MODULE_ID}.IgnoredModifiers3p.pf2e-flatten_pwol`,
  106. `${MODULE_ID}.IgnoredModifiers3p.pf2e-flatten_pwol_half`,
  107. ]
  108. IGNORED_MODIFIER_LABELS = IGNORED_MODIFIERS_I18N.map(str => tryLocalize(str, str)).
  109. concat(getSetting('additional-ignored-labels').split(';'))
  110. IGNORED_MODIFIER_LABELS_FOR_AC_ONLY = [
  111. // effect that replaces your AC item bonus and dex cap - super hard to calculate its "true" bonus so I just ignore.
  112. // however, this effect also has other modifiers which I don't want to ignore.
  113. `${MODULE_ID}.IgnoredModifiers.DrakeheartMutagen`,
  114. ].map(str => tryLocalize(str, str))
  115. }
  116. const sumMods = (modsList) => modsList.reduce((accumulator, curr) => accumulator + curr.modifier, 0)
  117. const modifierPositive = m => m.modifier > 0
  118. const modifierNegative = m => m.modifier < 0
  119. const getOffGuardAcMod = () => {
  120. const offGuardSlug = isNewerVersion(game.version, '5.3') ? 'off-guard' : 'flat-footed'
  121. const systemOffGuardCondition = game.pf2e.ConditionManager.getCondition(offGuardSlug)
  122. return {
  123. label: systemOffGuardCondition.name,
  124. modifier: -2,
  125. type: 'circumstance',
  126. }
  127. }
  128. const dcModsOfStatistic = (dcStatistic, actorWithDc) => {
  129. return dcStatistic.modifiers
  130. // remove if not enabled, or ignored
  131. .filter(m => m.enabled && !m.ignored)
  132. // remove everything that should be ignored (including user-defined)
  133. .filter(m => !IGNORED_MODIFIER_LABELS.includes(m.label))
  134. // ignore item bonuses that come from armor, they're Resilient runes
  135. .filter(m => !(
  136. m.type === 'item'
  137. // comparing the modifier label to the names of the actor's Armor items
  138. && actorWithDc?.attributes.ac.modifiers.some(m2 => m2.label === m.label)
  139. ))
  140. }
  141. const rollModsFromChatMessage = (modifiersFromChatMessage, rollingActor, dcType) => {
  142. return modifiersFromChatMessage
  143. // enabled is false for one of the conditions if it can't stack with others
  144. .filter(m => m.enabled && !m.ignored)
  145. // ignoring standard things from list (including user-defined)
  146. .filter(m => !IGNORED_MODIFIER_LABELS.includes(m.label))
  147. // for attacks, ignore all "form" spells that replace your attack bonus
  148. .filter(m => !(dcType === 'armor' && m.slug.endsWith('-form')))
  149. // for attacks/skills, ignore Doubling Rings which are basically a permanent item bonus
  150. .filter(m => !m.slug.startsWith('doubling-rings'))
  151. // TODO - ignore item bonuses that are permanent (mostly skill items)
  152. // TODO - can next thing be removed?
  153. // for saving throws, ignore item bonuses that come from armor, they're Resilient runes
  154. .filter(m => !(
  155. m.type === 'item'
  156. // comparing the modifier label to the name of the rolling actor's Armor item
  157. && rollingActor?.attributes.ac.modifiers.some(m2 => m2.label === m.label)
  158. ))
  159. }
  160. const DEGREES = Object.freeze({
  161. CRIT_SUCC: 'CRIT_SUCC',
  162. SUCCESS: 'SUCCESS',
  163. FAILURE: 'FAILURE',
  164. CRIT_FAIL: 'CRIT_FAIL',
  165. })
  166. // REMEMBER: in Pf2e, delta 0-9 means SUCCESS, delta 10+ means CRIT SUCCESS, delta -1-9 is FAIL, delta -10- is CRIT FAIL
  167. const calcDegreeOfSuccess = (deltaFromDc) => {
  168. switch (true) {
  169. case deltaFromDc >= 10:
  170. return DEGREES.CRIT_SUCC
  171. case deltaFromDc <= -10:
  172. return DEGREES.CRIT_FAIL
  173. case deltaFromDc >= 1:
  174. return DEGREES.SUCCESS
  175. case deltaFromDc <= -1:
  176. return DEGREES.FAILURE
  177. case deltaFromDc === 0:
  178. return DEGREES.SUCCESS
  179. }
  180. // impossible
  181. console.error(`${MODULE_ID} | calcDegreeOfSuccess got wrong number: ${deltaFromDc}`)
  182. return DEGREES.CRIT_FAIL
  183. }
  184. const calcDegreePlusRoll = (deltaFromDc, dieRoll) => {
  185. const degree = calcDegreeOfSuccess(deltaFromDc)
  186. // handle natural 20 and natural 1
  187. if (dieRoll === 20) {
  188. switch (degree) {
  189. case 'CRIT_SUCC':
  190. return DEGREES.CRIT_SUCC
  191. case 'SUCCESS':
  192. return DEGREES.CRIT_SUCC
  193. case 'FAILURE':
  194. return DEGREES.SUCCESS
  195. case 'CRIT_FAIL':
  196. return DEGREES.FAILURE
  197. }
  198. } else if (dieRoll === 1) {
  199. switch (degree) {
  200. case 'CRIT_SUCC':
  201. return DEGREES.SUCCESS
  202. case 'SUCCESS':
  203. return DEGREES.FAILURE
  204. case 'FAILURE':
  205. return DEGREES.CRIT_FAIL
  206. case 'CRIT_FAIL':
  207. return DEGREES.CRIT_FAIL
  208. }
  209. } else return degree
  210. }
  211. const shouldIgnoreStrikeCritFailToFail = (oldDOS, newDOS, isStrike) => {
  212. // only ignore in this somewhat common edge case:
  213. return (
  214. // fail changed to crit fail, or vice versa
  215. ((oldDOS === DEGREES.FAILURE && newDOS === DEGREES.CRIT_FAIL)
  216. || (oldDOS === DEGREES.CRIT_FAIL && newDOS === DEGREES.FAILURE))
  217. // and this game setting is enabled
  218. && getSetting('ignore-crit-fail-over-fail-on-attacks')
  219. // and it was a Strike attack
  220. && isStrike
  221. )
  222. }
  223. /**
  224. * dcFlavorSuffix will be e.g. 'Off-Guard -2, Frightened -1'
  225. */
  226. const insertDcFlavorSuffix = ($flavorText, dcFlavorSuffix, dcActorType) => {
  227. const showDefenseHighlightsToEveryone = getSetting('show-defense-highlights-to-everyone')
  228. const dataVisibility = showDefenseHighlightsToEveryone ? 'all' : 'gm'
  229. const messageKey = dcActorType === 'target' ? `${MODULE_ID}.Message.TargetHas`
  230. : dcActorType === 'caster' ? `${MODULE_ID}.Message.CasterHas`
  231. : `${MODULE_ID}.Message.ActorHas`
  232. $flavorText.find('div.degree-of-success').before(
  233. `<div data-visibility="${dataVisibility}">
  234. ${tryLocalize(messageKey, 'Target has:')} <b>${dcFlavorSuffix}</b>
  235. </div>`)
  236. }
  237. const hook_preCreateChatMessage = async (chatMessage, data) => {
  238. // continue only if message is a PF2e roll message with a rolling actor
  239. if (
  240. !chatMessage.flags
  241. || !chatMessage.flags.pf2e
  242. || !chatMessage.flags.pf2e.modifiers
  243. || !chatMessage.flags.pf2e.context.dc
  244. || !chatMessage.flags.pf2e.context.actor
  245. ) return true
  246. const rollingActor = game.actors.get(chatMessage.flags.pf2e.context.actor)
  247. // here I assume the PF2E system always includes the d20 roll as the first roll! and as the first term of that roll!
  248. const roll = chatMessage.rolls[0]
  249. const rollTotal = roll?.total !== undefined ? roll.total : parseInt(chatMessage.content)
  250. // dc.value is usually defined, but apparently not when Escaping vs an enemy's Athletics DC
  251. const rollDc = chatMessage.flags.pf2e.context.dc.value ?? chatMessage.flags.pf2e.context.dc.parent?.dc?.value
  252. const deltaFromDc = rollTotal - rollDc
  253. // using roll.terms[0].total will work when rolling 1d20+9, or 2d20kh+9 (RollTwice RE), or 10+9 (SubstituteRoll RE)
  254. const dieRoll = roll.terms[0].total
  255. const currentDegreeOfSuccess = calcDegreePlusRoll(deltaFromDc, dieRoll)
  256. const dcSlug = chatMessage.flags.pf2e.context.dc.slug ?? chatMessage.flags.pf2e.context.dc.parent?.slug
  257. const isStrike = dcSlug === 'armor'
  258. const isSpell = chatMessage.flags.pf2e.origin?.type === 'spell'
  259. const targetedTokenUuid = chatMessage.flags.pf2e.context.target?.token
  260. const targetedActorUuid = chatMessage.flags.pf2e.context.target?.actor
  261. const targetedToken = targetedTokenUuid ? fromUuidSync(targetedTokenUuid) : undefined
  262. // targetedActorUuid will return the TOKEN uuid if it's an unlinked token! so, we're probably going to ignore it
  263. const targetedActor = targetedToken?.actor ? targetedToken.actor
  264. : targetedActorUuid ? fromUuidSync(targetedActorUuid)
  265. : undefined
  266. const originUuid = chatMessage.flags.pf2e.origin?.uuid
  267. const originItem = originUuid ? fromUuidSync(originUuid) : undefined
  268. const allModifiersInChatMessage = chatMessage.flags.pf2e.modifiers
  269. /*
  270. NOTE - from this point on, I use the term "modifier" or "mod" to refer to conditions/effects/feats that have granted
  271. a bonus or penalty to the roll or to the DC the roll was against. I will filter rollMods and dcMods to only include
  272. relevant non-ignored modifiers, and then calculate which modifiers actually made a significant impact on the outcome.
  273. The "modifier" objects in these lists are generally ModifierPf2e class objects, which have a "label", a "type", and
  274. a "modifier" field (their signed numerical value).
  275. */
  276. const rollMods = rollModsFromChatMessage(allModifiersInChatMessage, rollingActor, dcSlug)
  277. let dcMods
  278. let actorWithDc
  279. if (isStrike && targetedActor) {
  280. actorWithDc = targetedActor
  281. dcMods = dcModsOfStatistic(targetedActor.system.attributes.ac, actorWithDc)
  282. const offGuardMod = getOffGuardAcMod()
  283. const isTargetEphemerallyOffGuard = chatMessage.flags.pf2e.context.options.includes(
  284. 'target:condition:off-guard')
  285. if (isTargetEphemerallyOffGuard && !dcMods.some(m => m.label === offGuardMod.label)) {
  286. const messageFlavorHtml = $(`<div>${chatMessage.flavor}</div>`)
  287. const dcTooltipsStr = messageFlavorHtml.find('div.target-dc > span > span.adjusted').attr('data-tooltip')
  288. if (dcTooltipsStr === undefined) {
  289. // TODO - find when it happens and fix it
  290. console.error(`${MODULE_ID} | failed to find target DC tooltips in message flavor:`, chatMessage.flavor)
  291. console.error(`${MODULE_ID} | message flavor as string: ${chatMessage.flavor}`)
  292. console.error(
  293. `${MODULE_ID} | please show these three error messages to shemetz on Discord and include a bit of context to explain what happened! 🙏`)
  294. offGuardMod.label = 'Off-Guard'
  295. dcMods.push(offGuardMod)
  296. } else {
  297. const dcTooltips = dcTooltipsStr.split('\n').map(s => s.replace('<div>', '').replace('</div>', ''))
  298. const offGuardTooltip = dcTooltips.find(t => t.includes(game.i18n.localize('PF2E.condition.off-guard.name')))
  299. offGuardMod.label = offGuardTooltip.split(':')[0]
  300. dcMods.push(offGuardMod)
  301. }
  302. }
  303. dcMods = dcMods.filter(m => !IGNORED_MODIFIER_LABELS_FOR_AC_ONLY.includes(m.label))
  304. } else if (isSpell && !!originItem) {
  305. // (note: originItem will be undefined in the rare case of a message created through a module like Quick Send To Chat)
  306. // if saving against spell, DC is the Spellcasting DC which means it's affected by stuff like Frightened and Stupefied
  307. actorWithDc = originItem.actor
  308. dcMods = dcModsOfStatistic(originItem.spellcasting.statistic.dc, actorWithDc)
  309. } else if (originItem?.category === 'class') {
  310. // 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
  311. // NOTE: this will not work for embedded Check buttons that come from Note REs. see https://github.com/foundryvtt/pf2e/issues/9824
  312. actorWithDc = originItem.actor
  313. dcMods = dcModsOfStatistic(originItem.parent.classDC, actorWithDc)
  314. } else if (targetedActor && dcSlug) {
  315. // if there's a target, but it's not an attack, then it's probably a skill check against one of the target's
  316. // save DCs or perception DC or possibly a skill DC
  317. actorWithDc = targetedActor
  318. const dcStatistic = targetedActor.saves[dcSlug] || targetedActor.skills[dcSlug] || targetedActor[dcSlug]
  319. // dcStatistic should always be defined. (otherwise it means I didn't account for all cases here!)
  320. dcMods = dcModsOfStatistic(dcStatistic.dc, actorWithDc)
  321. } else {
  322. // happens if e.g. rolling from a @Check style button
  323. dcMods = []
  324. }
  325. /**
  326. * wouldChangeOutcome(x) returns true if a bonus of x ("penalty" if x is negative) changes the degree of success
  327. */
  328. const wouldChangeOutcome = (extra) => {
  329. const newDegreeOfSuccess = calcDegreePlusRoll(deltaFromDc + extra, dieRoll)
  330. return newDegreeOfSuccess !== currentDegreeOfSuccess &&
  331. !shouldIgnoreStrikeCritFailToFail(currentDegreeOfSuccess, newDegreeOfSuccess, isStrike)
  332. }
  333. const positiveRollMods = rollMods.filter(modifierPositive)
  334. const negativeRollMods = rollMods.filter(modifierNegative)
  335. const positiveDcMods = dcMods.filter(modifierPositive)
  336. const negativeDcMods = dcMods.filter(modifierNegative)
  337. const necessaryPositiveRollMods = positiveRollMods.filter(m => wouldChangeOutcome(-m.modifier))
  338. const necessaryNegativeRollMods = negativeRollMods.filter(m => wouldChangeOutcome(-m.modifier))
  339. const necessaryPositiveDcMods = positiveDcMods.filter(m => wouldChangeOutcome(m.modifier))
  340. const necessaryNegativeDcMods = negativeDcMods.filter(m => wouldChangeOutcome(m.modifier))
  341. const rollModsPositiveTotal = sumMods(positiveRollMods) - sumMods(negativeDcMods)
  342. const rollModsNegativeTotal = sumMods(negativeRollMods) - sumMods(positiveDcMods)
  343. // sum of modifiers that were necessary to reach the current outcome - these are the biggest bonuses/penalties.
  344. const rollModsNecessaryPositiveTotal = sumMods(necessaryPositiveRollMods) - sumMods(necessaryPositiveDcMods)
  345. const rollModsNecessaryNegativeTotal = sumMods(necessaryNegativeRollMods) - sumMods(necessaryNegativeDcMods)
  346. // sum of all other modifiers. if this sum's changing does not affect the outcome it means modifiers were unnecessary
  347. const rollModsRemainingPositiveTotal = rollModsPositiveTotal - rollModsNecessaryPositiveTotal
  348. const rollModsRemainingNegativeTotal = rollModsNegativeTotal - rollModsNecessaryNegativeTotal
  349. // based on the above sums and the following booleans, we can determine which modifiers were significant and how much
  350. const didPositiveModifiersChangeOutcome = wouldChangeOutcome(-rollModsPositiveTotal)
  351. const didNegativeModifiersChangeOutcome = wouldChangeOutcome(-rollModsNegativeTotal)
  352. const didRemainingPositivesChangeOutcome = wouldChangeOutcome(-rollModsRemainingPositiveTotal)
  353. const didRemainingNegativesChangeOutcome = wouldChangeOutcome(-rollModsRemainingNegativeTotal)
  354. const calcSignificance = (modifierValue) => {
  355. const isNegativeMod = modifierValue < 0
  356. const isPositiveMod = modifierValue > 0
  357. const changedOutcome = wouldChangeOutcome(-modifierValue)
  358. if (isPositiveMod && changedOutcome)
  359. return SIGNIFICANCE.ESSENTIAL
  360. if (isPositiveMod && !changedOutcome && didPositiveModifiersChangeOutcome && didRemainingPositivesChangeOutcome)
  361. return SIGNIFICANCE.HELPFUL
  362. if (isNegativeMod && changedOutcome)
  363. return SIGNIFICANCE.HARMFUL
  364. if (isNegativeMod && !changedOutcome && didNegativeModifiersChangeOutcome && didRemainingNegativesChangeOutcome)
  365. return SIGNIFICANCE.DETRIMENTAL
  366. return SIGNIFICANCE.NONE
  367. }
  368. const significantModifiers = []
  369. rollMods.forEach(m => {
  370. const modVal = m.modifier
  371. const significance = calcSignificance(modVal)
  372. if (significance === SIGNIFICANCE.NONE) return
  373. significantModifiers.push({
  374. appliedTo: 'roll',
  375. name: m.label,
  376. value: modVal,
  377. significance: significance,
  378. })
  379. })
  380. dcMods.forEach(m => {
  381. const modVal = m.modifier
  382. const significance = calcSignificance(-modVal)
  383. significantModifiers.push({
  384. appliedTo: 'dc',
  385. name: m.label,
  386. value: modVal,
  387. significance: significance,
  388. })
  389. })
  390. const oldFlavor = chatMessage.flavor
  391. // adding an artificial div to have a single parent element, enabling nicer editing of html
  392. const $editedFlavor = $(`<div>${oldFlavor}</div>`)
  393. // remove old highlights, in case of a reroll within the same message
  394. $editedFlavor.find('.pf2emm-highlight').
  395. removeClass('pf2emm-highlight').
  396. removeClass(`pf2emm-is-${SIGNIFICANCE.HARMFUL}`).
  397. removeClass(`pf2emm-is-${SIGNIFICANCE.HELPFUL}`).
  398. removeClass(`pf2emm-is-${SIGNIFICANCE.ESSENTIAL}`).
  399. removeClass(`pf2emm-is-${SIGNIFICANCE.DETRIMENTAL}`)
  400. significantModifiers.filter(m => m.appliedTo === 'roll').forEach(m => {
  401. const modVal = m.value
  402. const modName = m.name
  403. const modSignificance = m.significance
  404. if (modSignificance === SIGNIFICANCE.NONE) return
  405. const modValStr = (modVal < 0 ? '' : '+') + modVal
  406. $editedFlavor.find(`span.tag:contains(${modName} ${modValStr})`).
  407. addClass('pf2emm-highlight').
  408. addClass(`pf2emm-is-${m.significance}`)
  409. })
  410. const dcFlavorSuffixHtmls = []
  411. significantModifiers.filter(m => m.appliedTo === 'dc').forEach(m => {
  412. const modVal = m.value
  413. const modName = m.name
  414. const modSignificance = m.significance
  415. if (modSignificance === SIGNIFICANCE.NONE)
  416. if (!(isStrike && getSetting('always-show-defense-conditions', false)))
  417. return
  418. // remove number from end of name, because it's better to see "Frightened (-3)" than "Frightened 3 (-3)"
  419. const modNameNoNum = modName.match(/.* \d+/) ? modName.substring(0, modName.lastIndexOf(' ')) : modName
  420. const modValStr = (modVal < 0 ? '' : '+') + modVal
  421. dcFlavorSuffixHtmls.push(
  422. `<span class="pf2emm-suffix pf2emm-is-${m.significance}">${modNameNoNum} ${modValStr}</span>`)
  423. })
  424. const dcFlavorSuffix = dcFlavorSuffixHtmls.join(', ')
  425. $editedFlavor.find('.pf2emm-suffix').remove()
  426. if (dcFlavorSuffix) {
  427. // dcActorType is only used to make the string slightly more fitting
  428. const dcActorType = targetedActor ? 'target' : isSpell ? 'caster' : 'actor'
  429. insertDcFlavorSuffix($editedFlavor, dcFlavorSuffix, dcActorType)
  430. }
  431. // newFlavor will be the inner HTML without the artificial div
  432. const newFlavor = $editedFlavor.html()
  433. if (newFlavor !== oldFlavor) {
  434. data.flavor = newFlavor // just in case other hooks rely on it
  435. await chatMessage.updateSource({ 'flavor': newFlavor })
  436. }
  437. // hook call - to allow other modules/macros to trigger based on MM
  438. if (significantModifiers.length > 0) {
  439. Hooks.callAll('modifiersMatter', {
  440. rollingActor,
  441. actorWithDc, // can be undefined
  442. targetedToken, // can be undefined
  443. significantModifiers, // list of: {name: string, value: number, significance: string}
  444. chatMessage,
  445. })
  446. }
  447. return true
  448. }
  449. const exampleHookInspireCourage = () => {
  450. // this hook call is an example!
  451. // it will play a nice chime sound each time an Inspire Courage effect turns a miss into a hit (or hit to crit)
  452. Hooks.on('modifiersMatter', ({ rollingActor, significantModifiers }) => {
  453. console.log(`${rollingActor} was helped!`)
  454. significantModifiers.forEach(({ name, significance }) => {
  455. if (name.includes('Inspire Courage') && significance === 'ESSENTIAL') {
  456. AudioHelper.play({
  457. src: 'https://cdn.pixabay.com/audio/2022/01/18/audio_8db1f1b5a5.mp3',
  458. volume: 1.0,
  459. autoplay: true,
  460. loop: false,
  461. }, true)
  462. }
  463. })
  464. })
  465. }
  466. const getSetting = (settingName) => game.settings.get(MODULE_ID, settingName)
  467. Hooks.on('init', function () {
  468. game.settings.register(MODULE_ID, 'show-defense-highlights-to-everyone', {
  469. name: `${MODULE_ID}.Settings.show-defense-highlights-to-everyone.name`,
  470. hint: `${MODULE_ID}.Settings.show-defense-highlights-to-everyone.hint`,
  471. scope: 'world',
  472. config: true,
  473. default: true,
  474. type: Boolean,
  475. })
  476. game.settings.register(MODULE_ID, 'ignore-crit-fail-over-fail-on-attacks', {
  477. name: `${MODULE_ID}.Settings.ignore-crit-fail-over-fail-on-attacks.name`,
  478. hint: `${MODULE_ID}.Settings.ignore-crit-fail-over-fail-on-attacks.hint`,
  479. scope: 'client',
  480. config: true,
  481. default: false,
  482. type: Boolean,
  483. })
  484. game.settings.register(MODULE_ID, 'additional-ignored-labels', {
  485. name: `${MODULE_ID}.Settings.additional-ignored-labels.name`,
  486. hint: `${MODULE_ID}.Settings.additional-ignored-labels.hint`,
  487. scope: 'world',
  488. config: true,
  489. default: 'Example;Skill Potency',
  490. type: String,
  491. onChange: initializeIgnoredModifiers,
  492. })
  493. game.settings.register(MODULE_ID, 'always-show-defense-conditions', {
  494. name: `${MODULE_ID}.Settings.always-show-defense-conditions.name`,
  495. hint: `${MODULE_ID}.Settings.always-show-defense-conditions.hint`,
  496. scope: 'world',
  497. config: true,
  498. default: false,
  499. type: Boolean,
  500. })
  501. })
  502. Hooks.once('setup', function () {
  503. Hooks.on('preCreateChatMessage', hook_preCreateChatMessage)
  504. initializeIgnoredModifiers()
  505. console.info(`${MODULE_ID} | initialized`)
  506. })