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.

530 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 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. // remove duplicates where name is identical
  141. .filter((i1, idx, a) => a.findIndex(i2 => (i2.name === i1.name)) === idx)
  142. }
  143. const rollModsFromChatMessage = (modifiersFromChatMessage, rollingActor, dcType) => {
  144. return modifiersFromChatMessage
  145. // enabled is false for one of the conditions if it can't stack with others
  146. .filter(m => m.enabled && !m.ignored)
  147. // ignoring standard things from list (including user-defined)
  148. .filter(m => !IGNORED_MODIFIER_LABELS.includes(m.label))
  149. // for attacks, ignore all "form" spells that replace your attack bonus
  150. // it changed from 'ac' to 'armor' in pf2e v4.12
  151. .filter(m => !((dcType === 'ac' || dcType === 'armor') && m.slug.endsWith('-form')))
  152. // for attacks/skills, ignore Doubling Rings which are basically a permanent item bonus
  153. .filter(m => !m.slug.startsWith('doubling-rings'))
  154. // TODO - ignore item bonuses that are permanent (mostly skill items)
  155. // TODO - can next thing be removed?
  156. // for saving throws, ignore item bonuses that come from armor, they're Resilient runes
  157. .filter(m => !(
  158. m.type === 'item'
  159. // comparing the modifier label to the name of the rolling actor's Armor item
  160. && rollingActor?.attributes.ac.modifiers.some(m2 => m2.label === m.label)
  161. ))
  162. }
  163. const DEGREES = Object.freeze({
  164. CRIT_SUCC: 'CRIT_SUCC',
  165. SUCCESS: 'SUCCESS',
  166. FAILURE: 'FAILURE',
  167. CRIT_FAIL: 'CRIT_FAIL',
  168. })
  169. // REMEMBER: in Pf2e, delta 0-9 means SUCCESS, delta 10+ means CRIT SUCCESS, delta -1-9 is FAIL, delta -10- is CRIT FAIL
  170. const calcDegreeOfSuccess = (deltaFromDc) => {
  171. switch (true) {
  172. case deltaFromDc >= 10:
  173. return DEGREES.CRIT_SUCC
  174. case deltaFromDc <= -10:
  175. return DEGREES.CRIT_FAIL
  176. case deltaFromDc >= 1:
  177. return DEGREES.SUCCESS
  178. case deltaFromDc <= -1:
  179. return DEGREES.FAILURE
  180. case deltaFromDc === 0:
  181. return DEGREES.SUCCESS
  182. }
  183. // impossible
  184. console.error(`${MODULE_ID} | calcDegreeOfSuccess got wrong number: ${deltaFromDc}`)
  185. return DEGREES.CRIT_FAIL
  186. }
  187. const calcDegreePlusRoll = (deltaFromDc, dieRoll) => {
  188. const degree = calcDegreeOfSuccess(deltaFromDc)
  189. // handle natural 20 and natural 1
  190. if (dieRoll === 20) {
  191. switch (degree) {
  192. case 'CRIT_SUCC':
  193. return DEGREES.CRIT_SUCC
  194. case 'SUCCESS':
  195. return DEGREES.CRIT_SUCC
  196. case 'FAILURE':
  197. return DEGREES.SUCCESS
  198. case 'CRIT_FAIL':
  199. return DEGREES.FAILURE
  200. }
  201. } else if (dieRoll === 1) {
  202. switch (degree) {
  203. case 'CRIT_SUCC':
  204. return DEGREES.SUCCESS
  205. case 'SUCCESS':
  206. return DEGREES.FAILURE
  207. case 'FAILURE':
  208. return DEGREES.CRIT_FAIL
  209. case 'CRIT_FAIL':
  210. return DEGREES.CRIT_FAIL
  211. }
  212. } else return degree
  213. }
  214. const shouldIgnoreStrikeCritFailToFail = (oldDOS, newDOS, isStrike) => {
  215. // only ignore in this somewhat common edge case:
  216. return (
  217. // fail changed to crit fail, or vice versa
  218. ((oldDOS === DEGREES.FAILURE && newDOS === DEGREES.CRIT_FAIL)
  219. || (oldDOS === DEGREES.CRIT_FAIL && newDOS === DEGREES.FAILURE))
  220. // and this game setting is enabled
  221. && getSetting('ignore-crit-fail-over-fail-on-attacks')
  222. // and it was a Strike attack
  223. && isStrike
  224. )
  225. }
  226. /**
  227. * dcFlavorSuffix will be e.g. 'Off-Guard -2, Frightened -1'
  228. */
  229. const insertDcFlavorSuffix = ($flavorText, dcFlavorSuffix, dcActorType) => {
  230. const showDefenseHighlightsToEveryone = getSetting('show-defense-highlights-to-everyone')
  231. const dataVisibility = showDefenseHighlightsToEveryone ? 'all' : 'gm'
  232. const messageKey = dcActorType === 'target' ? `${MODULE_ID}.Message.TargetHas`
  233. : dcActorType === 'caster' ? `${MODULE_ID}.Message.CasterHas`
  234. : `${MODULE_ID}.Message.ActorHas`
  235. $flavorText.find('div.degree-of-success').before(
  236. `<div data-visibility="${dataVisibility}">
  237. ${tryLocalize(messageKey, 'Target has:')} <b>(${dcFlavorSuffix})</b>
  238. </div>`)
  239. }
  240. const hook_preCreateChatMessage = async (chatMessage, data) => {
  241. // continue only if message is a PF2e roll message with a rolling actor
  242. if (
  243. !chatMessage.flags
  244. || !chatMessage.flags.pf2e
  245. || !chatMessage.flags.pf2e.modifiers
  246. || !chatMessage.flags.pf2e.context.dc
  247. || !chatMessage.flags.pf2e.context.actor
  248. ) return true
  249. const rollingActor = game.actors.get(chatMessage.flags.pf2e.context.actor)
  250. // here I assume the PF2E system always includes the d20 roll as the first roll! and as the first term of that roll!
  251. const roll = chatMessage.rolls[0]
  252. const rollTotal = roll?.total !== undefined ? roll.total : parseInt(chatMessage.content)
  253. const rollDc = chatMessage.flags.pf2e.context.dc.value
  254. const deltaFromDc = rollTotal - rollDc
  255. // using roll.terms[0].total will work when rolling 1d20+9, or 2d20kh+9 (RollTwice RE), or 10+9 (SubstituteRoll RE)
  256. const dieRoll = roll.terms[0].total
  257. const currentDegreeOfSuccess = calcDegreePlusRoll(deltaFromDc, dieRoll)
  258. // noinspection JSDeprecatedSymbols (String.strike is irrelevant, IntelliJ!)
  259. const dcSlug = chatMessage.flags.pf2e.context.dc.slug
  260. const isStrike = dcSlug === 'ac' || dcSlug === 'armor' // it changed from 'ac' to 'armor' in pf2e v4.12
  261. const isSpell = chatMessage.flags.pf2e.origin?.type === 'spell'
  262. const targetedTokenUuid = chatMessage.flags.pf2e.context.target?.token
  263. const targetedActorUuid = chatMessage.flags.pf2e.context.target?.actor
  264. const targetedToken = targetedTokenUuid ? fromUuidSync(targetedTokenUuid) : undefined
  265. // targetedActorUuid will return the TOKEN uuid if it's an unlinked token! so, we're probably going to ignore it
  266. const targetedActor = targetedToken?.actor ? targetedToken.actor
  267. : targetedActorUuid ? fromUuidSync(targetedActorUuid)
  268. : undefined
  269. const originUuid = chatMessage.flags.pf2e.origin?.uuid
  270. const originItem = originUuid ? fromUuidSync(originUuid) : undefined
  271. const allModifiersInChatMessage = chatMessage.flags.pf2e.modifiers
  272. /*
  273. NOTE - from this point on, I use the term "modifier" or "mod" to refer to conditions/effects/feats that have granted
  274. a bonus or penalty to the roll or to the DC the roll was against. I will filter rollMods and dcMods to only include
  275. relevant non-ignored modifiers, and then calculate which modifiers actually made a significant impact on the outcome.
  276. The "modifier" objects in these lists are generally ModifierPf2e class objects, which have a "label", a "type", and
  277. a "modifier" field (their signed numerical value).
  278. */
  279. const rollMods = rollModsFromChatMessage(allModifiersInChatMessage, rollingActor, dcSlug)
  280. let dcMods
  281. let actorWithDc
  282. if (isStrike && targetedActor) {
  283. actorWithDc = targetedActor
  284. dcMods = dcModsOfStatistic(targetedActor.system.attributes.ac, actorWithDc)
  285. const offGuardMod = getOffGuardAcMod()
  286. // TODO: maybe simplify after next pf2e release, when `self.flanking` is coming back
  287. const isOffGuard = chatMessage.flags.pf2e.context.options.includes('target:condition:off-guard')
  288. const isFlanking = isNewerVersion(game.version, '5.3')
  289. ? (isOffGuard && !targetedActor.hasCondition('off-guard')) // flanking gives an ephemeral effect
  290. : chatMessage.flags.pf2e.context.options.includes('self:flanking')
  291. if ((isFlanking || isOffGuard) && !dcMods.some(m => m.label === offGuardMod.label)) {
  292. if (isFlanking) {
  293. offGuardMod.label = game.i18n.localize('PF2E.Item.Condition.Flanked')
  294. }
  295. dcMods.push(offGuardMod)
  296. }
  297. dcMods = dcMods.filter(m => !IGNORED_MODIFIER_LABELS_FOR_AC_ONLY.includes(m.label))
  298. } else if (isSpell && !!originItem) {
  299. // (note: originItem will be undefined in the rare case of a message created through a module like Quick Send To Chat)
  300. // if saving against spell, DC is the Spellcasting DC which means it's affected by stuff like Frightened and Stupefied
  301. actorWithDc = originItem.actor
  302. dcMods = dcModsOfStatistic(originItem.spellcasting.statistic.dc, actorWithDc)
  303. } else if (originItem?.category === 'class') {
  304. // 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
  305. // NOTE: this will not work for embedded Check buttons that come from Note REs. see https://github.com/foundryvtt/pf2e/issues/9824
  306. actorWithDc = originItem.actor
  307. dcMods = dcModsOfStatistic(originItem.parent.classDC, actorWithDc)
  308. } else if (targetedActor && dcSlug) {
  309. // if there's a target, but it's not an attack, then it's probably a skill check against one of the target's
  310. // save DCs or perception DC or possibly a skill DC
  311. actorWithDc = targetedActor
  312. const dcStatistic = targetedActor.saves[dcSlug] || targetedActor.skills[dcSlug] || targetedActor[dcSlug]
  313. // dcStatistic should always be defined. (otherwise it means I didn't account for all cases here!)
  314. dcMods = dcModsOfStatistic(dcStatistic.dc, actorWithDc)
  315. } else {
  316. // happens if e.g. rolling from a @Check style button
  317. dcMods = []
  318. }
  319. /**
  320. * wouldChangeOutcome(x) returns true if a bonus of x ("penalty" if x is negative) changes the degree of success
  321. */
  322. const wouldChangeOutcome = (extra) => {
  323. const newDegreeOfSuccess = calcDegreePlusRoll(deltaFromDc + extra, dieRoll)
  324. return newDegreeOfSuccess !== currentDegreeOfSuccess &&
  325. !shouldIgnoreStrikeCritFailToFail(currentDegreeOfSuccess, newDegreeOfSuccess, isStrike)
  326. }
  327. const positiveRollMods = rollMods.filter(modifierPositive)
  328. const negativeRollMods = rollMods.filter(modifierNegative)
  329. const positiveDcMods = dcMods.filter(modifierPositive)
  330. const negativeDcMods = dcMods.filter(modifierNegative)
  331. const necessaryPositiveRollMods = positiveRollMods.filter(m => wouldChangeOutcome(-m.modifier))
  332. const necessaryNegativeRollMods = negativeRollMods.filter(m => wouldChangeOutcome(-m.modifier))
  333. const necessaryPositiveDcMods = positiveDcMods.filter(m => wouldChangeOutcome(m.modifier))
  334. const necessaryNegativeDcMods = negativeDcMods.filter(m => wouldChangeOutcome(m.modifier))
  335. const rollModsPositiveTotal = sumMods(positiveRollMods) - sumMods(negativeDcMods)
  336. const rollModsNegativeTotal = sumMods(negativeRollMods) - sumMods(positiveDcMods)
  337. // sum of modifiers that were necessary to reach the current outcome - these are the biggest bonuses/penalties.
  338. const rollModsNecessaryPositiveTotal = sumMods(necessaryPositiveRollMods) - sumMods(necessaryPositiveDcMods)
  339. const rollModsNecessaryNegativeTotal = sumMods(necessaryNegativeRollMods) - sumMods(necessaryNegativeDcMods)
  340. // sum of all other modifiers. if this sum's changing does not affect the outcome it means modifiers were unnecessary
  341. const rollModsRemainingPositiveTotal = rollModsPositiveTotal - rollModsNecessaryPositiveTotal
  342. const rollModsRemainingNegativeTotal = rollModsNegativeTotal - rollModsNecessaryNegativeTotal
  343. // based on the above sums and the following booleans, we can determine which modifiers were significant and how much
  344. const didPositiveModifiersChangeOutcome = wouldChangeOutcome(-rollModsPositiveTotal)
  345. const didNegativeModifiersChangeOutcome = wouldChangeOutcome(-rollModsNegativeTotal)
  346. const didRemainingPositivesChangeOutcome = wouldChangeOutcome(-rollModsRemainingPositiveTotal)
  347. const didRemainingNegativesChangeOutcome = wouldChangeOutcome(-rollModsRemainingNegativeTotal)
  348. const calcSignificance = (modifierValue) => {
  349. const isNegativeMod = modifierValue < 0
  350. const isPositiveMod = modifierValue > 0
  351. const changedOutcome = wouldChangeOutcome(-modifierValue)
  352. if (isPositiveMod && changedOutcome)
  353. return SIGNIFICANCE.ESSENTIAL
  354. if (isPositiveMod && !changedOutcome && didPositiveModifiersChangeOutcome && didRemainingPositivesChangeOutcome)
  355. return SIGNIFICANCE.HELPFUL
  356. if (isNegativeMod && changedOutcome)
  357. return SIGNIFICANCE.HARMFUL
  358. if (isNegativeMod && !changedOutcome && didNegativeModifiersChangeOutcome && didRemainingNegativesChangeOutcome)
  359. return SIGNIFICANCE.DETRIMENTAL
  360. return SIGNIFICANCE.NONE
  361. }
  362. const significantModifiers = []
  363. rollMods.forEach(m => {
  364. const modVal = m.modifier
  365. const significance = calcSignificance(modVal)
  366. if (significance === SIGNIFICANCE.NONE) return
  367. significantModifiers.push({
  368. appliedTo: 'roll',
  369. name: m.label,
  370. value: modVal,
  371. significance: significance,
  372. })
  373. })
  374. dcMods.forEach(m => {
  375. const modVal = m.modifier
  376. const significance = calcSignificance(-modVal)
  377. significantModifiers.push({
  378. appliedTo: 'dc',
  379. name: m.label,
  380. value: modVal,
  381. significance: significance,
  382. })
  383. })
  384. const oldFlavor = chatMessage.flavor
  385. // adding an artificial div to have a single parent element, enabling nicer editing of html
  386. const $editedFlavor = $(`<div>${oldFlavor}</div>`)
  387. // remove old highlights, in case of a reroll within the same message
  388. $editedFlavor.find('.pf2e-modifiers-matter-highlight')
  389. .css('color', '')
  390. .css('font-weight', '')
  391. .removeClass('pf2e-modifiers-matter-highlight')
  392. significantModifiers.filter(m => m.appliedTo === 'roll').forEach(m => {
  393. const modVal = m.value
  394. const modName = m.name
  395. const modSignificance = m.significance
  396. if (modSignificance === SIGNIFICANCE.NONE) return
  397. const outcomeChangeColor = COLOR_BY_SIGNIFICANCE[modSignificance]
  398. const modValStr = (modVal < 0 ? '' : '+') + modVal
  399. // edit background color for full tags
  400. $editedFlavor.find(`span.tag:contains(${modName} ${modValStr}).tag_alt`)
  401. .css('background-color', outcomeChangeColor)
  402. // edit background+text colors for transparent tags, which have dark text by default
  403. $editedFlavor.find(`span.tag:contains(${modName} ${modValStr}).tag_transparent`)
  404. .css('color', outcomeChangeColor)
  405. .css('font-weight', 'bold')
  406. .addClass('pf2e-modifiers-matter-highlight')
  407. })
  408. const dcFlavorSuffixHtmls = []
  409. significantModifiers.filter(m => m.appliedTo === 'dc').forEach(m => {
  410. const modVal = m.value
  411. const modName = m.name
  412. const modSignificance = m.significance
  413. if (modSignificance === SIGNIFICANCE.NONE)
  414. if (!(isStrike && getSetting('always-show-defense-conditions', false)))
  415. return
  416. const outcomeChangeColor = COLOR_BY_SIGNIFICANCE[modSignificance]
  417. // remove number from end of name, because it's better to see "Frightened (-3)" than "Frightened 3 (-3)"
  418. const modNameNoNum = modName.match(/.* \d+/) ? modName.substring(0, modName.lastIndexOf(' ')) : modName
  419. const modValStr = (modVal < 0 ? '' : '+') + modVal
  420. dcFlavorSuffixHtmls.push(
  421. `<span class="pf2e-modifiers-matter-suffix" style="color: ${outcomeChangeColor}">${modNameNoNum} ${modValStr}</span>`)
  422. })
  423. const dcFlavorSuffix = dcFlavorSuffixHtmls.join(', ')
  424. $editedFlavor.find('.pf2e-modifiers-matter-suffix').remove()
  425. if (dcFlavorSuffix) {
  426. // dcActorType is only used to make the string slightly more fitting
  427. const dcActorType = targetedActor ? 'target' : isSpell ? 'caster' : 'actor'
  428. insertDcFlavorSuffix($editedFlavor, dcFlavorSuffix, dcActorType)
  429. }
  430. // newFlavor will be the inner HTML without the artificial div
  431. const newFlavor = $editedFlavor.html()
  432. if (newFlavor !== oldFlavor) {
  433. data.flavor = newFlavor // just in case other hooks rely on it
  434. await chatMessage.updateSource({ 'flavor': newFlavor })
  435. }
  436. // hook call - to allow other modules/macros to trigger based on MM
  437. if (significantModifiers.length > 0) {
  438. Hooks.callAll('modifiersMatter', {
  439. rollingActor,
  440. actorWithDc, // can be undefined
  441. targetedToken, // can be undefined
  442. significantModifiers, // list of: {name: string, value: number, significance: string}
  443. chatMessage,
  444. })
  445. }
  446. return true
  447. }
  448. const exampleHookInspireCourage = () => {
  449. // this hook call is an example!
  450. // it will play a nice chime sound each time an Inspire Courage effect turns a miss into a hit (or hit to crit)
  451. Hooks.on('modifiersMatter', ({ rollingActor, significantModifiers }) => {
  452. console.log(`${rollingActor} was helped!`)
  453. significantModifiers.forEach(({ name, significance }) => {
  454. if (name.includes('Inspire Courage') && significance === 'ESSENTIAL') {
  455. AudioHelper.play({
  456. src: 'https://cdn.pixabay.com/audio/2022/01/18/audio_8db1f1b5a5.mp3',
  457. volume: 1.0,
  458. autoplay: true,
  459. loop: false,
  460. }, true)
  461. }
  462. })
  463. })
  464. }
  465. const getSetting = (settingName) => game.settings.get(MODULE_ID, settingName)
  466. Hooks.on('init', function () {
  467. game.settings.register(MODULE_ID, 'show-defense-highlights-to-everyone', {
  468. name: `${MODULE_ID}.Settings.show-defense-highlights-to-everyone.name`,
  469. hint: `${MODULE_ID}.Settings.show-defense-highlights-to-everyone.hint`,
  470. scope: 'world',
  471. config: true,
  472. default: true,
  473. type: Boolean,
  474. })
  475. game.settings.register(MODULE_ID, 'ignore-crit-fail-over-fail-on-attacks', {
  476. name: `${MODULE_ID}.Settings.ignore-crit-fail-over-fail-on-attacks.name`,
  477. hint: `${MODULE_ID}.Settings.ignore-crit-fail-over-fail-on-attacks.hint`,
  478. scope: 'client',
  479. config: true,
  480. default: false,
  481. type: Boolean,
  482. })
  483. game.settings.register(MODULE_ID, 'additional-ignored-labels', {
  484. name: `${MODULE_ID}.Settings.additional-ignored-labels.name`,
  485. hint: `${MODULE_ID}.Settings.additional-ignored-labels.hint`,
  486. scope: 'world',
  487. config: true,
  488. default: 'Example;Skill Potency',
  489. type: String,
  490. onChange: initializeIgnoredModifiers,
  491. })
  492. game.settings.register(MODULE_ID, 'always-show-defense-conditions', {
  493. name: `${MODULE_ID}.Settings.always-show-defense-conditions.name`,
  494. hint: `${MODULE_ID}.Settings.always-show-defense-conditions.hint`,
  495. scope: 'world',
  496. config: true,
  497. default: false,
  498. type: Boolean,
  499. })
  500. })
  501. Hooks.once('setup', function () {
  502. Hooks.on('preCreateChatMessage', hook_preCreateChatMessage)
  503. initializeIgnoredModifiers()
  504. console.info(`${MODULE_ID} | initialized`)
  505. })