Add some jsdoc

This commit is contained in:
Dapoolp 2025-08-21 23:12:01 +02:00
parent a54efaeb48
commit 5c73b45193
23 changed files with 501 additions and 555 deletions

View file

@ -2358,6 +2358,7 @@
"beastformInapplicable": "A beastform can only be applied to a Character.", "beastformInapplicable": "A beastform can only be applied to a Character.",
"beastformAlreadyApplied": "The character already has a beastform applied!", "beastformAlreadyApplied": "The character already has a beastform applied!",
"noTargetsSelected": "No targets are selected.", "noTargetsSelected": "No targets are selected.",
"noTargetsSelectedOrPerm": "No targets are selected or with the update permission.",
"attackTargetDoesNotExist": "The target token no longer exists", "attackTargetDoesNotExist": "The target token no longer exists",
"insufficentAdvancements": "You don't have enough advancements left.", "insufficentAdvancements": "You don't have enough advancements left.",
"noAssignedPlayerCharacter": "You have no assigned character.", "noAssignedPlayerCharacter": "You have no assigned character.",

View file

@ -359,7 +359,9 @@ export default function DHApplicationMixin(Base) {
callback: async (target, event) => { callback: async (target, event) => {
const doc = await getDocFromElement(target), const doc = await getDocFromElement(target),
action = doc?.system?.attack ?? doc; action = doc?.system?.attack ?? doc;
return action && action.use(event, { byPassRoll: true }); const config = action.prepareConfig(event);
config.hasRoll = false;
return action && action.workflow.get("damage").execute(config, null, true);
} }
}); });

View file

