diff --git a/lang/en.json b/lang/en.json index 8e64ab7d..1105bef6 100755 --- a/lang/en.json +++ b/lang/en.json @@ -2849,7 +2849,9 @@ "tier": "Tier {tier} {type}", "character": "Level {level} Character", "companion": "Level {level} - {partner}", - "companionNoPartner": "No Partner" + "companionNoPartner": "No Partner", + "duplicateToNewTier": "Duplicate to New Tier", + "pickTierTitle": "Pick a new tier for this adversary" }, "daggerheartMenu": { "title": "Daggerheart Menu", diff --git a/module/applications/sidebar/tabs/actorDirectory.mjs b/module/applications/sidebar/tabs/actorDirectory.mjs index 86a5be56..3916ab19 100644 --- a/module/applications/sidebar/tabs/actorDirectory.mjs +++ b/module/applications/sidebar/tabs/actorDirectory.mjs @@ -47,7 +47,7 @@ export default class DhActorDirectory extends foundry.applications.sidebar.tabs. _getEntryContextOptions() { const options = super._getEntryContextOptions(); options.push({ - name: 'Duplicate To New Tier', + name: 'DAGGERHEART.UI.Sidebar.actorDirectory.duplicateToNewTier', icon: ``, condition: li => { const actor = game.actors.get(li.dataset.entryId); @@ -57,12 +57,24 @@ export default class DhActorDirectory extends foundry.applications.sidebar.tabs. const actor = game.actors.get(li.dataset.entryId); if (!actor) throw new Error('Unexpected missing actor'); + const tiers = [1, 2, 3, 4].filter((t) => t !== actor.system.tier); + const content = document.createElement("div"); + const select = document.createElement("select"); + select.name = "tier"; + select.append(...tiers.map(t => { + const option = document.createElement("option"); + option.value = t; + option.textContent = game.i18n.localize(`DAGGERHEART.GENERAL.Tiers.${t}`); + return option; + })); + content.append(select); + const tier = await foundry.applications.api.Dialog.input({ - window: { title: 'Pick a new tier for this adversary' }, - content: '', + window: { title: 'DAGGERHEART.UI.Sidebar.actorDirectory.pickTierTitle' }, + content, ok: { label: 'Create Adversary', - callback: (event, button, dialog) => Math.clamp(button.form.elements.tier.valueAsNumber, 1, 4) + callback: (event, button, dialog) => Number(button.form.elements.tier.value) } }); diff --git a/module/config/actorConfig.mjs b/module/config/actorConfig.mjs index c455e7d7..ac55117a 100644 --- a/module/config/actorConfig.mjs +++ b/module/config/actorConfig.mjs @@ -747,18 +747,22 @@ export const adversaryScalingData = { } }; -/** Scaling data used for an adversary's damage */ +/** + * Scaling data used for an adversary's damage. + * Tier 4 is missing certain adversary types and therefore skews upwards. + * We manually set tier 4 data to hopefully lead to better results + */ export const adversaryExpectedDamage = { basic: { - 1: { median: 7.5, deviation: 1 }, - 2: { median: 13, deviation: 2 }, - 3: { median: 15.5, deviation: 1.5 }, - 4: { median: 27, deviation: 3 } + 1: { mean: 7.321428571428571, deviation: 1.962519002770912 }, + 2: { mean: 12.444444444444445, deviation: 2.0631069425529676 }, + 3: { mean: 15.722222222222221, deviation: 2.486565208464823 }, + 4: { mean: 26, deviation: 5.2 } }, minion: { - 1: { median: 2, deviation: 1 }, - 2: { median: 5, deviation: 0.5 }, - 3: { median: 6.5, deviation: 1.5 }, - 4: { median: 11, deviation: 1 } + 1: { mean: 2.142857142857143, deviation: 1.0690449676496976 }, + 2: { mean: 5, deviation: 0.816496580927726 }, + 3: { mean: 6.5, deviation: 2.1213203435596424 }, + 4: { mean: 11, deviation: 1 } } }; diff --git a/module/data/actor/adversary.mjs b/module/data/actor/adversary.mjs index 11c72daa..f9a2f256 100644 --- a/module/data/actor/adversary.mjs +++ b/module/data/actor/adversary.mjs @@ -193,7 +193,6 @@ export default class DhpAdversary extends BaseDataActor { adjustForTier(tier) { const source = this.parent.toObject(true); - console.log('Actors and source', this.parent, source); /** @type {(2 | 3 | 4)[]} */ const tiers = new Array(Math.abs(tier - this.tier)) @@ -214,7 +213,7 @@ export default class DhpAdversary extends BaseDataActor { source.system.attack.roll.bonus += scale * entry.attack; } - // Get the median and median absolute deviation of expected damage in the previous and new tier + // Get the mean and standard deviation of expected damage in the previous and new tier const expectedDamageData = adversaryExpectedDamage[source.system.type] ?? adversaryExpectedDamage.basic; const currentDamageRange = { tier: source.system.tier, ...expectedDamageData[source.system.tier] }; const newDamageRange = { tier, ...expectedDamageData[tier] }; @@ -274,8 +273,8 @@ export default class DhpAdversary extends BaseDataActor { 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 deviation = (previousExpected - currentDamageRange.mean) / currentDamageRange.deviation; + const expected = newDamageRange.mean + newDamageRange.deviation * deviation; const value = hitPointParts[0].value; const getExpectedDie = () => Number(value.dice.replace('d', '')) / 2; diff --git a/tools/analyze-damage.mjs b/tools/analyze-damage.mjs index e94675b4..6d5da3de 100644 --- a/tools/analyze-damage.mjs +++ b/tools/analyze-damage.mjs @@ -26,8 +26,16 @@ for (const basefile of await fs.readdir(adversariesDirectory)) { }); } +const adversaryTypes = new Set(allData.map(a => a.adversaryType)); +for (const type of [...adversaryTypes].toSorted()) { + const perTier = Object.groupBy(allData.filter(a => a.adversaryType === type), a => a.tier); + console.log(`${type} per Tier: ${[1, 2, 3, 4].map(t => perTier[t]?.length ?? 0).join(" ")}`) +} + const result = { basic: compileData(allData.filter(d => d.adversaryType !== "minion")), + solos_and_bruisers: compileData(allData.filter(d => ["solo", "bruiser"].includes(d.adversaryType))), + leader_and_ranged: compileData(allData.filter(d => ["leader", "ranged"].includes(d.adversaryType))), minion: compileData(allData.filter(d => d.adversaryType === "minion")), }; @@ -39,23 +47,47 @@ function compileData(entries) { 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); + const allDamage = removeOutliers(tierEntries.map(d => d.damage).sort((a, b) => a - b)); + const mean = getMean(allDamage); + if (tier === 4) console.log(allDamage); results[tier] = { - medianDamage: median, - damageDeviation: getMedian(residuals), + mean, + deviation: getStandardDeviation(allDamage, { mean }), }; } return results; } +function removeOutliers(data) { + if (data.length <= 4) return data; + const startIdx = Math.floor(data.length * 0.25); + const endIdx = Math.ceil(data.length * 0.75); + const iqrBound = (data[endIdx] - data[startIdx]) * 1.25; + return data.filter((d) => d >= data[startIdx] - iqrBound && d <= data[endIdx] + iqrBound); +} + function getMedian(numbers) { + numbers = numbers.toSorted((a, b) => a - b); const medianIdx = numbers.length / 2; return medianIdx % 1 ? numbers[Math.floor(medianIdx)] : (numbers[medianIdx] + numbers[medianIdx - 1]) / 2; } +function getMean(numbers) { + if (numbers.length === 0) return NaN; + return numbers.reduce((r, a) => r + a, 0) / numbers.length; +} + +function getMedianAverageDeviation(numbers, { median }) { + const residuals = allDamage.map(d => Math.abs(d - median)); + return getMedian(residuals); +} + +function getStandardDeviation(numbers, { mean }) { + const deviations = numbers.map((r) => r - mean); + return Math.sqrt(deviations.reduce((r, d) => r + d * d, 0) / (numbers.length - 1)); +} + function parseDamage(damage) { const formula = damage.parts .filter(p => p.applyTo === 'hitPoints')