Feature/336 damage targeted resources (#376)

* Unify healing & damage

* create DHResourceData

* Damages parts roll

* h

* ChatMessage & takeDamage updates

* Adapt healing

* No, there was not a console.log !
This commit is contained in:
Dapoulp 2025-07-19 15:48:50 +02:00 committed by GitHub
parent 26376b49db
commit 7cbbb3168e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 415 additions and 232 deletions

View file

@ -35,6 +35,9 @@
"Settings": { "Settings": {
"resultBased": { "resultBased": {
"label": "Formula based on Hope/Fear result." "label": "Formula based on Hope/Fear result."
},
"applyTo": {
"label": "Targeted Resource"
} }
}, },
"TYPES": { "TYPES": {
@ -662,6 +665,10 @@
"armorStack": { "armorStack": {
"name": "Armor Stack", "name": "Armor Stack",
"abbreviation": "AS" "abbreviation": "AS"
},
"fear": {
"name": "Fear",
"abbreviation": "FR"
} }
}, },
"ItemResourceType": { "ItemResourceType": {
@ -1252,6 +1259,7 @@
}, },
"fear": "Fear", "fear": "Fear",
"features": "Features", "features": "Features",
"healing": "Healing",
"hitPoints": { "hitPoints": {
"single": "Hit Point", "single": "Hit Point",
"plural": "Hit Points", "plural": "Hit Points",

View file

@ -43,10 +43,11 @@ export default class DamageDialog extends HandlebarsApplicationMixin(Application
async _prepareContext(_options) { async _prepareContext(_options) {
const context = await super._prepareContext(_options); const context = await super._prepareContext(_options);
context.config = CONFIG.DH;
context.title = this.config.title context.title = this.config.title
? this.config.title ? this.config.title
: game.i18n.localize('DAGGERHEART.EFFECTS.ApplyLocations.damageRoll.name'); : game.i18n.localize('DAGGERHEART.EFFECTS.ApplyLocations.damageRoll.name');
context.extraFormula = this.config.extraFormula; // context.extraFormula = this.config.extraFormula;
context.formula = this.roll.constructFormula(this.config); context.formula = this.roll.constructFormula(this.config);
context.directDamage = this.config.directDamage; context.directDamage = this.config.directDamage;
context.selectedRollMode = this.config.selectedRollMode; context.selectedRollMode = this.config.selectedRollMode;
@ -55,13 +56,12 @@ export default class DamageDialog extends HandlebarsApplicationMixin(Application
label, label,
icon icon
})); }));
return context; return context;
} }
static updateRollConfiguration(_event, _, formData) { static updateRollConfiguration(_event, _, formData) {
const { ...rest } = foundry.utils.expandObject(formData.object); const { ...rest } = foundry.utils.expandObject(formData.object);
this.config.extraFormula = rest.extraFormula; foundry.utils.mergeObject(this.config.roll, rest.roll)
this.config.selectedRollMode = rest.selectedRollMode; this.config.selectedRollMode = rest.selectedRollMode;
this.render(); this.render();

View file

@ -10,12 +10,12 @@ export default class DamageReductionDialog extends HandlebarsApplicationMixin(Ap
this.reject = reject; this.reject = reject;
this.actor = actor; this.actor = actor;
this.damage = damage; this.damage = damage;
const canApplyArmor = damageType.every(t => actor.system.armorApplicableDamageTypes[t] === true); const canApplyArmor = damageType.every(t => actor.system.armorApplicableDamageTypes[t] === true);
const maxArmorMarks = canApplyArmor const maxArmorMarks = canApplyArmor
? Math.min( ? Math.min(
actor.system.armorScore - actor.system.armor.system.marks.value, actor.system.armorScore - actor.system.armor.system.marks.value,
actor.system.rules.damageReduction.maxArmorMarked.total actor.system.rules.damageReduction.maxArmorMarked.value
) )
: 0; : 0;
@ -100,7 +100,7 @@ export default class DamageReductionDialog extends HandlebarsApplicationMixin(Ap
context.armorScore = this.actor.system.armorScore; context.armorScore = this.actor.system.armorScore;
context.armorMarks = currentMarks; context.armorMarks = currentMarks;
context.basicMarksUsed = context.basicMarksUsed =
selectedArmorMarks.length === this.actor.system.rules.damageReduction.maxArmorMarked.total; selectedArmorMarks.length === this.actor.system.rules.damageReduction.maxArmorMarked.value;
const stressReductionStress = this.availableStressReductions const stressReductionStress = this.availableStressReductions
? stressReductions.reduce((acc, red) => acc + red.cost, 0) ? stressReductions.reduce((acc, red) => acc + red.cost, 0)

View file

@ -187,7 +187,6 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.attackTargetDoesNotExist')); ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.attackTargetDoesNotExist'));
return; return;
} }
game.canvas.pan(token); game.canvas.pan(token);
}; };
@ -207,15 +206,24 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
if (!confirm) return; if (!confirm) return;
} }
} }
if (targets.length === 0) if (targets.length === 0)
ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.noTargetsSelected')); return ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.noTargetsSelected'));
for (let target of targets) {
let damage = message.system.roll.total;
if (message.system.onSave && message.system.targets.find(t => t.id === target.id)?.saved?.success === true)
damage = Math.ceil(damage * (CONFIG.DH.ACTIONS.damageOnSave[message.system.onSave]?.mod ?? 1));
target.actor.takeDamage(damage, message.system.damage.damageType); for (let target of targets) {
let damages = message.system.damage;
if (message.system.onSave && message.system.targets.find(t => t.id === target.id)?.saved?.success === true) {
const mod = CONFIG.DH.ACTIONS.damageOnSave[message.system.onSave]?.mod ?? 1;
Object.entries(damages).forEach((k,v) => {
let newTotal = 0;
v.forEach(part => {
v.total = Math.ceil(v.total * mod);
newTotal += v.total;
})
})
}
target.actor.takeDamage(damages.roll);
} }
}; };
@ -224,10 +232,10 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
const targets = Array.from(game.user.targets); const targets = Array.from(game.user.targets);
if (targets.length === 0) if (targets.length === 0)
ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.noTargetsSelected')); return ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.noTargetsSelected'));
for (var target of targets) { for (var target of targets) {
target.actor.takeHealing([{ value: message.system.roll.total, type: message.system.roll.type }]); target.actor.takeHealing(message.system.roll);
} }
}; };

