From 50ee1ccd5bd3bb8ad7a72cdc597137b5732e805e Mon Sep 17 00:00:00 2001 From: Carlos Fernandez Date: Tue, 20 Jan 2026 19:59:04 -0500 Subject: [PATCH] Use a new algorithm using the median average deviation --- module/config/actorConfig.mjs | 72 ++++++---------------- module/data/actor/adversary.mjs | 31 ++++------ tools/analyze-damage.mjs | 105 ++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 74 deletions(-) create mode 100644 tools/analyze-damage.mjs diff --git a/module/config/actorConfig.mjs b/module/config/actorConfig.mjs index 97d72ead..681b217c 100644 --- a/module/config/actorConfig.mjs +++ b/module/config/actorConfig.mjs @@ -507,14 +507,11 @@ export const subclassFeatureLabels = { */ /** - * @type {Record & Record<1, { damage: number[] }>} + * @type {Record} * Scaling data used to change an adversary's tier. Each rank is applied incrementally. */ export const adversaryScalingData = { bruiser: { - 1: { - damage: [8, 11] - }, 2: { difficulty: 2, majorThreshold: 5, @@ -522,7 +519,6 @@ export const adversaryScalingData = { hp: 1, stress: 2, attack: 2, - damage: [12, 16] }, 3: { difficulty: 2, @@ -531,7 +527,6 @@ export const adversaryScalingData = { hp: 1, stress: 0, attack: 2, - damage: [18, 22] }, 4: { difficulty: 2, @@ -540,13 +535,9 @@ export const adversaryScalingData = { hp: 1, stress: 0, attack: 2, - damage: [30, 45] } }, horde: { - 1: { - damage: [5, 8] - }, 2: { difficulty: 2, majorThreshold: 5, @@ -554,7 +545,6 @@ export const adversaryScalingData = { hp: 2, stress: 0, attack: 0, - damage: [9, 13] }, 3: { difficulty: 2, @@ -563,7 +553,6 @@ export const adversaryScalingData = { hp: 0, stress: 1, attack: 1, - damage: [14, 19] }, 4: { difficulty: 2, @@ -572,13 +561,9 @@ export const adversaryScalingData = { hp: 2, stress: 0, attack: 0, - damage: [20, 30] } }, leader: { - 1: { - damage: [6, 9] - }, 2: { difficulty: 2, majorThreshold: 6, @@ -586,7 +571,6 @@ export const adversaryScalingData = { hp: 0, stress: 0, attack: 1, - damage: [12, 15] }, 3: { difficulty: 2, @@ -595,7 +579,6 @@ export const adversaryScalingData = { hp: 1, stress: 0, attack: 2, - damage: [15, 18] }, 4: { difficulty: 2, @@ -604,13 +587,9 @@ export const adversaryScalingData = { hp: 1, stress: 1, attack: 3, - damage: [25, 35] } }, minion: { - 1: { - damage: [1, 3] - }, 2: { difficulty: 2, majorThreshold: 0, @@ -618,7 +597,6 @@ export const adversaryScalingData = { hp: 0, stress: 0, attack: 1, - damage: [2, 4] }, 3: { difficulty: 2, @@ -627,7 +605,6 @@ export const adversaryScalingData = { hp: 0, stress: 1, attack: 1, - damage: [5, 8] }, 4: { difficulty: 2, @@ -636,13 +613,9 @@ export const adversaryScalingData = { hp: 0, stress: 0, attack: 1, - damage: [10, 12] } }, ranged: { - 1: { - damage: [6, 9] - }, 2: { difficulty: 2, majorThreshold: 3, @@ -650,7 +623,6 @@ export const adversaryScalingData = { hp: 1, stress: 0, attack: 1, - damage: [12, 16] }, 3: { difficulty: 2, @@ -659,7 +631,6 @@ export const adversaryScalingData = { hp: 1, stress: 1, attack: 2, - damage: [15, 18] }, 4: { difficulty: 2, @@ -668,13 +639,9 @@ export const adversaryScalingData = { hp: 1, stress: 1, attack: 1, - damage: [25, 35] } }, skulk: { - 1: { - damage: [5, 8] - }, 2: { difficulty: 2, majorThreshold: 3, @@ -682,7 +649,6 @@ export const adversaryScalingData = { hp: 1, stress: 1, attack: 1, - damage: [9, 13] }, 3: { difficulty: 2, @@ -691,7 +657,6 @@ export const adversaryScalingData = { hp: 1, stress: 1, attack: 1, - damage: [14, 18] }, 4: { difficulty: 2, @@ -700,13 +665,9 @@ export const adversaryScalingData = { hp: 1, stress: 1, attack: 1, - damage: [20, 35] } }, solo: { - 1: { - damage: [8, 11] - }, 2: { difficulty: 2, majorThreshold: 5, @@ -714,7 +675,6 @@ export const adversaryScalingData = { hp: 0, stress: 1, attack: 2, - damage: [15, 20] }, 3: { difficulty: 2, @@ -723,7 +683,6 @@ export const adversaryScalingData = { hp: 2, stress: 1, attack: 2, - damage: [20, 30] }, 4: { difficulty: 2, @@ -732,13 +691,9 @@ export const adversaryScalingData = { hp: 0, stress: 1, attack: 3, - damage: [30, 45] } }, standard: { - 1: { - damage: [4, 6] - }, 2: { difficulty: 2, majorThreshold: 3, @@ -746,7 +701,6 @@ export const adversaryScalingData = { hp: 0, stress: 0, attack: 1, - damage: [8, 12] }, 3: { difficulty: 2, @@ -755,7 +709,6 @@ export const adversaryScalingData = { hp: 1, stress: 1, attack: 1, - damage: [12, 17] }, 4: { difficulty: 2, @@ -764,13 +717,9 @@ export const adversaryScalingData = { hp: 0, stress: 1, attack: 1, - damage: [17, 20] } }, support: { - 1: { - damage: [3, 5] - }, 2: { difficulty: 2, majorThreshold: 3, @@ -778,7 +727,6 @@ export const adversaryScalingData = { hp: 1, stress: 1, attack: 1, - damage: [5, 12] }, 3: { difficulty: 2, @@ -787,7 +735,6 @@ export const adversaryScalingData = { hp: 0, stress: 0, attack: 1, - damage: [13, 16] }, 4: { difficulty: 2, @@ -796,7 +743,22 @@ export const adversaryScalingData = { hp: 1, stress: 1, attack: 1, - damage: [18, 25] } } }; + +/** 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 } + }, + minion: { + 1: { medianDamage: 2, damageDeviation: 1 }, + 2: { medianDamage: 5, damageDeviation: 0.5 }, + 3: { medianDamage: 6.5, damageDeviation: 1.5 }, + 4: { medianDamage: 11, damageDeviation: 1 } + } +}; diff --git a/module/data/actor/adversary.mjs b/module/data/actor/adversary.mjs index c3ae5adf..402e02ba 100644 --- a/module/data/actor/adversary.mjs +++ b/module/data/actor/adversary.mjs @@ -3,7 +3,7 @@ import { ActionField } from '../fields/actionField.mjs'; import BaseDataActor, { commonActorRules } from './base.mjs'; import { resourceField, bonusField } from '../fields/actorField.mjs'; import { parseTermsFromSimpleFormula } from '../../helpers/utils.mjs'; -import { adversaryScalingData } from '../../config/actorConfig.mjs'; +import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs'; export default class DhpAdversary extends BaseDataActor { static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary']; @@ -214,18 +214,11 @@ export default class DhpAdversary extends BaseDataActor { source.system.attack.roll.bonus += scale * entry.attack; } - /** Returns the mean and standard deviation of a series of average damages */ - const analyzeDamageRange = range => { - if (range.length <= 1) throw Error('Unexpected damage range, must have at least two entries'); - const mean = range.reduce((a, b) => a + b, 0) / range.length; - const deviations = range.map(r => r - mean); - const standardDeviation = Math.sqrt(deviations.reduce((r, d) => r + d * d, 0) / (range.length - 1)); - return { mean, standardDeviation }; - }; // Calculate mean and standard deviation of expected damage ranges in each tier. Also create a function to remap damage - const currentDamageRange = analyzeDamageRange(typeData[source.system.tier].damage); - const newDamageRange = analyzeDamageRange(typeData[tier].damage); + 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) { @@ -248,14 +241,14 @@ export default class DhpAdversary extends BaseDataActor { ) .join('+'); const terms = parseTermsFromSimpleFormula(formula); - const mean = terms.reduce((r, t) => r + (t.modifier ?? 0) + (t.dice ? (t.dice * (t.faces + 1)) / 2 : 0), 0); - return { formula, terms, mean }; + 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).mean; - const deviation = (atkAverage - currentDamageRange.mean) / currentDamageRange.standardDeviation; - const newAtkAverage = newDamageRange.mean + newDamageRange.standardDeviation * deviation; + 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); @@ -266,11 +259,11 @@ export default class DhpAdversary extends BaseDataActor { for (const action of Object.values(item.system.actions)) { const damage = action.damage; if (!damage) continue; - const { formula, mean } = parseDamage(damage); + const { formula, expected: mean } = parseDamage(damage); if (mean === 0) continue; - const deviation = (mean - currentDamageRange.mean) / currentDamageRange.standardDeviation; - const newMean = newDamageRange.mean + newDamageRange.standardDeviation * deviation; + 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)?')); diff --git a/tools/analyze-damage.mjs b/tools/analyze-damage.mjs new file mode 100644 index 00000000..e94675b4 --- /dev/null +++ b/tools/analyze-damage.mjs @@ -0,0 +1,105 @@ +/** + * Internal script to analyze damage and spit out results. + * There isn't enough entries in the database to make a full analysis, some tiers miss some types. + * This script only checks for "minions" and "everything else". + * Maybe if future book monsters can be part of what we release, we can analyze those too. + */ + +import fs from "fs/promises"; +import path from "path"; + +const allData = []; + +// Read adversary pack data for average damage for attacks +const adversariesDirectory = path.join("src/packs/adversaries"); +for (const basefile of await fs.readdir(adversariesDirectory)) { + if (!basefile.endsWith(".json")) continue; + const filepath = path.join(adversariesDirectory, basefile); + const data = JSON.parse(await fs.readFile(filepath, "utf8")); + if (data?.type !== "adversary" || data.system.type === "social") continue; + + allData.push({ + name: data.name, + tier: data.system.tier, + adversaryType: data.system.type, + damage: parseDamage(data.system.attack.damage), + }); +} + +const result = { + basic: compileData(allData.filter(d => d.adversaryType !== "minion")), + minion: compileData(allData.filter(d => d.adversaryType === "minion")), +}; + +console.log(result); + +/** Compiles all data for an adversary type (or all entries) */ +function compileData(entries) { + // Note: sorting numbers sorts by their string version by default + const results = {}; + for (const tier of [1, 2, 3, 4]) { + const tierEntries = entries.filter(e => e.tier === tier); + const allDamage = tierEntries.map(d => d.damage).sort((a, b) => a - b); + const median = getMedian(allDamage); + const residuals = allDamage.map(d => Math.abs(d - median)).sort((a, b) => a - b); + results[tier] = { + medianDamage: median, + damageDeviation: getMedian(residuals), + }; + } + + return results; +} + +function getMedian(numbers) { + const medianIdx = numbers.length / 2; + return medianIdx % 1 ? numbers[Math.floor(medianIdx)] : (numbers[medianIdx] + numbers[medianIdx - 1]) / 2; +} + +function parseDamage(damage) { + const formula = damage.parts + .filter(p => p.applyTo === 'hitPoints') + .map(p => + p.value.custom.enabled + ? p.value.custom.formula + : [p.value.flatMultiplier ? `${p.value.flatMultiplier}${p.value.dice}` : 0, p.value.bonus ?? 0] + .filter(p => !!p) + .join('+') + ) + .join('+'); + return getExpectedDamage(formula); +} + +/** + * Given a simple flavor-less formula with only +/- operators, returns a list of damage partial terms. + * All subtracted terms become negative terms. + */ +function getExpectedDamage(formula) { + const terms = formula.replace("+", " + ").replace("-", " - ").split(" ").map(t => t.trim()); + let multiplier = 1; + return terms.reduce((total, term) => { + if (term === "-") { + multiplier = -1; + return total; + } else if (term === "+") { + return total; + } + + const currentMultiplier = multiplier; + multiplier = 1; + + const number = Number(term); + if (!Number.isNaN(number)) { + return total + currentMultiplier * number; + } + + const dieMatch = term.match(/(\d+)d(\d+)/); + if (dieMatch) { + const numDice = Number(dieMatch[1]); + const faces = Number(dieMatch[2]); + return total + currentMultiplier * numDice * ((faces + 1) / 2); + } + + throw Error(`Unexpected term ${term} in formula ${formula}`); + }, 0); +}