mirror of
https://github.com/Foundryborne/daggerheart.git
synced 2026-06-05 20:34:15 +02:00
218 lines
10 KiB
JavaScript
218 lines
10 KiB
JavaScript
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;
|
|
}
|
|
}
|