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

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`)
})