|
|
- const MODULE_ID = 'pf2e-modifiers-matter'
- // 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
- // TODO - also effects from "rules" in general
- // so far: got Cover to work (flat modifier to ac)
-
- // 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.
-
- // strong green = this condition was necessary to achieve this result (others were potentially also necessary). this
- // means the one who caused this condition should definitely be congratulated/thanked.
- // weak green = this condition was not necessary to achieve this result, but degree of success did change due to
- // something in this direction, through a collection of weak green and/or strong green conditions. 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 conditions, PF2e hides some of them from the chat card.
- const POSITIVE_COLOR = '#008000'
- const WEAK_POSITIVE_COLOR = '#91a82a'
- const NO_CHANGE_COLOR = '#000000'
- const NEGATIVE_COLOR = '#ff0000'
- const WEAK_NEGATIVE_COLOR = '#ff852f'
- let IGNORED_MODIFIER_LABELS = []
-
- 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.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
- // also effects that replace your AC item bonus and dex cap - super hard to calculate their "true" bonus
- `${MODULE_ID}.IgnoredModifiers.DrakeheartMutagen`,
- ]
- IGNORED_MODIFIER_LABELS = IGNORED_MODIFIERS_I18N.map(str => tryLocalize(str, str)).
- concat(getSetting('additional-ignored-labels').split(';'))
- }
-
- const sumReducerMods = (accumulator, curr) => accumulator + curr.modifier
- const sumReducerAcConditions = (accumulator, curr) => accumulator + curr.value
- const isAcMod = m => m.group === 'ac' || m.group === 'all'
- const valuePositive = m => m.value > 0
- const valueNegative = m => m.value < 0
- const modifierPositive = m => m.modifier > 0
- const modifierNegative = m => m.modifier < 0
- const acModOfCon = i => i.modifiers?.find(isAcMod)
- const convertAcModifier = m => {
- if (!m.enabled && m.ignored) return m
- return {
- name: m.label,
- modifiers: [
- {
- group: 'ac',
- type: m.type,
- value: m.modifier,
- }],
- }
- }
- const getShieldAcCondition = (targetedToken) => {
- const raisedShieldModifier = targetedToken.actor.getShieldBonus()
- if (raisedShieldModifier) return {
- name: raisedShieldModifier.label,
- modifiers: [
- {
- group: 'ac',
- type: raisedShieldModifier.type,
- value: raisedShieldModifier.modifier,
- },
- ],
- }
- }
-
- const getFlankingAcCondition = () => {
- const systemFlanking = game.pf2e.ConditionManager.getCondition('flat-footed')
- return {
- name: systemFlanking.name,
- modifiers: [
- {
- group: 'ac',
- type: 'circumstance',
- value: -2,
- },
- ],
- }
- }
- const acConsOfToken = (targetedToken, isFlanking) => {
- const nameOfArmor = targetedToken.actor.attributes.ac.dexCap?.source || 'Modifier' // "Modifier" for NPCs
- return [].concat(targetedToken.actor.attributes.ac.modifiers.map(convertAcModifier))
- // shield - calculated by the system. a 'effect-raise-a-shield' condition will also exist on the token but get filtered out
- .concat(targetedToken.actor.getShieldBonus() ? [getShieldAcCondition(targetedToken)] : [])
- // flanking - calculated by the system
- .concat(isFlanking ? [getFlankingAcCondition()] : [])
- // remove all non-AC conditions and irrelevant items
- .filter(i => acModOfCon(i) !== undefined)
- // ignore armor because it's a passive constant (dex and prof are already in IGNORED_MODIFIER_LABELS)
- .filter(i => i.name !== nameOfArmor)
- // remove duplicates where name is identical
- .filter((i1, idx, a) => a.findIndex(i2 => (i2.name === i1.name)) === idx)
- // remove items where condition can't stack; by checking if another item has equal/higher mods of same type
- .filter((i1, idx1, a) => {
- const m1 = acModOfCon(i1)
- if (m1.type === 'untyped') return true // untyped always stacks
- // keeping if there isn't another mod item that this won't stack with
- return a.find((i2, idx2) => {
- const m2 = acModOfCon(i2)
- // looking for something with a different index
- return i1 !== i2
- // of the same type
- && m2.type === m1.type
- // with the same sign (-1 and -2 don't stack, but -1 and +2 do)
- && Math.sign(m2.value) === Math.sign(m1.value)
- && (
- // with higher value (if higher index)
- (Math.abs(m2.value) >= Math.abs(m1.value) && idx1 > idx2)
- // or equal-to-higher value (if lower index)
- || (Math.abs(m2.value) > Math.abs(m1.value) && idx1 < idx2)
- )
- }) === undefined
- })
- // remove everything that should be ignored (including user-defined)
- .filter(i => !IGNORED_MODIFIER_LABELS.includes(i.name))
- }
-
- const acModsFromCons = (acConditions) => acConditions.map(c => c.modifiers).deepFlatten().filter(isAcMod)
-
- 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)
- 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
- }
- }
- 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
- }
- }
- return degree
- }
-
- /**
- * acFlavorSuffix will be e.g. 'Flatfooted -2, Frightened -1'
- */
- const insertAcFlavorSuffix = ($flavorText, acFlavorSuffix) => {
- const showDefenseHighlightsToEveryone = getSetting('show-defense-highlights-to-everyone')
- const dataVisibility = showDefenseHighlightsToEveryone ? 'all' : 'gm'
- $flavorText.find('div.degree-of-success').before(
- `<div data-visibility="${dataVisibility}">
- ${tryLocalize(`${MODULE_ID}.Message.TargetHas`, 'Target has:')} <b>(${acFlavorSuffix})</b>
- </div>`)
- }
-
- const hook_preCreateChatMessage = async (chatMessage, data) => {
- // continue only if message is a PF2e roll message
- if (
- !data.flags
- || !data.flags.pf2e
- || data.flags.pf2e.modifiers === undefined
- || data.flags.pf2e.context.dc === undefined
- || data.flags.pf2e.context.dc === null
- ) return true
-
- // potentially include modifiers that apply to enemy AC (it's hard to do the same with ability/spell DCs though)
- const targetedToken = Array.from(game.user.targets)[0]
- const dcObj = data.flags.pf2e.context.dc
- const attackIsAgainstAc = dcObj.slug === 'ac'
- const isFlanking = chatMessage.flags.pf2e.context.options.includes('self:flanking')
- const targetAcConditions = (attackIsAgainstAc && targetedToken !== undefined) ? acConsOfToken(targetedToken,
- isFlanking) : []
-
- const conMods = data.flags.pf2e.modifiers
- // enabled is false for one of the conditions if it can't stack with others
- .filter(m => m.enabled && !m.ignored && !IGNORED_MODIFIER_LABELS.includes(m.label))
- // ignoring all "form" spells that replace your attack bonus
- .filter(m => !(attackIsAgainstAc && m.slug.endsWith('-form')))
- // ignoring Doubling Rings which are basically a permanent item bonus
- .filter(m => !m.slug.startsWith('doubling-rings'))
- const conModsPositiveTotal = conMods.filter(modifierPositive).reduce(sumReducerMods, 0)
- - acModsFromCons(targetAcConditions).filter(valueNegative).reduce(sumReducerAcConditions, 0)
- const conModsNegativeTotal = conMods.filter(modifierNegative).reduce(sumReducerMods, 0)
- - acModsFromCons(targetAcConditions).filter(valuePositive).reduce(sumReducerAcConditions, 0)
-
- const shouldIgnoreThisDegreeOfSuccess = (oldDOS, newDOS) => {
- // 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
- && data.flavor.includes(`${tryLocalize('PF2E.WeaponStrikeLabel', 'Strike')}:`)
- )
- }
-
- const roll = chatMessage.rolls[0] // I hope the main roll is always the first one!
- const rollTotal = parseInt(data.content || roll.total.toString())
- const rollDc = data.flags.pf2e.context.dc.value
- const deltaFromDc = rollTotal - rollDc
- // technically DoS can be higher or lower through nat 1 and nat 20, but it doesn't matter with this calculation
- const dieRoll = roll.terms[0].results[0].result
- const currentDegreeOfSuccess = calcDegreePlusRoll(deltaFromDc, dieRoll)
- // 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 &&
- !shouldIgnoreThisDegreeOfSuccess(currentDegreeOfSuccess, newDegreeOfSuccess)
- }
- const positiveConditionsChangedOutcome = wouldChangeOutcome(-conModsPositiveTotal)
- const negativeConditionsChangedOutcome = wouldChangeOutcome(-conModsNegativeTotal)
- // sum of condition modifiers that were necessary to reach the current outcome - these are the biggest bonuses/penalties.
- const conModsNecessaryPositiveTotal = conMods.filter(m => modifierPositive(m) && wouldChangeOutcome(-m.modifier)).
- reduce(sumReducerMods, 0)
- - acModsFromCons(targetAcConditions).
- filter(m => valueNegative(m) && wouldChangeOutcome(m.value)).
- reduce(sumReducerAcConditions, 0)
- const conModsNecessaryNegativeTotal = conMods.filter(m => modifierNegative(m) && wouldChangeOutcome(-m.modifier)).
- reduce(sumReducerMods, 0)
- - acModsFromCons(targetAcConditions).
- filter(m => valuePositive(m) && wouldChangeOutcome(m.value)).
- reduce(sumReducerAcConditions, 0)
- // sum of all other condition modifiers. if this sum's changing does not affect the outcome it means conditions were unnecessary
- const remainingPositivesChangedOutcome = wouldChangeOutcome(-(conModsPositiveTotal - conModsNecessaryPositiveTotal))
- const remainingNegativesChangedOutcome = wouldChangeOutcome(-(conModsNegativeTotal - conModsNecessaryNegativeTotal))
-
- // utility, because this calculation is done multiple times but requires a bunch of calculated variables
- const calcOutcomeChangeColor = (modifier) => {
- const isNegativeMod = modifier < 0
- const changedOutcome = wouldChangeOutcome(-modifier)
- // return (not marking condition modifier at all) if this condition modifier was absolutely not necessary
- if (
- (!isNegativeMod && !positiveConditionsChangedOutcome)
- || (isNegativeMod && !negativeConditionsChangedOutcome)
- || (!isNegativeMod && !remainingPositivesChangedOutcome && !changedOutcome)
- || (isNegativeMod && !remainingNegativesChangedOutcome && !changedOutcome)
- )
- return undefined
- return isNegativeMod
- ? (changedOutcome ? NEGATIVE_COLOR : WEAK_NEGATIVE_COLOR)
- : (changedOutcome ? POSITIVE_COLOR : WEAK_POSITIVE_COLOR)
- }
-
- const oldFlavor = chatMessage.flavor
- // adding an artificial div to have a single parent element, enabling nicer editing of html
- const $editedFlavor = $(`<div>${oldFlavor}</div>`)
- conMods.forEach(m => {
- const mod = m.modifier
- const outcomeChangeColor = calcOutcomeChangeColor(mod)
- if (!outcomeChangeColor) return
- const modifierValue = (mod < 0 ? '' : '+') + mod
- // edit background color for full tags
- $editedFlavor.find(`span.tag:contains(${m.label} ${modifierValue}).tag_alt`).
- css('background-color', outcomeChangeColor)
- // edit background+text colors for transparent tags, which have dark text by default
- $editedFlavor.find(`span.tag:contains(${m.label} ${modifierValue}).tag_transparent`).
- css('color', outcomeChangeColor).
- css('font-weight', 'bold')
- })
- const acFlavorSuffix = targetAcConditions.map(c => {
- const conditionAcMod = c.modifiers.filter(isAcMod).reduce(sumReducerAcConditions, -0)
- let outcomeChangeColor = calcOutcomeChangeColor(-conditionAcMod)
- if (!outcomeChangeColor) {
- if (getSetting('always-show-defense-conditions', false)) {
- outcomeChangeColor = NO_CHANGE_COLOR
- } else {
- return undefined
- }
- }
- const modifierValue = (conditionAcMod < 0 ? '' : '+') + conditionAcMod
- const modifierName = c.name
- return `<span style="color: ${outcomeChangeColor}">${modifierName} ${modifierValue}</span>`
- }).filter(s => s !== undefined).join(', ')
- if (acFlavorSuffix) {
- insertAcFlavorSuffix($editedFlavor, acFlavorSuffix)
- }
- // newFlavor will be the inner HTML without the artificial div
- const newFlavor = $editedFlavor.html()
- if (newFlavor !== oldFlavor) {
- data.flavor = newFlavor
-
- await chatMessage.updateSource({ 'flavor': newFlavor })
- CONFIG.compatibility.excludePatterns.pop()
- }
- return 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`)
- })
-
|