Fine tune damage conversion for actions

This commit is contained in:
Carlos Fernandez 2026-01-20 23:50:16 -05:00
parent 50ee1ccd5b
commit 5a95744b6f
2 changed files with 97 additions and 51 deletions

View file

@ -750,15 +750,15 @@ export const adversaryScalingData = {
/** Scaling data used for an adversary's damage */
export const adversaryExpectedDamage = {
basic: {
1: { medianDamage: 7.5, damageDeviation: 1 },
2: { medianDamage: 13, damageDeviation: 2 },
3: { medianDamage: 15.5, damageDeviation: 1.5 },
4: { medianDamage: 27, damageDeviation: 3 }
1: { median: 7.5, deviation: 1 },
2: { median: 13, deviation: 2 },
3: { median: 15.5, deviation: 1.5 },
4: { median: 27, deviation: 3 }
},
minion: {
1: { medianDamage: 2, damageDeviation: 1 },
2: { medianDamage: 5, damageDeviation: 0.5 },
3: { medianDamage: 6.5, damageDeviation: 1.5 },
4: { medianDamage: 11, damageDeviation: 1 }
1: { median: 2, deviation: 1 },
2: { median: 5, deviation: 0.5 },
3: { median: 6.5, deviation: 1.5 },
4: { median: 11, deviation: 1 }
}
};

View file

@ -214,22 +214,36 @@ export default class DhpAdversary extends BaseDataActor {
source.system.attack.roll.bonus += scale * entry.attack;
}
// Calculate mean and standard deviation of expected damage ranges in each tier. Also create a function to remap damage
// Get the median and median absolute deviation of expected damage in the previous and new tier
const expectedDamageData = adversaryExpectedDamage[source.system.type] ?? adversaryExpectedDamage.basic;
const currentDamageRange = expectedDamageData[source.system.tier];
const newDamageRange = expectedDamageData[tier];
const convertDamage = (damage, newMean) => {
const hitPointParts = damage.parts.filter(d => d.applyTo === 'hitPoints');
if (hitPointParts.length === 1 && !hitPointParts[0].value.custom.enabled) {
const value = hitPointParts[0].value;
value.flatMultiplier = Math.max(0, value.flatMultiplier + tier - source.system.tier);
const baseAverage = (value.flatMultiplier * (Number(value.dice.replace('d', '')) + 1)) / 2;
value.bonus = Math.round(newMean - baseAverage);
}
};
const currentDamageRange = { tier: source.system.tier, ...expectedDamageData[source.system.tier] };
const newDamageRange = { tier, ...expectedDamageData[tier] };
const parseDamage = damage => {
// Update damage of base attack
this.#convertDamage(source.system.attack.damage, "attack", currentDamageRange, newDamageRange);
// Update damage of each item action, making sure to also update the description if possible
for (const item of source.items) {
// todo: damage inlines (must be done before other changes so that it doesn't get incorrectly applied)
for (const action of Object.values(item.system.actions)) {
if (!action.damage) continue;
const formula = this.#parseDamage(action.damage).formula;
this.#convertDamage(action.damage, "action", currentDamageRange, newDamageRange);
const oldFormulaRegexp = new RegExp(formula.replace('+', '(?:\\s)?\\+(?:\\s)?'));
const newFormula = this.#parseDamage(action.damage).formula;
item.system.description = item.system.description.replace(oldFormulaRegexp, newFormula);
action.description = action.description.replace(oldFormulaRegexp, newFormula);
}
}
// Finally set the tier of the source data, now that everything is complete
source.system.tier = tier;
return source;
}
#parseDamage(damage) {
const formula = damage.parts
.filter(p => p.applyTo === 'hitPoints')
.map(p =>
@ -243,38 +257,70 @@ export default class DhpAdversary extends BaseDataActor {
const terms = parseTermsFromSimpleFormula(formula);
const expected = terms.reduce((r, t) => r + (t.modifier ?? 0) + (t.dice ? (t.dice * (t.faces + 1)) / 2 : 0), 0);
return { formula, terms, expected };
};
// Update damage of base attack
const atkAverage = parseDamage(source.system.attack.damage).expected;
const deviation = (atkAverage - currentDamageRange.medianDamage) / currentDamageRange.damageDeviation;
const newAtkAverage = newDamageRange.medianDamage + newDamageRange.damageDeviation * deviation;
const damage = source.system.attack.damage;
convertDamage(damage, newAtkAverage);
// Update damage of each item action, making sure to also update the description if possible
for (const item of source.items) {
// todo: damage inlines (must be done before other changes so that it doesn't get incorrectly applied)
for (const action of Object.values(item.system.actions)) {
const damage = action.damage;
if (!damage) continue;
const { formula, expected: mean } = parseDamage(damage);
if (mean === 0) continue;
const deviation = (mean - currentDamageRange.medianDamage) / currentDamageRange.damageDeviation;
const newMean = newDamageRange.medianDamage + newDamageRange.damageDeviation * deviation;
convertDamage(damage, newMean);
const oldFormulaRegexp = new RegExp(formula.replace('+', '(?:\\s)?\\+(?:\\s)?'));
const newFormula = parseDamage(action.damage).formula;
item.system.description = item.system.description.replace(oldFormulaRegexp, newFormula);
action.description = action.description.replace(oldFormulaRegexp, newFormula);
}
}
// 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
* @param type whether this is a basic "attack" or a regular "action"
*/
#convertDamage(damage, type, currentDamageRange, newDamageRange) {
const hitPointParts = damage.parts.filter(d => d.applyTo === 'hitPoints');
if (hitPointParts.length === 0) return; // nothing to do
const previousExpected = this.#parseDamage(damage).expected;
if (previousExpected === 0) return; // nothing to do
// others are not supported yet. Later on we should convert to terms, then convert from terms back to real data
if (!(hitPointParts.length === 1 && !hitPointParts[0].value.custom.enabled)) return;
const dieSizes = ['d4', 'd6', 'd8', 'd10', 'd12', 'd20'];
const steps = newDamageRange.tier - currentDamageRange.tier;
const increasing = steps > 0;
const deviation = (previousExpected - currentDamageRange.median) / currentDamageRange.deviation;
const expected = newDamageRange.median + newDamageRange.deviation * deviation;
const value = hitPointParts[0].value;
const getExpectedDie = () => Number(value.dice.replace('d', '')) / 2;
const getBaseAverage = () => value.flatMultiplier * getExpectedDie();
// 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.flatMultiplier : 0;
value.flatMultiplier = Math.max(minimum, newDamageRange.tier);
} else {
const currentIdx = dieSizes.indexOf(value.dice);
value.dice = 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.flatMultiplier);
const currentIdx = dieSizes.indexOf(value.dice);
// 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.flatMultiplier > 0) {
value.flatMultiplier -= increasing ? 1 : Math.abs(steps);
value.bonus = Math.round(expected - getBaseAverage());
if (value.bonus >= 0) return; // complete
}
stepsRequired = Math.ceil(Math.abs(value.bonus) / value.flatMultiplier);
value.dice = 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.flatMultiplier += overagesToRemove;
value.bonus = Math.round(expected - getBaseAverage());
}
}
}