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.

418 lines
18 KiB

1 year ago
  1. const MODULE_ID = 'pf2e-modifiers-matter'
  2. // TODO - figure out how to notice effects on the target that change their Ref/Fort/Will DC, e.g. when trying to Tumble Through against targeted enemy
  3. // TODO - also effects from "rules" in general
  4. // so far: got Cover to work (flat modifier to ac)
  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. // strong green = this condition was necessary to achieve this result (others were potentially also necessary). this
  14. // means the one who caused this condition should definitely be congratulated/thanked.
  15. // weak green = this condition was not necessary to achieve this result, but degree of success did change due to
  16. // something in this direction, through a collection of weak green and/or strong green conditions. for example,
  17. // 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
  18. // its own but they were necessary together.
  19. // if you had rolled a 13 in this case, the +2 would be strong green but the +1 would still be weak green, simply
  20. // because it's difficult to come up with an algorithm that would solve complex cases.
  21. // note, by the way, that in case of multiple non-stacking conditions, PF2e hides some of them from the chat card.
  22. const POSITIVE_COLOR = '#008000'
  23. const WEAK_POSITIVE_COLOR = '#91a82a'
  24. const NO_CHANGE_COLOR = '#000000'
  25. const NEGATIVE_COLOR = '#ff0000'
  26. const WEAK_NEGATIVE_COLOR = '#ff852f'
  27. let IGNORED_MODIFIER_LABELS = []
  28. let warnedAboutLocalization = false
  29. const tryLocalize = (key, defaultValue) => {
  30. const localized = game.i18n.localize(key)
  31. if (localized === key) {
  32. if (!warnedAboutLocalization) {
  33. console.warn(`${MODULE_ID}: failed to localize ${key}`)
  34. warnedAboutLocalization = true
  35. }
  36. return defaultValue
  37. }
  38. return localized
  39. }
  40. const initializeIgnoredModifiers = () => {
  41. const IGNORED_MODIFIERS_I18N = [
  42. 'PF2E.BaseModifier',
  43. 'PF2E.ModifierTitle',
  44. 'PF2E.MultipleAttackPenalty',
  45. 'PF2E.ProficiencyLevel0',
  46. 'PF2E.ProficiencyLevel1',
  47. 'PF2E.ProficiencyLevel2',
  48. 'PF2E.ProficiencyLevel3',
  49. 'PF2E.ProficiencyLevel4',
  50. 'PF2E.AbilityStr',
  51. 'PF2E.AbilityCon',
  52. 'PF2E.AbilityDex',
  53. 'PF2E.AbilityInt',
  54. 'PF2E.AbilityWis',
  55. 'PF2E.AbilityCha',
  56. 'PF2E.PotencyRuneLabel',
  57. 'PF2E.AutomaticBonusProgression.attackPotency',
  58. 'PF2E.AutomaticBonusProgression.defensePotency',
  59. 'PF2E.AutomaticBonusProgression.savePotency',
  60. 'PF2E.AutomaticBonusProgression.perceptionPotency',
  61. 'PF2E.NPC.Adjustment.EliteLabel',
  62. 'PF2E.NPC.Adjustment.WeakLabel',
  63. 'PF2E.MasterSavingThrow.fortitude',
  64. 'PF2E.MasterSavingThrow.reflex',
  65. 'PF2E.MasterSavingThrow.will',
  66. `${MODULE_ID}.IgnoredModifiers.DeviseAStratagem`, // Investigator
  67. `${MODULE_ID}.IgnoredModifiers.HuntersEdgeFlurry1`, // Ranger, replaces multiple attack penalty
  68. `${MODULE_ID}.IgnoredModifiers.HuntersEdgeFlurry2`, // same
  69. `${MODULE_ID}.IgnoredModifiers.HuntersEdgeFlurry3`, // same, Ranger's companion
  70. // NOTE: all spells that end in "form" are also ignored for the attack bonus; e.g. Ooze Form
  71. // also some battle form spells with different names:
  72. `${MODULE_ID}.IgnoredModifiers.BattleForm1`, // battle form
  73. `${MODULE_ID}.IgnoredModifiers.BattleForm2`, // battle form
  74. `${MODULE_ID}.IgnoredModifiers.BattleForm3`, // battle form
  75. `${MODULE_ID}.IgnoredModifiers.BattleForm4`, // battle form
  76. // also effects that replace your AC item bonus and dex cap - super hard to calculate their "true" bonus
  77. `${MODULE_ID}.IgnoredModifiers.DrakeheartMutagen`,
  78. ]
  79. IGNORED_MODIFIER_LABELS = IGNORED_MODIFIERS_I18N.map(str => tryLocalize(str, str)).
  80. concat(getSetting('additional-ignored-labels').split(';'))
  81. }
  82. const sumReducerMods = (accumulator, curr) => accumulator + curr.modifier
  83. const sumReducerAcConditions = (accumulator, curr) => accumulator + curr.value
  84. const isAcMod = m => m.group === 'ac' || m.group === 'all'
  85. const valuePositive = m => m.value > 0
  86. const valueNegative = m => m.value < 0
  87. const modifierPositive = m => m.modifier > 0
  88. const modifierNegative = m => m.modifier < 0
  89. const acModOfCon = i => i.modifiers?.find(isAcMod)
  90. const convertAcModifier = m => {
  91. if (!m.enabled && m.ignored) return m
  92. return {
  93. name: m.label,
  94. modifiers: [
  95. {
  96. group: 'ac',
  97. type: m.type,
  98. value: m.modifier,
  99. }],
  100. }
  101. }
  102. const getShieldAcCondition = (targetedToken) => {
  103. const raisedShieldModifier = targetedToken.actor.getShieldBonus()
  104. if (raisedShieldModifier) return {
  105. name: raisedShieldModifier.label,
  106. modifiers: [
  107. {
  108. group: 'ac',
  109. type: raisedShieldModifier.type,
  110. value: raisedShieldModifier.modifier,
  111. },
  112. ],
  113. }
  114. }
  115. const getFlankingAcCondition = () => {
  116. const systemFlanking = game.pf2e.ConditionManager.getCondition('flat-footed')
  117. return {
  118. name: systemFlanking.name,
  119. modifiers: [
  120. {
  121. group: 'ac',
  122. type: 'circumstance',
  123. value: -2,
  124. },
  125. ],
  126. }
  127. }
  128. const acConsOfToken = (targetedToken, isFlanking) => {
  129. const nameOfArmor = targetedToken.actor.attributes.ac.dexCap?.source || 'Modifier' // "Modifier" for NPCs
  130. return [].concat(targetedToken.actor.attributes.ac.modifiers.map(convertAcModifier))
  131. // shield - calculated by the system. a 'effect-raise-a-shield' condition will also exist on the token but get filtered out
  132. .concat(targetedToken.actor.getShieldBonus() ? [getShieldAcCondition(targetedToken)] : [])
  133. // flanking - calculated by the system
  134. .concat(isFlanking ? [getFlankingAcCondition()] : [])
  135. // remove all non-AC conditions and irrelevant items
  136. .filter(i => acModOfCon(i) !== undefined)
  137. // ignore armor because it's a passive constant (dex and prof are already in IGNORED_MODIFIER_LABELS)
  138. .filter(i => i.name !== nameOfArmor)
  139. // remove duplicates where name is identical
  140. .filter((i1, idx, a) => a.findIndex(i2 => (i2.name === i1.name)) === idx)
  141. // remove items where condition can't stack; by checking if another item has equal/higher mods of same type
  142. .filter((i1, idx1, a) => {
  143. const m1 = acModOfCon(i1)
  144. if (m1.type === 'untyped') return true // untyped always stacks
  145. // keeping if there isn't another mod item that this won't stack with
  146. return a.find((i2, idx2) => {
  147. const m2 = acModOfCon(i2)
  148. // looking for something with a different index
  149. return i1 !== i2
  150. // of the same type
  151. && m2.type === m1.type
  152. // with the same sign (-1 and -2 don't stack, but -1 and +2 do)
  153. && Math.sign(m2.value) === Math.sign(m1.value)
  154. && (
  155. // with higher value (if higher index)
  156. (Math.abs(m2.value) >= Math.abs(m1.value) && idx1 > idx2)
  157. // or equal-to-higher value (if lower index)
  158. || (Math.abs(m2.value) > Math.abs(m1.value) && idx1 < idx2)
  159. )
  160. }) === undefined
  161. })
  162. // remove everything that should be ignored (including user-defined)
  163. .filter(i => !IGNORED_MODIFIER_LABELS.includes(i.name))
  164. }
  165. const acModsFromCons = (acConditions) => acConditions.map(c => c.modifiers).deepFlatten().filter(isAcMod)
  166. const DEGREES = Object.freeze({
  167. CRIT_SUCC: 'CRIT_SUCC',
  168. SUCCESS: 'SUCCESS',
  169. FAILURE: 'FAILURE',
  170. CRIT_FAIL: 'CRIT_FAIL',
  171. })
  172. // REMEMBER: in Pf2e, delta 0-9 means SUCCESS, delta 10+ means CRIT SUCCESS, delta -1-9 is FAIL, delta -10- is CRIT FAIL
  173. const calcDegreeOfSuccess = (deltaFromDc) => {
  174. switch (true) {
  175. case deltaFromDc >= 10:
  176. return DEGREES.CRIT_SUCC
  177. case deltaFromDc <= -10:
  178. return DEGREES.CRIT_FAIL
  179. case deltaFromDc >= 1:
  180. return DEGREES.SUCCESS
  181. case deltaFromDc <= -1:
  182. return DEGREES.FAILURE
  183. case deltaFromDc === 0:
  184. return DEGREES.SUCCESS
  185. }
  186. // impossible
  187. console.error(`${MODULE_ID} | calcDegreeOfSuccess got wrong number: ${deltaFromDc}`)
  188. return DEGREES.CRIT_FAIL
  189. }
  190. const calcDegreePlusRoll = (deltaFromDc, dieRoll) => {
  191. const degree = calcDegreeOfSuccess(deltaFromDc)
  192. if (dieRoll === 20) {
  193. switch (degree) {
  194. case 'CRIT_SUCC':
  195. return DEGREES.CRIT_SUCC
  196. case 'SUCCESS':
  197. return DEGREES.CRIT_SUCC
  198. case 'FAILURE':
  199. return DEGREES.SUCCESS
  200. case 'CRIT_FAIL':
  201. return DEGREES.FAILURE
  202. }
  203. }
  204. if (dieRoll === 1) {
  205. switch (degree) {
  206. case 'CRIT_SUCC':
  207. return DEGREES.SUCCESS
  208. case 'SUCCESS':
  209. return DEGREES.FAILURE
  210. case 'FAILURE':
  211. return DEGREES.CRIT_FAIL
  212. case 'CRIT_FAIL':
  213. return DEGREES.CRIT_FAIL
  214. }
  215. }
  216. return degree
  217. }
  218. /**
  219. * acFlavorSuffix will be e.g. 'Flatfooted -2, Frightened -1'
  220. */
  221. const insertAcFlavorSuffix = ($flavorText, acFlavorSuffix) => {
  222. const showDefenseHighlightsToEveryone = getSetting('show-defense-highlights-to-everyone')
  223. const dataVisibility = showDefenseHighlightsToEveryone ? 'all' : 'gm'
  224. $flavorText.find('div.degree-of-success').before(
  225. `<div data-visibility="${dataVisibility}">
  226. ${tryLocalize(`${MODULE_ID}.Message.TargetHas`, 'Target has:')} <b>(${acFlavorSuffix})</b>
  227. </div>`)
  228. }
  229. const hook_preCreateChatMessage = async (chatMessage, data) => {
  230. // continue only if message is a PF2e roll message
  231. if (
  232. !data.flags
  233. || !data.flags.pf2e
  234. || data.flags.pf2e.modifiers === undefined
  235. || data.flags.pf2e.context.dc === undefined
  236. || data.flags.pf2e.context.dc === null
  237. ) return true
  238. // potentially include modifiers that apply to enemy AC (it's hard to do the same with ability/spell DCs though)
  239. const targetedToken = Array.from(game.user.targets)[0]
  240. const dcObj = data.flags.pf2e.context.dc
  241. const attackIsAgainstAc = dcObj.slug === 'ac'
  242. const isFlanking = chatMessage.flags.pf2e.context.options.includes('self:flanking')
  243. const targetAcConditions = (attackIsAgainstAc && targetedToken !== undefined) ? acConsOfToken(targetedToken,
  244. isFlanking) : []
  245. const conMods = data.flags.pf2e.modifiers
  246. // enabled is false for one of the conditions if it can't stack with others
  247. .filter(m => m.enabled && !m.ignored && !IGNORED_MODIFIER_LABELS.includes(m.label))
  248. // ignoring all "form" spells that replace your attack bonus
  249. .filter(m => !(attackIsAgainstAc && m.slug.endsWith('-form')))
  250. // ignoring Doubling Rings which are basically a permanent item bonus
  251. .filter(m => !m.slug.startsWith('doubling-rings'))
  252. const conModsPositiveTotal = conMods.filter(modifierPositive).reduce(sumReducerMods, 0)
  253. - acModsFromCons(targetAcConditions).filter(valueNegative).reduce(sumReducerAcConditions, 0)
  254. const conModsNegativeTotal = conMods.filter(modifierNegative).reduce(sumReducerMods, 0)
  255. - acModsFromCons(targetAcConditions).filter(valuePositive).reduce(sumReducerAcConditions, 0)
  256. const shouldIgnoreThisDegreeOfSuccess = (oldDOS, newDOS) => {
  257. // only ignore in this somewhat common edge case:
  258. return (
  259. // fail changed to crit fail, or vice versa
  260. ((oldDOS === DEGREES.FAILURE && newDOS === DEGREES.CRIT_FAIL)
  261. || (oldDOS === DEGREES.CRIT_FAIL && newDOS === DEGREES.FAILURE))
  262. // and this game setting is enabled
  263. && getSetting('ignore-crit-fail-over-fail-on-attacks')
  264. // and it was a Strike attack
  265. && data.flavor.includes(`${tryLocalize('PF2E.WeaponStrikeLabel', 'Strike')}:`)
  266. )
  267. }
  268. const roll = chatMessage.rolls[0] // I hope the main roll is always the first one!
  269. const rollTotal = parseInt(data.content || roll.total.toString())
  270. const rollDc = data.flags.pf2e.context.dc.value
  271. const deltaFromDc = rollTotal - rollDc
  272. // technically DoS can be higher or lower through nat 1 and nat 20, but it doesn't matter with this calculation
  273. const dieRoll = roll.terms[0].results[0].result
  274. const currentDegreeOfSuccess = calcDegreePlusRoll(deltaFromDc, dieRoll)
  275. // wouldChangeOutcome(x) returns true if a bonus of x ("penalty" if x is negative) changes the degree of success
  276. const wouldChangeOutcome = (extra) => {
  277. const newDegreeOfSuccess = calcDegreePlusRoll(deltaFromDc + extra, dieRoll)
  278. return newDegreeOfSuccess !== currentDegreeOfSuccess &&
  279. !shouldIgnoreThisDegreeOfSuccess(currentDegreeOfSuccess, newDegreeOfSuccess)
  280. }
  281. const positiveConditionsChangedOutcome = wouldChangeOutcome(-conModsPositiveTotal)
  282. const negativeConditionsChangedOutcome = wouldChangeOutcome(-conModsNegativeTotal)
  283. // sum of condition modifiers that were necessary to reach the current outcome - these are the biggest bonuses/penalties.
  284. const conModsNecessaryPositiveTotal = conMods.filter(m => modifierPositive(m) && wouldChangeOutcome(-m.modifier)).
  285. reduce(sumReducerMods, 0)
  286. - acModsFromCons(targetAcConditions).
  287. filter(m => valueNegative(m) && wouldChangeOutcome(m.value)).
  288. reduce(sumReducerAcConditions, 0)
  289. const conModsNecessaryNegativeTotal = conMods.filter(m => modifierNegative(m) && wouldChangeOutcome(-m.modifier)).
  290. reduce(sumReducerMods, 0)
  291. - acModsFromCons(targetAcConditions).
  292. filter(m => valuePositive(m) && wouldChangeOutcome(m.value)).
  293. reduce(sumReducerAcConditions, 0)
  294. // sum of all other condition modifiers. if this sum's changing does not affect the outcome it means conditions were unnecessary
  295. const remainingPositivesChangedOutcome = wouldChangeOutcome(-(conModsPositiveTotal - conModsNecessaryPositiveTotal))
  296. const remainingNegativesChangedOutcome = wouldChangeOutcome(-(conModsNegativeTotal - conModsNecessaryNegativeTotal))
  297. // utility, because this calculation is done multiple times but requires a bunch of calculated variables
  298. const calcOutcomeChangeColor = (modifier) => {
  299. const isNegativeMod = modifier < 0
  300. const changedOutcome = wouldChangeOutcome(-modifier)
  301. // return (not marking condition modifier at all) if this condition modifier was absolutely not necessary
  302. if (
  303. (!isNegativeMod && !positiveConditionsChangedOutcome)
  304. || (isNegativeMod && !negativeConditionsChangedOutcome)
  305. || (!isNegativeMod && !remainingPositivesChangedOutcome && !changedOutcome)
  306. || (isNegativeMod && !remainingNegativesChangedOutcome && !changedOutcome)
  307. )
  308. return undefined
  309. return isNegativeMod
  310. ? (changedOutcome ? NEGATIVE_COLOR : WEAK_NEGATIVE_COLOR)
  311. : (changedOutcome ? POSITIVE_COLOR : WEAK_POSITIVE_COLOR)
  312. }
  313. const oldFlavor = chatMessage.flavor
  314. // adding an artificial div to have a single parent element, enabling nicer editing of html
  315. const $editedFlavor = $(`<div>${oldFlavor}</div>`)
  316. conMods.forEach(m => {
  317. const mod = m.modifier
  318. const outcomeChangeColor = calcOutcomeChangeColor(mod)
  319. if (!outcomeChangeColor) return
  320. const modifierValue = (mod < 0 ? '' : '+') + mod
  321. // edit background color for full tags
  322. $editedFlavor.find(`span.tag:contains(${m.label} ${modifierValue}).tag_alt`).
  323. css('background-color', outcomeChangeColor)
  324. // edit background+text colors for transparent tags, which have dark text by default
  325. $editedFlavor.find(`span.tag:contains(${m.label} ${modifierValue}).tag_transparent`).
  326. css('color', outcomeChangeColor).
  327. css('font-weight', 'bold')
  328. })
  329. const acFlavorSuffix = targetAcConditions.map(c => {
  330. const conditionAcMod = c.modifiers.filter(isAcMod).reduce(sumReducerAcConditions, -0)
  331. let outcomeChangeColor = calcOutcomeChangeColor(-conditionAcMod)
  332. if (!outcomeChangeColor) {
  333. if (getSetting('always-show-defense-conditions', false)) {
  334. outcomeChangeColor = NO_CHANGE_COLOR
  335. } else {
  336. return undefined
  337. }
  338. }
  339. const modifierValue = (conditionAcMod < 0 ? '' : '+') + conditionAcMod
  340. const modifierName = c.name
  341. return `<span style="color: ${outcomeChangeColor}">${modifierName} ${modifierValue}</span>`
  342. }).filter(s => s !== undefined).join(', ')
  343. if (acFlavorSuffix) {
  344. insertAcFlavorSuffix($editedFlavor, acFlavorSuffix)
  345. }
  346. // newFlavor will be the inner HTML without the artificial div
  347. const newFlavor = $editedFlavor.html()
  348. if (newFlavor !== oldFlavor) {
  349. data.flavor = newFlavor
  350. await chatMessage.updateSource({ 'flavor': newFlavor })
  351. CONFIG.compatibility.excludePatterns.pop()
  352. }
  353. return true
  354. }
  355. const getSetting = (settingName) => game.settings.get(MODULE_ID, settingName)
  356. Hooks.on('init', function () {
  357. game.settings.register(MODULE_ID, 'show-defense-highlights-to-everyone', {
  358. name: `${MODULE_ID}.Settings.show-defense-highlights-to-everyone.name`,
  359. hint: `${MODULE_ID}.Settings.show-defense-highlights-to-everyone.hint`,
  360. scope: 'world',
  361. config: true,
  362. default: true,
  363. type: Boolean,
  364. })
  365. game.settings.register(MODULE_ID, 'ignore-crit-fail-over-fail-on-attacks', {
  366. name: `${MODULE_ID}.Settings.ignore-crit-fail-over-fail-on-attacks.name`,
  367. hint: `${MODULE_ID}.Settings.ignore-crit-fail-over-fail-on-attacks.hint`,
  368. scope: 'client',
  369. config: true,
  370. default: false,
  371. type: Boolean,
  372. })
  373. game.settings.register(MODULE_ID, 'additional-ignored-labels', {
  374. name: `${MODULE_ID}.Settings.additional-ignored-labels.name`,
  375. hint: `${MODULE_ID}.Settings.additional-ignored-labels.hint`,
  376. scope: 'world',
  377. config: true,
  378. default: 'Example;Skill Potency',
  379. type: String,
  380. onChange: initializeIgnoredModifiers,
  381. })
  382. game.settings.register(MODULE_ID, 'always-show-defense-conditions', {
  383. name: `${MODULE_ID}.Settings.always-show-defense-conditions.name`,
  384. hint: `${MODULE_ID}.Settings.always-show-defense-conditions.hint`,
  385. scope: 'world',
  386. config: true,
  387. default: false,
  388. type: Boolean,
  389. })
  390. })
  391. Hooks.once('setup', function () {
  392. Hooks.on('preCreateChatMessage', hook_preCreateChatMessage)
  393. initializeIgnoredModifiers()
  394. console.info(`${MODULE_ID} | initialized`)
  395. })