mirror of
https://github.com/Foundryborne/daggerheart.git
synced 2026-01-21 17:14:40 +01:00
Use a new algorithm using the median average deviation
This commit is contained in:
parent
81bdc7901d
commit
50ee1ccd5b
3 changed files with 134 additions and 74 deletions
|
|
@ -507,14 +507,11 @@ export const subclassFeatureLabels = {
|
|||
*/
|
||||
|
||||
/**
|
||||
* @type {Record<string, Record<2 | 3 | 4, TierData> & Record<1, { damage: number[] }>}
|
||||
* @type {Record<string, Record<2 | 3 | 4, TierData>}
|
||||
* 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 }
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)?'));
|
||||
|
|
|
|||
105
tools/analyze-damage.mjs
Normal file
105
tools/analyze-damage.mjs
Normal file
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue