diff --git a/module/applications/sidebar/tabs/actorDirectory.mjs b/module/applications/sidebar/tabs/actorDirectory.mjs
index d40443a0..86a5be56 100644
--- a/module/applications/sidebar/tabs/actorDirectory.mjs
+++ b/module/applications/sidebar/tabs/actorDirectory.mjs
@@ -43,4 +43,39 @@ export default class DhActorDirectory extends foundry.applications.sidebar.tabs.
event.dataTransfer.setDragImage(preview, w / 2, h / 2);
}
}
+
+ _getEntryContextOptions() {
+ const options = super._getEntryContextOptions();
+ options.push({
+ name: 'Duplicate To New Tier',
+ icon: ``,
+ 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 tier = await foundry.applications.api.Dialog.input({
+ window: { title: 'Pick a new tier for this adversary' },
+ content: '',
+ ok: {
+ label: 'Create Adversary',
+ callback: (event, button, dialog) => Math.clamp(button.form.elements.tier.valueAsNumber, 1, 4)
+ }
+ });
+
+ 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;
+ }
}
diff --git a/module/config/actorConfig.mjs b/module/config/actorConfig.mjs
index fdef7d03..97d72ead 100644
--- a/module/config/actorConfig.mjs
+++ b/module/config/actorConfig.mjs
@@ -494,3 +494,309 @@ export const subclassFeatureLabels = {
2: 'DAGGERHEART.ITEMS.DomainCard.specializationTitle',
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 & Record<1, { damage: number[] }>}
+ * 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,
+ severeThreshold: 10,
+ hp: 1,
+ stress: 2,
+ attack: 2,
+ damage: [12, 16]
+ },
+ 3: {
+ difficulty: 2,
+ majorThreshold: 7,
+ severeThreshold: 15,
+ hp: 1,
+ stress: 0,
+ attack: 2,
+ damage: [18, 22]
+ },
+ 4: {
+ difficulty: 2,
+ majorThreshold: 12,
+ severeThreshold: 25,
+ hp: 1,
+ stress: 0,
+ attack: 2,
+ damage: [30, 45]
+ }
+ },
+ horde: {
+ 1: {
+ damage: [5, 8]
+ },
+ 2: {
+ difficulty: 2,
+ majorThreshold: 5,
+ severeThreshold: 8,
+ hp: 2,
+ stress: 0,
+ attack: 0,
+ damage: [9, 13]
+ },
+ 3: {
+ difficulty: 2,
+ majorThreshold: 5,
+ severeThreshold: 12,
+ hp: 0,
+ stress: 1,
+ attack: 1,
+ damage: [14, 19]
+ },
+ 4: {
+ difficulty: 2,
+ majorThreshold: 10,
+ severeThreshold: 15,
+ hp: 2,
+ stress: 0,
+ attack: 0,
+ damage: [20, 30]
+ }
+ },
+ leader: {
+ 1: {
+ damage: [6, 9]
+ },
+ 2: {
+ difficulty: 2,
+ majorThreshold: 6,
+ severeThreshold: 10,
+ hp: 0,
+ stress: 0,
+ attack: 1,
+ damage: [12, 15]
+ },
+ 3: {
+ difficulty: 2,
+ majorThreshold: 6,
+ severeThreshold: 15,
+ hp: 1,
+ stress: 0,
+ attack: 2,
+ damage: [15, 18]
+ },
+ 4: {
+ difficulty: 2,
+ majorThreshold: 12,
+ severeThreshold: 25,
+ hp: 1,
+ stress: 1,
+ attack: 3,
+ damage: [25, 35]
+ }
+ },
+ minion: {
+ 1: {
+ damage: [1, 3]
+ },
+ 2: {
+ difficulty: 2,
+ majorThreshold: 0,
+ severeThreshold: 0,
+ hp: 0,
+ stress: 0,
+ attack: 1,
+ damage: [2, 4]
+ },
+ 3: {
+ difficulty: 2,
+ majorThreshold: 0,
+ severeThreshold: 0,
+ hp: 0,
+ stress: 1,
+ attack: 1,
+ damage: [5, 8]
+ },
+ 4: {
+ difficulty: 2,
+ majorThreshold: 0,
+ severeThreshold: 0,
+ hp: 0,
+ stress: 0,
+ attack: 1,
+ damage: [10, 12]
+ }
+ },
+ ranged: {
+ 1: {
+ damage: [6, 9]
+ },
+ 2: {
+ difficulty: 2,
+ majorThreshold: 3,
+ severeThreshold: 6,
+ hp: 1,
+ stress: 0,
+ attack: 1,
+ damage: [12, 16]
+ },
+ 3: {
+ difficulty: 2,
+ majorThreshold: 7,
+ severeThreshold: 14,
+ hp: 1,
+ stress: 1,
+ attack: 2,
+ damage: [15, 18]
+ },
+ 4: {
+ difficulty: 2,
+ majorThreshold: 5,
+ severeThreshold: 10,
+ hp: 1,
+ stress: 1,
+ attack: 1,
+ damage: [25, 35]
+ }
+ },
+ skulk: {
+ 1: {
+ damage: [5, 8]
+ },
+ 2: {
+ difficulty: 2,
+ majorThreshold: 3,
+ severeThreshold: 8,
+ hp: 1,
+ stress: 1,
+ attack: 1,
+ damage: [9, 13]
+ },
+ 3: {
+ difficulty: 2,
+ majorThreshold: 8,
+ severeThreshold: 12,
+ hp: 1,
+ stress: 1,
+ attack: 1,
+ damage: [14, 18]
+ },
+ 4: {
+ difficulty: 2,
+ majorThreshold: 8,
+ severeThreshold: 10,
+ hp: 1,
+ stress: 1,
+ attack: 1,
+ damage: [20, 35]
+ }
+ },
+ solo: {
+ 1: {
+ damage: [8, 11]
+ },
+ 2: {
+ difficulty: 2,
+ majorThreshold: 5,
+ severeThreshold: 10,
+ hp: 0,
+ stress: 1,
+ attack: 2,
+ damage: [15, 20]
+ },
+ 3: {
+ difficulty: 2,
+ majorThreshold: 7,
+ severeThreshold: 15,
+ hp: 2,
+ stress: 1,
+ attack: 2,
+ damage: [20, 30]
+ },
+ 4: {
+ difficulty: 2,
+ majorThreshold: 12,
+ severeThreshold: 25,
+ hp: 0,
+ stress: 1,
+ attack: 3,
+ damage: [30, 45]
+ }
+ },
+ standard: {
+ 1: {
+ damage: [4, 6]
+ },
+ 2: {
+ difficulty: 2,
+ majorThreshold: 3,
+ severeThreshold: 8,
+ hp: 0,
+ stress: 0,
+ attack: 1,
+ damage: [8, 12]
+ },
+ 3: {
+ difficulty: 2,
+ majorThreshold: 7,
+ severeThreshold: 15,
+ hp: 1,
+ stress: 1,
+ attack: 1,
+ damage: [12, 17]
+ },
+ 4: {
+ difficulty: 2,
+ majorThreshold: 10,
+ severeThreshold: 15,
+ hp: 0,
+ stress: 1,
+ attack: 1,
+ damage: [17, 20]
+ }
+ },
+ support: {
+ 1: {
+ damage: [3, 5]
+ },
+ 2: {
+ difficulty: 2,
+ majorThreshold: 3,
+ severeThreshold: 8,
+ hp: 1,
+ stress: 1,
+ attack: 1,
+ damage: [5, 12]
+ },
+ 3: {
+ difficulty: 2,
+ majorThreshold: 7,
+ severeThreshold: 12,
+ hp: 0,
+ stress: 0,
+ attack: 1,
+ damage: [13, 16]
+ },
+ 4: {
+ difficulty: 2,
+ majorThreshold: 8,
+ severeThreshold: 10,
+ hp: 1,
+ stress: 1,
+ attack: 1,
+ damage: [18, 25]
+ }
+ }
+};
diff --git a/module/data/actor/adversary.mjs b/module/data/actor/adversary.mjs
index 16e7e37a..c3ae5adf 100644
--- a/module/data/actor/adversary.mjs
+++ b/module/data/actor/adversary.mjs
@@ -2,6 +2,8 @@ import DHAdversarySettings from '../../applications/sheets-configs/adversary-set
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';
export default class DhpAdversary extends BaseDataActor {
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary'];
@@ -188,4 +190,98 @@ export default class DhpAdversary extends BaseDataActor {
];
return tags;
}
+
+ 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))
+ .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;
+ }
+
+ /** 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 convertDamage = (damage, newMean) => {
+ const hitPointParts = damage.parts.filter(d => d.applyTo === 'hitPoints');
+ if (hitPointParts.length === 1 && !hitPointParts[0].value.custom.enabled) {
+ const value = hitPointParts[0].value;
+ value.flatMultiplier = Math.max(0, value.flatMultiplier + tier - source.system.tier);
+ const baseAverage = (value.flatMultiplier * (Number(value.dice.replace('d', '')) + 1)) / 2;
+ value.bonus = Math.round(newMean - baseAverage);
+ }
+ };
+
+ const 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('+');
+ 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 };
+ };
+
+ // 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 damage = source.system.attack.damage;
+ convertDamage(damage, newAtkAverage);
+
+ // Update damage of each item action, making sure to also update the description if possible
+ for (const item of source.items) {
+ // todo: damage inlines (must be done before other changes so that it doesn't get incorrectly applied)
+
+ for (const action of Object.values(item.system.actions)) {
+ const damage = action.damage;
+ if (!damage) continue;
+ const { formula, mean } = parseDamage(damage);
+ if (mean === 0) continue;
+
+ const deviation = (mean - currentDamageRange.mean) / currentDamageRange.standardDeviation;
+ const newMean = newDamageRange.mean + newDamageRange.standardDeviation * deviation;
+ convertDamage(damage, newMean);
+
+ const oldFormulaRegexp = new RegExp(formula.replace('+', '(?:\\s)?\\+(?:\\s)?'));
+ const newFormula = parseDamage(action.damage).formula;
+ item.system.description = item.system.description.replace(oldFormulaRegexp, newFormula);
+ action.description = action.description.replace(oldFormulaRegexp, newFormula);
+ }
+ }
+
+ // Finally set the tier of the source data, now that everything is complete
+ source.system.tier = tier;
+ return source;
+ }
}
diff --git a/module/helpers/utils.mjs b/module/helpers/utils.mjs
index 396ed2fa..c4f8000c 100644
--- a/module/helpers/utils.mjs
+++ b/module/helpers/utils.mjs
@@ -474,3 +474,38 @@ export async function getCritDamageBonus(formula) {
const critRoll = new Roll(formula);
return critRoll.dice.reduce((acc, dice) => acc + dice.faces * dice.number, 0);
}
+
+/**
+ * Given a simple flavor-less formula with only +/- operators, returns a list of damage partial terms.
+ * All subtracted terms become negative terms.
+ */
+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.modifier) termToModify.modifier *= -1;
+ if (termToModify.dice) termToModify.dice *= -1;
+ }
+ return result;
+ }
+
+ result.unshift({
+ modifier: term instanceof foundry.dice.terms.NumericTerm ? term.number : 0,
+ dice: term instanceof foundry.dice.terms.Die ? term.number : 0,
+ faces: term.faces ?? null,
+ });
+
+ return result;
+ },
+ [],
+ );
+}