diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..56ce8818 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.json text eol=lf diff --git a/module/data/actor/adversary.mjs b/module/data/actor/adversary.mjs index d69e17ad..d6d0dcdf 100644 --- a/module/data/actor/adversary.mjs +++ b/module/data/actor/adversary.mjs @@ -3,8 +3,7 @@ import { ActionField } from '../fields/actionField.mjs'; import { commonActorRules } from './base.mjs'; import DhCreature from './creature.mjs'; import { bonusField } from '../fields/actorField.mjs'; -import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs'; -import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs'; +import { getTierAdjustedAdversary } from './tierAdjustment.mjs'; export default class DhpAdversary extends DhCreature { static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary']; @@ -206,205 +205,6 @@ export default class DhpAdversary extends DhCreature { /** Returns source data for this actor adjusted to a new tier, which can be used to create a new actor. */ adjustForTier(tier) { const source = this.parent.toObject(true); - - /** @type {(2 | 3 | 4)[]} */ - const tiers = new Array(Math.abs(tier - this.tier)) - .fill(0) - .map((_, idx) => idx + Math.min(tier, this.tier) + 1); - if (tier < this.tier) tiers.reverse(); - const typeData = adversaryScalingData[source.system.type] ?? adversaryScalingData[source.system.standard]; - const tierEntries = tiers.map(t => ({ tier: t, ...typeData[t] })); - - // Apply simple tier changes - const scale = tier > this.tier ? 1 : -1; - for (const entry of tierEntries) { - source.system.difficulty += scale * entry.difficulty; - source.system.damageThresholds.major += scale * entry.majorThreshold; - source.system.damageThresholds.severe += scale * entry.severeThreshold; - source.system.resources.hitPoints.max += scale * entry.hp; - source.system.resources.stress.max += scale * entry.stress; - source.system.attack.roll.bonus += scale * entry.attack; - } - - // Get the mean and standard deviation of expected damage in the previous and new tier - // The data we have is for attack scaling, but we reuse this for action scaling later - const expectedDamageData = adversaryExpectedDamage[source.system.type] ?? adversaryExpectedDamage.basic; - const damageMeta = { - currentDamageRange: { tier: source.system.tier, ...expectedDamageData[source.system.tier] }, - newDamageRange: { tier, ...expectedDamageData[tier] }, - type: 'attack' - }; - - // Update damage of base attack - try { - this.#adjustActionDamage(source.system.attack, damageMeta); - } catch (err) { - ui.notifications.warn('Failed to convert attack damage of adversary'); - console.error(err); - } - - // Update damage of each item action, making sure to also update the description if possible - const damageRegex = /@Damage\[([^\[\]]*)\]({[^}]*})?/g; - for (const item of source.items) { - // Replace damage inlines with new formulas - for (const withDescription of [item.system, ...Object.values(item.system.actions)]) { - withDescription.description = withDescription.description.replace(damageRegex, (match, inner) => { - const { value: formula } = parseInlineParams(inner); - if (!formula || !type) return match; - - try { - const adjusted = this.#calculateAdjustedDamage(formula, { ...damageMeta, type: 'action' }); - const newFormula = [ - adjusted.diceQuantity ? `${adjusted.diceQuantity}d${adjusted.faces}` : null, - adjusted.bonus - ] - .filter(p => !!p) - .join('+'); - return match.replace(formula, newFormula); - } catch { - return match; - } - }); - } - - // Update damage in item actions - // Parse damage, and convert all formula matches in the descriptions to the new damage - for (const action of Object.values(item.system.actions)) { - try { - const result = this.#adjustActionDamage(action, { ...damageMeta, type: 'action' }); - if (!result) continue; - - for (const { previousFormula, formula } of Object.values(result)) { - const oldFormulaRegexp = new RegExp( - previousFormula.replace(' ', '').replace('+', '(?:\\s)?\\+(?:\\s)?') - ); - item.system.description = item.system.description.replace(oldFormulaRegexp, formula); - action.description = action.description.replace(oldFormulaRegexp, formula); - } - } catch (err) { - ui.notifications.warn(`Failed to convert action damage for item ${item.name}`); - console.error(err); - } - } - } - - // Finally set the tier of the source data, now that everything is complete - source.system.tier = tier; - return source; - } - - /** - * Converts a damage object to a new damage range - * @returns {{ diceQuantity: number; faces: number; bonus: number }} the adjusted result as a combined term - * @throws error if the formula is the wrong type - */ - #calculateAdjustedDamage(formula, { currentDamageRange, newDamageRange, type }) { - const terms = parseTermsFromSimpleFormula(formula); - const flatTerms = terms.filter(t => t.diceQuantity === 0); - const diceTerms = terms.filter(t => t.diceQuantity > 0); - if (flatTerms.length > 1 || diceTerms.length > 1) { - throw new Error('invalid formula for conversion'); - } - const value = { - ...(diceTerms[0] ?? { diceQuantity: 0, faces: 1 }), - bonus: flatTerms[0]?.bonus ?? 0 - }; - const previousExpected = calculateExpectedValue(value); - if (previousExpected === 0) return value; // nothing to do - - const dieSizes = [4, 6, 8, 10, 12, 20]; - const steps = newDamageRange.tier - currentDamageRange.tier; - const increasing = steps > 0; - const deviation = (previousExpected - currentDamageRange.mean) / currentDamageRange.deviation; - const expected = Math.max(1, newDamageRange.mean + newDamageRange.deviation * deviation); - - // If this was just a flat number, convert to the expected damage and exit - if (value.diceQuantity === 0) { - value.bonus = Math.round(expected); - return value; - } - - const getExpectedDie = () => calculateExpectedValue({ diceQuantity: 1, faces: value.faces }) || 1; - const getBaseAverage = () => calculateExpectedValue({ ...value, bonus: 0 }); - - // Check the number of base overages over the expected die. In the end, if the bonus inflates too much, we add a die - const baseOverages = Math.floor(value.bonus / getExpectedDie()); - - // Prestep. Change number of dice for attacks, bump up/down for actions - // We never bump up to d20, though we might bump down from it - if (type === 'attack') { - const minimum = increasing ? value.diceQuantity : 0; - value.diceQuantity = Math.max(minimum, newDamageRange.tier); - } else { - const currentIdx = dieSizes.indexOf(value.faces); - value.faces = dieSizes[Math.clamp(currentIdx + steps, 0, 4)]; - } - - value.bonus = Math.round(expected - getBaseAverage()); - - // Attempt to handle negative values. - // If we can do it with only step downs, do so. Otherwise remove tier dice, and try again - if (value.bonus < 0) { - let stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity); - const currentIdx = dieSizes.indexOf(value.faces); - - // If step downs alone don't suffice, change the flat modifier, then calculate steps required again - // If this isn't sufficient, the result will be slightly off. This is unlikely to happen - if (type !== 'attack' && stepsRequired > currentIdx && value.diceQuantity > 0) { - value.diceQuantity -= increasing ? 1 : Math.abs(steps); - value.bonus = Math.round(expected - getBaseAverage()); - if (value.bonus >= 0) return value; // complete - } - - stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity); - value.faces = dieSizes[Math.max(0, currentIdx - stepsRequired)]; - value.bonus = Math.max(0, Math.round(expected - getBaseAverage())); - } - - // If value is really high, we add a number of dice based on the number of overages - // This attempts to preserve a similar amount of variance when increasing an action - const overagesToRemove = Math.floor(value.bonus / getExpectedDie()) - baseOverages; - if (type !== 'attack' && increasing && overagesToRemove > 0) { - value.diceQuantity += overagesToRemove; - value.bonus = Math.round(expected - getBaseAverage()); - } - - return value; - } - - /** - * Updates damage to reflect a specific value. - * @throws if damage structure is invalid for conversion - * @returns the converted formula and value as a simplified term, or null if it doesn't deal HP damage - */ - #adjustActionDamage(action, damageMeta) { - if (!action.damage?.parts.hitPoints) return null; - - const result = {}; - for (const property of ['value', 'valueAlt']) { - const data = action.damage.parts.hitPoints[property]; - const previousFormula = data.custom.enabled - ? data.custom.formula - : [data.flatMultiplier ? `${data.flatMultiplier}${data.dice}` : 0, data.bonus ?? 0] - .filter(p => !!p) - .join('+'); - const value = this.#calculateAdjustedDamage(previousFormula, damageMeta); - const formula = [value.diceQuantity ? `${value.diceQuantity}d${value.faces}` : null, value.bonus] - .filter(p => !!p) - .join('+'); - if (value.diceQuantity) { - data.custom.enabled = false; - data.bonus = value.bonus; - data.dice = `d${value.faces}`; - data.flatMultiplier = value.diceQuantity; - } else if (!value.diceQuantity) { - data.custom.enabled = true; - data.custom.formula = formula; - } - - result[property] = { previousFormula, formula, value }; - } - - return result; + return getTierAdjustedAdversary(source, tier); } } diff --git a/module/data/actor/tierAdjustment.mjs b/module/data/actor/tierAdjustment.mjs new file mode 100644 index 00000000..785eec2b --- /dev/null +++ b/module/data/actor/tierAdjustment.mjs @@ -0,0 +1,218 @@ +import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs'; +import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs'; + +export function getTierAdjustedAdversary(source, tier) { + const currentTier = source.tier ?? 1; + + /** @type {(2 | 3 | 4)[]} */ + const tiers = new Array(Math.abs(tier - currentTier)) + .fill(0) + .map((_, idx) => idx + Math.min(tier, currentTier) + 1); + if (tier < currentTier) tiers.reverse(); + const typeData = adversaryScalingData[source.system.type] ?? adversaryScalingData[source.system.standard]; + const tierEntries = tiers.map(t => ({ tier: t, ...typeData[t] })); + + // Apply simple tier changes + const scale = tier > currentTier ? 1 : -1; + for (const entry of tierEntries) { + source.system.difficulty += scale * entry.difficulty; + source.system.damageThresholds.major += scale * entry.majorThreshold; + source.system.damageThresholds.severe += scale * entry.severeThreshold; + source.system.resources.hitPoints.max += scale * entry.hp; + source.system.resources.stress.max += scale * entry.stress; + source.system.attack.roll.bonus += scale * entry.attack; + } + + // Get the mean and standard deviation of expected damage in the previous and new tier + // The data we have is for attack scaling, but we reuse this for action scaling later + const expectedDamageData = adversaryExpectedDamage[source.system.type] ?? adversaryExpectedDamage.basic; + const damageMeta = { + currentDamageRange: { tier: source.system.tier, ...expectedDamageData[source.system.tier] }, + newDamageRange: { tier, ...expectedDamageData[tier] } + }; + + // Store initial attack damage for abilities that have you deal a "standard attack" + const initialAttack = { + type: source.system.attack.damage?.parts.hitPoints?.type?.toSorted(), + value: getDamagePartsFormula(source.system.attack.damage?.parts.hitPoints?.value) + }; + + // Update damage of base attack. + try { + const damage = source.system.attack.damage; + if (!damage?.parts.hitPoints) throw new Error('Unexpected missing attack in adversary'); + + for (const property of ['value', 'valueAlt']) { + const data = damage.parts.hitPoints[property]; + const previousFormula = getDamagePartsFormula(data); + const { value, formula } = calculateAdjustedDamage(previousFormula, 'attack', damageMeta); + applyAdjustedDamage(data, value, formula); + } + } catch (err) { + ui.notifications.warn('Failed to convert attack damage of adversary'); + console.error(err); + } + + // Update damage of each item action, making sure to also update the description if possible + const damageRegex = /@Damage\[([^\[\]]*)\]({[^}]*})?/g; + for (const item of source.items) { + // Replace damage inlines with new formulas. Keep a record for a specific check later + const descriptionFormulas = []; + for (const withDescription of [item.system, ...Object.values(item.system.actions)]) { + withDescription.description = withDescription.description.replace(damageRegex, (match, inner) => { + const { value: formula } = parseInlineParams(inner); + if (!formula || !type) return match; + + try { + const newFormula = calculateAdjustedDamage(formula, 'action', damageMeta)?.formula; + descriptionFormulas.push(formula); + return match.replace(formula, newFormula); + } catch { + return match; + } + }); + } + + // Update damage in item actions and convert all formula matches in the descriptions to the new damage + for (const action of Object.values(item.system.actions)) { + if (!action.damage?.parts.hitPoints) continue; + try { + // Apply conversions and save a record. If it matches attack damage *and* Its not in the description, use attack conversion instead + const result = []; + for (const property of ['value', 'valueAlt']) { + const { [property]: data, type: damageType } = action.damage.parts.hitPoints; + const previousFormula = getDamagePartsFormula(data); + const isActuallyAttack = + previousFormula === initialAttack.value && + foundry.utils.equals(damageType.toSorted(), initialAttack.type) && + !descriptionFormulas.includes(previousFormula); + const type = isActuallyAttack ? 'attack' : 'action'; + const { value, formula } = calculateAdjustedDamage(previousFormula, type, damageMeta); + applyAdjustedDamage(data, value, formula); + result.push({ previousFormula, formula }); + } + + // Override text in the description with those values + for (const { previousFormula, formula } of Object.values(result)) { + const oldFormulaRegexp = new RegExp( + previousFormula.replace(' ', '').replace('+', '(?:\\s)?\\+(?:\\s)?') + ); + item.system.description = item.system.description.replace(oldFormulaRegexp, formula); + action.description = action.description.replace(oldFormulaRegexp, formula); + } + } catch (err) { + ui.notifications.warn(`Failed to convert action damage for item ${item.name}`); + console.error(err); + } + } + } + + // Finally set the tier of the source data, now that everything is complete + source.system.tier = tier; + return source; +} + +/** + * Converts a damage object to a new damage range + * @returns {{ diceQuantity: number; faces: number; bonus: number }} the adjusted result as a combined term + * @throws error if the formula is the wrong type + */ +function calculateAdjustedDamage(formula, type, { currentDamageRange, newDamageRange }) { + const terms = parseTermsFromSimpleFormula(formula); + const flatTerms = terms.filter(t => t.diceQuantity === 0); + const diceTerms = terms.filter(t => t.diceQuantity > 0); + if (flatTerms.length > 1 || diceTerms.length > 1) { + throw new Error('invalid formula for conversion'); + } + const value = { + ...(diceTerms[0] ?? { diceQuantity: 0, faces: 1 }), + bonus: flatTerms[0]?.bonus ?? 0 + }; + const previousExpected = calculateExpectedValue(value); + if (previousExpected === 0) return value; // nothing to do + + const dieSizes = [4, 6, 8, 10, 12, 20]; + const steps = newDamageRange.tier - currentDamageRange.tier; + const increasing = steps > 0; + const deviation = (previousExpected - currentDamageRange.mean) / currentDamageRange.deviation; + const expected = Math.max(1, newDamageRange.mean + newDamageRange.deviation * deviation); + + // If this was just a flat number, convert to the expected damage and exit + if (value.diceQuantity === 0) { + value.bonus = Math.round(expected); + return value; + } + + const getExpectedDie = () => calculateExpectedValue({ diceQuantity: 1, faces: value.faces }) || 1; + const getBaseAverage = () => calculateExpectedValue({ ...value, bonus: 0 }); + + // Check the number of base overages over the expected die. In the end, if the bonus inflates too much, we add a die + const baseOverages = Math.floor(value.bonus / getExpectedDie()); + + // Prestep. Change number of dice for attacks, bump up/down for actions + // We never bump up to d20, though we might bump down from it + if (type === 'attack') { + const minimum = increasing ? value.diceQuantity : 0; + value.diceQuantity = Math.max(minimum, newDamageRange.tier); + } else { + const currentIdx = dieSizes.indexOf(value.faces); + value.faces = dieSizes[Math.clamp(currentIdx + steps, 0, 4)]; + } + + value.bonus = Math.round(expected - getBaseAverage()); + + // Attempt to handle negative values. + // If we can do it with only step downs, do so. Otherwise remove tier dice, and try again + if (value.bonus < 0) { + let stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity); + const currentIdx = dieSizes.indexOf(value.faces); + + // If step downs alone don't suffice, change the flat modifier, then calculate steps required again + // If this isn't sufficient, the result will be slightly off. This is unlikely to happen + if (type !== 'attack' && stepsRequired > currentIdx && value.diceQuantity > 0) { + value.diceQuantity -= increasing ? 1 : Math.abs(steps); + value.bonus = Math.round(expected - getBaseAverage()); + if (value.bonus >= 0) return value; // complete + } + + stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity); + value.faces = dieSizes[Math.max(0, currentIdx - stepsRequired)]; + value.bonus = Math.max(0, Math.round(expected - getBaseAverage())); + } + + // If value is really high, we add a number of dice based on the number of overages + // This attempts to preserve a similar amount of variance when increasing an action + const overagesToRemove = Math.floor(value.bonus / getExpectedDie()) - baseOverages; + if (type !== 'attack' && increasing && overagesToRemove > 0) { + value.diceQuantity += overagesToRemove; + value.bonus = Math.round(expected - getBaseAverage()); + } + + const newFormula = [value.diceQuantity ? `${value.diceQuantity}d${value.faces}` : null, value.bonus] + .filter(p => !!p) + .join('+'); + return { value, formula: newFormula }; +} + +function getDamagePartsFormula(data) { + return data.custom.enabled + ? data.custom.formula + : [data.flatMultiplier ? `${data.flatMultiplier}${data.dice}` : 0, data.bonus ?? 0].filter(p => !!p).join('+'); +} + +/** + * Updates damage to reflect a specific value. + * @throws if damage structure is invalid for conversion + * @returns the converted formula and value as a simplified term, or null if it doesn't deal HP damage + */ +function applyAdjustedDamage(diceData, value, formula) { + if (value.diceQuantity) { + diceData.custom.enabled = false; + diceData.bonus = value.bonus; + diceData.dice = `d${value.faces}`; + diceData.flatMultiplier = value.diceQuantity; + } else if (!value.diceQuantity) { + diceData.custom.enabled = true; + diceData.custom.formula = formula; + } +}