From 5c73b45193a3c45f0aaed2bc60eb52de08c9516e Mon Sep 17 00:00:00 2001 From: Dapoolp Date: Thu, 21 Aug 2025 23:12:01 +0200 Subject: [PATCH] Add some jsdoc --- lang/en.json | 1 + .../sheets/api/application-mixin.mjs | 4 +- module/applications/ui/chatLog.mjs | 96 +------ module/data/action/baseAction.mjs | 270 ++++-------------- module/data/action/damageAction.mjs | 60 ---- module/data/action/macroAction.mjs | 11 - module/data/chat-message/_modules.mjs | 2 +- .../{adversaryRoll.mjs => actorRoll.mjs} | 0 module/data/fields/action/_module.mjs | 1 - module/data/fields/action/costField.mjs | 85 ++++++ module/data/fields/action/damageField.mjs | 103 ++++--- module/data/fields/action/effectsField.mjs | 46 ++- module/data/fields/action/healingField.mjs | 12 - module/data/fields/action/macroField.mjs | 5 + module/data/fields/action/rangeField.mjs | 9 +- module/data/fields/action/rollField.mjs | 20 +- module/data/fields/action/saveField.mjs | 117 +++++--- module/data/fields/action/targetField.mjs | 41 ++- module/data/fields/action/usesField.mjs | 34 +++ module/dice/d20Roll.mjs | 1 + module/dice/damageRoll.mjs | 10 +- module/documents/chatMessage.mjs | 122 ++++---- templates/ui/chat/parts/button-part.hbs | 6 +- 23 files changed, 501 insertions(+), 555 deletions(-) rename module/data/chat-message/{adversaryRoll.mjs => actorRoll.mjs} (100%) delete mode 100644 module/data/fields/action/healingField.mjs diff --git a/lang/en.json b/lang/en.json index 003a5e65..e746dc40 100755 --- a/lang/en.json +++ b/lang/en.json @@ -2358,6 +2358,7 @@ "beastformInapplicable": "A beastform can only be applied to a Character.", "beastformAlreadyApplied": "The character already has a beastform applied!", "noTargetsSelected": "No targets are selected.", + "noTargetsSelectedOrPerm": "No targets are selected or with the update permission.", "attackTargetDoesNotExist": "The target token no longer exists", "insufficentAdvancements": "You don't have enough advancements left.", "noAssignedPlayerCharacter": "You have no assigned character.", diff --git a/module/applications/sheets/api/application-mixin.mjs b/module/applications/sheets/api/application-mixin.mjs index 83dc1581..8bef331b 100644 --- a/module/applications/sheets/api/application-mixin.mjs +++ b/module/applications/sheets/api/application-mixin.mjs @@ -359,7 +359,9 @@ export default function DHApplicationMixin(Base) { callback: async (target, event) => { const doc = await getDocFromElement(target), 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); } }); diff --git a/module/applications/ui/chatLog.mjs b/module/applications/ui/chatLog.mjs index 5031a28c..21762818 100644 --- a/module/applications/ui/chatLog.mjs +++ b/module/applications/ui/chatLog.mjs @@ -1,5 +1,3 @@ -import { emitAsGM, GMUpdateEvent } from '../../systemRegistration/socket.mjs'; - export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLog { constructor(options) { super(options); @@ -55,21 +53,9 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo } 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 => 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 => 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); } - 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) { const buttonType = event.target.dataset.type ?? 'damage', 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 : item.system.actions.get(event.currentTarget.id); - if (event.currentTarget.dataset.directDamage) action.use(event, { byPassRoll: true }); - else action.use(event); + if (event.currentTarget.dataset.directDamage) { + const config = action.prepareConfig(event); + config.hasRoll = false; + action.workflow.get("damage").execute(config, null, true); + } else action.use(event); } async actionUseButton(event, message) { diff --git a/module/data/action/baseAction.mjs b/module/data/action/baseAction.mjs index 6597b8e2..ac89e593 100644 --- a/module/data/action/baseAction.mjs +++ b/module/data/action/baseAction.mjs @@ -44,17 +44,16 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel return schemaFields; } - static defineWorkflow() { - const workflow = []; - Object.values(this.schema.fields).forEach(s => { - if(s.execute) workflow.push( { order: s.order, execute: s.execute } ); + defineWorkflow() { + const workflow = new Map(); + Object.entries(this.schema.fields).forEach(([k,s]) => { + 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 } ); - workflow.sort((a, b) => a.order - b.order); - return workflow.map(s => s.execute); + if(this.schema.fields.damage) workflow.set("applyDamage", { order: 75, execute: game.system.api.fields.ActionFields.DamageField.applyDamage.bind(this) } ); + return new Map([...workflow.entries()].sort(([aKey, aValue], [bKey, bValue]) => aValue.order - bValue.order)); } - static get workflow() { + get workflow() { if ( this.hasOwnProperty("_workflow") ) return this._workflow; const workflow = Object.freeze(this.defineWorkflow()); 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; } - get workflow() { - return this.constructor.workflow; - } - prepareData() { this.name = this.name || game.i18n.localize(CONFIG.DH.ACTIONS.actionTypes[this.type].name); this.img = this.img ?? this.parent?.parent?.img; @@ -133,8 +128,10 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel } async executeWorkflow(config) { - for(const part of this.workflow) { - if(await part.call(this, config) === false) return; + for(const [key, part] of this.workflow) { + 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(); - let { byPassRoll } = options, - config = this.prepareConfig(event, byPassRoll); + let config = this.prepareConfig(event); - Object.values(this.schema.fields).forEach( clsField => { + /* Object.values(this.schema.fields).forEach( clsField => { if (clsField?.prepareConfig) { - const keep = clsField.prepareConfig.call(this, config); - if (config.isFastForward && !keep) return; + // const keep = clsField.prepareConfig.call(this, config); + // if (config.isFastForward && !keep) return; + if(clsField.prepareConfig.call(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; } + // Execute the Action Worflow in order based of schema fields 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 await this.consume(config); @@ -189,9 +170,8 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel } /* */ - prepareConfig(event, byPass = false) { - const hasRoll = this.getUseHasRoll(byPass); - return { + prepareBaseConfig(event) { + const config = { event, title: `${this.item.name}: ${this.name}`, source: { @@ -200,105 +180,60 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel actor: this.actor.uuid }, dialog: { - configure: hasRoll + // configure: this.hasRoll }, type: this.type, - hasRoll: hasRoll, + hasRoll: this.hasRoll, hasDamage: this.hasDamagePart && this.type !== 'healing', hasHealing: this.hasDamagePart && this.type === 'healing', hasEffect: !!this.effects?.length, - isDirect: !!this.damage?.direct, hasSave: this.hasSave, + isDirect: !!this.damage?.direct, selectedRollMode: game.settings.get('core', 'rollMode'), - isFastForward: event.shiftKey, + // isFastForward: event.shiftKey, 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) { 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) { - 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 - } - }; + await game.system.api.fields.ActionFields.CostField.consume.call(this, config, successCost); + await game.system.api.fields.ActionFields.UsesField.consume.call(this, config, successCost); - for (var cost of config.costs) { - 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) { + if (config.roll && !config.roll.success && successCost) { setTimeout(() => { (config.message ?? config.parent).update({ 'system.successConsumed': true }); }, 50); } } - /* */ - /* ROLL */ - getUseHasRoll(byPass = false) { - return this.hasRoll && !byPass; + static applyKeybindings(config) { + config.dialog.configure ??= !(config.event.shiftKey || config.event.altKey || config.event.ctrlKey); } + /** + * 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() { return !!this.roll?.type; } @@ -306,120 +241,15 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel get hasDamagePart() { return this.damage?.parts?.length; } - /* ROLL */ - - /* SAVE */ + get hasSave() { return !!this.save?.trait; } - /* SAVE */ - /* EFFECTS */ get hasEffect() { 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. * @returns {string[]} An array of localized tag strings. diff --git a/module/data/action/damageAction.mjs b/module/data/action/damageAction.mjs index 38d8852a..b4b3e17c 100644 --- a/module/data/action/damageAction.mjs +++ b/module/data/action/damageAction.mjs @@ -1,65 +1,5 @@ -import { setsEqual } from '../../helpers/utils.mjs'; import DHBaseAction from './baseAction.mjs'; export default class DHDamageAction extends DHBaseAction { 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); - } */ } diff --git a/module/data/action/macroAction.mjs b/module/data/action/macroAction.mjs index cca8a3a1..b8338cd8 100644 --- a/module/data/action/macroAction.mjs +++ b/module/data/action/macroAction.mjs @@ -2,15 +2,4 @@ import DHBaseAction from './baseAction.mjs'; export default class DHMacroAction extends DHBaseAction { 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); - } - } */ } diff --git a/module/data/chat-message/_modules.mjs b/module/data/chat-message/_modules.mjs index a4e2c1fd..36c6ee9d 100644 --- a/module/data/chat-message/_modules.mjs +++ b/module/data/chat-message/_modules.mjs @@ -1,5 +1,5 @@ import DHAbilityUse from "./abilityUse.mjs"; -import DHActorRoll from "./adversaryRoll.mjs"; +import DHActorRoll from "./actorRoll.mjs"; export const config = { abilityUse: DHAbilityUse, diff --git a/module/data/chat-message/adversaryRoll.mjs b/module/data/chat-message/actorRoll.mjs similarity index 100% rename from module/data/chat-message/adversaryRoll.mjs rename to module/data/chat-message/actorRoll.mjs diff --git a/module/data/fields/action/_module.mjs b/module/data/fields/action/_module.mjs index e6caa963..7a33e147 100644 --- a/module/data/fields/action/_module.mjs +++ b/module/data/fields/action/_module.mjs @@ -6,6 +6,5 @@ export { default as EffectsField } from './effectsField.mjs'; export { default as SaveField } from './saveField.mjs'; export { default as BeastformField } from './beastformField.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 MacroField } from './macroField.mjs'; diff --git a/module/data/fields/action/costField.mjs b/module/data/fields/action/costField.mjs index 0b6ac715..67485881 100644 --- a/module/data/fields/action/costField.mjs +++ b/module/data/fields/action/costField.mjs @@ -1,6 +1,8 @@ const fields = foundry.data.fields; export default class CostField extends fields.ArrayField { + + /** @inheritDoc */ constructor(options = {}, context = {}) { const element = new fields.SchemaField({ key: new fields.StringField({ @@ -20,6 +22,11 @@ export default class CostField extends fields.ArrayField { 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) { const costs = this.cost?.length ? foundry.utils.deepClone(this.cost) : []; config.costs = CostField.calcCosts.call(this, costs); @@ -29,6 +36,12 @@ export default class CostField extends fields.ArrayField { return hasCost; } + /** + * + * Must be called within Action context. + * @param {*} costs + * @returns + */ static calcCosts(costs) { const resources = CostField.getResources.call(this, costs); 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) { const realCosts = CostField.getRealCosts.call(this, costs), 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) { const actorResources = foundry.utils.deepClone(this.actor.system.resources); if (this.actor.system.partner) @@ -93,6 +118,11 @@ export default class CostField extends fields.ArrayField { }; } + /** + * + * @param {*} costs + * @returns + */ static getRealCosts(costs) { const realCosts = costs?.length ? costs.filter(c => c.enabled) : []; let mergedCosts = []; @@ -104,6 +134,12 @@ export default class CostField extends fields.ArrayField { 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) { max ??= 0; if (isNaN(max)) { @@ -112,4 +148,53 @@ export default class CostField extends fields.ArrayField { } 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); + } } diff --git a/module/data/fields/action/damageField.mjs b/module/data/fields/action/damageField.mjs index e493f6dc..608e244a 100644 --- a/module/data/fields/action/damageField.mjs +++ b/module/data/fields/action/damageField.mjs @@ -1,10 +1,15 @@ import FormulaField from '../formulaField.mjs'; +import { setsEqual } from '../../../helpers/utils.mjs'; const fields = foundry.data.fields; export default class DamageField extends fields.SchemaField { + /** + * Action Workflow order + */ order = 20; + /** @inheritDoc */ constructor(options, context = {}) { const damageFields = { parts: new fields.ArrayField(new fields.EmbeddedDataField(DHDamageData)), @@ -17,60 +22,66 @@ export default class DamageField extends fields.SchemaField { 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; - - const systemData = data.system ?? data; 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, applyTo: p.applyTo })); if (!formulas.length) return false; - formulas = DamageField.formatFormulas.call(this, formulas, systemData); + formulas = DamageField.formatFormulas.call(this, formulas, config); - delete systemData.evaluate; - const config = { - ...systemData, + const damageConfig = { + ...config, roll: formulas, dialog: {}, data: this.getRollData() }; - if(DamageField.getAutomation() === CONFIG.DH.SETTINGS.actionAutomationChoices.always.id) config.dialog.configure = false; - if (this.hasSave) config.onSave = this.save.damageMod; + delete damageConfig.evaluate; + + if(DamageField.getAutomation() === CONFIG.DH.SETTINGS.actionAutomationChoices.always.id) damageConfig.dialog.configure = false; + if (config.hasSave) config.onSave = damageConfig.onSave = this.save.damageMod; - if (data.message || data.system) { - config.source.message = data.message?._id ?? data._id; - config.directDamage = false; - } else { - config.directDamage = true; - } + damageConfig.source.message = config.message?._id ?? messageId; + damageConfig.directDamage = !!damageConfig.source?.message; - if(config.source?.message && game.modules.get('dice-so-nice')?.active) - await game.dice3d.waitFor3DAnimationByMessageID(config.source.message); + if(damageConfig.source?.message && game.modules.get('dice-so-nice')?.active) + await game.dice3d.waitFor3DAnimationByMessageID(damageConfig.source.message); - if(!(await CONFIG.Dice.daggerheart.DamageRoll.build(config))) return false; - - if(DamageField.getAutomation()) { - - } + const damageResult = await CONFIG.Dice.daggerheart.DamageRoll.build(damageConfig); + if(!damageResult) return false; + config.damage = damageResult.damage; } - async applyDamage(config, targets) { - console.log(config, this) + /** + * Apply Damage/Healing Action Worflow part. + * @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods. + * @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. + */ + static async applyDamage(config, targets = null, force = false) { 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) { const actor = fromUuidSync(target.actorId); + if(!actor) continue; if ( - !this.hasHealing && - this.onSave && - this.system.hitTargets.find(t => t.id === target.id)?.saved?.success === true + !config.hasHealing && + config.onSave && + 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]) => { v.total = 0; v.parts.forEach(part => { @@ -80,12 +91,18 @@ export default class DamageField extends fields.SchemaField { }); } - // this.consumeOnSuccess(); - if (this.hasHealing) actor.takeHealing(config.damage); - else actor.takeDamage(config.damage, this.isDirect); + if (config.hasHealing) actor.takeHealing(config.damage); + else actor.takeDamage(config.damage, config.isDirect); } } + /** + * + * Must be called within Action context. + * @param {*} part + * @param {*} data + * @returns + */ static getFormulaValue(part, data) { let formulaValue = part.value; @@ -100,11 +117,18 @@ export default class DamageField extends fields.SchemaField { return formulaValue; } - static formatFormulas(formulas, systemData) { + /** + * + * Must be called within Action context. + * @param {*} formulas + * @param {*} systemData + * @returns + */ + static formatFormulas(formulas, data) { const formattedFormulas = []; formulas.forEach(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( f => setsEqual(f.damageTypes, formula.damageTypes) && f.applyTo === formula.applyTo ); @@ -114,10 +138,19 @@ export default class DamageField extends fields.SchemaField { return formattedFormulas; } + /** + * Return the automation setting for execute method for current user role + * @returns {string} Id from settingsConfig.mjs actionAutomationChoices + */ 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 the automation setting for applyDamage method for current user role + * @returns {boolean} If applyDamage should be triggered automatically + */ 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) } diff --git a/module/data/fields/action/effectsField.mjs b/module/data/fields/action/effectsField.mjs index 1a943532..f0bd4e1f 100644 --- a/module/data/fields/action/effectsField.mjs +++ b/module/data/fields/action/effectsField.mjs @@ -1,8 +1,12 @@ const fields = foundry.data.fields; export default class EffectsField extends fields.ArrayField { + /** + * Action Workflow order + */ order = 100; + /** @inheritDoc */ constructor(options = {}, context = {}) { const element = new fields.SchemaField({ _id: new fields.DocumentIdField(), @@ -11,27 +15,39 @@ export default class EffectsField extends fields.ArrayField { super(element, options, context); } - async execute(config) { - if(!this.hasEffect) return; - if(!config.message) { + /** + * Apply Effects 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 {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(''); 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()) { - EffectsField.applyEffects.call(this, config.targets); + if(EffectsField.getAutomation() || force) { + 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) { - 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) { + 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, @@ -42,6 +58,12 @@ export default class EffectsField extends fields.ArrayField { }); } + /** + * + * @param {*} effect + * @param {*} actor + * @returns + */ static async applyEffect(effect, actor) { const existingEffect = actor.effects.find(e => e.origin === effect.uuid); if (existingEffect) { @@ -63,6 +85,10 @@ export default class EffectsField extends fields.ArrayField { 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() { 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) } diff --git a/module/data/fields/action/healingField.mjs b/module/data/fields/action/healingField.mjs deleted file mode 100644 index 98f4f5ea..00000000 --- a/module/data/fields/action/healingField.mjs +++ /dev/null @@ -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); - } -} diff --git a/module/data/fields/action/macroField.mjs b/module/data/fields/action/macroField.mjs index 6fc0c650..7ae0746f 100644 --- a/module/data/fields/action/macroField.mjs +++ b/module/data/fields/action/macroField.mjs @@ -3,10 +3,15 @@ const fields = foundry.data.fields; export default class MacroField extends fields.DocumentUUIDField { order = 70; + /** @inheritDoc */ constructor(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) { const fixUUID = !this.macro.includes('Macro.') ? `Macro.${this.macro}` : this.macro, macro = await fromUuid(fixUUID); diff --git a/module/data/fields/action/rangeField.mjs b/module/data/fields/action/rangeField.mjs index d6709a78..1237e507 100644 --- a/module/data/fields/action/rangeField.mjs +++ b/module/data/fields/action/rangeField.mjs @@ -1,6 +1,8 @@ const fields = foundry.data.fields; export default class RangeField extends fields.StringField { + + /** @inheritDoc */ constructor(context = {}) { const options = { choices: CONFIG.DH.GENERAL.range, @@ -11,7 +13,12 @@ export default class RangeField extends fields.StringField { 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) { - return true; + return; } } diff --git a/module/data/fields/action/rollField.mjs b/module/data/fields/action/rollField.mjs index 9922971d..a6fceb39 100644 --- a/module/data/fields/action/rollField.mjs +++ b/module/data/fields/action/rollField.mjs @@ -112,20 +112,31 @@ export class DHActionRollData extends foundry.abstract.DataModel { export default class RollField extends fields.EmbeddedDataField { order = 10; + /** @inheritDoc */ constructor(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) { if(!config.hasRoll) return; config = await this.actor.diceRoll(config); 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) { - 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 = { 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; 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() { 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) } diff --git a/module/data/fields/action/saveField.mjs b/module/data/fields/action/saveField.mjs index 248a4e5f..96fcc267 100644 --- a/module/data/fields/action/saveField.mjs +++ b/module/data/fields/action/saveField.mjs @@ -1,11 +1,14 @@ import { abilities } from "../../../config/actorConfig.mjs"; -import { emitAsGM, GMUpdateEvent } from "../../../systemRegistration/socket.mjs"; const fields = foundry.data.fields; export default class SaveField extends fields.SchemaField { + /** + * Action Workflow order + */ order = 50; + /** @inheritDoc */ constructor(options = {}, context = {}) { const saveFields = { trait: new fields.StringField({ @@ -22,52 +25,61 @@ export default class SaveField extends fields.SchemaField { super(saveFields, options, context); } - async execute(config) { - if(!this.hasSave) return; - if(!config.message) { + /** + * Reaction 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. + * @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(''); 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) { - SaveField.rollAllSave.call(this, config.targets, config.event, config.message); + 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); } - } + /** + * 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) { - if(!targets) return; + if(!targets || !game.user.isGM) return; targets.forEach(target => { const actor = fromUuidSync(target.actorId); if(actor) { - if (game.user === actor.owner) + const rollSave = game.user === actor.owner ? SaveField.rollSave.call(this, actor, event, message) - .then(result => - emitAsGM( - GMUpdateEvent.UpdateSaveMessage, - SaveField.updateSaveMessage.bind(this, result, message, target.id), - { - action: this.uuid, - message: message._id, - token: target.id, - result - } - ) - ); - else - actor.owner + : actor.owner .query('reactionRoll', { actionId: this.uuid, actorId: actor.uuid, event, 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; const title = actor.isNPC ? game.i18n.localize('DAGGERHEART.GENERAL.reactionRoll') @@ -90,30 +102,55 @@ export default class SaveField extends fields.SchemaField { 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) { if (!result) return; - const updateMsg = this.updateChatMessage.bind(this, message, targetId, { - flags: { - [game.system.id]: { - reactionRolls: { - [targetId]: - { - result: result.roll.total, - success: result.roll.success + const updateMsg = function(message, targetId, result) { + setTimeout(async () => { + const chatMessage = ui.chat.collection.get(message._id), + changes = { + flags: { + [game.system.id]: { + reactionRolls: { + [targetId]: + { + result: result.roll.total, + success: result.roll.success + } } + } } - } - } - }); + }; + await chatMessage.update(changes); + }, 100); + }; if (game.modules.get('dice-so-nice')?.active) - game.dice3d.waitFor3DAnimationByMessageID(result.message.id ?? result.message._id).then(() => updateMsg()); - else updateMsg(); + game.dice3d.waitFor3DAnimationByMessageID(result.message.id ?? result.message._id).then(() => updateMsg(message, targetId, result)); + 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() { 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 }) { return new Promise(async (resolve, reject) => { const actor = await fromUuid(actorId), diff --git a/module/data/fields/action/targetField.mjs b/module/data/fields/action/targetField.mjs index 43f4a574..98648eb0 100644 --- a/module/data/fields/action/targetField.mjs +++ b/module/data/fields/action/targetField.mjs @@ -1,6 +1,8 @@ const fields = foundry.data.fields; export default class TargetField extends fields.SchemaField { + + /** @inheritDoc */ constructor(options = {}, context = {}) { const targetFields = { type: new fields.StringField({ @@ -13,16 +15,22 @@ export default class TargetField extends fields.SchemaField { 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) { if (!this.target?.type) return config.targets = []; config.hasTarget = true; 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) targets = [this.actor.token ?? this.actor.prototypeToken]; else { targets = Array.from(game.user.targets); 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 = []; } } @@ -33,24 +41,43 @@ export default class TargetField extends fields.SchemaField { 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) { return true; // return !amount || (targets.length > amount); } - static isTargetFriendly(target) { - const actorDisposition = this.actor.token - ? this.actor.token.disposition - : this.actor.prototypeToken.disposition, + /** + * Compare 2 Actors disposition between each other + * @param {*} actor First actor document. + * @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; return ( - (this.target.type === CONFIG.DH.GENERAL.targetTypes.friendly.id && + (type === CONFIG.DH.GENERAL.targetTypes.friendly.id && actorDisposition === targetDisposition) || - (this.target.type === CONFIG.DH.GENERAL.targetTypes.hostile.id && + (type === CONFIG.DH.GENERAL.targetTypes.hostile.id && actorDisposition + targetDisposition === 0) ); } + /** + * Format actor to useful datas for Action roll workflow. + * @param {*} actor Actor object to format. + * @returns {*} Formatted Actor. + */ static formatTarget(actor) { return { id: actor.id, diff --git a/module/data/fields/action/usesField.mjs b/module/data/fields/action/usesField.mjs index f4ee30b9..ab8ce09e 100644 --- a/module/data/fields/action/usesField.mjs +++ b/module/data/fields/action/usesField.mjs @@ -3,6 +3,8 @@ import FormulaField from '../formulaField.mjs'; const fields = foundry.data.fields; export default class UsesField extends fields.SchemaField { + + /** @inheritDoc */ constructor(options = {}, context = {}) { const usesFields = { value: new fields.NumberField({ nullable: true, initial: null }), @@ -20,6 +22,11 @@ export default class UsesField extends fields.SchemaField { 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) { const uses = this.uses?.max ? foundry.utils.deepClone(this.uses) : null; if (uses && !uses.value) uses.value = 0; @@ -29,6 +36,12 @@ export default class UsesField extends fields.SchemaField { return hasUses; } + /** + * + * Must be called within Action context. + * @param {*} uses + * @returns {object} + */ static calcUses(uses) { if (!uses) return null; 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) { if (!uses) return true; 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; } + + /** + * 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 }); + } } diff --git a/module/dice/d20Roll.mjs b/module/dice/d20Roll.mjs index 63d84744..3065b121 100644 --- a/module/dice/d20Roll.mjs +++ b/module/dice/d20Roll.mjs @@ -147,6 +147,7 @@ export default class D20Roll extends DHRoll { }); data.success = config.targets.some(target => target.hit); } else if (config.roll.difficulty) data.success = roll.isCritical || roll.total >= config.roll.difficulty; + config.successConsumed = data.success; data.advantage = { type: config.roll.advantage, diff --git a/module/dice/damageRoll.mjs b/module/dice/damageRoll.mjs index d7b1dcb0..4d293d9d 100644 --- a/module/dice/damageRoll.mjs +++ b/module/dice/damageRoll.mjs @@ -4,12 +4,12 @@ import DHRoll from './dhRoll.mjs'; export default class DamageRoll extends DHRoll { constructor(formula, data = {}, options = {}) { super(formula, data, options); - if(options.dialog.configure === false) this.constructFormula(options); } static DefaultDialog = DamageDialog; 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(); 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)) ), 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); if (config.source?.message) diff --git a/module/documents/chatMessage.mjs b/module/documents/chatMessage.mjs index e36c1df3..9de2a5c7 100644 --- a/module/documents/chatMessage.mjs +++ b/module/documents/chatMessage.mjs @@ -1,3 +1,5 @@ +import { emitAsGM, GMUpdateEvent } from "../systemRegistration/socket.mjs"; + export default class DhpChatMessage extends foundry.documents.ChatMessage { targetHook = null; @@ -86,19 +88,29 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage { itemDesc.setAttribute("open", ""); } - if(!game.user.isGM) { + if(!this.isAuthor && !this.speakerActor?.isOwner) { const applyButtons = html.querySelector(".apply-buttons"); applyButtons?.remove(); - if(!this.isAuthor && !this.speakerActor?.isOwner) { - const buttons = html.querySelectorAll(".ability-card-footer > .ability-use-button"); - buttons.forEach(b => b.remove()); - } + const buttons = html.querySelectorAll(".ability-card-footer > .ability-use-button"); + buttons.forEach(b => b.remove()); } } addChatListeners(html) { + html.querySelectorAll('.duality-action-damage').forEach(element => + element.addEventListener('click', this.onRollDamage.bind(this)) + ); + 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 => @@ -116,17 +128,17 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage { }); } - getTargetList() { - const targets = this.system.hitTargets ?? []; - return targets.map(target => game.canvas.tokens.documentCollection.find(t => t.actor?.uuid === target.actorId)); + async onRollDamage(event) { + event.stopPropagation(); + this.system.action?.workflow.get("damage")?.execute(this.system, this._id, true); } - async onDamage(event) { + async onApplyDamage(event) { event.stopPropagation(); - const targets = this.getTargetList(); + const targets = this.filterPermTargets(this.system.hitTargets); 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) { const confirm = await foundry.applications.api.DialogV2.confirm({ window: { title: 'Pending Reaction Rolls found' }, @@ -137,62 +149,58 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage { } 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')); + + this.consumeOnSuccess(); + this.system.action?.workflow.get("applyDamage")?.execute(this.system, targets, true); + } - 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(); - if (this.system.hasHealing) target.actor.takeHealing(damages); - 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) { - 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 onRollAllSave(event) { + event.stopPropagation(); + if (!game.user.isGM) return; + const targets = this.system.hitTargets; + this.system.action?.workflow.get("save")?.execute(this.system, targets, true); } async onApplyEffect(event) { event.stopPropagation(); - const actor = await foundry.utils.fromUuid(this.system.source.actor); - 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) - ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.noTargetsSelected')); - this.consumeOnSuccess(); - await action.applyEffects(event, this, targets); - } + const targets = this.filterPermTargets(this.system.hitTargets); + if (targets.length === 0) + ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.noTargetsSelectedOrPerm')); + this.consumeOnSuccess(); + 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() { - if (!this.system.successConsumed && !this.targetSelection) { - const action = this.system.action; - if (action) action.consume(this.system, true); - } + if (!this.system.successConsumed && !this.targetSelection) + this.system.action?.consume(this.system, true); } hoverTarget(event) { diff --git a/templates/ui/chat/parts/button-part.hbs b/templates/ui/chat/parts/button-part.hbs index f83972f7..21317939 100644 --- a/templates/ui/chat/parts/button-part.hbs +++ b/templates/ui/chat/parts/button-part.hbs @@ -1,17 +1,17 @@
{{#if hasDamage}} {{#unless (empty damage)}} - {{#if canButtonApply}}{{/if}} + {{else}} {{/unless}} {{/if}} {{#if hasHealing}} {{#unless (empty damage)}} - {{#if canButtonApply}}{{/if}} + {{else}} {{/unless}} {{/if}} - {{#if (and hasEffect canButtonApply)}}{{/if}} + {{#if (and hasEffect)}}{{/if}}
\ No newline at end of file