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( `