diff --git a/lang/en.json b/lang/en.json index 1105bef6..8e64ab7d 100755 --- a/lang/en.json +++ b/lang/en.json @@ -2849,9 +2849,7 @@ "tier": "Tier {tier} {type}", "character": "Level {level} Character", "companion": "Level {level} - {partner}", - "companionNoPartner": "No Partner", - "duplicateToNewTier": "Duplicate to New Tier", - "pickTierTitle": "Pick a new tier for this adversary" + "companionNoPartner": "No Partner" }, "daggerheartMenu": { "title": "Daggerheart Menu", diff --git a/module/applications/sidebar/tabs/actorDirectory.mjs b/module/applications/sidebar/tabs/actorDirectory.mjs index 9d8f16e1..86a5be56 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: 'DAGGERHEART.UI.Sidebar.actorDirectory.duplicateToNewTier', + name: 'Duplicate To New Tier', icon: ``, condition: li => { const actor = game.actors.get(li.dataset.entryId); @@ -57,27 +57,12 @@ 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({ - classes: ['dh-style', 'dialog'], - window: { title: 'DAGGERHEART.UI.Sidebar.actorDirectory.pickTierTitle' }, - content, + window: { title: 'Pick a new tier for this adversary' }, + content: '', ok: { label: 'Create Adversary', - callback: (event, button, dialog) => Number(button.form.elements.tier.value) + callback: (event, button, dialog) => Math.clamp(button.form.elements.tier.valueAsNumber, 1, 4) } }); diff --git a/module/config/actorConfig.mjs b/module/config/actorConfig.mjs index ac55117a..c455e7d7 100644 --- a/module/config/actorConfig.mjs +++ b/module/config/actorConfig.mjs @@ -747,22 +747,18 @@ export const adversaryScalingData = { } }; -/** - * 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 - */ +/** Scaling data used for an adversary's damage */ export const adversaryExpectedDamage = { basic: { - 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 } + 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: { mean: 2.142857142857143, deviation: 1.0690449676496976 }, - 2: { mean: 5, deviation: 0.816496580927726 }, - 3: { mean: 6.5, deviation: 2.1213203435596424 }, - 4: { mean: 11, deviation: 1 } + 1: { median: 2, deviation: 1 }, + 2: { median: 5, deviation: 0.5 }, + 3: { median: 6.5, deviation: 1.5 }, + 4: { median: 11, deviation: 1 } } }; diff --git a/module/data/actor/adversary.mjs b/module/data/actor/adversary.mjs index f9a2f256..11c72daa 100644 --- a/module/data/actor/adversary.mjs +++ b/module/data/actor/adversary.mjs @@ -193,6 +193,7 @@ 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)) @@ -213,7 +214,7 @@ export default class DhpAdversary extends BaseDataActor { source.system.attack.roll.bonus += scale * entry.attack; } - // Get the mean and standard deviation of expected damage in the previous and new tier + // 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 = { tier: source.system.tier, ...expectedDamageData[source.system.tier] }; const newDamageRange = { tier, ...expectedDamageData[tier] }; @@ -273,8 +274,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.mean) / currentDamageRange.deviation; - const expected = newDamageRange.mean + newDamageRange.deviation * deviation; + 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; diff --git a/tools/analyze-damage.mjs b/tools/analyze-damage.mjs index 6d5da3de..e94675b4 100644 --- a/tools/analyze-damage.mjs +++ b/tools/analyze-damage.mjs @@ -26,16 +26,8 @@ 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")), }; @@ -47,47 +39,23 @@ function compileData(entries) { const results = {}; for (const tier of [1, 2, 3, 4]) { const tierEntries = entries.filter(e => e.tier === tier); - const allDamage = removeOutliers(tierEntries.map(d => d.damage).sort((a, b) => a - b)); - const mean = getMean(allDamage); - if (tier === 4) console.log(allDamage); + 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] = { - mean, - deviation: getStandardDeviation(allDamage, { mean }), + medianDamage: median, + damageDeviation: getMedian(residuals), }; } 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')