mirror of
https://github.com/Foundryborne/daggerheart.git
synced 2026-06-05 20:34:15 +02:00
Compare commits
5 commits
f1a530f57f
...
2bc1c04c93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc1c04c93 | ||
|
|
493998cc95 | ||
|
|
251d7e4e13 | ||
|
|
a209b035c8 | ||
|
|
9487b07e43 |
10 changed files with 290 additions and 217 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
|
@ -3,8 +3,7 @@ import { ActionField } from '../fields/actionField.mjs';
|
||||||
import { commonActorRules } from './base.mjs';
|
import { commonActorRules } from './base.mjs';
|
||||||
import DhCreature from './creature.mjs';
|
import DhCreature from './creature.mjs';
|
||||||
import { bonusField } from '../fields/actorField.mjs';
|
import { bonusField } from '../fields/actorField.mjs';
|
||||||
import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs';
|
import { getTierAdjustedAdversary } from './tierAdjustment.mjs';
|
||||||
import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs';
|
|
||||||
|
|
||||||
export default class DhpAdversary extends DhCreature {
|
export default class DhpAdversary extends DhCreature {
|
||||||
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary'];
|
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary'];
|
||||||
|
|
@ -206,205 +205,6 @@ export default class DhpAdversary extends DhCreature {
|
||||||
/** Returns source data for this actor adjusted to a new tier, which can be used to create a new actor. */
|
/** 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);
|
||||||
|
return getTierAdjustedAdversary(source, tier);
|
||||||
/** @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
|
|
||||||
// Parse damage, and convert all formula matches in the descriptions to the new damage
|
|
||||||
for (const action of Object.values(item.system.actions)) {
|
|
||||||
try {
|
|
||||||
const result = this.#adjustActionDamage(action, { ...damageMeta, type: 'action' });
|
|
||||||
if (!result) continue;
|
|
||||||
|
|
||||||
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, or null if it doesn't deal HP damage
|
|
||||||
*/
|
|
||||||
#adjustActionDamage(action, damageMeta) {
|
|
||||||
if (!action.damage?.parts.hitPoints) return null;
|
|
||||||
|
|
||||||
const result = {};
|
|
||||||
for (const property of ['value', 'valueAlt']) {
|
|
||||||
const data = action.damage.parts.hitPoints[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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
218
module/data/actor/tierAdjustment.mjs
Normal file
218
module/data/actor/tierAdjustment.mjs
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs';
|
||||||
|
import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs';
|
||||||
|
|
||||||
|
export function getTierAdjustedAdversary(source, tier) {
|
||||||
|
const currentTier = source.tier ?? 1;
|
||||||
|
|
||||||
|
/** @type {(2 | 3 | 4)[]} */
|
||||||
|
const tiers = new Array(Math.abs(tier - currentTier))
|
||||||
|
.fill(0)
|
||||||
|
.map((_, idx) => idx + Math.min(tier, currentTier) + 1);
|
||||||
|
if (tier < currentTier) 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 > currentTier ? 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] }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store initial attack damage for abilities that have you deal a "standard attack"
|
||||||
|
const initialAttack = {
|
||||||
|
type: source.system.attack.damage?.parts.hitPoints?.type?.toSorted(),
|
||||||
|
value: getDamagePartsFormula(source.system.attack.damage?.parts.hitPoints?.value)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update damage of base attack.
|
||||||
|
try {
|
||||||
|
const damage = source.system.attack.damage;
|
||||||
|
if (!damage?.parts.hitPoints) throw new Error('Unexpected missing attack in adversary');
|
||||||
|
|
||||||
|
for (const property of ['value', 'valueAlt']) {
|
||||||
|
const data = damage.parts.hitPoints[property];
|
||||||
|
const previousFormula = getDamagePartsFormula(data);
|
||||||
|
const { value, formula } = calculateAdjustedDamage(previousFormula, 'attack', damageMeta);
|
||||||
|
applyAdjustedDamage(data, value, formula);
|
||||||
|
}
|
||||||
|
} 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. Keep a record for a specific check later
|
||||||
|
const descriptionFormulas = [];
|
||||||
|
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 newFormula = calculateAdjustedDamage(formula, 'action', damageMeta)?.formula;
|
||||||
|
descriptionFormulas.push(formula);
|
||||||
|
return match.replace(formula, newFormula);
|
||||||
|
} catch {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update damage in item actions and convert all formula matches in the descriptions to the new damage
|
||||||
|
for (const action of Object.values(item.system.actions)) {
|
||||||
|
if (!action.damage?.parts.hitPoints) continue;
|
||||||
|
try {
|
||||||
|
// Apply conversions and save a record. If it matches attack damage *and* Its not in the description, use attack conversion instead
|
||||||
|
const result = [];
|
||||||
|
for (const property of ['value', 'valueAlt']) {
|
||||||
|
const { [property]: data, type: damageType } = action.damage.parts.hitPoints;
|
||||||
|
const previousFormula = getDamagePartsFormula(data);
|
||||||
|
const isActuallyAttack =
|
||||||
|
previousFormula === initialAttack.value &&
|
||||||
|
foundry.utils.equals(damageType.toSorted(), initialAttack.type) &&
|
||||||
|
!descriptionFormulas.includes(previousFormula);
|
||||||
|
const type = isActuallyAttack ? 'attack' : 'action';
|
||||||
|
const { value, formula } = calculateAdjustedDamage(previousFormula, type, damageMeta);
|
||||||
|
applyAdjustedDamage(data, value, formula);
|
||||||
|
result.push({ previousFormula, formula });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override text in the description with those values
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
function calculateAdjustedDamage(formula, type, { currentDamageRange, newDamageRange }) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFormula = [value.diceQuantity ? `${value.diceQuantity}d${value.faces}` : null, value.bonus]
|
||||||
|
.filter(p => !!p)
|
||||||
|
.join('+');
|
||||||
|
return { value, formula: newFormula };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDamagePartsFormula(data) {
|
||||||
|
return data.custom.enabled
|
||||||
|
? data.custom.formula
|
||||||
|
: [data.flatMultiplier ? `${data.flatMultiplier}${data.dice}` : 0, data.bonus ?? 0].filter(p => !!p).join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, or null if it doesn't deal HP damage
|
||||||
|
*/
|
||||||
|
function applyAdjustedDamage(diceData, value, formula) {
|
||||||
|
if (value.diceQuantity) {
|
||||||
|
diceData.custom.enabled = false;
|
||||||
|
diceData.bonus = value.bonus;
|
||||||
|
diceData.dice = `d${value.faces}`;
|
||||||
|
diceData.flatMultiplier = value.diceQuantity;
|
||||||
|
} else if (!value.diceQuantity) {
|
||||||
|
diceData.custom.enabled = true;
|
||||||
|
diceData.custom.formula = formula;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import BaseDataItem from './base.mjs';
|
||||||
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
|
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
|
||||||
import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs';
|
import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs';
|
||||||
import ItemLinkFields from '../fields/itemLinkFields.mjs';
|
import ItemLinkFields from '../fields/itemLinkFields.mjs';
|
||||||
import { addLinkedItemsDiff, getFeaturesHTMLData, updateLinkedItemApps } from '../../helpers/utils.mjs';
|
import { addLinkedItemsDiff, fromUuids, getFeaturesHTMLData, updateLinkedItemApps } from '../../helpers/utils.mjs';
|
||||||
|
|
||||||
export default class DHClass extends BaseDataItem {
|
export default class DHClass extends BaseDataItem {
|
||||||
/** @inheritDoc */
|
/** @inheritDoc */
|
||||||
|
|
@ -73,15 +73,16 @@ export default class DHClass extends BaseDataItem {
|
||||||
const uuids = [this.parent.uuid, this.parent._stats?.compendiumSource].filter(u => !!u);
|
const uuids = [this.parent.uuid, this.parent._stats?.compendiumSource].filter(u => !!u);
|
||||||
const subclasses = game.items.filter(x => x.type === 'subclass' && uuids.includes(x.system.linkedClass));
|
const subclasses = game.items.filter(x => x.type === 'subclass' && uuids.includes(x.system.linkedClass));
|
||||||
for (const pack of game.packs) {
|
for (const pack of game.packs) {
|
||||||
|
const packIds = [];
|
||||||
const indexes = await pack.getIndex({ fields: ['system.linkedClass'] });
|
const indexes = await pack.getIndex({ fields: ['system.linkedClass'] });
|
||||||
for (const index of indexes) {
|
for (const index of indexes) {
|
||||||
if (index.type !== 'subclass') continue;
|
if (index.type !== 'subclass') continue;
|
||||||
if (!uuids.includes(index.system?.linkedClass)) continue;
|
if (!uuids.includes(index.system?.linkedClass)) continue;
|
||||||
if (subclasses.find(x => x.uuid === index.uuid)) continue;
|
if (subclasses.find(x => x.uuid === index.uuid)) continue;
|
||||||
|
packIds.push(index._id);
|
||||||
const subclass = await foundry.utils.fromUuid(index.uuid);
|
|
||||||
subclasses.push(subclass);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (packIds.length > 0) subclasses.push(...(await pack.getDocuments({ _id__in: packIds })));
|
||||||
}
|
}
|
||||||
|
|
||||||
return subclasses;
|
return subclasses;
|
||||||
|
|
@ -216,6 +217,10 @@ export default class DHClass extends BaseDataItem {
|
||||||
classItems.push(contentLink.outerHTML);
|
classItems.push(contentLink.outerHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preload all class features for acquisition from the cache
|
||||||
|
// todo: make feature acquisition async and replace feature helpers for methods
|
||||||
|
await fromUuids(this._source.features.map(f => f.item));
|
||||||
|
|
||||||
const hopeFeatures = await getFeaturesHTMLData(this.hopeFeatures);
|
const hopeFeatures = await getFeaturesHTMLData(this.hopeFeatures);
|
||||||
const classFeatures = await getFeaturesHTMLData(this.classFeatures);
|
const classFeatures = await getFeaturesHTMLData(this.classFeatures);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { getFeaturesHTMLData } from '../../helpers/utils.mjs';
|
import { fromUuids, getFeaturesHTMLData } from '../../helpers/utils.mjs';
|
||||||
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
|
|
||||||
import ItemLinkFields from '../fields/itemLinkFields.mjs';
|
import ItemLinkFields from '../fields/itemLinkFields.mjs';
|
||||||
import BaseDataItem from './base.mjs';
|
import BaseDataItem from './base.mjs';
|
||||||
|
|
||||||
|
|
@ -91,6 +90,11 @@ export default class DHSubclass extends BaseDataItem {
|
||||||
const spellcastTrait = this.spellcastingTrait
|
const spellcastTrait = this.spellcastingTrait
|
||||||
? game.i18n.localize(CONFIG.DH.ACTOR.abilities[this.spellcastingTrait].label)
|
? game.i18n.localize(CONFIG.DH.ACTOR.abilities[this.spellcastingTrait].label)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Preload all class features for acquisition from the cache
|
||||||
|
// todo: make feature acquisition async and replace feature helpers for methods
|
||||||
|
await fromUuids(this._source.features.map(f => f.item));
|
||||||
|
|
||||||
const foundationFeatures = await getFeaturesHTMLData(this.foundationFeatures);
|
const foundationFeatures = await getFeaturesHTMLData(this.foundationFeatures);
|
||||||
const specializationFeatures = await getFeaturesHTMLData(this.specializationFeatures);
|
const specializationFeatures = await getFeaturesHTMLData(this.specializationFeatures);
|
||||||
const masteryFeatures = await getFeaturesHTMLData(this.masteryFeatures);
|
const masteryFeatures = await getFeaturesHTMLData(this.masteryFeatures);
|
||||||
|
|
|
||||||
|
|
@ -135,11 +135,11 @@ export default class DualityRoll extends D20Roll {
|
||||||
this.terms = [this.terms[0], this.terms[1], this.terms[2]];
|
this.terms = [this.terms[0], this.terms[1], this.terms[2]];
|
||||||
|
|
||||||
this.terms[0] = new game.system.api.dice.diceTypes.HopeDie({
|
this.terms[0] = new game.system.api.dice.diceTypes.HopeDie({
|
||||||
faces: this.data.rules.dualityRoll?.defaultHopeDice ?? 12
|
faces: this.terms[0]?.faces ?? this.data.rules.dualityRoll?.defaultHopeDice ?? 12
|
||||||
});
|
});
|
||||||
this.terms[1] = new foundry.dice.terms.OperatorTerm({ operator: '+' });
|
this.terms[1] = new foundry.dice.terms.OperatorTerm({ operator: '+' });
|
||||||
this.terms[2] = new game.system.api.dice.diceTypes.FearDie({
|
this.terms[2] = new game.system.api.dice.diceTypes.FearDie({
|
||||||
faces: this.data.rules.dualityRoll?.defaultFearDice ?? 12
|
faces: this.terms[2]?.faces ?? this.data.rules.dualityRoll?.defaultFearDice ?? 12
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -865,6 +865,45 @@ export function camelize(str) {
|
||||||
.replace(/\s+/g, '');
|
.replace(/\s+/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bulk load a list of documents using uuids. Returns the documents in the same order */
|
||||||
|
export async function fromUuids(uuids) {
|
||||||
|
// Set up base entries. Each step works on a sublist of these objects
|
||||||
|
const entries = uuids.map(uuid => ({
|
||||||
|
uuid,
|
||||||
|
parsed: foundry.utils.parseUuid(uuid),
|
||||||
|
value: foundry.utils.fromUuidSync(uuid)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Handle missing uuids for embedded documents first
|
||||||
|
// A value may be index data, so we check if its a document
|
||||||
|
const packEmbeddedEntries = entries.filter(
|
||||||
|
e =>
|
||||||
|
!(e.value instanceof Document) &&
|
||||||
|
e.parsed.collection instanceof foundry.documents.collections.CompendiumCollection &&
|
||||||
|
e.parsed.embedded.length > 0
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
packEmbeddedEntries.map(async e => {
|
||||||
|
e.value = await fromUuid(e.uuid);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle missing top level pack stuff, by batching per pack
|
||||||
|
const missingTopLevel = entries.filter(e => !(e.value instanceof Document) && e.value?.pack);
|
||||||
|
for (const packGroup of Object.values(Object.groupBy(missingTopLevel, e => e.value.pack))) {
|
||||||
|
const pack = game.packs.get(packGroup[0].value.pack);
|
||||||
|
if (!pack) continue;
|
||||||
|
|
||||||
|
const ids = packGroup.map(p => p.parsed.id);
|
||||||
|
const documents = await pack.getDocuments({ _id__in: ids });
|
||||||
|
for (const p of packGroup) {
|
||||||
|
p.value = documents.find(d => d.id === p.parsed.id) ?? p.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.map(e => e.value);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Triggers DiceSoNice rolls or dice roll audio for rolls. Not used for duality rolls.
|
* Triggers DiceSoNice rolls or dice roll audio for rolls. Not used for duality rolls.
|
||||||
* @param { Roll[] } rolls
|
* @param { Roll[] } rolls
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@
|
||||||
ul {
|
ul {
|
||||||
list-style: disc;
|
list-style: disc;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Fixes centering and makes it not render over scrollbar
|
||||||
|
&:hover button.toggle:enabled {
|
||||||
|
display: flex;
|
||||||
|
right: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
&.attack.active {
|
&.attack.active {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldsets-section {
|
.fieldsets-section {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@
|
||||||
{{formField systemFields.motivesAndTactics value=document._source.system.motivesAndTactics label=(localize "DAGGERHEART.ACTORS.Adversary.FIELDS.motivesAndTactics.label")}}
|
{{formField systemFields.motivesAndTactics value=document._source.system.motivesAndTactics label=(localize "DAGGERHEART.ACTORS.Adversary.FIELDS.motivesAndTactics.label")}}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="flex">
|
||||||
|
<legend>{{localize "DAGGERHEART.GENERAL.DamageThresholds.title"}}</legend>
|
||||||
|
{{formGroup systemFields.damageThresholds.fields.major value=document._source.system.damageThresholds.major label=(localize "DAGGERHEART.GENERAL.DamageThresholds.majorThreshold")}}
|
||||||
|
{{formGroup systemFields.damageThresholds.fields.severe value=document._source.system.damageThresholds.severe label=(localize "DAGGERHEART.GENERAL.DamageThresholds.severeThreshold")}}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="fieldsets-section">
|
<div class="fieldsets-section">
|
||||||
<fieldset class="flex">
|
<fieldset class="flex">
|
||||||
<legend>{{localize "DAGGERHEART.GENERAL.Resource.plural"}}</legend>
|
<legend>{{localize "DAGGERHEART.GENERAL.Resource.plural"}}</legend>
|
||||||
|
|
@ -26,10 +32,4 @@
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="flex">
|
|
||||||
<legend>{{localize "DAGGERHEART.GENERAL.DamageThresholds.title"}}</legend>
|
|
||||||
{{formGroup systemFields.damageThresholds.fields.major value=document._source.system.damageThresholds.major label=(localize "DAGGERHEART.GENERAL.DamageThresholds.majorThreshold")}}
|
|
||||||
{{formGroup systemFields.damageThresholds.fields.severe value=document._source.system.damageThresholds.severe label=(localize "DAGGERHEART.GENERAL.DamageThresholds.severeThreshold")}}
|
|
||||||
</fieldset>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue