mirror of
https://github.com/Foundryborne/daggerheart.git
synced 2026-03-10 19:17:09 +01:00
Improve handling of minions and hordes
This commit is contained in:
parent
c61c90b372
commit
9220a4ad1e
2 changed files with 164 additions and 78 deletions
|
|
@ -2,7 +2,7 @@ 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 { parseTermsFromSimpleFormula } from '../../helpers/utils.mjs';
|
import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs';
|
||||||
import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs';
|
import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs';
|
||||||
|
|
||||||
export default class DhpAdversary extends BaseDataActor {
|
export default class DhpAdversary extends BaseDataActor {
|
||||||
|
|
@ -191,6 +191,7 @@ 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) {
|
adjustForTier(tier) {
|
||||||
const source = this.parent.toObject(true);
|
const source = this.parent.toObject(true);
|
||||||
|
|
||||||
|
|
@ -214,26 +215,64 @@ export default class DhpAdversary extends BaseDataActor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the mean and standard 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
|
||||||
|
// 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 expectedDamageData = adversaryExpectedDamage[source.system.type] ?? adversaryExpectedDamage.basic;
|
||||||
const currentDamageRange = { tier: source.system.tier, ...expectedDamageData[source.system.tier] };
|
const damageMeta = {
|
||||||
const newDamageRange = { tier, ...expectedDamageData[tier] };
|
currentDamageRange: { tier: source.system.tier, ...expectedDamageData[source.system.tier] },
|
||||||
|
newDamageRange: { tier, ...expectedDamageData[tier] },
|
||||||
|
type: 'attack'
|
||||||
|
};
|
||||||
|
|
||||||
// Update damage of base attack
|
// Update damage of base attack
|
||||||
this.#convertDamage(source.system.attack.damage, "attack", currentDamageRange, newDamageRange);
|
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
|
// 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) {
|
for (const item of source.items) {
|
||||||
// todo: damage inlines (must be done before other changes so that it doesn't get incorrectly applied)
|
// 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)) {
|
for (const action of Object.values(item.system.actions)) {
|
||||||
if (!action.damage) continue;
|
if (!action.damage) continue;
|
||||||
const formula = this.#parseDamage(action.damage).formula;
|
|
||||||
this.#convertDamage(action.damage, "action", currentDamageRange, newDamageRange);
|
|
||||||
|
|
||||||
const oldFormulaRegexp = new RegExp(formula.replace('+', '(?:\\s)?\\+(?:\\s)?'));
|
// Parse damage, and convert all formula matches in the descriptions to the new damage
|
||||||
const newFormula = this.#parseDamage(action.damage).formula;
|
try {
|
||||||
item.system.description = item.system.description.replace(oldFormulaRegexp, newFormula);
|
const result = this.#adjustActionDamage(action, { ...damageMeta, type: 'action' });
|
||||||
action.description = action.description.replace(oldFormulaRegexp, newFormula);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,55 +281,51 @@ export default class DhpAdversary extends BaseDataActor {
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
#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 expected = terms.reduce((r, t) => r + (t.modifier ?? 0) + (t.dice ? (t.dice * (t.faces + 1)) / 2 : 0), 0);
|
|
||||||
return { formula, terms, expected };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a damage object to a new damage range
|
* Converts a damage object to a new damage range
|
||||||
* @param type whether this is a basic "attack" or a regular "action"
|
* @returns {{ diceQuantity: number; faces: number; bonus: number }} the adjusted result as a combined term
|
||||||
|
* @throws error if the formula is the wrong type
|
||||||
*/
|
*/
|
||||||
#convertDamage(damage, type, currentDamageRange, newDamageRange) {
|
#calculateAdjustedDamage(formula, { currentDamageRange, newDamageRange, type }) {
|
||||||
const hitPointParts = damage.parts.filter(d => d.applyTo === 'hitPoints');
|
const terms = parseTermsFromSimpleFormula(formula);
|
||||||
if (hitPointParts.length === 0) return; // nothing to do
|
const flatTerms = terms.filter(t => t.diceQuantity === 0);
|
||||||
const previousExpected = this.#parseDamage(damage).expected;
|
const diceTerms = terms.filter(t => t.diceQuantity > 0);
|
||||||
if (previousExpected === 0) return; // nothing to do
|
if (flatTerms.length > 1 || diceTerms.length > 1) {
|
||||||
// others are not supported yet. Later on we should convert to terms, then convert from terms back to real data
|
throw new Error('invalid formula for conversion');
|
||||||
if (!(hitPointParts.length === 1 && !hitPointParts[0].value.custom.enabled)) return;
|
}
|
||||||
|
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 = ['d4', 'd6', 'd8', 'd10', 'd12', 'd20'];
|
const dieSizes = [4, 6, 8, 10, 12, 20];
|
||||||
const steps = newDamageRange.tier - currentDamageRange.tier;
|
const steps = newDamageRange.tier - currentDamageRange.tier;
|
||||||
const increasing = steps > 0;
|
const increasing = steps > 0;
|
||||||
const deviation = (previousExpected - currentDamageRange.mean) / currentDamageRange.deviation;
|
const deviation = (previousExpected - currentDamageRange.mean) / currentDamageRange.deviation;
|
||||||
const expected = newDamageRange.mean + newDamageRange.deviation * deviation;
|
const expected = Math.max(1, newDamageRange.mean + newDamageRange.deviation * deviation);
|
||||||
|
|
||||||
const value = hitPointParts[0].value;
|
// If this was just a flat number, convert to the expected damage and exit
|
||||||
const getExpectedDie = () => Number(value.dice.replace('d', '')) / 2;
|
if (value.diceQuantity === 0) {
|
||||||
const getBaseAverage = () => value.flatMultiplier * getExpectedDie();
|
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
|
// 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());
|
const baseOverages = Math.floor(value.bonus / getExpectedDie());
|
||||||
|
|
||||||
// Prestep. Change number of dice for attacks, bump up/down for actions
|
// 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
|
// We never bump up to d20, though we might bump down from it
|
||||||
if (type === "attack") {
|
if (type === 'attack') {
|
||||||
const minimum = increasing ? value.flatMultiplier : 0;
|
const minimum = increasing ? value.diceQuantity : 0;
|
||||||
value.flatMultiplier = Math.max(minimum, newDamageRange.tier);
|
value.diceQuantity = Math.max(minimum, newDamageRange.tier);
|
||||||
} else {
|
} else {
|
||||||
const currentIdx = dieSizes.indexOf(value.dice);
|
const currentIdx = dieSizes.indexOf(value.faces);
|
||||||
value.dice = dieSizes[Math.clamp(currentIdx + steps, 0, 4)]
|
value.faces = dieSizes[Math.clamp(currentIdx + steps, 0, 4)];
|
||||||
}
|
}
|
||||||
|
|
||||||
value.bonus = Math.round(expected - getBaseAverage());
|
value.bonus = Math.round(expected - getBaseAverage());
|
||||||
|
|
@ -298,28 +333,68 @@ export default class DhpAdversary extends BaseDataActor {
|
||||||
// Attempt to handle negative values.
|
// Attempt to handle negative values.
|
||||||
// If we can do it with only step downs, do so. Otherwise remove tier dice, and try again
|
// If we can do it with only step downs, do so. Otherwise remove tier dice, and try again
|
||||||
if (value.bonus < 0) {
|
if (value.bonus < 0) {
|
||||||
let stepsRequired = Math.ceil(Math.abs(value.bonus) / value.flatMultiplier);
|
let stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity);
|
||||||
const currentIdx = dieSizes.indexOf(value.dice);
|
const currentIdx = dieSizes.indexOf(value.faces);
|
||||||
|
|
||||||
// If step downs alone don't suffice, change the flat modifier, then calculate steps required again
|
// 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 this isn't sufficient, the result will be slightly off. This is unlikely to happen
|
||||||
if (type !== "attack" && stepsRequired > currentIdx && value.flatMultiplier > 0) {
|
if (type !== 'attack' && stepsRequired > currentIdx && value.diceQuantity > 0) {
|
||||||
value.flatMultiplier -= increasing ? 1 : Math.abs(steps);
|
value.diceQuantity -= increasing ? 1 : Math.abs(steps);
|
||||||
value.bonus = Math.round(expected - getBaseAverage());
|
value.bonus = Math.round(expected - getBaseAverage());
|
||||||
if (value.bonus >= 0) return; // complete
|
if (value.bonus >= 0) return value; // complete
|
||||||
}
|
}
|
||||||
|
|
||||||
stepsRequired = Math.ceil(Math.abs(value.bonus) / value.flatMultiplier);
|
stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity);
|
||||||
value.dice = dieSizes[Math.max(0, currentIdx - stepsRequired)];
|
value.faces = dieSizes[Math.max(0, currentIdx - stepsRequired)];
|
||||||
value.bonus = Math.max(0, Math.round(expected - getBaseAverage()));
|
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
|
// 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
|
// This attempts to preserve a similar amount of variance when increasing an action
|
||||||
const overagesToRemove = Math.floor(value.bonus / getExpectedDie()) - baseOverages;
|
const overagesToRemove = Math.floor(value.bonus / getExpectedDie()) - baseOverages;
|
||||||
if (type !== "attack" && increasing && overagesToRemove > 0) {
|
if (type !== 'attack' && increasing && overagesToRemove > 0) {
|
||||||
value.flatMultiplier += overagesToRemove;
|
value.diceQuantity += overagesToRemove;
|
||||||
value.bonus = Math.round(expected - getBaseAverage());
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -485,34 +485,45 @@ export function htmlToText(html) {
|
||||||
/**
|
/**
|
||||||
* Given a simple flavor-less formula with only +/- operators, returns a list of damage partial terms.
|
* Given a simple flavor-less formula with only +/- operators, returns a list of damage partial terms.
|
||||||
* All subtracted terms become negative terms.
|
* All subtracted terms become negative terms.
|
||||||
|
* If there are no dice, it returns 0d1 for that term.
|
||||||
*/
|
*/
|
||||||
export function parseTermsFromSimpleFormula(formula) {
|
export function parseTermsFromSimpleFormula(formula) {
|
||||||
const roll = formula instanceof Roll ? formula : new Roll(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.
|
// Parse from right to left so that when we hit an operator, we already have the term.
|
||||||
return roll.terms.reduceRight(
|
return roll.terms.reduceRight((result, term) => {
|
||||||
(result, term) => {
|
|
||||||
// Ignore + terms, we assume + by default
|
// Ignore + terms, we assume + by default
|
||||||
if (term.expression === " + ") return result;
|
if (term.expression === ' + ') return result;
|
||||||
|
|
||||||
// - terms modify the last term we parsed
|
// - terms modify the last term we parsed
|
||||||
if (term.expression === " - ") {
|
if (term.expression === ' - ') {
|
||||||
const termToModify = result[0];
|
const termToModify = result[0];
|
||||||
if (termToModify) {
|
if (termToModify) {
|
||||||
if (termToModify.modifier) termToModify.modifier *= -1;
|
if (termToModify.bonus) termToModify.bonus *= -1;
|
||||||
if (termToModify.dice) termToModify.dice *= -1;
|
if (termToModify.dice) termToModify.dice *= -1;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
result.unshift({
|
result.unshift({
|
||||||
modifier: term instanceof foundry.dice.terms.NumericTerm ? term.number : 0,
|
bonus: term instanceof foundry.dice.terms.NumericTerm ? term.number : 0,
|
||||||
dice: term instanceof foundry.dice.terms.Die ? term.number : 0,
|
diceQuantity: term instanceof foundry.dice.terms.Die ? term.number : 0,
|
||||||
faces: term.faces ?? null,
|
faces: term.faces ?? 1
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
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);
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue