mirror of
https://github.com/Foundryborne/daggerheart.git
synced 2026-03-07 06:26:13 +01:00
[PR][Feature] Add support for changing the tier of an adversary (#1503)
* Add support for changing the tier of an adversary * Move scaling data to actorConfig * Use a new algorithm using the median average deviation * Fine tune damage conversion for actions * Use standard deviation instead and change dialog type * Use daggerheart style for dialog * Formatting * Improve handling of minions and hordes * Changed to using lookup for Group Attack damage * Added lookup for Horde feature * Remove spaces in damage formulas --------- Co-authored-by: WBHarry <williambjrklund@gmail.com>
This commit is contained in:
parent
6cbe770880
commit
7022630316
32 changed files with 762 additions and 32 deletions
|
|
@ -2978,7 +2978,9 @@
|
||||||
"tier": "Tier {tier} {type}",
|
"tier": "Tier {tier} {type}",
|
||||||
"character": "Level {level} Character",
|
"character": "Level {level} Character",
|
||||||
"companion": "Level {level} - {partner}",
|
"companion": "Level {level} - {partner}",
|
||||||
"companionNoPartner": "No Partner"
|
"companionNoPartner": "No Partner",
|
||||||
|
"duplicateToNewTier": "Duplicate to New Tier",
|
||||||
|
"pickTierTitle": "Pick a new tier for this adversary"
|
||||||
},
|
},
|
||||||
"daggerheartMenu": {
|
"daggerheartMenu": {
|
||||||
"title": "Daggerheart Menu",
|
"title": "Daggerheart Menu",
|
||||||
|
|
|
||||||
|
|
@ -43,4 +43,54 @@ export default class DhActorDirectory extends foundry.applications.sidebar.tabs.
|
||||||
event.dataTransfer.setDragImage(preview, w / 2, h / 2);
|
event.dataTransfer.setDragImage(preview, w / 2, h / 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getEntryContextOptions() {
|
||||||
|
const options = super._getEntryContextOptions();
|
||||||
|
options.push({
|
||||||
|
name: 'DAGGERHEART.UI.Sidebar.actorDirectory.duplicateToNewTier',
|
||||||
|
icon: `<i class="fa-solid fa-arrow-trend-up" inert></i>`,
|
||||||
|
condition: li => {
|
||||||
|
const actor = game.actors.get(li.dataset.entryId);
|
||||||
|
return actor?.type === 'adversary' && actor.system.type !== 'social';
|
||||||
|
},
|
||||||
|
callback: async li => {
|
||||||
|
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,
|
||||||
|
ok: {
|
||||||
|
label: 'Create Adversary',
|
||||||
|
callback: (event, button, dialog) => Number(button.form.elements.tier.value)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tier === actor.system.tier) {
|
||||||
|
ui.notifications.warn('This actor is already at this tier');
|
||||||
|
} else if (tier) {
|
||||||
|
const source = actor.system.adjustForTier(tier);
|
||||||
|
await Actor.create(source);
|
||||||
|
ui.notifications.info(`Tier ${tier} ${actor.name} created`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -494,3 +494,275 @@ export const subclassFeatureLabels = {
|
||||||
2: 'DAGGERHEART.ITEMS.DomainCard.specializationTitle',
|
2: 'DAGGERHEART.ITEMS.DomainCard.specializationTitle',
|
||||||
3: 'DAGGERHEART.ITEMS.DomainCard.masteryTitle'
|
3: 'DAGGERHEART.ITEMS.DomainCard.masteryTitle'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} TierData
|
||||||
|
* @property {number} difficulty
|
||||||
|
* @property {number} majorThreshold
|
||||||
|
* @property {number} severeThreshold
|
||||||
|
* @property {number} hp
|
||||||
|
* @property {number} stress
|
||||||
|
* @property {number} attack
|
||||||
|
* @property {number[]} damage
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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: {
|
||||||
|
2: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 5,
|
||||||
|
severeThreshold: 10,
|
||||||
|
hp: 1,
|
||||||
|
stress: 2,
|
||||||
|
attack: 2,
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 7,
|
||||||
|
severeThreshold: 15,
|
||||||
|
hp: 1,
|
||||||
|
stress: 0,
|
||||||
|
attack: 2,
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 12,
|
||||||
|
severeThreshold: 25,
|
||||||
|
hp: 1,
|
||||||
|
stress: 0,
|
||||||
|
attack: 2,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
horde: {
|
||||||
|
2: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 5,
|
||||||
|
severeThreshold: 8,
|
||||||
|
hp: 2,
|
||||||
|
stress: 0,
|
||||||
|
attack: 0,
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 5,
|
||||||
|
severeThreshold: 12,
|
||||||
|
hp: 0,
|
||||||
|
stress: 1,
|
||||||
|
attack: 1,
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 10,
|
||||||
|
severeThreshold: 15,
|
||||||
|
hp: 2,
|
||||||
|
stress: 0,
|
||||||
|
attack: 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leader: {
|
||||||
|
2: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 6,
|
||||||
|
severeThreshold: 10,
|
||||||
|
hp: 0,
|
||||||
|
stress: 0,
|
||||||
|
attack: 1,
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 6,
|
||||||
|
severeThreshold: 15,
|
||||||
|
hp: 1,
|
||||||
|
stress: 0,
|
||||||
|
attack: 2,
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 12,
|
||||||
|
severeThreshold: 25,
|
||||||
|
hp: 1,
|
||||||
|
stress: 1,
|
||||||
|
attack: 3,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
minion: {
|
||||||
|
2: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 0,
|
||||||
|
severeThreshold: 0,
|
||||||
|
hp: 0,
|
||||||
|
stress: 0,
|
||||||
|
attack: 1,
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 0,
|
||||||
|
severeThreshold: 0,
|
||||||
|
hp: 0,
|
||||||
|
stress: 1,
|
||||||
|
attack: 1,
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 0,
|
||||||
|
severeThreshold: 0,
|
||||||
|
hp: 0,
|
||||||
|
stress: 0,
|
||||||
|
attack: 1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ranged: {
|
||||||
|
2: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 3,
|
||||||
|
severeThreshold: 6,
|
||||||
|
hp: 1,
|
||||||
|
stress: 0,
|
||||||
|
attack: 1,
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 7,
|
||||||
|
severeThreshold: 14,
|
||||||
|
hp: 1,
|
||||||
|
stress: 1,
|
||||||
|
attack: 2,
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 5,
|
||||||
|
severeThreshold: 10,
|
||||||
|
hp: 1,
|
||||||
|
stress: 1,
|
||||||
|
attack: 1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
skulk: {
|
||||||
|
2: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 3,
|
||||||
|
severeThreshold: 8,
|
||||||
|
hp: 1,
|
||||||
|
stress: 1,
|
||||||
|
attack: 1,
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 8,
|
||||||
|
severeThreshold: 12,
|
||||||
|
hp: 1,
|
||||||
|
stress: 1,
|
||||||
|
attack: 1,
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 8,
|
||||||
|
severeThreshold: 10,
|
||||||
|
hp: 1,
|
||||||
|
stress: 1,
|
||||||
|
attack: 1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
solo: {
|
||||||
|
2: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 5,
|
||||||
|
severeThreshold: 10,
|
||||||
|
hp: 0,
|
||||||
|
stress: 1,
|
||||||
|
attack: 2,
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 7,
|
||||||
|
severeThreshold: 15,
|
||||||
|
hp: 2,
|
||||||
|
stress: 1,
|
||||||
|
attack: 2,
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 12,
|
||||||
|
severeThreshold: 25,
|
||||||
|
hp: 0,
|
||||||
|
stress: 1,
|
||||||
|
attack: 3,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
standard: {
|
||||||
|
2: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 3,
|
||||||
|
severeThreshold: 8,
|
||||||
|
hp: 0,
|
||||||
|
stress: 0,
|
||||||
|
attack: 1,
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 7,
|
||||||
|
severeThreshold: 15,
|
||||||
|
hp: 1,
|
||||||
|
stress: 1,
|
||||||
|
attack: 1,
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 10,
|
||||||
|
severeThreshold: 15,
|
||||||
|
hp: 0,
|
||||||
|
stress: 1,
|
||||||
|
attack: 1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
support: {
|
||||||
|
2: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 3,
|
||||||
|
severeThreshold: 8,
|
||||||
|
hp: 1,
|
||||||
|
stress: 1,
|
||||||
|
attack: 1,
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 7,
|
||||||
|
severeThreshold: 12,
|
||||||
|
hp: 0,
|
||||||
|
stress: 0,
|
||||||
|
attack: 1,
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
difficulty: 2,
|
||||||
|
majorThreshold: 8,
|
||||||
|
severeThreshold: 10,
|
||||||
|
hp: 1,
|
||||||
|
stress: 1,
|
||||||
|
attack: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: { 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: { mean: 2.142857142857143, deviation: 1.0690449676496976 },
|
||||||
|
2: { mean: 5, deviation: 0.816496580927726 },
|
||||||
|
3: { mean: 6.5, deviation: 2.1213203435596424 },
|
||||||
|
4: { mean: 11, deviation: 1 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,20 @@ export default class DHAttackAction extends DHDamageAction {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get damageFormula() {
|
||||||
|
const hitPointsPart = this.damage.parts.find(x => x.applyTo === CONFIG.DH.GENERAL.healingTypes.hitPoints.id);
|
||||||
|
if (!hitPointsPart) return '0';
|
||||||
|
|
||||||
|
return hitPointsPart.value.getFormula();
|
||||||
|
}
|
||||||
|
|
||||||
|
get altDamageFormula() {
|
||||||
|
const hitPointsPart = this.damage.parts.find(x => x.applyTo === CONFIG.DH.GENERAL.healingTypes.hitPoints.id);
|
||||||
|
if (!hitPointsPart) return '0';
|
||||||
|
|
||||||
|
return hitPointsPart.valueAlt.getFormula();
|
||||||
|
}
|
||||||
|
|
||||||
async use(event, options) {
|
async use(event, options) {
|
||||||
const result = await super.use(event, options);
|
const result = await super.use(event, options);
|
||||||
if (!result.message) return;
|
if (!result.message) return;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import DHAdversarySettings from '../../applications/sheets-configs/adversary-set
|
||||||
import { ActionField } from '../fields/actionField.mjs';
|
import { ActionField } from '../fields/actionField.mjs';
|
||||||
import BaseDataActor, { commonActorRules } from './base.mjs';
|
import BaseDataActor, { commonActorRules } from './base.mjs';
|
||||||
import { resourceField, bonusField } from '../fields/actorField.mjs';
|
import { resourceField, bonusField } from '../fields/actorField.mjs';
|
||||||
|
import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs';
|
||||||
|
import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs';
|
||||||
|
|
||||||
export default class DhpAdversary extends BaseDataActor {
|
export default class DhpAdversary extends BaseDataActor {
|
||||||
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary'];
|
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary'];
|
||||||
|
|
@ -195,4 +197,211 @@ export default class DhpAdversary extends BaseDataActor {
|
||||||
];
|
];
|
||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns source data for this actor adjusted to a new tier, which can be used to create a new actor. */
|
||||||
|
adjustForTier(tier) {
|
||||||
|
const source = this.parent.toObject(true);
|
||||||
|
|
||||||
|
/** @type {(2 | 3 | 4)[]} */
|
||||||
|
const tiers = new Array(Math.abs(tier - this.tier))
|
||||||
|
.fill(0)
|
||||||
|
.map((_, idx) => idx + Math.min(tier, this.tier) + 1);
|
||||||
|
if (tier < this.tier) tiers.reverse();
|
||||||
|
const typeData = adversaryScalingData[source.system.type] ?? adversaryScalingData[source.system.standard];
|
||||||
|
const tierEntries = tiers.map(t => ({ tier: t, ...typeData[t] }));
|
||||||
|
|
||||||
|
// Apply simple tier changes
|
||||||
|
const scale = tier > this.tier ? 1 : -1;
|
||||||
|
for (const entry of tierEntries) {
|
||||||
|
source.system.difficulty += scale * entry.difficulty;
|
||||||
|
source.system.damageThresholds.major += scale * entry.majorThreshold;
|
||||||
|
source.system.damageThresholds.severe += scale * entry.severeThreshold;
|
||||||
|
source.system.resources.hitPoints.max += scale * entry.hp;
|
||||||
|
source.system.resources.stress.max += scale * entry.stress;
|
||||||
|
source.system.attack.roll.bonus += scale * entry.attack;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the mean and standard deviation of expected damage in the previous and new tier
|
||||||
|
// The data we have is for attack scaling, but we reuse this for action scaling later
|
||||||
|
const expectedDamageData = adversaryExpectedDamage[source.system.type] ?? adversaryExpectedDamage.basic;
|
||||||
|
const damageMeta = {
|
||||||
|
currentDamageRange: { tier: source.system.tier, ...expectedDamageData[source.system.tier] },
|
||||||
|
newDamageRange: { tier, ...expectedDamageData[tier] },
|
||||||
|
type: 'attack'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update damage of base attack
|
||||||
|
try {
|
||||||
|
this.#adjustActionDamage(source.system.attack, damageMeta);
|
||||||
|
} catch (err) {
|
||||||
|
ui.notifications.warn('Failed to convert attack damage of adversary');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update damage of each item action, making sure to also update the description if possible
|
||||||
|
const damageRegex = /@Damage\[([^\[\]]*)\]({[^}]*})?/g;
|
||||||
|
for (const item of source.items) {
|
||||||
|
// Replace damage inlines with new formulas
|
||||||
|
for (const withDescription of [item.system, ...Object.values(item.system.actions)]) {
|
||||||
|
withDescription.description = withDescription.description.replace(damageRegex, (match, inner) => {
|
||||||
|
const { value: formula } = parseInlineParams(inner);
|
||||||
|
if (!formula || !type) return match;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adjusted = this.#calculateAdjustedDamage(formula, { ...damageMeta, type: 'action' });
|
||||||
|
const newFormula = [
|
||||||
|
adjusted.diceQuantity ? `${adjusted.diceQuantity}d${adjusted.faces}` : null,
|
||||||
|
adjusted.bonus
|
||||||
|
]
|
||||||
|
.filter(p => !!p)
|
||||||
|
.join('+');
|
||||||
|
return match.replace(formula, newFormula);
|
||||||
|
} catch {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update damage in item actions
|
||||||
|
for (const action of Object.values(item.system.actions)) {
|
||||||
|
if (!action.damage) continue;
|
||||||
|
|
||||||
|
// Parse damage, and convert all formula matches in the descriptions to the new damage
|
||||||
|
try {
|
||||||
|
const result = this.#adjustActionDamage(action, { ...damageMeta, type: 'action' });
|
||||||
|
for (const { previousFormula, formula } of Object.values(result)) {
|
||||||
|
const oldFormulaRegexp = new RegExp(
|
||||||
|
previousFormula.replace(' ', '').replace('+', '(?:\\s)?\\+(?:\\s)?')
|
||||||
|
);
|
||||||
|
item.system.description = item.system.description.replace(oldFormulaRegexp, formula);
|
||||||
|
action.description = action.description.replace(oldFormulaRegexp, formula);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
ui.notifications.warn(`Failed to convert action damage for item ${item.name}`);
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
* @returns {{ diceQuantity: number; faces: number; bonus: number }} the adjusted result as a combined term
|
||||||
|
* @throws error if the formula is the wrong type
|
||||||
|
*/
|
||||||
|
#calculateAdjustedDamage(formula, { currentDamageRange, newDamageRange, type }) {
|
||||||
|
const terms = parseTermsFromSimpleFormula(formula);
|
||||||
|
const flatTerms = terms.filter(t => t.diceQuantity === 0);
|
||||||
|
const diceTerms = terms.filter(t => t.diceQuantity > 0);
|
||||||
|
if (flatTerms.length > 1 || diceTerms.length > 1) {
|
||||||
|
throw new Error('invalid formula for conversion');
|
||||||
|
}
|
||||||
|
const value = {
|
||||||
|
...(diceTerms[0] ?? { diceQuantity: 0, faces: 1 }),
|
||||||
|
bonus: flatTerms[0]?.bonus ?? 0
|
||||||
|
};
|
||||||
|
const previousExpected = calculateExpectedValue(value);
|
||||||
|
if (previousExpected === 0) return value; // nothing to do
|
||||||
|
|
||||||
|
const dieSizes = [4, 6, 8, 10, 12, 20];
|
||||||
|
const steps = newDamageRange.tier - currentDamageRange.tier;
|
||||||
|
const increasing = steps > 0;
|
||||||
|
const deviation = (previousExpected - currentDamageRange.mean) / currentDamageRange.deviation;
|
||||||
|
const expected = Math.max(1, newDamageRange.mean + newDamageRange.deviation * deviation);
|
||||||
|
|
||||||
|
// If this was just a flat number, convert to the expected damage and exit
|
||||||
|
if (value.diceQuantity === 0) {
|
||||||
|
value.bonus = Math.round(expected);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExpectedDie = () => calculateExpectedValue({ diceQuantity: 1, faces: value.faces }) || 1;
|
||||||
|
const getBaseAverage = () => calculateExpectedValue({ ...value, bonus: 0 });
|
||||||
|
|
||||||
|
// 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.diceQuantity : 0;
|
||||||
|
value.diceQuantity = Math.max(minimum, newDamageRange.tier);
|
||||||
|
} else {
|
||||||
|
const currentIdx = dieSizes.indexOf(value.faces);
|
||||||
|
value.faces = 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.diceQuantity);
|
||||||
|
const currentIdx = dieSizes.indexOf(value.faces);
|
||||||
|
|
||||||
|
// 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.diceQuantity > 0) {
|
||||||
|
value.diceQuantity -= increasing ? 1 : Math.abs(steps);
|
||||||
|
value.bonus = Math.round(expected - getBaseAverage());
|
||||||
|
if (value.bonus >= 0) return value; // complete
|
||||||
|
}
|
||||||
|
|
||||||
|
stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity);
|
||||||
|
value.faces = 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.diceQuantity += overagesToRemove;
|
||||||
|
value.bonus = Math.round(expected - getBaseAverage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates damage to reflect a specific value.
|
||||||
|
* @throws if damage structure is invalid for conversion
|
||||||
|
* @returns the converted formula and value as a simplified term
|
||||||
|
*/
|
||||||
|
#adjustActionDamage(action, damageMeta) {
|
||||||
|
// The current algorithm only returns a value if there is a single damage part
|
||||||
|
const hpDamageParts = action.damage.parts.filter(d => d.applyTo === 'hitPoints');
|
||||||
|
if (hpDamageParts.length !== 1) throw new Error('incorrect number of hp parts');
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
for (const property of ['value', 'valueAlt']) {
|
||||||
|
const data = hpDamageParts[0][property];
|
||||||
|
const previousFormula = data.custom.enabled
|
||||||
|
? data.custom.formula
|
||||||
|
: [data.flatMultiplier ? `${data.flatMultiplier}${data.dice}` : 0, data.bonus ?? 0]
|
||||||
|
.filter(p => !!p)
|
||||||
|
.join('+');
|
||||||
|
const value = this.#calculateAdjustedDamage(previousFormula, damageMeta);
|
||||||
|
const formula = [value.diceQuantity ? `${value.diceQuantity}d${value.faces}` : null, value.bonus]
|
||||||
|
.filter(p => !!p)
|
||||||
|
.join('+');
|
||||||
|
if (value.diceQuantity) {
|
||||||
|
data.custom.enabled = false;
|
||||||
|
data.bonus = value.bonus;
|
||||||
|
data.dice = `d${value.faces}`;
|
||||||
|
data.flatMultiplier = value.diceQuantity;
|
||||||
|
} else if (!value.diceQuantity) {
|
||||||
|
data.custom.enabled = true;
|
||||||
|
data.custom.formula = formula;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[property] = { previousFormula, formula, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -495,3 +495,49 @@ export function htmlToText(html) {
|
||||||
|
|
||||||
return tempDivElement.textContent || tempDivElement.innerText || '';
|
return tempDivElement.textContent || tempDivElement.innerText || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a simple flavor-less formula with only +/- operators, returns a list of damage partial terms.
|
||||||
|
* All subtracted terms become negative terms.
|
||||||
|
* If there are no dice, it returns 0d1 for that term.
|
||||||
|
*/
|
||||||
|
export function parseTermsFromSimpleFormula(formula) {
|
||||||
|
const roll = formula instanceof Roll ? formula : new Roll(formula);
|
||||||
|
|
||||||
|
// Parse from right to left so that when we hit an operator, we already have the term.
|
||||||
|
return roll.terms.reduceRight((result, term) => {
|
||||||
|
// Ignore + terms, we assume + by default
|
||||||
|
if (term.expression === ' + ') return result;
|
||||||
|
|
||||||
|
// - terms modify the last term we parsed
|
||||||
|
if (term.expression === ' - ') {
|
||||||
|
const termToModify = result[0];
|
||||||
|
if (termToModify) {
|
||||||
|
if (termToModify.bonus) termToModify.bonus *= -1;
|
||||||
|
if (termToModify.dice) termToModify.dice *= -1;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.unshift({
|
||||||
|
bonus: term instanceof foundry.dice.terms.NumericTerm ? term.number : 0,
|
||||||
|
diceQuantity: term instanceof foundry.dice.terms.Die ? term.number : 0,
|
||||||
|
faces: term.faces ?? 1
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the expectede value from a formula or the results of parseTermsFromSimpleFormula.
|
||||||
|
* @returns {number} the average result of rolling the given dice
|
||||||
|
*/
|
||||||
|
export function calculateExpectedValue(formulaOrTerms) {
|
||||||
|
const terms = Array.isArray(formulaOrTerms)
|
||||||
|
? formulaOrTerms
|
||||||
|
: typeof formulaOrTerms === 'string'
|
||||||
|
? parseTermsFromSimpleFormula(formulaOrTerms)
|
||||||
|
: [formulaOrTerms];
|
||||||
|
return terms.reduce((r, t) => r + (t.bonus ?? 0) + (t.diceQuantity ? (t.diceQuantity * (t.faces + 1)) / 2 : 0), 0);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@
|
||||||
"name": "Group Attack",
|
"name": "Group Attack",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 4 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"vgguNWz8vG8aoLXR": {
|
"vgguNWz8vG8aoLXR": {
|
||||||
|
|
|
||||||
|
|
@ -218,10 +218,10 @@
|
||||||
},
|
},
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"name": "Horde (1d6+3)",
|
"name": "Horde",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>1d6+3</strong> physical damage instead.</p>",
|
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {},
|
"actions": {},
|
||||||
"originItemType": null,
|
"originItemType": null,
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,7 @@
|
||||||
"name": "Group Attack",
|
"name": "Group Attack",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 6 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"cbAvPSIhwBMBTI3D": {
|
"cbAvPSIhwBMBTI3D": {
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,7 @@
|
||||||
"name": "Group Attack",
|
"name": "Group Attack",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all Cult @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all Cult @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"EH1preaTWBD4rOvx": {
|
"EH1preaTWBD4rOvx": {
|
||||||
|
|
|
||||||
|
|
@ -224,10 +224,10 @@
|
||||||
},
|
},
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"name": "Horde (2d4+1)",
|
"name": "Horde",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>2d4+1</strong> physical damage instead.</p>",
|
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {},
|
"actions": {},
|
||||||
"originItemType": null,
|
"originItemType": null,
|
||||||
|
|
|
||||||
|
|
@ -218,10 +218,10 @@
|
||||||
},
|
},
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"name": "Horde (2d4+1)",
|
"name": "Horde",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>2d4+1</strong> physical damage instead.</p>",
|
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {},
|
"actions": {},
|
||||||
"originItemType": null,
|
"originItemType": null,
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,7 @@
|
||||||
"name": "Group Attack",
|
"name": "Group Attack",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"vXHZVb0Y7Hqu3uso": {
|
"vXHZVb0Y7Hqu3uso": {
|
||||||
|
|
|
||||||
|
|
@ -317,7 +317,7 @@
|
||||||
"name": "Group Attack",
|
"name": "Group Attack",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 12 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"QHNRSEQmqOcaoXq4": {
|
"QHNRSEQmqOcaoXq4": {
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@
|
||||||
"_id": "9RduwBLYcBaiouYk",
|
"_id": "9RduwBLYcBaiouYk",
|
||||||
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
|
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>1d4+1</strong> physical damage instead.</p>",
|
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {},
|
"actions": {},
|
||||||
"originItemType": null,
|
"originItemType": null,
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,7 @@
|
||||||
"_id": "fsaBlCjTdq1jM23G",
|
"_id": "fsaBlCjTdq1jM23G",
|
||||||
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 1 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"q8chow47nQLR9qeF": {
|
"q8chow47nQLR9qeF": {
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,7 @@
|
||||||
"name": "Group Attack",
|
"name": "Group Attack",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"DjbPQowW1OdBD9Zn": {
|
"DjbPQowW1OdBD9Zn": {
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@
|
||||||
"name": "Group Attack",
|
"name": "Group Attack",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 10 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"eo7J0v1B5zPHul1M": {
|
"eo7J0v1B5zPHul1M": {
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,7 @@
|
||||||
"_id": "1k5TmQIAunM7Bv32",
|
"_id": "1k5TmQIAunM7Bv32",
|
||||||
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name] within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name] within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"aoQDb2m32NDxE6ZP": {
|
"aoQDb2m32NDxE6ZP": {
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@
|
||||||
"_id": "K08WlZwGqzEo4idT",
|
"_id": "K08WlZwGqzEo4idT",
|
||||||
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 4 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"xTMNAHcoErKuR6TZ": {
|
"xTMNAHcoErKuR6TZ": {
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,7 @@
|
||||||
"name": "Group Attack",
|
"name": "Group Attack",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 11 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"tvQetauskZoHDR5y": {
|
"tvQetauskZoHDR5y": {
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@
|
||||||
"_id": "Q7DRbWjHl64CNwag",
|
"_id": "Q7DRbWjHl64CNwag",
|
||||||
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
|
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>1d4+1</strong> physical damage instead.</p>",
|
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {},
|
"actions": {},
|
||||||
"originItemType": null,
|
"originItemType": null,
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@
|
||||||
"_id": "R9vrwFNl5BD1YXJo",
|
"_id": "R9vrwFNl5BD1YXJo",
|
||||||
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"DJBNtd3hWjwsjPwq": {
|
"DJBNtd3hWjwsjPwq": {
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@
|
||||||
"_id": "CQZQiEiRH70Br5Ge",
|
"_id": "CQZQiEiRH70Br5Ge",
|
||||||
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 3 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"ghgFZskDiizJDjcn": {
|
"ghgFZskDiizJDjcn": {
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@
|
||||||
"_id": "wl9KKEpVWDBu62hU",
|
"_id": "wl9KKEpVWDBu62hU",
|
||||||
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 1 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"Sz55uB8xkoNytLwJ": {
|
"Sz55uB8xkoNytLwJ": {
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@
|
||||||
"_id": "9Zuu892SO5NmtI4w",
|
"_id": "9Zuu892SO5NmtI4w",
|
||||||
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
|
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>1d4+1</strong> physical damage instead.</p>",
|
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {},
|
"actions": {},
|
||||||
"originItemType": null,
|
"originItemType": null,
|
||||||
|
|
|
||||||
|
|
@ -254,12 +254,12 @@
|
||||||
},
|
},
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"name": "Horde (1d4+2)",
|
"name": "Horde",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"_id": "4dSzqtYvH385r9Ng",
|
"_id": "4dSzqtYvH385r9Ng",
|
||||||
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
|
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>1d4+2</strong> physical damage instead.</p>",
|
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {},
|
"actions": {},
|
||||||
"originItemType": null,
|
"originItemType": null,
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,7 @@
|
||||||
"_id": "WiobzuyvJ46zfsOv",
|
"_id": "WiobzuyvJ46zfsOv",
|
||||||
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"ZC5pKIb9N82vgMWu": {
|
"ZC5pKIb9N82vgMWu": {
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,7 @@
|
||||||
"name": "Group Attack",
|
"name": "Group Attack",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 8 physical damage each. Combine this damage.</p>",
|
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {
|
"actions": {
|
||||||
"euP8VA4wvfsCpwN1": {
|
"euP8VA4wvfsCpwN1": {
|
||||||
|
|
|
||||||
|
|
@ -218,10 +218,10 @@
|
||||||
},
|
},
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"name": "Horde (2d6+5)",
|
"name": "Horde",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>2d6+5</strong> physical damage instead.</p>",
|
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {},
|
"actions": {},
|
||||||
"originItemType": null,
|
"originItemType": null,
|
||||||
|
|
|
||||||
|
|
@ -218,12 +218,12 @@
|
||||||
},
|
},
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"name": "Horde (1d4+2)",
|
"name": "Horde",
|
||||||
"type": "feature",
|
"type": "feature",
|
||||||
"_id": "nNJGAhWu0IuS2ybn",
|
"_id": "nNJGAhWu0IuS2ybn",
|
||||||
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
|
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
|
||||||
"system": {
|
"system": {
|
||||||
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>1d4+2</strong> physical damage instead.</p>",
|
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
|
||||||
"resource": null,
|
"resource": null,
|
||||||
"actions": {},
|
"actions": {},
|
||||||
"originItemType": null,
|
"originItemType": null,
|
||||||
|
|
|
||||||
137
tools/analyze-damage.mjs
Normal file
137
tools/analyze-damage.mjs
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
/**
|
||||||
|
* 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 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")),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = removeOutliers(tierEntries.map(d => d.damage).sort((a, b) => a - b));
|
||||||
|
const mean = getMean(allDamage);
|
||||||
|
if (tier === 4) console.log(allDamage);
|
||||||
|
results[tier] = {
|
||||||
|
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')
|
||||||
|
.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