View file

@ -89,6 +89,11 @@ export const healingTypes = {
id: 'armorStack', id: 'armorStack',
label: 'DAGGERHEART.CONFIG.HealingType.armorStack.name', label: 'DAGGERHEART.CONFIG.HealingType.armorStack.name',
abbreviation: 'DAGGERHEART.CONFIG.HealingType.armorStack.abbreviation' abbreviation: 'DAGGERHEART.CONFIG.HealingType.armorStack.abbreviation'
},
fear: {
id: 'fear',
label: 'DAGGERHEART.CONFIG.HealingType.fear.name',
abbreviation: 'DAGGERHEART.CONFIG.HealingType.fear.abbreviation'
} }
}; };

View file

@ -93,10 +93,32 @@ export class DHDamageField extends fields.SchemaField {
} }
} }
export class DHDamageData extends foundry.abstract.DataModel { export class DHResourceData extends foundry.abstract.DataModel {
/** @override */ /** @override */
static defineSchema() { static defineSchema() {
return { return {
applyTo: new fields.StringField({
choices: CONFIG.DH.GENERAL.healingTypes,
required: true,
blank: false,
initial: CONFIG.DH.GENERAL.healingTypes.hitPoints.id,
label: 'DAGGERHEART.ACTIONS.Settings.applyTo.label'
}),
resultBased: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.ACTIONS.Settings.resultBased.label'
}),
value: new fields.EmbeddedDataField(DHActionDiceData),
valueAlt: new fields.EmbeddedDataField(DHActionDiceData)
}
}
}
export class DHDamageData extends DHResourceData {
/** @override */
static defineSchema() {
return {
...super.defineSchema(),
base: new fields.BooleanField({ initial: false, readonly: true, label: 'Base' }), base: new fields.BooleanField({ initial: false, readonly: true, label: 'Base' }),
type: new fields.SetField( type: new fields.SetField(
new fields.StringField({ new fields.StringField({
@ -106,16 +128,9 @@ export class DHDamageData extends foundry.abstract.DataModel {
required: true required: true
}), }),
{ {
label: 'Type', label: 'Type'
initial: 'physical'
} }
), )
resultBased: new fields.BooleanField({ }
initial: false,
label: 'DAGGERHEART.ACTIONS.Settings.resultBased.label'
}),
value: new fields.EmbeddedDataField(DHActionDiceData),
valueAlt: new fields.EmbeddedDataField(DHActionDiceData)
};
} }
} }

View file

