[Rework] ChatMessage Damage (#2079)
Some checks are pending
Project CI / build (24.x) (push) Waiting to run

* Initial rework

* Removed unneeded method

* Removed outcommented code

* Added migration

* Fixed DamageActions

* Semi-corrected reroll of one of multiple results on a die

* Code improvement

* Added aseDie.rerollResult method to specifically reroll a specific result die in a grouping

* TagTeamDialog somewhat working

* Added migration for TagTeamData

* Fix for mean tagTeamDialog.finish

* .

* Improved migration

* Internalised ChatDamageData.prepareRolls to its own constructor

* Moved DamageTypes to roll.options

* TagTeamDialog fixes to accomodate new flat damage.types structure

* Changed so all default rolls become BaseRolls to access convenience functions and getters

* .

* Fixed so that party.tagTeam now uses a declared damageRollData(ChatDamageData) field. rollData.options.damage is retired.

* Critical damage fixes

* Corrected TagTeamDialog rerender logic when selecting a roll

* Fixed so that DamageTypes are retained and joined together throughout TagTeamDialog

* Removed some remaining types.<>.roll references

* [Rework] Damage and Damage Resource split (#2094)

* Changed the label of ActionSheet damage.resources to Mark Resources

* Fix assigning includeBase

* Localize add resource

* Maybe simplify API

* Fix onRollSimple

* Handle erroring action update differently

---------

Co-authored-by: Carlos Fernandez <cfern1990@gmail.com>
This commit is contained in:
WBHarry 2026-07-19 03:02:57 +02:00 committed by GitHub
parent 4b651836ff
commit effab8db0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 1007 additions and 788 deletions

View file

@ -13,18 +13,19 @@ export default class DHAttackAction extends DHDamageAction {
if (this.damage.includeBase) {
const baseDamage = this.getParentHitPointDamage();
if (baseDamage) {
if (!this.damage.parts.hitPoints) {
this.damage.parts.hitPoints = baseDamage;
if (!this.damage.main) {
this.damage.main = baseDamage;
} else {
for (const type of baseDamage.type) this.damage.parts.hitPoints.type.add(type);
for (const type of baseDamage.type) this.damage.main.type.add(type);
this.damage.parts.hitPoints.value.custom = {
this.damage.main.value.custom = {
enabled: true,
formula: `${baseDamage.value.getFormula()} + ${this.damage.parts.hitPoints.value.getFormula()}`
formula: `${baseDamage.value.getFormula()} + ${this.damage.main.value.getFormula()}`
};
}
}
}
if (this.roll.useDefault) {
this.roll.trait = this.item.system.attack.roll.trait;
this.roll.type = 'attack';
@ -33,18 +34,18 @@ export default class DHAttackAction extends DHDamageAction {
}
getParentHitPointDamage() {
return this.item?.system?.attack.damage.parts.hitPoints;
return this.item?.system?.attack.damage.main;
}
get damageFormula() {
const hitPointsPart = this.damage.parts.hitPoints;
const hitPointsPart = this.damage.main;
if (!hitPointsPart) return '0';
return hitPointsPart.value.getFormula();
}
get altDamageFormula() {
const hitPointsPart = this.damage.parts.hitPoints;
const hitPointsPart = this.damage.main;
if (!hitPointsPart) return '0';
return hitPointsPart.valueAlt.getFormula();
@ -73,7 +74,7 @@ export default class DHAttackAction extends DHDamageAction {
if (range) labels.push(game.i18n.localize(`DAGGERHEART.CONFIG.Range.${range}.short`));
const useAltDamage = this.actor?.effects?.find(x => x.type === 'horde')?.active;
for (const { value, valueAlt, type } of damage.parts) {
for (const { value, valueAlt, type } of [damage.main, ...damage.resources].filter(d => !!d)) {
const usedValue = useAltDamage ? valueAlt : value;
const damageString = Roll.replaceFormulaData(usedValue.getFormula(), this.actor?.getRollData() ?? {});
const str = damageString
@ -82,7 +83,7 @@ export default class DHAttackAction extends DHDamageAction {
x: game.i18n.localize('DAGGERHEART.GENERAL.damage')
});
const icons = Array.from(type)
const icons = Array.from(type ?? [])
.map(t => CONFIG.DH.GENERAL.damageTypes[t]?.icon)
.filter(Boolean);

View file

@ -289,7 +289,6 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
hasEffect: this.hasEffect,
hasSave: this.hasSave,
onSave: this.save?.damageMod,
isDirect: !!this.damage?.direct,
selectedMessageMode: game.settings.get('core', 'messageMode'),
data: this.getRollData(),
evaluate: this.hasRoll,
@ -307,20 +306,20 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
};
if (this.damage) {
config.isDirect = this.damage.direct;
config.isDirect = !!this.damage.main?.direct;
const groupAttackTokens = this.damage.groupAttack
const groupAttackTokens = this.damage.main?.groupAttack
? game.system.api.fields.ActionFields.DamageField.getGroupAttackTokens(
this.actor.id,
this.damage.groupAttack
this.damage.main.groupAttack
)
: null;
config.damageOptions = {
groupAttack: this.damage.groupAttack
groupAttack: this.damage.main?.groupAttack
? {
numAttackers: Math.max(groupAttackTokens.length, 1),
range: this.damage.groupAttack
range: this.damage.main.groupAttack
}
: null
};
@ -430,11 +429,11 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
}
get hasDamage() {
return Boolean(Object.keys(this.damage?.parts ?? {}).length) && this.type !== 'healing';
return this.type !== 'healing' && (Boolean(this.damage.main) || !foundry.utils.isEmpty(this.damage.resources));
}
get hasHealing() {
return Boolean(Object.keys(this.damage?.parts ?? {}).length) && this.type === 'healing';
return this.type === 'healing' && !foundry.utils.isEmpty(this.damage.resources);
}
get hasSave() {
@ -470,6 +469,25 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
return acc;
}, {});
}
if (source.damage?.parts && !source.damage.resources && !source.damage.main) {
source.damage.main = null;
source.damage.resources = {};
for (const [partKey, part] of Object.entries(source.damage.parts)) {
if (partKey === 'hitPoints' && source.type !== 'healing') {
source.damage.main = {
...part,
includeBase: source.damage.includeBase,
direct: source.damage.direct,
groupAttack: source.damage.groupAttack
};
} else {
source.damage.resources[partKey] = part;
}
}
delete source.damage.parts;
}
}
}

View file

@ -8,11 +8,8 @@ export default class DHDamageAction extends DHBaseAction {
* @returns Formula string
*/
getDamageFormula() {
const strings = [];
for (const { value } of this.damage.parts) {
strings.push(Roll.replaceFormulaData(value.getFormula(), this.actor?.getRollData() ?? {}));
}
if (!this.damage.main) return '';
return strings.join(' + ');
return Roll.replaceFormulaData(this.damage.main.value.getFormula(), this.actor?.getRollData() ?? {});
}
}

View file

@ -84,13 +84,11 @@ export default class DhpAdversary extends DhCreature {
type: 'attack'
},
damage: {
parts: {
hitPoints: {
type: ['physical'],
applyTo: 'hitPoints',
value: {
multiplier: 'flat'
}
main: {
type: ['physical'],
applyTo: 'hitPoints',
value: {
multiplier: 'flat'
}
}
}

View file

@ -104,15 +104,13 @@ export default class DhCharacter extends DhCreature {
trait: 'strength'
},
damage: {
parts: {
hitPoints: {
type: ['physical'],
applyTo: 'hitPoints',
value: {
custom: {
enabled: true,
formula: '@profd4'
}
main: {
type: ['physical'],
applyTo: 'hitPoints',
value: {
custom: {
enabled: true,
formula: '@profd4'
}
}
}
@ -838,7 +836,7 @@ export default class DhCharacter extends DhCreature {
isReversed: true
};
this.attack.damage.parts.hitPoints.value.custom.formula = `@prof${this.basicAttackDamageDice}${this.rules.attack.damage.bonus ? ` + ${this.rules.attack.damage.bonus}` : ''}`;
this.attack.damage.main.value.custom.formula = `@prof${this.basicAttackDamageDice}${this.rules.attack.damage.bonus ? ` + ${this.rules.attack.damage.bonus}` : ''}`;
// Clamp resources (must be done last to ensure all updates occur)
this.resources.clamp();

View file

@ -40,17 +40,17 @@ export function getTierAdjustedAdversary(source, 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: getFormula(source.system.attack.damage?.parts.hitPoints?.value)
type: source.system.attack.damage?.main?.type?.toSorted(),
value: getFormula(source.system.attack.damage?.main?.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');
if (!damage?.main) throw new Error('Unexpected missing damage in adversary');
for (const property of ['value', 'valueAlt']) {
const data = damage.parts.hitPoints[property];
const data = damage.main[property];
const previousFormula = getFormula(data);
const value = calculateAdjustedDamage(previousFormula, 'attack', damageMeta);
applyAdjustedDamage(data, value);
@ -82,12 +82,12 @@ export function getTierAdjustedAdversary(source, tier) {
// 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;
if (!action.damage?.main) 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 { [property]: data, type: damageType } = action.damage.main;
const previousFormula = getFormula(data);
const isActuallyAttack =
previousFormula === initialAttack.value &&
@ -199,7 +199,7 @@ function calculateAdjustedDamage(formula, type, { currentDamageRange, newDamageR
}
/**
* Get formula from either damage parts *or* a simple formula object.
* Get formula from either damage data *or* a simple formula object.
* @returns {string} the new formula data
*/
function getFormula(data) {

View file

@ -1,4 +1,5 @@
import { triggerChatRollFx } from '../../helpers/utils.mjs';
import { ChatDamageData } from './chatDamageData.mjs';
const fields = foundry.data.fields;
@ -49,7 +50,7 @@ export default class DHActorRoll extends foundry.abstract.TypeDataModel {
originItem: originItemField(),
action: new fields.StringField()
}),
damage: new fields.ObjectField(),
damage: new fields.EmbeddedDataField(ChatDamageData),
damageOptions: new fields.ObjectField(),
costs: new fields.ArrayField(new fields.ObjectField()),
successConsumed: new fields.BooleanField({ initial: false })
@ -132,28 +133,21 @@ export default class DHActorRoll extends foundry.abstract.TypeDataModel {
});
}
/* TODO: Change how damage data is stored somehow to enable better rerolling */
async getRerolledDamage() {
if (!this.damage) return;
if (!this.damage.active) return;
const rerolls = [];
const update = { system: { damage: {} } };
for (const partKey in this.damage) {
const part = this.damage[partKey];
const testRoll = Roll.fromData(part.parts[0].roll);
const rerolled = await testRoll.reroll();
rerolls.push(rerolled);
const update = { system: { damage: { main: null, resources: _replace({}) } } };
if (this.damage.main) {
const reroll = await this.damage.main.reroll();
rerolls.push(reroll);
update.system.damage.main = reroll.toJSON();
}
if (!update.system.damage[partKey]) update.system.damage[partKey] = { parts: [part.parts[0]] };
const partData = update.system.damage[partKey].parts[0];
update.system.damage[partKey].total = rerolled.total;
partData.modifierTotal = rerolled.terms.reduce((acc, x) => {
if (x.isDeterministic && !x.operator) acc += x.total;
return acc;
}, 0);
partData.dice = rerolled.dice.map(d => ({ ...d.toJSON(), dice: d.denomination }));
partData.total = rerolled.total;
partData.roll = rerolled.toJSON();
for (const key of Object.keys(this.damage.resources)) {
const reroll = await this.damage.resources[key].reroll();
rerolls.push(reroll);
update.system.damage.resources[key] = reroll.toJSON();
}
await triggerChatRollFx(rerolls);
@ -198,6 +192,43 @@ export default class DHActorRoll extends foundry.abstract.TypeDataModel {
this.isGM = game.user.isGM; //temp
}
static migrateData(source) {
const { main, resources, ...flatDamageKeys } = source.damage ?? {};
if (source.damage && !main && !resources) {
source.damage.main = null;
source.damage.resources = {};
const getRoll = key => {
const damageData = source.damage[key];
const oldRoll = damageData.parts[0]?.roll;
return oldRoll ? JSON.stringify({
...oldRoll,
class: 'BaseRoll',
options: {
...oldRoll.options,
damageTypes: damageData.parts[0].damageTypes ?? []
}
}) : null;
};
for (const key of Object.keys(flatDamageKeys)) {
if (key === 'hitPoints' && source.hasDamage && !source.hasHealing) {
source.damage.main = getRoll('hitPoints');
}
else {
source.damage.resources[key] = getRoll(key);
}
}
}
for (const key of Object.keys(flatDamageKeys)) {
delete source.damage[key];
}
return source;
}
getTargetList() {
const targets =
this.targetMode && this.parent.isAuthor

View file

@ -0,0 +1,58 @@
import { triggerChatRollFx } from '../../helpers/utils.mjs';
export class ChatDamageData extends foundry.abstract.DataModel {
constructor(data = {}, options = {}) {
super(data, options);
this._prepareRolls();
}
static defineSchema() {
const fields = foundry.data.fields;
return {
main: new fields.JSONField({ nullable: true, validate: ChatDamageData.#validateRoll}),
resources: new fields.TypedObjectField(new fields.JSONField({validate: ChatDamageData.#validateRoll}))
};
}
get active() {
return !!this.main || Boolean(Object.keys(this.resources).length);
}
static #validateRoll(rollJSON) {
if (rollJSON) {
const roll = JSON.parse(rollJSON);
if (!roll.evaluated) throw new Error('Roll objects added to ChatMessage documents must be evaluated');
}
}
_prepareRolls() {
this.main &&= Roll.fromData(this.main);
for (const key of Object.keys(this.resources)) {
this.resources[key] = Roll.fromData(this.resources[key]);
}
}
async rerollDamageDie(isResource, damageType, dice, resultIndex) {
const reroll = isResource ? this.resources[damageType] : this.main;
const rerollDice = reroll.dice[dice];
await rerollDice.rerollResult(resultIndex);
await reroll._evaluate();
const rerolledResult = rerollDice.results[rerollDice.results.length - 1];
if (rerolledResult) {
const fakeRoll = {
_evaluated: true,
dice: [new foundry.dice.terms.Die({
...rerollDice,
results: [rerolledResult],
total: rerolledResult.value,
faces: rerollDice.faces
})],
options: { appearance: {} }
};
await triggerChatRollFx([fakeRoll]);
}
}
}

View file

@ -12,20 +12,10 @@ export default class DamageField extends fields.SchemaField {
/** @inheritDoc */
constructor(options, context = {}) {
const damageFields = {
parts: new IterableTypedObjectField(DHDamageData),
includeBase: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.ACTIONS.Settings.includeBase.label'
}),
direct: new fields.BooleanField({ initial: false, label: 'DAGGERHEART.CONFIG.DamageType.direct.name' }),
groupAttack: new fields.StringField({
choices: CONFIG.DH.GENERAL.groupAttackRange,
blank: true,
label: 'DAGGERHEART.ACTIONS.Settings.groupAttack.label'
})
};
super(damageFields, options, context);
super({
main: new fields.EmbeddedDataField(DHDamageData, { nullable: true }),
resources: new IterableTypedObjectField(DHResourceData)
}, options, context);
}
/**
@ -41,25 +31,23 @@ export default class DamageField extends fields.SchemaField {
this.hasRoll &&
DamageField.getAutomation() === CONFIG.DH.SETTINGS.actionAutomationChoices.never.id &&
!force
)
) {
return;
}
let formulas = this.damage.parts.map(p => ({
formula: DamageField.getFormulaValue.call(this, p, config).getFormula(this.actor),
damageTypes: p.applyTo === 'hitPoints' && !p.type.size ? new Set(['physical']) : p.type,
applyTo: p.applyTo
}));
const damageFormula = this.damage.main ?
DamageField.formatFormulas.call(this, [this.damage.main], config)[0] : null;
const resourceFormulas = DamageField.formatFormulas.call(this, this.damage.resources, config);
if (!formulas.length) return false;
formulas = DamageField.formatFormulas.call(this, formulas, config);
if (!damageFormula && !resourceFormulas.length) return false;
messageId = config.message?._id ?? messageId;
const message = game.messages.get(messageId);
const damageConfig = {
dialog: {},
...config,
roll: formulas,
damageFormula,
resourceFormulas,
data: this.getRollData(),
isCritical: Boolean(message?.system.roll?.isCritical)
};
@ -92,7 +80,7 @@ export default class DamageField extends fields.SchemaField {
const targetDamage = [];
const damagePromises = [];
for (let target of targets) {
for (const target of targets) {
const actor = foundry.utils.fromUuidSync(target.actorId);
if (!actor) continue;
if (!config.hasHealing && config.onSave && target.saved?.success === true) {
@ -114,13 +102,12 @@ export default class DamageField extends fields.SchemaField {
actor.takeHealing(config.damage).then(updates => targetDamage.push({ token, updates }))
);
else {
const configDamage = foundry.utils.deepClone(config.damage);
const hpDamageMultiplier = config.actionActor?.system.rules?.attack?.damage?.hpDamageMultiplier ?? 1;
const hpDamageTakenMultiplier = actor.system.rules?.attack?.damage?.hpDamageTakenMultiplier;
if (configDamage.hitPoints) {
for (const part of configDamage.hitPoints.parts) {
part.total = Math.ceil(part.total * hpDamageMultiplier * hpDamageTakenMultiplier);
}
const configDamage = config.damage.clone();
configDamage.main &&= configDamage.main.toJSON();
if (configDamage.main) {
const multiplier = config.actionActor?.system.rules?.attack?.damage?.hpDamageMultiplier ?? 1;
const takenMultiplier = actor.system.rules?.attack?.damage?.hpDamageTakenMultiplier;
configDamage.main.total = Math.ceil(configDamage.main.total * multiplier * takenMultiplier);
}
damagePromises.push(
@ -183,13 +170,19 @@ export default class DamageField extends fields.SchemaField {
/**
* Prepare formulas for Damage Roll
* Must be called within Action context or similar.
* @param {object[]} formulas Array of formatted formulas object
* @param {DHResourceData[]} damageData Array of DHResourceData
* @param {object} data Action getRollData
* @returns
*/
static formatFormulas(formulas, data) {
static formatFormulas(damageData, data) {
const formulas = damageData.map(x => ({
formula: DamageField.getFormulaValue.call(this, x, data).getFormula(this.actor),
damageTypes: x.type ?? new Set(),
applyTo: x.applyTo
}));
const formattedFormulas = [];
formulas.forEach(formula => {
for (const formula of formulas) {
if (isNaN(formula.formula))
formula.formula = Roll.replaceFormulaData(formula.formula, this.getRollData(data));
const same = formattedFormulas.find(
@ -197,7 +190,8 @@ export default class DamageField extends fields.SchemaField {
);
if (same) same.formula += ` + ${formula.formula}`;
else formattedFormulas.push(formula);
});
}
return formattedFormulas;
}
@ -294,6 +288,7 @@ export class DHResourceData extends foundry.abstract.DataModel {
/** @override */
static defineSchema() {
return {
base: new fields.BooleanField({ initial: false, readonly: true, label: 'Base' }),
applyTo: new fields.StringField({
choices: CONFIG.DH.GENERAL.healingTypes,
required: true,
@ -316,7 +311,16 @@ export class DHDamageData extends DHResourceData {
static defineSchema() {
return {
...super.defineSchema(),
base: new fields.BooleanField({ initial: false, readonly: true, label: 'Base' }),
includeBase: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.ACTIONS.Settings.includeBase.label'
}),
direct: new fields.BooleanField({ initial: false, label: 'DAGGERHEART.CONFIG.DamageType.direct.name' }),
groupAttack: new fields.StringField({
choices: CONFIG.DH.GENERAL.groupAttackRange,
blank: true,
label: 'DAGGERHEART.ACTIONS.Settings.groupAttack.label'
}),
type: new fields.SetField(
new fields.StringField({
choices: CONFIG.DH.GENERAL.damageTypes,

View file

@ -238,6 +238,7 @@ export function ActionMixin(Base) {
result = this.parent;
} else {
result = await this.item.update({ [path]: updates }, options);
if (!result) return result;
}
return this.inCollection

View file

@ -67,13 +67,11 @@ export default class DHWeapon extends AttachableItem {
type: 'attack'
},
damage: {
parts: {
hitPoints: {
type: ['physical'],
value: {
multiplier: 'prof',
dice: 'd8'
}
main: {
type: ['physical'],
value: {
multiplier: 'prof',
dice: 'd8'
}
}
}
@ -230,11 +228,12 @@ export default class DHWeapon extends AttachableItem {
game.i18n.localize(`DAGGERHEART.CONFIG.Burden.${burden}`)
];
for (const { value, type } of attack.damage.parts) {
if (attack.damage.main) {
const { value, type } = attack.damage.main;
const parts = value.custom.enabled ? [game.i18n.localize('DAGGERHEART.GENERAL.custom')] : [value.dice];
if (!value.custom.enabled && value.bonus) parts.push(value.bonus.signedString());
if (type.size > 0) {
if (type?.size) {
const typeTags = Array.from(type)
.map(t => game.i18n.localize(`DAGGERHEART.CONFIG.DamageType.${t}.abbreviation`))
.join(' | ');
@ -243,7 +242,7 @@ export default class DHWeapon extends AttachableItem {
tags.push(parts.join(''));
}
return tags;
}
@ -258,10 +257,10 @@ export default class DHWeapon extends AttachableItem {
if (roll.trait) labels.push(game.i18n.localize(`DAGGERHEART.CONFIG.Traits.${roll.trait}.short`));
if (range) labels.push(game.i18n.localize(`DAGGERHEART.CONFIG.Range.${range}.short`));
for (const { value, type } of damage.parts) {
for (const { value, type } of [damage.main, ...damage.resources].filter(d => !!d)) {
const str = Roll.replaceFormulaData(value.getFormula(), this.actor?.getRollData() ?? {});
const icons = Array.from(type)
const icons = Array.from(type ?? [])
.map(t => CONFIG.DH.GENERAL.damageTypes[t]?.icon)
.filter(Boolean);

View file

@ -1,3 +1,5 @@
import { ChatDamageData } from './chat-message/chatDamageData.mjs';
export default class TagTeamData extends foundry.abstract.DataModel {
static defineSchema() {
const fields = foundry.data.fields;
@ -37,6 +39,7 @@ export class MemberData extends foundry.abstract.DataModel {
}),
rollChoice: new fields.StringField({ nullable: true, initial: null }),
rollData: new fields.JSONField({ nullable: true, initial: null }),
damageRollData: new fields.EmbeddedDataField(ChatDamageData),
selected: new fields.BooleanField({ initial: false })
};
}