@ -1,5 +1,3 @@
import { emitAsGM, GMUpdateEvent } from '../../systemRegistration/socket.mjs';
export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLog { export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLog {
constructor(options) { constructor(options) {
super(options); super(options);
@ -55,21 +53,9 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
} }
addChatListeners = async (app, html, data) => { addChatListeners = async (app, html, data) => {
html.querySelectorAll('.duality-action-damage').forEach(element =>
element.addEventListener('click', event => this.onRollDamage(event, data.message))
);
html.querySelectorAll('.target-save').forEach(element =>
element.addEventListener('click', event => this.onRollSave(event, data.message))
);
html.querySelectorAll('.roll-all-save-button').forEach(element =>
element.addEventListener('click', event => this.onRollAllSave(event, data.message))
);
html.querySelectorAll('.simple-roll-button').forEach(element => html.querySelectorAll('.simple-roll-button').forEach(element =>
element.addEventListener('click', event => this.onRollSimple(event, data.message)) element.addEventListener('click', event => this.onRollSimple(event, data.message))
); );
html.querySelectorAll('.healing-button').forEach(element =>
element.addEventListener('click', event => this.onHealing(event, data.message))
);
html.querySelectorAll('.ability-use-button').forEach(element => html.querySelectorAll('.ability-use-button').forEach(element =>
element.addEventListener('click', event => this.abilityUseButton(event, data.message)) element.addEventListener('click', event => this.abilityUseButton(event, data.message))
); );
@ -90,81 +76,6 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
super.close(options); super.close(options);
} }
async getActor(uuid) {
return await foundry.utils.fromUuid(uuid);
}
getAction(actor, itemId, actionId) {
const item = actor.items.get(itemId),
action =
actor.system.attack?._id === actionId
? actor.system.attack
: item.system.attack?._id === actionId
? item.system.attack
: item?.system?.actions?.get(actionId);
return action;
}
async onRollDamage(event, message) {
event.stopPropagation();
const actor = await this.getActor(message.system.source.actor);
if (game.user.character?.id !== actor.id && !game.user.isGM) return true;
if (message.system.source.item && message.system.source.action) {
const action = this.getAction(actor, message.system.source.item, message.system.source.action);
if (!action || !action?.hasDamagePart) return;
// await game.system.api.fields.ActionFields.DamageField.execute.call(action, message, true);
action.schema.fields.damage.execute.call(action, message, true);
}
}
async onRollSave(event, message) {
event.stopPropagation();
const actor = await this.getActor(message.system.source.actor),
tokenId = event.target.closest('[data-token]')?.dataset.token,
token = game.canvas.tokens.get(tokenId);
if (!token?.actor || !token.isOwner) return true;
if (message.system.source.item && message.system.source.action) {
const action = this.getAction(actor, message.system.source.item, message.system.source.action);
if (!action || !action?.hasSave) return;
action.rollSave(token.actor, event, message).then(result =>
emitAsGM(
GMUpdateEvent.UpdateSaveMessage,
action.updateSaveMessage.bind(action, result, message, token.id),
{
action: action.uuid,
message: message._id,
token: token.id,
result
}
)
);
}
}
async onRollAllSave(event, message) {
event.stopPropagation();
if (!game.user.isGM) return;
const targets = event.target.parentElement.querySelectorAll('[data-token] .target-save');
const actor = await this.getActor(message.system.source.actor),
action = this.getAction(actor, message.system.source.item, message.system.source.action);
targets.forEach(async el => {
const tokenId = el.closest('[data-token]')?.dataset.token,
token = game.canvas.tokens.get(tokenId);
if (!token.actor) return;
if (game.user === token.actor.owner) el.dispatchEvent(new PointerEvent('click', { shiftKey: true }));
else {
token.actor.owner
.query('reactionRoll', {
actionId: action.uuid,
actorId: token.actor.uuid,
event,
message
})
.then(result => action.updateSaveMessage(result, message, token.id));
}
});
}
async onRollSimple(event, message) { async onRollSimple(event, message) {
const buttonType = event.target.dataset.type ?? 'damage', const buttonType = event.target.dataset.type ?? 'damage',
total = message.rolls.reduce((a, c) => a + Roll.fromJSON(c).total, 0), total = message.rolls.reduce((a, c) => a + Roll.fromJSON(c).total, 0),
@ -198,8 +109,11 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
item.system.attack?.id === event.currentTarget.id item.system.attack?.id === event.currentTarget.id
? item.system.attack ? item.system.attack
: item.system.actions.get(event.currentTarget.id); : item.system.actions.get(event.currentTarget.id);
if (event.currentTarget.dataset.directDamage) action.use(event, { byPassRoll: true }); if (event.currentTarget.dataset.directDamage) {
else action.use(event); const config = action.prepareConfig(event);
config.hasRoll = false;
action.workflow.get("damage").execute(config, null, true);
} else action.use(event);
} }
async actionUseButton(event, message) { async actionUseButton(event, message) {

View file

@ -44,17 +44,16 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
return schemaFields; return schemaFields;
} }
static defineWorkflow() { defineWorkflow() {
const workflow = []; const workflow = new Map();
Object.values(this.schema.fields).forEach(s => { Object.entries(this.schema.fields).forEach(([k,s]) => {
if(s.execute) workflow.push( { order: s.order, execute: s.execute } ); if(s.execute) workflow.set(k, { order: s.order, execute: s.execute.bind(this) } );
}); });
if(this.schema.fields.damage) workflow.push( { order: 75, execute: this.schema.fields.damage.applyDamage } ); if(this.schema.fields.damage) workflow.set("applyDamage", { order: 75, execute: game.system.api.fields.ActionFields.DamageField.applyDamage.bind(this) } );
workflow.sort((a, b) => a.order - b.order); return new Map([...workflow.entries()].sort(([aKey, aValue], [bKey, bValue]) => aValue.order - bValue.order));
return workflow.map(s => s.execute);
} }
static get workflow() { get workflow() {
if ( this.hasOwnProperty("_workflow") ) return this._workflow; if ( this.hasOwnProperty("_workflow") ) return this._workflow;
const workflow = Object.freeze(this.defineWorkflow()); const workflow = Object.freeze(this.defineWorkflow());
Object.defineProperty(this, "_workflow", {value: workflow, writable: false}); Object.defineProperty(this, "_workflow", {value: workflow, writable: false});
@ -66,10 +65,6 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
return fields.DataField.isPrototypeOf(field) && field; return fields.DataField.isPrototypeOf(field) && field;
} }
get workflow() {
return this.constructor.workflow;
}
prepareData() { prepareData() {
this.name = this.name || game.i18n.localize(CONFIG.DH.ACTIONS.actionTypes[this.type].name); this.name = this.name || game.i18n.localize(CONFIG.DH.ACTIONS.actionTypes[this.type].name);
this.img = this.img ?? this.parent?.parent?.img; this.img = this.img ?? this.parent?.parent?.img;
@ -133,8 +128,10 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
} }
async executeWorkflow(config) { async executeWorkflow(config) {
for(const part of this.workflow) { for(const [key, part] of this.workflow) {
if(await part.call(this, config) === false) return; if (Hooks.call(`${CONFIG.DH.id}.pre${key.capitalize()}Action`, this, config) === false) return;
if(await part.execute(config) === false) return;
if (Hooks.call(`${CONFIG.DH.id}.post${key.capitalize()}Action`, this, config) === false) return;
} }
} }
@ -143,15 +140,15 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
if (this.chatDisplay) await this.toChat(); if (this.chatDisplay) await this.toChat();
let { byPassRoll } = options, let config = this.prepareConfig(event);
config = this.prepareConfig(event, byPassRoll);
Object.values(this.schema.fields).forEach( clsField => { /* Object.values(this.schema.fields).forEach( clsField => {
if (clsField?.prepareConfig) { if (clsField?.prepareConfig) {
const keep = clsField.prepareConfig.call(this, config); // const keep = clsField.prepareConfig.call(this, config);
if (config.isFastForward && !keep) return; // if (config.isFastForward && !keep) return;
if(clsField.prepareConfig.call(this, config) === false) return;
} }
}) }) */
if (Hooks.call(`${CONFIG.DH.id}.preUseAction`, this, config) === false) return; if (Hooks.call(`${CONFIG.DH.id}.preUseAction`, this, config) === false) return;
@ -161,25 +158,9 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
if (!config) return; if (!config) return;
} }
// Execute the Action Worflow in order based of schema fields
await this.executeWorkflow(config); await this.executeWorkflow(config);
// if (config.hasRoll) {
// const rollConfig = this.prepareRoll(config);
// config.roll = rollConfig;
// config = await this.actor.diceRoll(config);
// if (!config) return;
// }
// if (this.doFollowUp(config)) {
// if (this.rollDamage && this.damage.parts.length) await this.rollDamage(event, config);
// else if (this.trigger) await this.trigger(event, config);
// else if (this.hasSave || this.hasEffect) {
// const roll = new CONFIG.Dice.daggerheart.DHRoll('');
// roll._evaluated = true;
// await CONFIG.Dice.daggerheart.DHRoll.toMessage(roll, config);
// }
// }
// Consume resources // Consume resources
await this.consume(config); await this.consume(config);
@ -189,9 +170,8 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
} }
/* */ /* */
prepareConfig(event, byPass = false) { prepareBaseConfig(event) {
const hasRoll = this.getUseHasRoll(byPass); const config = {
return {
event, event,
title: `${this.item.name}: ${this.name}`, title: `${this.item.name}: ${this.name}`,
source: { source: {
@ -200,105 +180,60 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
actor: this.actor.uuid actor: this.actor.uuid
}, },
dialog: { dialog: {
configure: hasRoll // configure: this.hasRoll
}, },
type: this.type, type: this.type,
hasRoll: hasRoll, hasRoll: this.hasRoll,
hasDamage: this.hasDamagePart && this.type !== 'healing', hasDamage: this.hasDamagePart && this.type !== 'healing',
hasHealing: this.hasDamagePart && this.type === 'healing', hasHealing: this.hasDamagePart && this.type === 'healing',
hasEffect: !!this.effects?.length, hasEffect: !!this.effects?.length,
isDirect: !!this.damage?.direct,
hasSave: this.hasSave, hasSave: this.hasSave,
isDirect: !!this.damage?.direct,
selectedRollMode: game.settings.get('core', 'rollMode'), selectedRollMode: game.settings.get('core', 'rollMode'),
isFastForward: event.shiftKey, // isFastForward: event.shiftKey,
data: this.getRollData(), data: this.getRollData(),
evaluate: hasRoll evaluate: this.hasRoll
}; };
DHBaseAction.applyKeybindings(config);
return config;
}
prepareConfig(event) {
const config = this.prepareBaseConfig(event);
Object.values(this.schema.fields).forEach( clsField => {
if (clsField?.prepareConfig) {
// const keep = clsField.prepareConfig.call(this, config);
// if (config.isFastForward && !keep) return;
if(clsField.prepareConfig.call(this, config) === false) return;
}
})
return config;
} }
requireConfigurationDialog(config) { requireConfigurationDialog(config) {
return !config.event.shiftKey && !config.hasRoll && (config.costs?.length || config.uses); return !config.event.shiftKey && !config.hasRoll && (config.costs?.length || config.uses);
} }
// prepareRoll() {
// const roll = {
// baseModifiers: this.roll.getModifier(),
// label: 'Attack',
// type: this.actionType,
// difficulty: this.roll?.difficulty,
// formula: this.roll.getFormula(),
// advantage: CONFIG.DH.ACTIONS.advantageState[this.roll.advState].value
// };
// if (this.roll?.type === 'diceSet' || !this.hasRoll) roll.lite = true;
// return roll;
// }
// doFollowUp(config) {
// return !config.hasRoll;
// }
async consume(config, successCost = false) { async consume(config, successCost = false) {
const actor= this.actor.system.partner ?? this.actor, await game.system.api.fields.ActionFields.CostField.consume.call(this, config, successCost);
usefulResources = { await game.system.api.fields.ActionFields.UsesField.consume.call(this, config, successCost);
...foundry.utils.deepClone(actor.system.resources),
fear: {
value: game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear),
max: game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxFear,
reversed: false
}
};
for (var cost of config.costs) { if (config.roll && !config.roll.success && successCost) {
if (cost.keyIsID) {
usefulResources[cost.key] = {
value: cost.value,
target: this.parent.parent,
keyIsID: true
};
}
}
const resources = game.system.api.fields.ActionFields.CostField.getRealCosts(config.costs)
.filter(
c =>
(!successCost && (!c.consumeOnSuccess || config.roll?.success)) ||
(successCost && c.consumeOnSuccess)
)
.reduce((a, c) => {
const resource = usefulResources[c.key];
if (resource) {
a.push({
key: c.key,
value: (c.total ?? c.value) * (resource.isReversed ? 1 : -1),
target: resource.target,
keyIsID: resource.keyIsID
});
return a;
}
}, []);
await actor.modifyResource(resources);
if (
config.uses?.enabled &&
((!successCost && (!config.uses?.consumeOnSuccess || config.roll?.success)) ||
(successCost && config.uses?.consumeOnSuccess))
)
this.update({ 'uses.value': this.uses.value + 1 });
if (config.roll?.success || successCost) {
setTimeout(() => { setTimeout(() => {
(config.message ?? config.parent).update({ 'system.successConsumed': true }); (config.message ?? config.parent).update({ 'system.successConsumed': true });
}, 50); }, 50);
} }
} }
/* */
/* ROLL */ static applyKeybindings(config) {
getUseHasRoll(byPass = false) { config.dialog.configure ??= !(config.event.shiftKey || config.event.altKey || config.event.ctrlKey);
return this.hasRoll && !byPass;
} }
/**
* Getters to know which parts the action of composed of. A field can exist but configured to not be used.
* @returns {boolean} Does that part in the action.
*/
get hasRoll() { get hasRoll() {
return !!this.roll?.type; return !!this.roll?.type;
} }
@ -306,120 +241,15 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
get hasDamagePart() { get hasDamagePart() {
return this.damage?.parts?.length; return this.damage?.parts?.length;
} }
/* ROLL */
/* SAVE */
get hasSave() { get hasSave() {
return !!this.save?.trait; return !!this.save?.trait;
} }
/* SAVE */
/* EFFECTS */
get hasEffect() { get hasEffect() {
return this.effects?.length > 0; return this.effects?.length > 0;
} }
async applyEffects(event, data, targets) {
targets ??= data.system.targets;
const force = true; /* Where should this come from? */
if (!this.effects?.length || !targets.length) return;
let effects = this.effects;
targets.forEach(async token => {
if (!token.hit && !force) return;
if (this.hasSave && token.saved.success === true) {
effects = this.effects.filter(e => e.onSave === true);
}
if (!effects.length) return;
effects.forEach(async e => {
const actor = canvas.tokens.get(token.id)?.actor,
effect = this.item.effects.get(e._id);
if (!actor || !effect) return;
await this.applyEffect(effect, actor);
});
});
}
async applyEffect(effect, actor) {
const existingEffect = actor.effects.find(e => e.origin === effect.uuid);
if (existingEffect) {
return effect.update(
foundry.utils.mergeObject({
...effect.constructor.getInitialDuration(),
disabled: false
})
);
}
// Otherwise, create a new effect on the target
const effectData = foundry.utils.mergeObject({
...effect.toObject(),
disabled: false,
transfer: false,
origin: effect.uuid
});
await ActiveEffect.implementation.create(effectData, { parent: actor });
}
/* EFFECTS */
/* SAVE */
// async rollSave(actor, event, message) {
// if (!actor) return;
// const title = actor.isNPC
// ? game.i18n.localize('DAGGERHEART.GENERAL.reactionRoll')
// : game.i18n.format('DAGGERHEART.UI.Chat.dualityRoll.abilityCheckTitle', {
// ability: game.i18n.localize(abilities[this.save.trait]?.label)
// });
// return actor.diceRoll({
// event,
// title,
// roll: {
// trait: this.save.trait,
// difficulty: this.save.difficulty ?? this.actor?.baseSaveDifficulty,
// type: 'reaction'
// },
// type: 'trait',
// hasRoll: true,
// data: actor.getRollData()
// });
// }
// updateSaveMessage(result, message, targetId) {
// if (!result) return;
// const updateMsg = this.updateChatMessage.bind(this, message, targetId, {
// result: result.roll.total,
// success: result.roll.success
// });
// if (game.modules.get('dice-so-nice')?.active)
// game.dice3d.waitFor3DAnimationByMessageID(result.message.id ?? result.message._id).then(() => updateMsg());
// else updateMsg();
// }
// static rollSaveQuery({ actionId, actorId, event, message }) {
// return new Promise(async (resolve, reject) => {
// const actor = await fromUuid(actorId),
// action = await fromUuid(actionId);
// if (!actor || !actor?.isOwner) reject();
// action.rollSave(actor, event, message).then(result => resolve(result));
// });
// }
/* SAVE */
async updateChatMessage(message, targetId, changes/* , chain = true */) {
setTimeout(async () => {
const chatMessage = ui.chat.collection.get(message._id);
await chatMessage.update(changes);
}, 100);
// if (chain) {
// if (message.system.source.message)
// this.updateChatMessage(ui.chat.collection.get(message.system.source.message), targetId, changes, false);
// const relatedChatMessages = ui.chat.collection.filter(c => c.system.source?.message === message._id);
// relatedChatMessages.forEach(c => {
// this.updateChatMessage(c, targetId, changes, false);
// });
// }
}
/** /**
* Generates a list of localized tags for this action. * Generates a list of localized tags for this action.
* @returns {string[]} An array of localized tag strings. * @returns {string[]} An array of localized tag strings.

View file

@ -1,65 +1,5 @@
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 {
static extraSchemas = [...super.extraSchemas, 'damage', 'target', 'effects']; static extraSchemas = [...super.extraSchemas, 'damage', 'target', 'effects'];
/* getFormulaValue(part, data) {
let formulaValue = part.value;
if (data.hasRoll && part.resultBased && data.roll.result.duality === -1) return part.valueAlt;
const isAdversary = this.actor.type === 'adversary';
if (isAdversary && this.actor.system.type === CONFIG.DH.ACTOR.adversaryTypes.horde.id) {
const hasHordeDamage = this.actor.effects.find(x => x.type === 'horde');
if (hasHordeDamage && !hasHordeDamage.disabled) return part.valueAlt;
}
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) {
const systemData = data.system ?? data;
let formulas = this.damage.parts.map(p => ({
formula: this.getFormulaValue(p, systemData).getFormula(this.actor),
damageTypes: p.applyTo === 'hitPoints' && !p.type.size ? new Set(['physical']) : p.type,
applyTo: p.applyTo
}));
if (!formulas.length) return;
formulas = this.formatFormulas(formulas, systemData);
delete systemData.evaluate;
const config = {
...systemData,
roll: formulas,
dialog: {},
data: this.getRollData()
};
if (this.hasSave) config.onSave = this.save.damageMod;
if (data.system) {
config.source.message = data._id;
config.directDamage = false;
} else {
config.directDamage = true;
}
return CONFIG.Dice.daggerheart.DamageRoll.build(config);
} */
} }