@ -1,4 +1,4 @@
import { DHActionDiceData, DHActionRollData, DHDamageField } from './actionDice.mjs'; import { DHActionDiceData, DHActionRollData, DHDamageData, DHDamageField, DHResourceData } from './actionDice.mjs';
import DhpActor from '../../documents/actor.mjs'; import DhpActor from '../../documents/actor.mjs';
import D20RollDialog from '../../applications/dialogs/d20RollDialog.mjs'; import D20RollDialog from '../../applications/dialogs/d20RollDialog.mjs';
@ -96,21 +96,7 @@ export default class DHBaseAction extends foundry.abstract.DataModel {
onSave: new fields.BooleanField({ initial: false }) onSave: new fields.BooleanField({ initial: false })
}) })
), ),
healing: new fields.SchemaField({ healing: new fields.EmbeddedDataField(DHResourceData),
type: new fields.StringField({
choices: CONFIG.DH.GENERAL.healingTypes,
required: true,
blank: false,
initial: CONFIG.DH.GENERAL.healingTypes.hitPoints.id,
label: 'Healing'
}),
resultBased: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.ACTIONS.Settings.resultBased.label'
}),
value: new fields.EmbeddedDataField(DHActionDiceData),
valueAlt: new fields.EmbeddedDataField(DHActionDiceData)
}),
beastform: new fields.SchemaField({ beastform: new fields.SchemaField({
tierAccess: new fields.SchemaField({ tierAccess: new fields.SchemaField({
exact: new fields.NumberField({ integer: true, nullable: true, initial: null }) exact: new fields.NumberField({ integer: true, nullable: true, initial: null })
@ -156,7 +142,7 @@ export default class DHBaseAction extends foundry.abstract.DataModel {
static getSourceConfig(parent) { static getSourceConfig(parent) {
const updateSource = {}; const updateSource = {};
updateSource.img ??= parent?.img ?? parent?.system?.img; updateSource.img ??= parent?.img ?? parent?.system?.img;
if (parent?.type === 'weapon') { if (parent?.type === 'weapon' && this === game.system.api.models.actions.actionsTypes.attack) {
updateSource['damage'] = { includeBase: true }; updateSource['damage'] = { includeBase: true };
updateSource['range'] = parent?.system?.attack?.range; updateSource['range'] = parent?.system?.attack?.range;
updateSource['roll'] = { updateSource['roll'] = {
@ -177,6 +163,7 @@ export default class DHBaseAction extends foundry.abstract.DataModel {
} }
getRollData(data = {}) { getRollData(data = {}) {
if(!this.actor) return null;
const actorData = this.actor.getRollData(false); const actorData = this.actor.getRollData(false);
// Add Roll results to RollDatas // Add Roll results to RollDatas
@ -191,6 +178,8 @@ export default class DHBaseAction extends foundry.abstract.DataModel {
} }
async use(event, ...args) { async use(event, ...args) {
if(!this.actor) throw new Error("An Action can't be used outside of an Actor context.");
const isFastForward = event.shiftKey || (!this.hasRoll && !this.hasSave); const isFastForward = event.shiftKey || (!this.hasRoll && !this.hasSave);
// Prepare base Config // Prepare base Config
const initConfig = this.initActionConfig(event); const initConfig = this.initActionConfig(event);
@ -227,7 +216,6 @@ export default class DHBaseAction extends foundry.abstract.DataModel {
if (Hooks.call(`${CONFIG.DH.id}.preUseAction`, this, config) === false) return; if (Hooks.call(`${CONFIG.DH.id}.preUseAction`, this, config) === false) return;
// Display configuration window if necessary // Display configuration window if necessary
// if (config.dialog?.configure && this.requireConfigurationDialog(config)) {
if (this.requireConfigurationDialog(config)) { if (this.requireConfigurationDialog(config)) {
config = await D20RollDialog.configure(null, config); config = await D20RollDialog.configure(null, config);
if (!config) return; if (!config) return;

View file

@ -1,3 +1,4 @@
import { setsEqual } from '../../helpers/utils.mjs';
import DHBaseAction from './baseAction.mjs'; import DHBaseAction from './baseAction.mjs';
export default class DHDamageAction extends DHBaseAction { export default class DHDamageAction extends DHBaseAction {
@ -18,28 +19,40 @@ export default class DHDamageAction extends DHBaseAction {
return formulaValue; return formulaValue;
} }
formatFormulas(formulas, systemData) {
const formattedFormulas = [];
formulas.forEach(formula => {
if (isNaN(formula.formula)) formula.formula = Roll.replaceFormulaData(formula.formula, this.getRollData(systemData));
const same = formattedFormulas.find(f => setsEqual(f.damageTypes, formula.damageTypes) && f.applyTo === formula.applyTo);
if(same)
same.formula += ` + ${formula.formula}`;
else
formattedFormulas.push(formula);
})
return formattedFormulas;
}
async rollDamage(event, data) { async rollDamage(event, data) {
const systemData = data.system ?? data; const systemData = data.system ?? data;
let formula = this.damage.parts.map(p => this.getFormulaValue(p, data).getFormula(this.actor)).join(' + '),
damageTypes = [...new Set(this.damage.parts.reduce((a, c) => a.concat([...c.type]), []))]; let formulas = this.damage.parts.map(p => ({
formula: this.getFormulaValue(p, data).getFormula(this.actor),
damageTypes: p.applyTo === 'hitPoints' && !p.type.size ? new Set(['physical']) : p.type,
applyTo: p.applyTo
}));
damageTypes = !damageTypes.length ? ['physical'] : damageTypes; if(!formulas.length) return;
if (!formula || formula == '') return; formulas = this.formatFormulas(formulas, systemData);
let roll = { formula: formula, total: formula },
bonusDamage = [];
if (isNaN(formula)) formula = Roll.replaceFormulaData(formula, this.getRollData(systemData));
const config = { const config = {
title: game.i18n.format('DAGGERHEART.UI.Chat.damageRoll.title', { damage: this.name }), title: game.i18n.format('DAGGERHEART.UI.Chat.damageRoll.title', { damage: this.name }),
roll: { formula }, roll: formulas,
targets: systemData.targets.filter(t => t.hit) ?? data.targets, targets: systemData.targets.filter(t => t.hit) ?? data.targets,
hasSave: this.hasSave, hasSave: this.hasSave,
isCritical: systemData.roll?.isCritical ?? false, isCritical: systemData.roll?.isCritical ?? false,
source: systemData.source, source: systemData.source,
data: this.getRollData(), data: this.getRollData(),
damageTypes,
event event
}; };
if (this.hasSave) config.onSave = this.save.damageMod; if (this.hasSave) config.onSave = this.save.damageMod;
@ -50,10 +63,6 @@ export default class DHDamageAction extends DHBaseAction {
config.directDamage = true; config.directDamage = true;
} }
roll = CONFIG.Dice.daggerheart.DamageRoll.build(config); return CONFIG.Dice.daggerheart.DamageRoll.build(config);
} }
// get modifiers() {
// return [];
// }
} }

View file

@ -15,25 +15,25 @@ export default class DHHealingAction extends DHBaseAction {
} }
async rollHealing(event, data) { async rollHealing(event, data) {
let formulaValue = this.getFormulaValue(data), const systemData = data.system ?? data;
formula = formulaValue.getFormula(this.actor); let formulas = [{
formula: this.getFormulaValue(data).getFormula(this.actor),
if (!formula || formula == '') return; applyTo: this.healing.applyTo
let roll = { formula: formula, total: formula }, }];
bonusDamage = [];
const config = { const config = {
title: game.i18n.format('DAGGERHEART.UI.Chat.healingRoll.title', { title: game.i18n.format('DAGGERHEART.UI.Chat.healingRoll.title', {
healing: game.i18n.localize(CONFIG.DH.GENERAL.healingTypes[this.healing.type].label) healing: game.i18n.localize(CONFIG.DH.GENERAL.healingTypes[this.healing.applyTo].label)
}), }),
roll: { formula }, roll: formulas,
targets: (data.system?.targets ?? data.targets).filter(t => t.hit), targets: (data.system?.targets ?? data.targets).filter(t => t.hit),
messageType: 'healing', messageType: 'healing',
type: this.healing.type, source: systemData.source,
data: this.getRollData(),
event event
}; };
roll = CONFIG.Dice.daggerheart.DamageRoll.build(config); return CONFIG.Dice.daggerheart.DamageRoll.build(config);
} }
get chatTemplate() { get chatTemplate() {

View file

@ -170,11 +170,10 @@ export default class DhCharacter extends BaseDataActor {
rules: new fields.SchemaField({ rules: new fields.SchemaField({
damageReduction: new fields.SchemaField({ damageReduction: new fields.SchemaField({
maxArmorMarked: new fields.SchemaField({ maxArmorMarked: new fields.SchemaField({
value: new fields.NumberField({ required: true, integer: true, initial: 1 }), value: new fields.NumberField({
bonus: new fields.NumberField({
required: true, required: true,
integer: true, integer: true,
initial: 0, initial: 1,
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.maxArmorMarkedBonus' label: 'DAGGERHEART.GENERAL.Rules.damageReduction.maxArmorMarkedBonus'
}), }),
stressExtra: new fields.NumberField({ stressExtra: new fields.NumberField({

View file

@ -140,28 +140,22 @@ export default class D20Roll extends DHRoll {
return modifiers; return modifiers;
} }
static async buildEvaluate(roll, config = {}, message = {}) {
if (config.evaluate !== false) await roll.evaluate();
this.postEvaluate(roll, config);
}
static postEvaluate(roll, config = {}) { static postEvaluate(roll, config = {}) {
super.postEvaluate(roll, config); const data = super.postEvaluate(roll, config);
if (config.targets?.length) { if (config.targets?.length) {
config.targets.forEach(target => { config.targets.forEach(target => {
const difficulty = config.roll.difficulty ?? target.difficulty ?? target.evasion; const difficulty = config.roll.difficulty ?? target.difficulty ?? target.evasion;
target.hit = this.isCritical || roll.total >= difficulty; target.hit = this.isCritical || roll.total >= difficulty;
}); });
} else if (config.roll.difficulty) } else if (config.roll.difficulty)
config.roll.success = roll.isCritical || roll.total >= config.roll.difficulty; data.success = roll.isCritical || roll.total >= config.roll.difficulty;
config.roll.advantage = { data.advantage = {
type: config.roll.advantage, type: config.roll.advantage,
dice: roll.dAdvantage?.denomination, dice: roll.dAdvantage?.denomination,
value: roll.dAdvantage?.total value: roll.dAdvantage?.total
}; };
config.roll.isCritical = roll.isCritical; data.isCritical = roll.isCritical;
config.roll.extra = roll.dice data.extra = roll.dice
.filter(d => !roll.baseTerms.includes(d)) .filter(d => !roll.baseTerms.includes(d))
.map(d => { .map(d => {
return { return {
@ -169,7 +163,8 @@ export default class D20Roll extends DHRoll {
value: d.total value: d.total
}; };
}); });
config.roll.modifierTotal = this.calculateTotalModifiers(roll); data.modifierTotal = this.calculateTotalModifiers(roll);
return data;
} }
resetFormula() { resetFormula() {

View file

@ -10,10 +10,24 @@ export default class DamageRoll extends DHRoll {
static DefaultDialog = DamageDialog; static DefaultDialog = DamageDialog;
static async postEvaluate(roll, config = {}) { static async buildEvaluate(roll, config = {}, message = {}) {
super.postEvaluate(roll, config); if ( config.evaluate !== false ) {
config.roll.type = config.type; for ( const roll of config.roll ) await roll.roll.evaluate();
config.roll.modifierTotal = this.calculateTotalModifiers(roll); }
roll._evaluated = true;
const parts = config.roll.map(r => this.postEvaluate(r));
config.roll = this.unifyDamageRoll(parts);
}
static postEvaluate(roll, config = {}) {
return {
...roll,
...super.postEvaluate(roll.roll, config),
damageTypes: [...(roll.damageTypes ?? [])],
roll: roll.roll,
type: config.type,
modifierTotal: this.calculateTotalModifiers(roll.roll)
}
} }
static async buildPost(roll, config, message) { static async buildPost(roll, config, message) {
@ -24,12 +38,50 @@ export default class DamageRoll extends DHRoll {
} }
} }
applyBaseBonus() { static unifyDamageRoll(rolls) {
const unified = {};
rolls.forEach(r => {
const resource = unified[r.applyTo] ?? { formula: '', total: 0, parts: [] };
resource.formula += `${resource.formula !== '' ? ' + ' : ''}${r.formula}`;
resource.total += r.total;
resource.parts.push(r);
unified[r.applyTo] = resource;
})
return unified;
}
static formatGlobal(rolls) {
let formula, total;
const applyTo = new Set(rolls.flatMap(r => r.applyTo));
if(applyTo.size > 1) {
const data = {};
rolls.forEach(r => {
if(data[r.applyTo]) {
data[r.applyTo].formula += ` + ${r.formula}` ;
data[r.applyTo].total += r.total ;
} else {
data[r.applyTo] = {
formula: r.formula,
total: r.total
}
}
});
formula = Object.entries(data).reduce((a, [k,v]) => a + ` ${k}: ${v.formula}`, '');
total = Object.entries(data).reduce((a, [k,v]) => a + ` ${k}: ${v.total}`, '');
} else {
formula = rolls.map(r => r.formula).join(' + ');
total = rolls.reduce((a,c) => a + c.total, 0)
}
return {formula, total}
}
applyBaseBonus(part) {
const modifiers = [], const modifiers = [],
type = this.options.messageType ?? 'damage'; type = this.options.messageType ?? 'damage',
options = part ?? this.options;
modifiers.push(...this.getBonus(`${type}`, `${type.capitalize()} Bonus`)); modifiers.push(...this.getBonus(`${type}`, `${type.capitalize()} Bonus`));
this.options.damageTypes?.forEach(t => { options.damageTypes?.forEach(t => {
modifiers.push(...this.getBonus(`${type}.${t}`, `${t.capitalize()} ${type.capitalize()} Bonus`)); modifiers.push(...this.getBonus(`${type}.${t}`, `${t.capitalize()} ${type.capitalize()} Bonus`));
}); });
const weapons = ['primaryWeapon', 'secondaryWeapon']; const weapons = ['primaryWeapon', 'secondaryWeapon'];
@ -42,13 +94,36 @@ export default class DamageRoll extends DHRoll {
} }
constructFormula(config) { constructFormula(config) {
super.constructFormula(config); this.options.roll.forEach( part => {
part.roll = new Roll(part.formula);
this.constructFormulaPart(config, part)
})
return this.options.roll;
}
if (config.isCritical) { constructFormulaPart(config, part) {
const tmpRoll = new Roll(this._formula)._evaluateSync({ maximize: true }), part.roll.terms = Roll.parse(part.roll.formula, config.data);
criticalBonus = tmpRoll.total - this.constructor.calculateTotalModifiers(tmpRoll);
this.terms.push(...this.formatModifier(criticalBonus)); if(part.applyTo === CONFIG.DH.GENERAL.healingTypes.hitPoints.id) {
part.modifiers = this.applyBaseBonus(part);
this.addModifiers(part);
part.modifiers?.forEach(m => {
part.roll.terms.push(...this.formatModifier(m.value));
});
} }
return (this._formula = this.constructor.getFormula(this.terms));
if (part.extraFormula) {
part.roll.terms.push(
new foundry.dice.terms.OperatorTerm({ operator: '+' }),
...this.constructor.parse(part.extraFormula, this.options.data)
);
}
if (config.isCritical && part.applyTo === CONFIG.DH.GENERAL.healingTypes.hitPoints.id) {
const tmpRoll = Roll.fromTerms(part.roll.terms)._evaluateSync({ maximize: true }),
criticalBonus = tmpRoll.total - this.constructor.calculateTotalModifiers(tmpRoll);
part.roll.terms.push(...this.formatModifier(criticalBonus));
}
return (part.roll._formula = this.constructor.getFormula(part.roll.terms));
} }
} }

View file

@ -47,7 +47,7 @@ export default class DHRoll extends Roll {
static async buildEvaluate(roll, config = {}, message = {}) { static async buildEvaluate(roll, config = {}, message = {}) {
if (config.evaluate !== false) await roll.evaluate(); if (config.evaluate !== false) await roll.evaluate();
this.postEvaluate(roll, config); config.roll = this.postEvaluate(roll, config);
} }
static async buildPost(roll, config, message) { static async buildPost(roll, config, message) {
@ -57,25 +57,26 @@ export default class DHRoll extends Roll {
// Create Chat Message // Create Chat Message
if (config.source?.message) { if (config.source?.message) {
if(Object.values(config.roll)?.length) {
const pool = foundry.dice.terms.PoolTerm.fromRolls(Object.values(config.roll).flatMap(r => r.parts.map(p => p.roll)));
roll = Roll.fromTerms([pool]);
}
if (game.modules.get('dice-so-nice')?.active) await game.dice3d.showForRoll(roll, game.user, true); if (game.modules.get('dice-so-nice')?.active) await game.dice3d.showForRoll(roll, game.user, true);
} else { } else
config.message = await this.toMessage(roll, config); config.message = await this.toMessage(roll, config);
}
} }
static postEvaluate(roll, config = {}) { static postEvaluate(roll, config = {}) {
if (!config.roll) config.roll = {}; return {
config.roll.total = roll.total; total: roll.total,
config.roll.formula = roll.formula; formula: roll.formula,
config.roll.dice = []; dice: roll.dice.map(d => ({
roll.dice.forEach(d => {
config.roll.dice.push({
dice: d.denomination, dice: d.denomination,
total: d.total, total: d.total,
formula: d.formula, formula: d.formula,
results: d.results results: d.results
}); }))
}); }
} }
static async toMessage(roll, config) { static async toMessage(roll, config) {
@ -118,8 +119,9 @@ export default class DHRoll extends Roll {
return []; return [];
} }
addModifiers() { addModifiers(roll) {
this.options.roll.modifiers?.forEach(m => { roll = roll ?? this.options.roll;
roll.modifiers?.forEach(m => {
this.terms.push(...this.formatModifier(m.value)); this.terms.push(...this.formatModifier(m.value));
}); });
} }

View file

@ -161,21 +161,21 @@ export default class DualityRoll extends D20Roll {
} }
static postEvaluate(roll, config = {}) { static postEvaluate(roll, config = {}) {
super.postEvaluate(roll, config); const data = super.postEvaluate(roll, config);
config.roll.hope = { data.hope = {
dice: roll.dHope.denomination, dice: roll.dHope.denomination,
value: roll.dHope.total value: roll.dHope.total
}; };
config.roll.fear = { data.fear = {
dice: roll.dFear.denomination, dice: roll.dFear.denomination,
value: roll.dFear.total value: roll.dFear.total
}; };
config.roll.rally = { data.rally = {
dice: roll.dRally?.denomination, dice: roll.dRally?.denomination,
value: roll.dRally?.total value: roll.dRally?.total
}; };
config.roll.result = { data.result = {
duality: roll.withHope ? 1 : roll.withFear ? -1 : 0, duality: roll.withHope ? 1 : roll.withFear ? -1 : 0,
total: roll.dHope.total + roll.dFear.total, total: roll.dHope.total + roll.dFear.total,
label: roll.totalLabel label: roll.totalLabel
@ -184,6 +184,8 @@ export default class DualityRoll extends D20Roll {
if(roll._rallyIndex && roll.data?.parent) if(roll._rallyIndex && roll.data?.parent)
roll.data.parent.deleteEmbeddedDocuments('ActiveEffect', [roll._rallyIndex]); roll.data.parent.deleteEmbeddedDocuments('ActiveEffect', [roll._rallyIndex]);
setDiceSoNiceForDualityRoll(roll, config.roll.advantage.type); setDiceSoNiceForDualityRoll(roll, data.advantage.type);
return data;
} }
} }

View file

@ -391,59 +391,70 @@ export default class DhpActor extends Actor {
return canUseArmor || canUseStress; return canUseArmor || canUseStress;
} }
async takeDamage(baseDamage, type) { async takeDamage(damages) {
if (Hooks.call(`${CONFIG.DH.id}.preTakeDamage`, this, baseDamage, type) === false) return null; if (Hooks.call(`${CONFIG.DH.id}.preTakeDamage`, this, damages) === false) return null;
if (this.type === 'companion') { if (this.type === 'companion') {
await this.modifyResource([{ value: 1, key: 'stress' }]); await this.modifyResource([{ value: 1, key: 'stress' }]);
return; return;
} }
type = !Array.isArray(type) ? [type] : type; const updates = [];
const hpDamage = this.calculateDamage(baseDamage, type); Object.entries(damages).forEach(([key, damage]) => {
damage.parts.forEach(part => {
if(part.applyTo === CONFIG.DH.GENERAL.healingTypes.hitPoints.id)
part.total = this.calculateDamage(part.total, part.damageTypes);
const update = updates.find(u => u.key === key);
if(update) {
update.value += part.total;
update.damageTypes.add(...new Set(part.damageTypes));
} else updates.push({ value: part.total, key, damageTypes: new Set(part.damageTypes) })
})
});
if (!hpDamage) return; if (Hooks.call(`${CONFIG.DH.id}.postCalculateDamage`, this, damages) === false) return null;
const updates = [{ value: hpDamage, type: 'hitPoints' }]; if(!updates.length) return;
if (this.type === 'character' && this.system.armor && this.#canReduceDamage(hpDamage, type)) { const hpDamage = updates.find(u => u.key === CONFIG.DH.GENERAL.healingTypes.hitPoints.id);
const armorStackResult = await this.owner.query('armorStack', { if(hpDamage) {
actorId: this.uuid, hpDamage.value = this.convertDamageToThreshold(hpDamage.value);
damage: hpDamage, if (this.type === 'character' && this.system.armor && this.#canReduceDamage(hpDamage.value, hpDamage.damageTypes)) {
type: type const armorStackResult = await this.owner.query('armorStack', {
}); actorId: this.uuid,
if (armorStackResult) { damage: hpDamage.value,
const { modifiedDamage, armorSpent, stressSpent } = armorStackResult; type: [...hpDamage.damageTypes]
updates.find(u => u.type === 'hitPoints').value = modifiedDamage; });
updates.push( if (armorStackResult) {
...(armorSpent ? [{ value: armorSpent, key: 'armorStack' }] : []), const { modifiedDamage, armorSpent, stressSpent } = armorStackResult;
...(stressSpent ? [{ value: stressSpent, key: 'stress' }] : []) updates.find(u => u.key === 'hitPoints').value = modifiedDamage;
); updates.push(
...(armorSpent ? [{ value: armorSpent, key: 'armorStack' }] : []),
...(stressSpent ? [{ value: stressSpent, key: 'stress' }] : [])
);
}
} }
} }
updates.forEach( u =>
u.value = u.key === 'fear' || this.system?.resources?.[u.key]?.isReversed === false ? u.value * -1 : u.value
);
await this.modifyResource(updates); await this.modifyResource(updates);
if (Hooks.call(`${CONFIG.DH.id}.postTakeDamage`, this, damage, type) === false) return null; if (Hooks.call(`${CONFIG.DH.id}.postTakeDamage`, this, damages) === false) return null;
} }
calculateDamage(baseDamage, type) { calculateDamage(baseDamage, type) {
if (Hooks.call(`${CONFIG.DH.id}.preCalculateDamage`, this, baseDamage, type) === false) return null;
/* if(this.system.resistance[type]?.immunity) return 0;
if(this.system.resistance[type]?.resistance) baseDamage = Math.ceil(baseDamage / 2); */
if (this.canResist(type, 'immunity')) return 0; if (this.canResist(type, 'immunity')) return 0;
if (this.canResist(type, 'resistance')) baseDamage = Math.ceil(baseDamage / 2); if (this.canResist(type, 'resistance')) baseDamage = Math.ceil(baseDamage / 2);
// const flatReduction = this.system.resistance[type].reduction;
const flatReduction = this.getDamageTypeReduction(type); const flatReduction = this.getDamageTypeReduction(type);
const damage = Math.max(baseDamage - (flatReduction ?? 0), 0); const damage = Math.max(baseDamage - (flatReduction ?? 0), 0);
const hpDamage = this.convertDamageToThreshold(damage);
if (Hooks.call(`${CONFIG.DH.id}.postCalculateDamage`, this, baseDamage, type) === false) return null; return damage;
return hpDamage;
} }
canResist(type, resistance) { canResist(type, resistance) {
@ -461,8 +472,13 @@ export default class DhpActor extends Actor {
} }
async takeHealing(resources) { async takeHealing(resources) {
resources.forEach(r => (r.value *= -1)); const updates = Object.entries(resources).map(([key, value]) => (
await this.modifyResource(resources); {
key: key,
value: !(key === 'fear' || this.system?.resources?.[key]?.isReversed === false) ? value.total * -1 : value.total
}
))
await this.modifyResource(updates);
} }
async modifyResource(resources) { async modifyResource(resources) {

View file

@ -16,7 +16,7 @@ export default class DHToken extends TokenDocument {
}); });
bars.sort((a, b) => a.label.compare(b.label)); bars.sort((a, b) => a.label.compare(b.label));
const invalidAttributes = ['gold', 'levelData', 'actions', 'rules.damageReduction.maxArmorMarked.value']; const invalidAttributes = ['gold', 'levelData', 'actions'];
const values = attributes.value.reduce((acc, v) => { const values = attributes.value.reduce((acc, v) => {
const a = v.join('.'); const a = v.join('.');
if (invalidAttributes.some(x => a.startsWith(x))) return acc; if (invalidAttributes.some(x => a.startsWith(x))) return acc;

View file

@ -311,3 +311,15 @@ export const itemAbleRollParse = (value, actor, item) => {
return ''; return '';
} }
}; };
export const arraysEqual = (a, b) =>
a.length === b.length &&
[...new Set([...a, ...b])].every(
v => a.filter(e => e === v).length === b.filter(e => e === v).length
);
export const setsEqual = (a, b) =>
a.size === b.size &&
[...a].every(
value => b.has(value)
);

View file

@ -56,4 +56,15 @@
color: light-dark(@dark, @beige); color: light-dark(@dark, @beige);
} }
.damage-formula {
display: flex;
justify-content: space-between;
.damage-details {
font-style: italic;
display: flex;
align-items: center;
gap: 5px;
}
}
} }

View file

@ -109,7 +109,7 @@
height: 34px; height: 34px;
.tags { .tags {
justify-content: flex-start; justify-content: flex-start;
margin: 5px; margin: 4px;
height: inherit; height: inherit;
.tag { .tag {
padding: 0.3rem 0.5rem; padding: 0.3rem 0.5rem;

View file

@ -164,6 +164,11 @@
} }
} }
} }
.damage-resource {
font-weight: 600;
margin-top: 5px;
}
} }
.dice-total { .dice-total {

View file

@ -22,7 +22,12 @@
{{formField ../fields.value.fields.bonus value=dmg.value.bonus name=(concat ../path "damage.parts." index ".value.bonus") localize=true classes="inline-child"}} {{formField ../fields.value.fields.bonus value=dmg.value.bonus name=(concat ../path "damage.parts." index ".value.bonus") localize=true classes="inline-child"}}
</div> </div>
{{/if}} {{/if}}
{{formField ../fields.type value=dmg.type name=(concat ../path "damage.parts." index ".type") localize=true}} <div class="nest-inputs">
{{formField ../fields.applyTo value=dmg.applyTo name=(concat ../path "damage.parts." realIndex ".applyTo") localize=true}}
{{#if (eq dmg.applyTo 'hitPoints')}}
{{formField ../fields.type value=dmg.type name=(concat ../path "damage.parts." index ".type") localize=true}}
{{/if}}
</div>
{{#if ../horde}} {{#if ../horde}}
<fieldset class="one-column"> <fieldset class="one-column">
<legend>{{localize "DAGGERHEART.ACTORS.Adversary.hordeDamage"}}</legend> <legend>{{localize "DAGGERHEART.ACTORS.Adversary.hordeDamage"}}</legend>
@ -56,7 +61,12 @@
{{> formula fields=../../fields.value.fields type=../fields.type dmg=dmg source=dmg.value target="value" realIndex=realIndex}} {{> formula fields=../../fields.value.fields type=../fields.type dmg=dmg source=dmg.value target="value" realIndex=realIndex}}
</fieldset> </fieldset>
{{/if}} {{/if}}
{{formField ../../fields.type value=dmg.type name=(concat "damage.parts." realIndex ".type") localize=true}} <div class="nest-inputs">
{{formField ../../fields.applyTo value=dmg.applyTo name=(concat "damage.parts." realIndex ".applyTo") localize=true}}
{{#if (eq dmg.applyTo 'hitPoints')}}
{{formField ../../fields.type value=dmg.type name=(concat "damage.parts." realIndex ".type") localize=true}}
{{/if}}
</div>
<input type="hidden" name="damage.parts.{{realIndex}}.base" value="{{dmg.base}}"> <input type="hidden" name="damage.parts.{{realIndex}}.base" value="{{dmg.base}}">
</fieldset> </fieldset>
{{#unless dmg.base}}<div class="fas fa-trash" data-action="removeDamage" data-index="{{realIndex}}"></div>{{/unless}} {{#unless dmg.base}}<div class="fas fa-trash" data-action="removeDamage" data-index="{{realIndex}}"></div>{{/unless}}

View file

@ -1,43 +1,41 @@
<fieldset class="action-category"> <fieldset class="one-column">
<legend class="action-category-label" data-action="toggleSection" data-section="effects"> <legend>
<div>Healing</div> {{localize "DAGGERHEART.GENERAL.healing"}}
</legend> </legend>
<div class="action-category-data open"> {{#if (and (not @root.isNPC) @root.hasRoll)}}
<fieldset> {{formField fields.resultBased value=source.resultBased name="healing.resultBased" localize=true classes="checkbox"}}
{{formField fields.type value=source.type name="healing.type" localize=true}} {{/if}}
{{#if (and (not @root.isNPC) @root.hasRoll)}} {{#if (and (not @root.isNPC) @root.hasRoll source.resultBased)}}
{{formField fields.resultBased value=source.resultBased name="healing.resultBased" localize=true}} <div class="nest-inputs">
{{/if}} <fieldset class="one-column">
{{#if (and (not @root.isNPC) @root.hasRoll source.resultBased)}} <legend>
<fieldset> <div>With Hope</div>
<legend> </legend>
<div>With Hope</div>
</legend>
{{> formula fields=fields.value.fields source=source.value target="value"}}
</fieldset>
<fieldset>
<legend>
<div>With Fear</div>
</legend>
{{> formula fields=fields.valueAlt.fields source=source.valueAlt target="valueAlt"}}
</fieldset>
{{else}}
{{> formula fields=fields.value.fields source=source.value target="value"}} {{> formula fields=fields.value.fields source=source.value target="value"}}
{{/if}} </fieldset>
</fieldset> <fieldset class="one-column">
</div> <legend>
<div>With Fear</div>
</legend>
{{> formula fields=fields.valueAlt.fields source=source.valueAlt target="valueAlt"}}
</fieldset>
</div>
{{else}}
{{> formula fields=fields.value.fields source=source.value target="value"}}
{{/if}}
{{formField fields.applyTo value=source.type name="healing.applyTo" localize=true}}
</fieldset> </fieldset>
{{#*inline "formula"}} {{#*inline "formula"}}
<div class="multi-display"> {{formField fields.custom.fields.enabled value=source.custom.enabled name=(concat "healing." target ".custom.enabled") classes="checkbox"}}
{{formField fields.custom.fields.enabled value=source.custom.enabled name=(concat "healing." target ".custom.enabled")}} {{#if source.custom.enabled}}
{{#if source.custom.enabled}} {{formField fields.custom.fields.formula value=source.custom.formula name=(concat "healing." target ".custom.formula") localize=true}}
{{formField fields.custom.fields.formula value=source.custom.formula name=(concat "healing." target ".custom.formula") localize=true}} {{else}}
{{else}} <div class="nest-inputs">
{{formField fields.multiplier value=source.multiplier name=(concat "healing." target ".multiplier") localize=true}} {{formField fields.multiplier value=source.multiplier name=(concat "healing." target ".multiplier") localize=true}}
{{formField fields.dice value=source.dice name=(concat "healing." target ".dice")}} {{formField fields.dice value=source.dice name=(concat "healing." target ".dice")}}
{{formField fields.bonus value=source.bonus name=(concat "healing." target ".bonus") localize=true}} {{formField fields.bonus value=source.bonus name=(concat "healing." target ".bonus") localize=true}}
{{/if}} </div>
</div> {{/if}}
{{/inline}} {{/inline}}

View file

@ -2,10 +2,26 @@
<header class="dialog-header"> <header class="dialog-header">
<h1>{{title}}</h1> <h1>{{title}}</h1>
</header> </header>
<span class="formula-label"><b>Formula:</b> {{@root.formula}}</span> {{#each @root.formula}}
<div class="form-group"> <div class="damage-formula">
<input type="text" value="{{extraFormula}}" name="extraFormula" placeholder="Situational Bonus"> <span class="damage-resource"><b>Formula:</b> {{roll.formula}}</span>
</div> <span class="damage-details">
{{#with (lookup @root.config.GENERAL.healingTypes applyTo)}}
{{localize label}}
{{/with}}
{{#if damageTypes}}
{{#each damageTypes as | type | }}
{{#with (lookup @root.config.GENERAL.damageTypes type)}}
<i class="fa-solid {{icon}}"></i>
{{/with}}
{{/each}}
{{/if}}
</span>
</div>
<div class="form-group">
<input type="text" value="{{extraFormula}}" name="roll.{{ @index }}.extraFormula" placeholder="Situational Bonus">
</div>
{{/each}}
<div class="damage-section-controls"> <div class="damage-section-controls">
{{#if directDamage}} {{#if directDamage}}
<select class="roll-mode-select" name="selectedRollMode"> <select class="roll-mode-select" name="selectedRollMode">

View file

@ -1,27 +1,31 @@
<div class="dice-roll daggerheart chat roll" data-action="expandRoll"> <div class="dice-roll daggerheart chat roll" data-action="expandRoll">
<div class="dice-flavor">{{title}}</div> <div class="dice-flavor">{{title}}</div>
<div class="dice-result"> <div class="dice-result">
<div class="dice-formula">{{roll.formula}}</div> {{#each roll as | resource index | }}
<div class="dice-tooltip"> <div class="dice-formula">{{resource.formula}}</div>
<div class="wrapper"> <div class="dice-tooltip">
<section class="tooltip-part"> <div class="wrapper">
{{#each roll.dice}} {{#each resource.parts}}
<div class="dice"> <section class="tooltip-part">
<header class="part-header flexrow"> <div class="dice">
<span class="part-formula">{{formula}}</span> <header class="part-header flexrow">
<span class="part-total">{{total}}</span> <span class="part-formula">{{formula}}</span>
</header> <span class="part-total">{{total}}</span>
<ol class="dice-rolls"> </header>
{{#each results}} <ol class="dice-rolls">
<li class="roll die {{../denomination}} min">{{result}}</li> {{#each dice}}
{{/each}} {{#each results}}
</ol> <li class="roll die {{../dice}} min">{{result}}</li>
</div> {{/each}}
{{/each}}
</ol>
</div>
</section>
{{/each}} {{/each}}
</section> </div>
</div> </div>
</div> <div class="dice-total">{{resource.total}}</div>
<div class="dice-total">{{roll.total}}</div> {{/each}}
<div class="flexrow"> <div class="flexrow">
<button class="healing-button"><span>{{localize "DAGGERHEART.UI.Chat.healingRoll.heal"}}</span></button> <button class="healing-button"><span>{{localize "DAGGERHEART.UI.Chat.healingRoll.heal"}}</span></button>
</div> </div>

View file

@ -1,28 +1,33 @@
<div class="dice-roll daggerheart chat roll" data-action="expandRoll"> <div class="dice-roll daggerheart chat roll" data-action="expandRoll">
{{#unless noTitle}}<div class="dice-flavor">{{damage.title}}</div>{{/unless}} {{#unless noTitle}}<div class="dice-flavor">{{damage.title}}</div>{{/unless}}
<div class="dice-result"> <div class="dice-result">
<div class="dice-formula">{{damage.roll.formula}}</div> {{#each damage.roll as | roll index | }}
<div class="dice-tooltip"> <div class="dice-flavor">{{localize (concat 'DAGGERHEART.CONFIG.HealingType.' index '.name')}}</div>
<div class="wrapper"> <div class="dice-formula">{{roll.formula}}</div>
<section class="tooltip-part"> <div class="dice-tooltip">
{{#each damage.roll.dice}} <div class="wrapper">
<div class="dice"> {{#each roll.parts}}
<header class="part-header flexrow"> <section class="tooltip-part">
<span class="part-formula">{{formula}}</span> <div class="dice">
<span class="part-total">{{total}}</span> <header class="part-header flexrow">
</header> <span class="part-formula">{{formula}}</span>
<ol class="dice-rolls"> <span class="part-total">{{total}}</span>
{{#each results}} </header>
<li class="roll die {{../dice}} min">{{result}}</li> <ol class="dice-rolls">
{{/each}} {{#each dice}}
</ol> {{#each results}}
</div> <li class="roll die {{../dice}} min">{{result}}</li>
{{/each}} {{/each}}
{{#if damage.roll.modifierTotal}}<div class="duality-modifier">{{#if (gt damage.roll.modifierTotal 0)}}+{{/if}}{{damage.roll.modifierTotal}}</div>{{/if}} {{/each}}
<div class="duality-result">Total: {{damage.roll.total}}</div> </ol>
</section> </div>
{{#if modifierTotal}}<div class="duality-modifier">{{#if (gt modifierTotal 0)}}+{{/if}}{{modifierTotal}}</div>{{/if}}
<div class="duality-result">Total: {{total}}</div>
</section>
{{/each}}
</div>
</div> </div>
</div> <div class="dice-total">{{roll.total}}</div>
<div class="dice-total">{{damage.roll.total}}</div> {{/each}}
</div> </div>
</div> </div>