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( `
${tryLocalize(`${MODULE_ID}.Message.TargetHas`, 'Target has:')} (${acFlavorSuffix})
`) } 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 = $(`
${oldFlavor}
`) 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 `${modifierName} ${modifierValue}` }).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`) })