View file

@ -2,15 +2,4 @@ import DHBaseAction from './baseAction.mjs';
export default class DHMacroAction extends DHBaseAction { export default class DHMacroAction extends DHBaseAction {
static extraSchemas = [...super.extraSchemas, 'macro']; static extraSchemas = [...super.extraSchemas, 'macro'];
/* async trigger(event, ...args) {
const fixUUID = !this.macro.includes('Macro.') ? `Macro.${this.macro}` : this.macro,
macro = await fromUuid(fixUUID);
try {
if (!macro) throw new Error(`No macro found for the UUID: ${this.macro}.`);
macro.execute();
} catch (error) {
ui.notifications.error(error);
}
} */
} }

View file

@ -1,5 +1,5 @@
import DHAbilityUse from "./abilityUse.mjs"; import DHAbilityUse from "./abilityUse.mjs";
import DHActorRoll from "./adversaryRoll.mjs"; import DHActorRoll from "./actorRoll.mjs";
export const config = { export const config = {
abilityUse: DHAbilityUse, abilityUse: DHAbilityUse,

View file

@ -6,6 +6,5 @@ export { default as EffectsField } from './effectsField.mjs';
export { default as SaveField } from './saveField.mjs'; export { default as SaveField } from './saveField.mjs';
export { default as BeastformField } from './beastformField.mjs'; export { default as BeastformField } from './beastformField.mjs';
export { default as DamageField } from './damageField.mjs'; export { default as DamageField } from './damageField.mjs';
export { default as HealingField } from './healingField.mjs';
export { default as RollField } from './rollField.mjs'; export { default as RollField } from './rollField.mjs';
export { default as MacroField } from './macroField.mjs'; export { default as MacroField } from './macroField.mjs';

View file

@ -1,6 +1,8 @@
const fields = foundry.data.fields; const fields = foundry.data.fields;
export default class CostField extends fields.ArrayField { export default class CostField extends fields.ArrayField {
/** @inheritDoc */
constructor(options = {}, context = {}) { constructor(options = {}, context = {}) {
const element = new fields.SchemaField({ const element = new fields.SchemaField({
key: new fields.StringField({ key: new fields.StringField({
@ -20,6 +22,11 @@ export default class CostField extends fields.ArrayField {
super(element, options, context); super(element, options, context);
} }
/**
* Update Action Workflow config object.
* Must be called within Action context.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
*/
prepareConfig(config) { prepareConfig(config) {
const costs = this.cost?.length ? foundry.utils.deepClone(this.cost) : []; const costs = this.cost?.length ? foundry.utils.deepClone(this.cost) : [];
config.costs = CostField.calcCosts.call(this, costs); config.costs = CostField.calcCosts.call(this, costs);
@ -29,6 +36,12 @@ export default class CostField extends fields.ArrayField {
return hasCost; return hasCost;
} }
/**
*
* Must be called within Action context.
* @param {*} costs
* @returns
*/
static calcCosts(costs) { static calcCosts(costs) {
const resources = CostField.getResources.call(this, costs); const resources = CostField.getResources.call(this, costs);
return costs.map(c => { return costs.map(c => {
@ -47,6 +60,12 @@ export default class CostField extends fields.ArrayField {
}); });
} }
/**
* Check if the current Actor currently has all needed resources.
* Must be called within Action context.
* @param {*} costs
* @returns {boolean}
*/
static hasCost(costs) { static hasCost(costs) {
const realCosts = CostField.getRealCosts.call(this, costs), const realCosts = CostField.getRealCosts.call(this, costs),
hasFearCost = realCosts.findIndex(c => c.key === 'fear'); hasFearCost = realCosts.findIndex(c => c.key === 'fear');
@ -73,6 +92,12 @@ export default class CostField extends fields.ArrayField {
); );
} }
/**
* Get all Actor resources + parent Item potential one.
* Must be called within Action context.
* @param {*} costs
* @returns
*/
static getResources(costs) { static getResources(costs) {
const actorResources = foundry.utils.deepClone(this.actor.system.resources); const actorResources = foundry.utils.deepClone(this.actor.system.resources);
if (this.actor.system.partner) if (this.actor.system.partner)
@ -93,6 +118,11 @@ export default class CostField extends fields.ArrayField {
}; };
} }
/**
*
* @param {*} costs
* @returns
*/
static getRealCosts(costs) { static getRealCosts(costs) {
const realCosts = costs?.length ? costs.filter(c => c.enabled) : []; const realCosts = costs?.length ? costs.filter(c => c.enabled) : [];
let mergedCosts = []; let mergedCosts = [];
@ -104,6 +134,12 @@ export default class CostField extends fields.ArrayField {
return mergedCosts; return mergedCosts;
} }
/**
* Format scalable max cost, inject Action datas if it's a formula.
* Must be called within Action context.
* @param {number|string} max Configured maximum for that resource.
* @returns {number} The max cost value.
*/
static formatMax(max) { static formatMax(max) {
max ??= 0; max ??= 0;
if (isNaN(max)) { if (isNaN(max)) {
@ -112,4 +148,53 @@ export default class CostField extends fields.ArrayField {
} }
return Number(max); return Number(max);
} }
/**
* Consume configured action resources.
* Must be called within Action context.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
* @param {boolean} [successCost=false] Consume only resources configured as "On Success only" if not already consumed.
*/
static async consume(config, successCost = false) {
const actor= this.actor.system.partner ?? this.actor,
usefulResources = {
...foundry.utils.deepClone(actor.system.resources),
fear: {
value: game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear),
max: game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxFear,
reversed: false
}
};
for (var cost of config.costs) {
if (cost.keyIsID) {
usefulResources[cost.key] = {
value: cost.value,
target: this.parent.parent,
keyIsID: true
};
}
}
const resources = CostField.getRealCosts(config.costs)
.filter(
c =>
(!successCost && (!c.consumeOnSuccess || config.roll?.success)) ||
(successCost && c.consumeOnSuccess)
)
.reduce((a, c) => {
const resource = usefulResources[c.key];
if (resource) {
a.push({
key: c.key,
value: (c.total ?? c.value) * (resource.isReversed ? 1 : -1),
target: resource.target,
keyIsID: resource.keyIsID
});
return a;
}
}, []);
await actor.modifyResource(resources);
}
} }

View file

@ -1,10 +1,15 @@
import FormulaField from '../formulaField.mjs'; import FormulaField from '../formulaField.mjs';
import { setsEqual } from '../../../helpers/utils.mjs';
const fields = foundry.data.fields; const fields = foundry.data.fields;
export default class DamageField extends fields.SchemaField { export default class DamageField extends fields.SchemaField {
/**
* Action Workflow order
*/
order = 20; order = 20;
/** @inheritDoc */
constructor(options, context = {}) { constructor(options, context = {}) {
const damageFields = { const damageFields = {
parts: new fields.ArrayField(new fields.EmbeddedDataField(DHDamageData)), parts: new fields.ArrayField(new fields.EmbeddedDataField(DHDamageData)),
@ -17,60 +22,66 @@ export default class DamageField extends fields.SchemaField {
super(damageFields, options, context); super(damageFields, options, context);
} }
async execute(data, force = false) { /**
* Roll Damage/Healing Action Workflow part.
* Must be called within Action context.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
* @param {string} [messageId=null] ChatMessage Id where the clicked button belong.
* @param {boolean} [force=false] If the method should be executed outside of Action workflow, for ChatMessage button for example.
*/
async execute(config, messageId = null, force = false) {
if((this.hasRoll && DamageField.getAutomation() === CONFIG.DH.SETTINGS.actionAutomationChoices.never.id) && !force) return; if((this.hasRoll && DamageField.getAutomation() === CONFIG.DH.SETTINGS.actionAutomationChoices.never.id) && !force) return;
const systemData = data.system ?? data;
let formulas = this.damage.parts.map(p => ({ let formulas = this.damage.parts.map(p => ({
formula: DamageField.getFormulaValue.call(this, p, systemData).getFormula(this.actor), formula: DamageField.getFormulaValue.call(this, p, config).getFormula(this.actor),
damageTypes: p.applyTo === 'hitPoints' && !p.type.size ? new Set(['physical']) : p.type, damageTypes: p.applyTo === 'hitPoints' && !p.type.size ? new Set(['physical']) : p.type,
applyTo: p.applyTo applyTo: p.applyTo
})); }));
if (!formulas.length) return false; if (!formulas.length) return false;
formulas = DamageField.formatFormulas.call(this, formulas, systemData); formulas = DamageField.formatFormulas.call(this, formulas, config);
delete systemData.evaluate; const damageConfig = {
const config = { ...config,
...systemData,
roll: formulas, roll: formulas,
dialog: {}, dialog: {},
data: this.getRollData() data: this.getRollData()
}; };
if(DamageField.getAutomation() === CONFIG.DH.SETTINGS.actionAutomationChoices.always.id) config.dialog.configure = false; delete damageConfig.evaluate;
if (this.hasSave) config.onSave = this.save.damageMod;
if (data.message || data.system) { if(DamageField.getAutomation() === CONFIG.DH.SETTINGS.actionAutomationChoices.always.id) damageConfig.dialog.configure = false;
config.source.message = data.message?._id ?? data._id; if (config.hasSave) config.onSave = damageConfig.onSave = this.save.damageMod;
config.directDamage = false;
} else { damageConfig.source.message = config.message?._id ?? messageId;
config.directDamage = true; damageConfig.directDamage = !!damageConfig.source?.message;
if(damageConfig.source?.message && game.modules.get('dice-so-nice')?.active)
await game.dice3d.waitFor3DAnimationByMessageID(damageConfig.source.message);
const damageResult = await CONFIG.Dice.daggerheart.DamageRoll.build(damageConfig);
if(!damageResult) return false;
config.damage = damageResult.damage;
} }
if(config.source?.message && game.modules.get('dice-so-nice')?.active) /**
await game.dice3d.waitFor3DAnimationByMessageID(config.source.message); * Apply Damage/Healing Action Worflow part.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
if(!(await CONFIG.Dice.daggerheart.DamageRoll.build(config))) return false; * @param {*[]} targets Arrays of targets to bypass pre-selected ones.
* @param {boolean} force If the method should be executed outside of Action workflow, for ChatMessage button for example.
if(DamageField.getAutomation()) { */
static async applyDamage(config, targets = null, force = false) {
}
}
async applyDamage(config, targets) {
console.log(config, this)
targets ??= config.targets; targets ??= config.targets;
if(!config.damage || !targets?.length || !DamageField.getApplyAutomation()) return; if(!config.damage || !targets?.length || (!DamageField.getApplyAutomation() && !force)) return;
for (let target of targets) { for (let target of targets) {
const actor = fromUuidSync(target.actorId); const actor = fromUuidSync(target.actorId);
if(!actor) continue;
if ( if (
!this.hasHealing && !config.hasHealing &&
this.onSave && config.onSave &&
this.system.hitTargets.find(t => t.id === target.id)?.saved?.success === true target.saved?.success === true
) { ) {
const mod = CONFIG.DH.ACTIONS.damageOnSave[this.onSave]?.mod ?? 1; const mod = CONFIG.DH.ACTIONS.damageOnSave[config.onSave]?.mod ?? 1;
Object.entries(config.damage).forEach(([k, v]) => { Object.entries(config.damage).forEach(([k, v]) => {
v.total = 0; v.total = 0;
v.parts.forEach(part => { v.parts.forEach(part => {
@ -80,12 +91,18 @@ export default class DamageField extends fields.SchemaField {
}); });
} }
// this.consumeOnSuccess(); if (config.hasHealing) actor.takeHealing(config.damage);
if (this.hasHealing) actor.takeHealing(config.damage); else actor.takeDamage(config.damage, config.isDirect);
else actor.takeDamage(config.damage, this.isDirect);
} }
} }
/**
*
* Must be called within Action context.
* @param {*} part
* @param {*} data
* @returns
*/
static getFormulaValue(part, data) { static getFormulaValue(part, data) {
let formulaValue = part.value; let formulaValue = part.value;
@ -100,11 +117,18 @@ export default class DamageField extends fields.SchemaField {
return formulaValue; return formulaValue;
} }
static formatFormulas(formulas, systemData) { /**
*
* Must be called within Action context.
* @param {*} formulas
* @param {*} systemData
* @returns
*/
static formatFormulas(formulas, data) {
const formattedFormulas = []; const formattedFormulas = [];
formulas.forEach(formula => { formulas.forEach(formula => {
if (isNaN(formula.formula)) if (isNaN(formula.formula))
formula.formula = Roll.replaceFormulaData(formula.formula, this.getRollData(systemData)); formula.formula = Roll.replaceFormulaData(formula.formula, this.getRollData(data));
const same = formattedFormulas.find( const same = formattedFormulas.find(
f => setsEqual(f.damageTypes, formula.damageTypes) && f.applyTo === formula.applyTo f => setsEqual(f.damageTypes, formula.damageTypes) && f.applyTo === formula.applyTo
); );
@ -114,10 +138,19 @@ export default class DamageField extends fields.SchemaField {
return formattedFormulas; return formattedFormulas;
} }
/**
* Return the automation setting for execute method for current user role
* @returns {string} Id from settingsConfig.mjs actionAutomationChoices
*/
static getAutomation() { static getAutomation() {
return (game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.damage.gm) || (!game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.damage.players) return (game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.damage.gm) || (!game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.damage.players)
} }
/**
* Return the automation setting for applyDamage method for current user role
* @returns {boolean} If applyDamage should be triggered automatically
*/
static getApplyAutomation() { static getApplyAutomation() {
return (game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.damageApply.gm) || (!game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.damageApply.players) return (game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.damageApply.gm) || (!game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.damageApply.players)
} }

View file

@ -1,8 +1,12 @@
const fields = foundry.data.fields; const fields = foundry.data.fields;
export default class EffectsField extends fields.ArrayField { export default class EffectsField extends fields.ArrayField {
/**
* Action Workflow order
*/
order = 100; order = 100;
/** @inheritDoc */
constructor(options = {}, context = {}) { constructor(options = {}, context = {}) {
const element = new fields.SchemaField({ const element = new fields.SchemaField({
_id: new fields.DocumentIdField(), _id: new fields.DocumentIdField(),
@ -11,27 +15,39 @@ export default class EffectsField extends fields.ArrayField {
super(element, options, context); super(element, options, context);
} }
async execute(config) { /**
if(!this.hasEffect) return; * Apply Effects Action Workflow part.
if(!config.message) { * Must be called within Action context.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
* @param {object[]} [targets=null] Array of targets to override pre-selected ones.
* @param {boolean} [force=false] If the method should be executed outside of Action workflow, for ChatMessage button for example.
*/
async execute(config, targets = null, force = false) {
if(!config.hasEffect) return;
let message = config.message ?? ui.chat.collection.get(config.parent?._id);
if(!message) {
const roll = new CONFIG.Dice.daggerheart.DHRoll(''); const roll = new CONFIG.Dice.daggerheart.DHRoll('');
roll._evaluated = true; roll._evaluated = true;
config.message = await CONFIG.Dice.daggerheart.DHRoll.toMessage(roll, config); message = config.message = await CONFIG.Dice.daggerheart.DHRoll.toMessage(roll, config);
} }
if(EffectsField.getAutomation()) { if(EffectsField.getAutomation() || force) {
EffectsField.applyEffects.call(this, config.targets); targets ??= config.targets.filter(t => !config.hasRoll || t.hit);
EffectsField.applyEffects.call(this, config.targets.filter(t => !config.hasRoll || t.hit));
} }
} }
/**
*
* Must be called within Action context.
* @param {*} targets
* @returns
*/
static async applyEffects(targets) { static async applyEffects(targets) {
const force = true; /* Where should this come from? */
if (!this.effects?.length || !targets?.length) return; if (!this.effects?.length || !targets?.length) return;
let effects = this.effects; let effects = this.effects;
targets.forEach(async token => { targets.forEach(async token => {
if (!token.hit && !force) return; if (this.hasSave && token.saved.success === true)
if (this.hasSave && token.saved.success === true) {
effects = this.effects.filter(e => e.onSave === true); effects = this.effects.filter(e => e.onSave === true);
}
if (!effects.length) return; if (!effects.length) return;
effects.forEach(async e => { effects.forEach(async e => {
const actor = canvas.tokens.get(token.id)?.actor, const actor = canvas.tokens.get(token.id)?.actor,
@ -42,6 +58,12 @@ export default class EffectsField extends fields.ArrayField {
}); });
} }
/**
*
* @param {*} effect
* @param {*} actor
* @returns
*/
static async applyEffect(effect, actor) { static async applyEffect(effect, actor) {
const existingEffect = actor.effects.find(e => e.origin === effect.uuid); const existingEffect = actor.effects.find(e => e.origin === effect.uuid);
if (existingEffect) { if (existingEffect) {
@ -63,6 +85,10 @@ export default class EffectsField extends fields.ArrayField {
await ActiveEffect.implementation.create(effectData, { parent: actor }); await ActiveEffect.implementation.create(effectData, { parent: actor });
} }
/**
* Return the automation setting for execute method for current user role
* @returns {boolean} If execute should be triggered automatically
*/
static getAutomation() { static getAutomation() {
return (game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.effect.gm) || (!game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.effect.players) return (game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.effect.gm) || (!game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.effect.players)
} }

View file

@ -1,12 +0,0 @@
import { DHDamageData } from './damageField.mjs';
const fields = foundry.data.fields;
export default class HealingField extends fields.SchemaField {
constructor(options, context = {}) {
const healingFields = {
parts: new fields.ArrayField(new fields.EmbeddedDataField(DHDamageData))
};
super(healingFields, options, context);
}
}

View file

@ -3,10 +3,15 @@ const fields = foundry.data.fields;
export default class MacroField extends fields.DocumentUUIDField { export default class MacroField extends fields.DocumentUUIDField {
order = 70; order = 70;
/** @inheritDoc */
constructor(context = {}) { constructor(context = {}) {
super({ type: "Macro" }, context); super({ type: "Macro" }, context);
} }
/**
* Macro Action Workflow part.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods. Currently not used.
*/
async execute(config) { async execute(config) {
const fixUUID = !this.macro.includes('Macro.') ? `Macro.${this.macro}` : this.macro, const fixUUID = !this.macro.includes('Macro.') ? `Macro.${this.macro}` : this.macro,
macro = await fromUuid(fixUUID); macro = await fromUuid(fixUUID);

View file

@ -1,6 +1,8 @@
const fields = foundry.data.fields; const fields = foundry.data.fields;
export default class RangeField extends fields.StringField { export default class RangeField extends fields.StringField {
/** @inheritDoc */
constructor(context = {}) { constructor(context = {}) {
const options = { const options = {
choices: CONFIG.DH.GENERAL.range, choices: CONFIG.DH.GENERAL.range,
@ -11,7 +13,12 @@ export default class RangeField extends fields.StringField {
super(options, context); super(options, context);
} }
/**
* Update Action Workflow config object.
* NOT YET IMPLEMENTED.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
*/
prepareConfig(config) { prepareConfig(config) {
return true; return;
} }
} }

View file

@ -112,20 +112,31 @@ export class DHActionRollData extends foundry.abstract.DataModel {
export default class RollField extends fields.EmbeddedDataField { export default class RollField extends fields.EmbeddedDataField {
order = 10; order = 10;
/** @inheritDoc */
constructor(options, context = {}) { constructor(options, context = {}) {
super(DHActionRollData, options, context); super(DHActionRollData, options, context);
} }
/**
* Roll Action Workflow part.
* Must be called within Action context.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
*/
async execute(config) { async execute(config) {
if(!config.hasRoll) return; if(!config.hasRoll) return;
config = await this.actor.diceRoll(config); config = await this.actor.diceRoll(config);
if(!config) return false; if(!config) return false;
} }
/**
* Update Action Workflow config object.
* Must be called within Action context.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
*/
prepareConfig(config) { prepareConfig(config) {
if(!config.hasRoll) return true; if(!config.hasRoll) return;
config.dialog.configure = !RollField.getAutomation(); config.dialog.configure = RollField.getAutomation() ? !config.dialog.configure : config.dialog.configure;
const roll = { const roll = {
baseModifiers: this.roll.getModifier(), baseModifiers: this.roll.getModifier(),
@ -138,9 +149,12 @@ export default class RollField extends fields.EmbeddedDataField {
if (this.roll.type === 'diceSet' || !this.hasRoll) roll.lite = true; if (this.roll.type === 'diceSet' || !this.hasRoll) roll.lite = true;
config.roll = roll; config.roll = roll;
return true;
} }
/**
* Return the automation setting for execute method for current user role
* @returns {boolean} If execute should be triggered automatically
*/
static getAutomation() { static getAutomation() {
return (game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.roll.gm) || (!game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.roll.players) return (game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.roll.gm) || (!game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.roll.players)
} }

View file

@ -1,11 +1,14 @@
import { abilities } from "../../../config/actorConfig.mjs"; import { abilities } from "../../../config/actorConfig.mjs";
import { emitAsGM, GMUpdateEvent } from "../../../systemRegistration/socket.mjs";
const fields = foundry.data.fields; const fields = foundry.data.fields;
export default class SaveField extends fields.SchemaField { export default class SaveField extends fields.SchemaField {
/**
* Action Workflow order
*/
order = 50; order = 50;
/** @inheritDoc */
constructor(options = {}, context = {}) { constructor(options = {}, context = {}) {
const saveFields = { const saveFields = {
trait: new fields.StringField({ trait: new fields.StringField({
@ -22,52 +25,61 @@ export default class SaveField extends fields.SchemaField {
super(saveFields, options, context); super(saveFields, options, context);
} }
async execute(config) { /**
if(!this.hasSave) return; * Reaction Roll Action Workflow part.
if(!config.message) { * Must be called within Action context.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
* @param {object[]} [targets=null] Array of targets to override pre-selected ones.
* @param {boolean} [force=false] If the method should be executed outside of Action workflow, for ChatMessage button for example.
*/
async execute(config, targets = null, force = false) {
if(!config.hasSave) return;
let message = config.message ?? ui.chat.collection.get(config.parent?._id);
if(!message) {
const roll = new CONFIG.Dice.daggerheart.DHRoll(''); const roll = new CONFIG.Dice.daggerheart.DHRoll('');
roll._evaluated = true; roll._evaluated = true;
config.message = await CONFIG.Dice.daggerheart.DHRoll.toMessage(roll, config); message = config.message = await CONFIG.Dice.daggerheart.DHRoll.toMessage(roll, config);
}
if(SaveField.getAutomation() !== CONFIG.DH.SETTINGS.actionAutomationChoices.never.id || force) {
targets ??= config.targets.filter(t => !config.hasRoll || t.hit);
SaveField.rollAllSave.call(this, targets, config.event, message);
} }
if(SaveField.getAutomation() !== CONFIG.DH.SETTINGS.actionAutomationChoices.never.id) {
SaveField.rollAllSave.call(this, config.targets, config.event, config.message);
}
} }
/**
* Roll a Reaction Roll for all targets. Send a query to the owner if the User is not.
* Must be called within Action context.
* @param {object[]} targets Array of formatted targets.
* @param {Event} event Triggering event
* @param {ChatMessage} message The ChatMessage the triggered button comes from.
*/
static async rollAllSave(targets, event, message) { static async rollAllSave(targets, event, message) {
if(!targets) return; if(!targets || !game.user.isGM) return;
targets.forEach(target => { targets.forEach(target => {
const actor = fromUuidSync(target.actorId); const actor = fromUuidSync(target.actorId);
if(actor) { if(actor) {
if (game.user === actor.owner) const rollSave = game.user === actor.owner ?
SaveField.rollSave.call(this, actor, event, message) SaveField.rollSave.call(this, actor, event, message)
.then(result => : actor.owner
emitAsGM(
GMUpdateEvent.UpdateSaveMessage,
SaveField.updateSaveMessage.bind(this, result, message, target.id),
{
action: this.uuid,
message: message._id,
token: target.id,
result
}
)
);
else
actor.owner
.query('reactionRoll', { .query('reactionRoll', {
actionId: this.uuid, actionId: this.uuid,
actorId: actor.uuid, actorId: actor.uuid,
event, event,
message message
}) });
.then(result => SaveField.updateSaveMessage.call(this, result, message, target.id)); rollSave.then(result => SaveField.updateSaveMessage.call(this, result, message, target.id));
} }
}); });
} }
static async rollSave(actor, event, message) { /**
* Roll a Reaction Roll for the specified Actor against the Action difficulty.
* Must be called within Action context.
* @param {*} actor Actor document
* @param {Event} event Triggering event
* @returns {object} Actor diceRoll config result.
*/
static async rollSave(actor, event) {
if (!actor) return; if (!actor) return;
const title = actor.isNPC const title = actor.isNPC
? game.i18n.localize('DAGGERHEART.GENERAL.reactionRoll') ? game.i18n.localize('DAGGERHEART.GENERAL.reactionRoll')
@ -90,9 +102,18 @@ export default class SaveField extends fields.SchemaField {
return actor.diceRoll(rollConfig); return actor.diceRoll(rollConfig);
} }
/**
* Update a Roll ChatMessage for a token according to his Reaction Roll result.
* @param {object} result Result from the Reaction Roll
* @param {object} message ChatMessage to update
* @param {string} targetId Token ID
*/
static updateSaveMessage(result, message, targetId) { static updateSaveMessage(result, message, targetId) {
if (!result) return; if (!result) return;
const updateMsg = this.updateChatMessage.bind(this, message, targetId, { const updateMsg = function(message, targetId, result) {
setTimeout(async () => {
const chatMessage = ui.chat.collection.get(message._id),
changes = {
flags: { flags: {
[game.system.id]: { [game.system.id]: {
reactionRolls: { reactionRolls: {
@ -104,16 +125,32 @@ export default class SaveField extends fields.SchemaField {
} }
} }
} }
}); };
await chatMessage.update(changes);
}, 100);
};
if (game.modules.get('dice-so-nice')?.active) if (game.modules.get('dice-so-nice')?.active)
game.dice3d.waitFor3DAnimationByMessageID(result.message.id ?? result.message._id).then(() => updateMsg()); game.dice3d.waitFor3DAnimationByMessageID(result.message.id ?? result.message._id).then(() => updateMsg(message, targetId, result));
else updateMsg(); else updateMsg(message, targetId, result);
} }
/**
* Return the automation setting for execute method for current user role
* @returns {string} Id from settingsConfig.mjs actionAutomationChoices
*/
static getAutomation() { static getAutomation() {
return (game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.save.gm) || (!game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.save.players) return (game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.save.gm) || (!game.user.isGM && game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).roll.save.players)
} }
/**
* Send a query to an Actor owner to roll a Reaction Roll then send back the result.
* @param {object} param0
* @param {string} param0.actionId Action ID
* @param {string} param0.actorId Actor ID
* @param {Event} param0.event Triggering event
* @param {ChatMessage} param0.message Chat Message to update
* @returns
*/
static rollSaveQuery({ actionId, actorId, event, message }) { static rollSaveQuery({ actionId, actorId, event, message }) {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
const actor = await fromUuid(actorId), const actor = await fromUuid(actorId),

View file

@ -1,6 +1,8 @@
const fields = foundry.data.fields; const fields = foundry.data.fields;
export default class TargetField extends fields.SchemaField { export default class TargetField extends fields.SchemaField {
/** @inheritDoc */
constructor(options = {}, context = {}) { constructor(options = {}, context = {}) {
const targetFields = { const targetFields = {
type: new fields.StringField({ type: new fields.StringField({
@ -13,16 +15,22 @@ export default class TargetField extends fields.SchemaField {
super(targetFields, options, context); super(targetFields, options, context);
} }
/**
* Update Action Workflow config object.
* Must be called within Action context.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
*/
prepareConfig(config) { prepareConfig(config) {
if (!this.target?.type) return config.targets = []; if (!this.target?.type) return config.targets = [];
config.hasTarget = true; config.hasTarget = true;
let targets; let targets;
// If the Action is configured as self-targeted, set targets as the owner.
if (this.target?.type === CONFIG.DH.GENERAL.targetTypes.self.id) if (this.target?.type === CONFIG.DH.GENERAL.targetTypes.self.id)
targets = [this.actor.token ?? this.actor.prototypeToken]; targets = [this.actor.token ?? this.actor.prototypeToken];
else { else {
targets = Array.from(game.user.targets); targets = Array.from(game.user.targets);
if (this.target.type !== CONFIG.DH.GENERAL.targetTypes.any.id) { if (this.target.type !== CONFIG.DH.GENERAL.targetTypes.any.id) {
targets = targets.filter(t => TargetField.isTargetFriendly.call(this, t)); targets = targets.filter(target => TargetField.isTargetFriendly(this.actor, target, this.target.type));
if (this.target.amount && targets.length > this.target.amount) targets = []; if (this.target.amount && targets.length > this.target.amount) targets = [];
} }
} }
@ -33,24 +41,43 @@ export default class TargetField extends fields.SchemaField {
return hasTargets; return hasTargets;
} }
/**
* Check if the number of selected targets respect the amount set in the Action.
* NOT YET IMPLEMENTED. Will be with Target Picker.
* @param {number} amount Max amount of targets configured in the action.
* @param {*[]} targets Array of targeted tokens.
* @returns {boolean} If the amount of targeted tokens does not exceed action configured one.
*/
static checkTargets(amount, targets) { static checkTargets(amount, targets) {
return true; return true;
// return !amount || (targets.length > amount); // return !amount || (targets.length > amount);
} }
static isTargetFriendly(target) { /**
const actorDisposition = this.actor.token * Compare 2 Actors disposition between each other
? this.actor.token.disposition * @param {*} actor First actor document.
: this.actor.prototypeToken.disposition, * @param {*} target Second actor document.
* @param {string} type Disposition id to compare (friendly/hostile).
* @returns {boolean} If both actors respect the provided type.
*/
static isTargetFriendly(actor, target, type) {
const actorDisposition = actor.token
? actor.token.disposition
: actor.prototypeToken.disposition,
targetDisposition = target.document.disposition; targetDisposition = target.document.disposition;
return ( return (
(this.target.type === CONFIG.DH.GENERAL.targetTypes.friendly.id && (type === CONFIG.DH.GENERAL.targetTypes.friendly.id &&
actorDisposition === targetDisposition) || actorDisposition === targetDisposition) ||
(this.target.type === CONFIG.DH.GENERAL.targetTypes.hostile.id && (type === CONFIG.DH.GENERAL.targetTypes.hostile.id &&
actorDisposition + targetDisposition === 0) actorDisposition + targetDisposition === 0)
); );
} }
/**
* Format actor to useful datas for Action roll workflow.
* @param {*} actor Actor object to format.
* @returns {*} Formatted Actor.
*/
static formatTarget(actor) { static formatTarget(actor) {
return { return {
id: actor.id, id: actor.id,

View file

@ -3,6 +3,8 @@ import FormulaField from '../formulaField.mjs';
const fields = foundry.data.fields; const fields = foundry.data.fields;
export default class UsesField extends fields.SchemaField { export default class UsesField extends fields.SchemaField {
/** @inheritDoc */
constructor(options = {}, context = {}) { constructor(options = {}, context = {}) {
const usesFields = { const usesFields = {
value: new fields.NumberField({ nullable: true, initial: null }), value: new fields.NumberField({ nullable: true, initial: null }),
@ -20,6 +22,11 @@ export default class UsesField extends fields.SchemaField {
super(usesFields, options, context); super(usesFields, options, context);
} }
/**
* Update Action Workflow config object.
* Must be called within Action context.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
*/
prepareConfig(config) { prepareConfig(config) {
const uses = this.uses?.max ? foundry.utils.deepClone(this.uses) : null; const uses = this.uses?.max ? foundry.utils.deepClone(this.uses) : null;
if (uses && !uses.value) uses.value = 0; if (uses && !uses.value) uses.value = 0;
@ -29,6 +36,12 @@ export default class UsesField extends fields.SchemaField {
return hasUses; return hasUses;
} }
/**
*
* Must be called within Action context.
* @param {*} uses
* @returns {object}
*/
static calcUses(uses) { static calcUses(uses) {
if (!uses) return null; if (!uses) return null;
return { return {
@ -38,6 +51,12 @@ export default class UsesField extends fields.SchemaField {
}; };
} }
/**
* Check if the Action still get atleast one unspent uses.
* Must be called within Action context.
* @param {*} uses
* @returns {boolean}
*/
static hasUses(uses) { static hasUses(uses) {
if (!uses) return true; if (!uses) return true;
let max = uses.max ?? 0; let max = uses.max ?? 0;
@ -47,4 +66,19 @@ export default class UsesField extends fields.SchemaField {
} }
return (uses.hasOwnProperty('enabled') && !uses.enabled) || uses.value + 1 <= max; return (uses.hasOwnProperty('enabled') && !uses.enabled) || uses.value + 1 <= max;
} }
/**
* Increment Action spent uses by 1.
* Must be called within Action context.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
* @param {boolean} [successCost=false] Consume only resources configured as "On Success only" if not already consumed.
*/
static async consume(config, successCost = false) {
if (
config.uses?.enabled &&
((!successCost && (!config.uses?.consumeOnSuccess || config.roll?.success)) ||
(successCost && config.uses?.consumeOnSuccess))
)
this.update({ 'uses.value': this.uses.value + 1 });
}
} }

View file

@ -147,6 +147,7 @@ export default class D20Roll extends DHRoll {
}); });
data.success = config.targets.some(target => target.hit); data.success = config.targets.some(target => target.hit);
} else if (config.roll.difficulty) data.success = roll.isCritical || roll.total >= config.roll.difficulty; } else if (config.roll.difficulty) data.success = roll.isCritical || roll.total >= config.roll.difficulty;
config.successConsumed = data.success;
data.advantage = { data.advantage = {
type: config.roll.advantage, type: config.roll.advantage,

View file

@ -4,12 +4,12 @@ import DHRoll from './dhRoll.mjs';
export default class DamageRoll extends DHRoll { export default class DamageRoll extends DHRoll {
constructor(formula, data = {}, options = {}) { constructor(formula, data = {}, options = {}) {
super(formula, data, options); super(formula, data, options);
if(options.dialog.configure === false) this.constructFormula(options);
} }
static DefaultDialog = DamageDialog; static DefaultDialog = DamageDialog;
static async buildEvaluate(roll, config = {}, message = {}) { static async buildEvaluate(roll, config = {}, message = {}) {
if (config.dialog.configure === false) roll.constructFormula(config);
if (config.evaluate !== false) for (const roll of config.roll) await roll.roll.evaluate(); if (config.evaluate !== false) for (const roll of config.roll) await roll.roll.evaluate();
roll._evaluated = true; roll._evaluated = true;
@ -38,7 +38,13 @@ export default class DamageRoll extends DHRoll {
Object.values(config.damage).flatMap(r => r.parts.map(p => p.roll)) Object.values(config.damage).flatMap(r => r.parts.map(p => p.roll))
), ),
diceRoll = Roll.fromTerms([pool]); diceRoll = Roll.fromTerms([pool]);
await game.dice3d.showForRoll(diceRoll, game.user, true, chatMessage.whisper, chatMessage.blind); await game.dice3d.showForRoll(
diceRoll,
game.user,
true,
chatMessage.whisper?.length > 0 ? chatMessage.whisper : null,
chatMessage.blind
);
} }
await super.buildPost(roll, config, message); await super.buildPost(roll, config, message);
if (config.source?.message) if (config.source?.message)

View file

@ -1,3 +1,5 @@
import { emitAsGM, GMUpdateEvent } from "../systemRegistration/socket.mjs";
export default class DhpChatMessage extends foundry.documents.ChatMessage { export default class DhpChatMessage extends foundry.documents.ChatMessage {
targetHook = null; targetHook = null;
@ -86,19 +88,29 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage {
itemDesc.setAttribute("open", ""); itemDesc.setAttribute("open", "");
} }
if(!game.user.isGM) { if(!this.isAuthor && !this.speakerActor?.isOwner) {
const applyButtons = html.querySelector(".apply-buttons"); const applyButtons = html.querySelector(".apply-buttons");
applyButtons?.remove(); applyButtons?.remove();
if(!this.isAuthor && !this.speakerActor?.isOwner) {
const buttons = html.querySelectorAll(".ability-card-footer > .ability-use-button"); const buttons = html.querySelectorAll(".ability-card-footer > .ability-use-button");
buttons.forEach(b => b.remove()); buttons.forEach(b => b.remove());
} }
} }
}
addChatListeners(html) { addChatListeners(html) {
html.querySelectorAll('.duality-action-damage').forEach(element =>
element.addEventListener('click', this.onRollDamage.bind(this))
);
html.querySelectorAll('.damage-button').forEach(element => html.querySelectorAll('.damage-button').forEach(element =>
element.addEventListener('click', this.onDamage.bind(this)) element.addEventListener('click', this.onApplyDamage.bind(this))
);
html.querySelectorAll('.target-save').forEach(element =>
element.addEventListener('click', this.onRollSave.bind(this))
);
html.querySelectorAll('.roll-all-save-button').forEach(element =>
element.addEventListener('click', this.onRollAllSave.bind(this))
); );
html.querySelectorAll('.duality-action-effect').forEach(element => html.querySelectorAll('.duality-action-effect').forEach(element =>
@ -116,17 +128,17 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage {
}); });
} }
getTargetList() { async onRollDamage(event) {
const targets = this.system.hitTargets ?? []; event.stopPropagation();
return targets.map(target => game.canvas.tokens.documentCollection.find(t => t.actor?.uuid === target.actorId)); this.system.action?.workflow.get("damage")?.execute(this.system, this._id, true);
} }
async onDamage(event) { async onApplyDamage(event) {
event.stopPropagation(); event.stopPropagation();
const targets = this.getTargetList(); const targets = this.filterPermTargets(this.system.hitTargets);
if (this.system.onSave) { if (this.system.onSave) {
const pendingingSaves = this.system.hitTargets.filter(t => t.saved.success === null); const pendingingSaves = targets.filter(t => t.saved.success === null);
if (pendingingSaves.length) { if (pendingingSaves.length) {
const confirm = await foundry.applications.api.DialogV2.confirm({ const confirm = await foundry.applications.api.DialogV2.confirm({
window: { title: 'Pending Reaction Rolls found' }, window: { title: 'Pending Reaction Rolls found' },
@ -137,62 +149,58 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage {
} }
if (targets.length === 0) if (targets.length === 0)
return ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.noTargetsSelected')); return ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.noTargetsSelectedOrPerm'));
for (let target of targets) {
let damages = foundry.utils.deepClone(this.system.damage);
if (
!this.system.hasHealing &&
this.system.onSave &&
this.system.hitTargets.find(t => t.id === target.id)?.saved?.success === true
) {
const mod = CONFIG.DH.ACTIONS.damageOnSave[this.system.onSave]?.mod ?? 1;
Object.entries(damages).forEach(([k, v]) => {
v.total = 0;
v.parts.forEach(part => {
part.total = Math.ceil(part.total * mod);
v.total += part.total;
});
});
}
this.consumeOnSuccess(); this.consumeOnSuccess();
if (this.system.hasHealing) target.actor.takeHealing(damages); this.system.action?.workflow.get("applyDamage")?.execute(this.system, targets, true);
else target.actor.takeDamage(damages, this.system.isDirect); }
async onRollSave(event) {
event.stopPropagation();
const tokenId = event.target.closest('[data-token]')?.dataset.token,
token = game.canvas.tokens.get(tokenId);
if (!token?.actor || !token.isOwner) return true;
if (this.system.source.item && this.system.source.action) {
const action = this.system.action;
if (!action || !action?.hasSave) return;
game.system.api.fields.ActionFields.SaveField.rollSave.call(action, token.actor, event).then(result =>
emitAsGM(
GMUpdateEvent.UpdateSaveMessage,
game.system.api.fields.ActionFields.SaveField.updateSaveMessage.bind(action, result, this, token.id),
{
action: action.uuid,
message: this._id,
token: token.id,
result
}
)
);
} }
} }
getAction(actor, itemId, actionId) { async onRollAllSave(event) {
const item = actor.items.get(itemId), event.stopPropagation();
action = if (!game.user.isGM) return;
actor.system.attack?._id === actionId const targets = this.system.hitTargets;
? actor.system.attack this.system.action?.workflow.get("save")?.execute(this.system, targets, true);
: item.system.attack?._id === actionId
? item.system.attack
: item?.system?.actions?.get(actionId);
return action;
} }
async onApplyEffect(event) { async onApplyEffect(event) {
event.stopPropagation(); event.stopPropagation();
const actor = await foundry.utils.fromUuid(this.system.source.actor); const targets = this.filterPermTargets(this.system.hitTargets);
if (!actor || !game.user.isGM) return true;
if (this.system.source.item && this.system.source.action) {
const action = this.getAction(actor, this.system.source.item, this.system.source.action);
if (!action || !action?.applyEffects) return;
const targets = this.getTargetList();
if (targets.length === 0) if (targets.length === 0)
ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.noTargetsSelected')); ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.noTargetsSelectedOrPerm'));
this.consumeOnSuccess(); this.consumeOnSuccess();
await action.applyEffects(event, this, targets); this.system.action?.workflow.get("effects")?.execute(this.system, targets, true);
} }
filterPermTargets(targets) {
return targets.filter(t => fromUuidSync(t.actorId)?.canUserModify(game.user, "update"))
} }
consumeOnSuccess() { consumeOnSuccess() {
if (!this.system.successConsumed && !this.targetSelection) { if (!this.system.successConsumed && !this.targetSelection)
const action = this.system.action; this.system.action?.consume(this.system, true);
if (action) action.consume(this.system, true);
}
} }
hoverTarget(event) { hoverTarget(event) {

View file

@ -1,17 +1,17 @@
<div class="roll-buttons"> <div class="roll-buttons">
{{#if hasDamage}} {{#if hasDamage}}
{{#unless (empty damage)}} {{#unless (empty damage)}}
{{#if canButtonApply}}<button class="duality-action damage-button">{{localize "DAGGERHEART.UI.Chat.damageRoll.dealDamage"}}</button>{{/if}} <button class="duality-action damage-button">{{localize "DAGGERHEART.UI.Chat.damageRoll.dealDamage"}}</button>
{{else}} {{else}}
<button class="duality-action duality-action-damage">{{localize "DAGGERHEART.UI.Chat.attackRoll.rollDamage"}}</button> <button class="duality-action duality-action-damage">{{localize "DAGGERHEART.UI.Chat.attackRoll.rollDamage"}}</button>
{{/unless}} {{/unless}}
{{/if}} {{/if}}
{{#if hasHealing}} {{#if hasHealing}}
{{#unless (empty damage)}} {{#unless (empty damage)}}
{{#if canButtonApply}}<button class="duality-action damage-button">{{localize "DAGGERHEART.UI.Chat.healingRoll.applyHealing"}}</button>{{/if}} <button class="duality-action damage-button">{{localize "DAGGERHEART.UI.Chat.healingRoll.applyHealing"}}</button>
{{else}} {{else}}
<button class="duality-action duality-action-damage">{{localize "DAGGERHEART.UI.Chat.attackRoll.rollHealing"}}</button> <button class="duality-action duality-action-damage">{{localize "DAGGERHEART.UI.Chat.attackRoll.rollHealing"}}</button>
{{/unless}} {{/unless}}
{{/if}} {{/if}}
{{#if (and hasEffect canButtonApply)}}<button class="duality-action-effect">{{localize "DAGGERHEART.UI.Chat.attackRoll.applyEffect"}}</button>{{/if}} {{#if (and hasEffect)}}<button class="duality-action-effect">{{localize "DAGGERHEART.UI.Chat.attackRoll.applyEffect"}}</button>{{/if}}
</div> </div>