Feature/112 items use action datamodel (#127)

* Create new actions classes

* actions types - attack roll

* fixes before merge

* First PR

* Add daggerheart.css to gitignore

* Update ToDo

* Remove console log

* Fixed chat /dr roll

* Remove jQuery

* Fixed so the different chat themes work again

* Fixed duality roll buttons

* Fix to advantage/disadvantage shortcut

* Extand action to other item types

* Roll fixes

* Fixes to adversary rolls

* resources

* Fixed adversary dice

---------

Co-authored-by: WBHarry <williambjrklund@gmail.com>
This commit is contained in:
Dapoulp 2025-06-13 13:03:50 +02:00 committed by GitHub
parent d6acbcb281
commit 3a0a4673ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 1712 additions and 933 deletions

View file

@ -3,6 +3,10 @@ import DHDualityRoll from '../data/chat-message/dualityRoll.mjs';
export default class DhpChatMessage extends foundry.documents.ChatMessage {
async renderHTML() {
if (this.type === 'dualityRoll' || this.type === 'adversaryRoll') {
this.content = await foundry.applications.handlebars.renderTemplate(this.content, this.system);
}
/* We can change to fully implementing the renderHTML function if needed, instead of augmenting it. */
const html = await super.renderHTML();

View file

@ -1,29 +1,33 @@
import DaggerheartSheet from '../sheets/daggerheart-sheet.mjs';
const { ApplicationV2 } = foundry.applications.api;
export default class DaggerheartActionConfig extends DaggerheartSheet(ApplicationV2) {
export default class DHActionConfig extends DaggerheartSheet(ApplicationV2) {
constructor(action) {
super({});
this.action = action;
this.openSection = null;
}
// get title(){
// return `Action - ${this.action.name}`;
// }
static DEFAULT_OPTIONS = {
tag: 'form',
id: 'daggerheart-action',
classes: ['daggerheart', 'views', 'action'],
position: { width: 600, height: 'auto' },
actions: {
toggleSection: this.toggleSection
toggleSection: this.toggleSection,
addEffect: this.addEffect,
removeEffect: this.removeEffect,
addElement: this.addElement,
removeElement: this.removeElement,
editEffect: this.editEffect,
addDamage: this.addDamage,
removeDamage: this.removeDamage
},
form: {
handler: this.updateForm,
closeOnSubmit: true
submitOnChange: true,
closeOnSubmit: false
}
};
@ -36,16 +40,9 @@ export default class DaggerheartActionConfig extends DaggerheartSheet(Applicatio
_getTabs() {
const tabs = {
effects: { active: true, cssClass: '', group: 'primary', id: 'effects', icon: null, label: 'Effects' },
useage: { active: false, cssClass: '', group: 'primary', id: 'useage', icon: null, label: 'Useage' },
conditions: {
active: false,
cssClass: '',
group: 'primary',
id: 'conditions',
icon: null,
label: 'Conditions'
}
base: { active: true, cssClass: '', group: 'primary', id: 'base', icon: null, label: 'Base' },
config: { active: false, cssClass: '', group: 'primary', id: 'config', icon: null, label: 'Configuration' },
effect: { active: false, cssClass: '', group: 'primary', id: 'effect', icon: null, label: 'Effect' }
};
for (const v of Object.values(tabs)) {
@ -58,9 +55,13 @@ export default class DaggerheartActionConfig extends DaggerheartSheet(Applicatio
async _prepareContext(_options) {
const context = await super._prepareContext(_options, 'action');
context.source = this.action.toObject(false);
context.openSection = this.openSection;
context.tabs = this._getTabs();
context.config = SYSTEM;
if(!!this.action.effects) context.effects = this.action.effects.map(e => this.action.item.effects.get(e._id));
if(this.action.damage?.hasOwnProperty('includeBase')) context.hasBaseDamage = !!this.action.parent.damage;
context.getRealIndex = this.getRealIndex.bind(this);
return context;
}
@ -69,15 +70,93 @@ export default class DaggerheartActionConfig extends DaggerheartSheet(Applicatio
this.render(true);
}
static async updateForm(event, _, formData) {
const data = foundry.utils.expandObject(
foundry.utils.mergeObject(this.action.toObject(), foundry.utils.expandObject(formData.object))
);
const newActions = this.action.parent.actions.map(x => x.toObject());
if (!newActions.findSplice(x => x.id === data.id, data)) {
newActions.push(data);
}
getRealIndex(index) {
const data = this.action.toObject(false);
return data.damage.parts.find(d => d.base) ? index - 1 : index;
}
_prepareSubmitData(event, formData) {
const submitData = foundry.utils.expandObject(formData.object);
// this.element.querySelectorAll("fieldset[disabled] :is(input, select)").forEach(input => {
// foundry.utils.setProperty(submitData, input.name, input.value);
// });
return submitData;
}
static async updateForm(event, _, formData) {
const submitData = this._prepareSubmitData(event, formData),
data = foundry.utils.expandObject(foundry.utils.mergeObject(this.action.toObject(), submitData)),
newActions = this.action.parent.actions.map(x => x.toObject()); // Find better way
if (!newActions.findSplice(x => x._id === data._id, data)) newActions.push(data);
const updates = await this.action.parent.parent.update({ 'system.actions': newActions });
if(!updates) return;
this.action = updates.system.actions[this.action.index];
this.render();
}
static addElement(event) {
const data = this.action.toObject(),
key = event.target.closest('.action-category-data').dataset.key;
if ( !this.action[key] ) return;
data[key].push({});
this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(data) });
}
static removeElement(event) {
const data = this.action.toObject(),
key = event.target.closest('.action-category-data').dataset.key,
index = event.target.dataset.index;
data[key].splice(index, 1);
this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(data) });
}
static addDamage(event) {
if ( !this.action.damage.parts ) return;
const data = this.action.toObject();
data.damage.parts.push({});
this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(data) });
}
static removeDamage(event) {
if ( !this.action.damage.parts ) return;
const data = this.action.toObject(),
index = event.target.dataset.index;
data.damage.parts.splice(index, 1);
this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(data) });
}
static async addEffect(event) {
if ( !this.action.effects ) return;
const effectData = this._addEffectData.bind(this)(),
[created] = await this.action.item.createEmbeddedDocuments("ActiveEffect", [effectData], { render: false }),
data = this.action.toObject();
data.effects.push( { '_id': created._id } )
this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(data) });
}
/**
* The data for a newly created applied effect.
* @returns {object}
* @protected
*/
_addEffectData() {
return {
name: this.action.item.name,
img: this.action.item.img,
origin: this.action.item.uuid,
transfer: false
};
}
static removeEffect(event) {
if ( !this.action.effects ) return;
const index = event.target.dataset.index,
effectId = this.action.effects[index]._id;
this.constructor.removeElement.bind(this)(event);
this.action.item.deleteEmbeddedDocuments("ActiveEffect", [effectId]);
}
static editEffect(event) {
await this.action.parent.parent.update({ 'system.actions': newActions });
}
}

View file

@ -14,7 +14,7 @@ export default class NpcRollSelectionDialog extends HandlebarsApplicationMixin(A
}
get title() {
return game.i18n.localize('DAGGERHEART.Application.Settings.Title');
return game.i18n.localize('DAGGERHEART.Application.RollSelection.Title');
}
static DEFAULT_OPTIONS = {

View file

@ -16,7 +16,6 @@ export default class RollSelectionDialog extends HandlebarsApplicationMixin(Appl
hope: ['d12'],
fear: ['d12'],
advantage: null,
disadvantage: null,
hopeResource: hopeResource
};
}
@ -30,9 +29,8 @@ export default class RollSelectionDialog extends HandlebarsApplicationMixin(Appl
height: 'auto'
},
actions: {
updateIsAdvantage: this.updateIsAdvantage,
selectExperience: this.selectExperience,
setAdvantage: this.setAdvantage,
setDisadvantage: this.setDisadvantage,
finish: this.finish
},
form: {
@ -61,7 +59,6 @@ export default class RollSelectionDialog extends HandlebarsApplicationMixin(Appl
context.hope = this.data.hope;
context.fear = this.data.fear;
context.advantage = this.data.advantage;
context.disadvantage = this.data.disadvantage;
context.experiences = Object.keys(this.experiences).map(id => ({ id, ...this.experiences[id] }));
context.hopeResource = this.data.hopeResource + 1;
@ -85,18 +82,10 @@ export default class RollSelectionDialog extends HandlebarsApplicationMixin(Appl
this.render();
}
static setAdvantage() {
this.data.advantage = this.data.advantage ? null : 'd6';
this.data.disadvantage = null;
this.render(true);
}
static setDisadvantage() {
this.data.advantage = null;
this.data.disadvantage = this.data.disadvantage ? null : 'd6';
this.render(true);
static updateIsAdvantage(_, button) {
const advantage = Boolean(button.dataset.advantage);
this.data.advantage = this.data.advantage === advantage ? null : advantage;
this.render();
}
static async finish() {

View file

@ -166,8 +166,6 @@ class DhpRangeSettings extends FormApplication {
}
export const registerDHSettings = () => {
// const debouncedReload = foundry.utils.debounce(() => window.location.reload(), 100);
game.settings.register(SYSTEM.id, SYSTEM.SETTINGS.gameSettings.General.AbilityArray, {
name: game.i18n.localize('DAGGERHEART.Settings.General.AbilityArray.Name'),
hint: game.i18n.localize('DAGGERHEART.Settings.General.AbilityArray.Hint'),
@ -274,7 +272,7 @@ export const registerDHSettings = () => {
name: game.i18n.localize('DAGGERHEART.Settings.DualityRollColor.Name'),
hint: game.i18n.localize('DAGGERHEART.Settings.DualityRollColor.Hint'),
scope: 'world',
config: true,
config: false,
type: Number,
choices: Object.values(DualityRollColor),
default: DualityRollColor.colorful.value

View file

@ -54,6 +54,20 @@ export default class DHAppearanceSettings extends HandlebarsApplicationMixin(App
static async save() {
await game.settings.set(SYSTEM.id, SYSTEM.SETTINGS.gameSettings.appearance, this.settings.toObject());
const reload = await foundry.applications.api.DialogV2.confirm({
id: 'reload-world-confirm',
modal: true,
rejectClose: false,
window: { title: 'SETTINGS.ReloadPromptTitle' },
position: { width: 400 },
content: `<p>${game.i18n.localize('SETTINGS.ReloadPromptBody')}</p>`
});
if (reload) {
await game.socket.emit('reload');
foundry.utils.debouncedReload();
}
this.close();
}
}

View file

@ -61,70 +61,42 @@ export default class AdversarySheet extends DaggerheartSheet(ActorSheetV2) {
}
static async reactionRoll(event) {
const { roll, diceResults, modifiers } = await this.actor.diceRoll(
{ title: `${this.actor.name} - Reaction Roll`, value: 0 },
event.shiftKey
);
const cls = getDocumentClass('ChatMessage');
const systemData = {
roll: roll._formula,
total: roll._total,
modifiers: modifiers,
diceResults: diceResults
const config = {
event: event,
title: `${this.actor.name} - Reaction Roll`,
roll: {
modifier: null,
type: 'reaction'
},
chatMessage: {
type: 'adversaryRoll',
template: 'systems/daggerheart/templates/chat/adversary-roll.hbs',
mute: true
}
};
const msg = new cls({
type: 'adversaryRoll',
system: systemData,
content: await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/chat/adversary-roll.hbs',
systemData
),
rolls: [roll]
});
cls.create(msg.toObject());
this.actor.diceRoll(config);
}
static async attackRoll() {
const { modifier, damage, name: attackName } = this.actor.system.attack;
const { roll, dice, advantageState, modifiers } = await this.actor.diceRoll(
{ title: `${this.actor.name} - Attack Roll`, value: modifier },
event.shiftKey
);
const targets = Array.from(game.user.targets).map(x => ({
id: x.id,
name: x.actor.name,
img: x.actor.img,
difficulty: x.actor.system.difficulty,
evasion: x.actor.system.evasion
}));
const cls = getDocumentClass('ChatMessage');
const systemData = {
title: attackName,
origin: this.document.id,
roll: roll._formula,
advantageState,
total: roll._total,
modifiers: modifiers,
dice: dice,
targets: targets,
damage: { value: damage.value, type: damage.type }
};
const msg = new cls({
type: 'adversaryRoll',
sound: CONFIG.sounds.dice,
system: systemData,
content: await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/chat/adversary-attack-roll.hbs',
systemData
),
rolls: [roll]
});
cls.create(msg.toObject());
static async attackRoll(event) {
const { modifier, damage, name: attackName } = this.actor.system.attack,
config = {
event: event,
title: attackName,
roll: {
modifier: modifier,
type: 'action'
},
chatMessage: {
type: 'adversaryRoll',
template: 'systems/daggerheart/templates/chat/adversary-attack-roll.hbs'
},
damage: {
value: damage.value,
type: damage.type
},
checkTarget: true
};
this.actor.diceRoll(config);
}
static async addExperience() {

View file

@ -33,6 +33,7 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
selectAncestry: this.selectAncestry,
selectCommunity: this.selectCommunity,
viewObject: this.viewObject,
useItem: this.useItem,
useFeature: this.useFeature,
takeShortRest: this.takeShortRest,
takeLongRest: this.takeLongRest,
@ -150,6 +151,10 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
super._attachPartListeners(partId, htmlElement, options);
htmlElement.querySelector('.level-value').addEventListener('change', this.onLevelChange.bind(this));
// To Remove when ContextMenu Handler is made
htmlElement
.querySelectorAll('[data-item-id]')
.forEach(element => element.addEventListener('contextmenu', this.editItem.bind(this)));
}
async _prepareContext(_options) {
@ -280,7 +285,24 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
}
static async rollAttribute(event, button) {
const { roll, hope, fear, advantage, disadvantage, modifiers } = await this.document.dualityRoll(
const abilityLabel = game.i18n.localize(abilities[button.dataset.attribute].label);
const config = {
event: event,
title: game.i18n.format('DAGGERHEART.Chat.DualityRoll.AbilityCheckTitle', {
ability: abilityLabel
}),
roll: {
label: abilityLabel,
modifier: button.dataset.value
},
chatMessage: {
template: 'systems/daggerheart/templates/chat/duality-roll.hbs'
}
};
this.document.diceRoll(config);
// Delete when new roll logic test done
/* const { roll, hope, fear, advantage, disadvantage, modifiers } = await this.document.dualityRoll(
{ title: game.i18n.localize(abilities[button.dataset.attribute].label), value: button.dataset.value },
event.shiftKey
);
@ -310,7 +332,7 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
systemContent
),
rolls: [roll]
});
}); */
}
static async toggleMarks(_, button) {
@ -348,51 +370,8 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
static async attackRoll(event, button) {
const weapon = await fromUuid(button.dataset.weapon);
const damage = {
value: `${this.document.system.proficiency}${weapon.system.damage.value}`,
type: weapon.system.damage.type
};
const modifier = this.document.system.traits[weapon.system.trait].value;
const { roll, hope, fear, advantage, disadvantage, modifiers } = await this.document.dualityRoll(
{ title: game.i18n.localize(abilities[weapon.system.trait].label), value: modifier },
event.shiftKey
);
const targets = Array.from(game.user.targets).map(x => ({
id: x.id,
name: x.actor.name,
img: x.actor.img,
difficulty: x.actor.system.difficulty,
evasion: x.actor.system.evasion
}));
const systemData = new DHDualityRoll({
title: weapon.name,
origin: this.document.id,
roll: roll._formula,
modifiers: modifiers,
hope: hope,
fear: fear,
advantage: advantage,
disadvantage: disadvantage,
damage: damage,
targets: targets
});
const cls = getDocumentClass('ChatMessage');
const msg = new cls({
type: 'dualityRoll',
sound: CONFIG.sounds.dice,
system: systemData,
content: await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/chat/attack-roll.hbs',
systemData
),
rolls: [roll]
});
await cls.create(msg.toObject());
if (!weapon) return;
weapon.use(event);
}
static openLevelUp() {
@ -470,6 +449,12 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
(await game.packs.get('daggerheart.communities'))?.render(true);
}
static useItem(event) {
const uuid = event.target.closest('[data-item-id]').dataset.itemId,
item = this.document.items.find(i => i.uuid === uuid);
item.use(event);
}
static async viewObject(_, button) {
const object = await fromUuid(button.dataset.value);
if (!object) return;
@ -482,6 +467,16 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
object.sheet.render(true);
}
editItem(event) {
const uuid = event.target.closest('[data-item-id]').dataset.itemId,
item = this.document.items.find(i => i.uuid === uuid);
if (!item) return;
if (item.sheet.editMode) item.sheet.editMode = false;
item.sheet.render(true);
}
static async takeShortRest() {
await new DhpDowntime(this.document, true).render(true);
await this.minimize();

View file

@ -0,0 +1,129 @@
import DhpApplicationMixin from './daggerheart-sheet.mjs';
import DHActionConfig from '../config/Action.mjs';
import { actionsTypes } from '../../data/_module.mjs';
export default function DHItemMixin(Base) {
return class DHItemSheetV2 extends DhpApplicationMixin(Base) {
constructor(options = {}) {
super(options);
}
static DEFAULT_OPTIONS = {
tag: 'form',
classes: ['daggerheart', 'sheet', 'item', 'dh-style'],
position: { width: 600 },
form: {
handler: this.updateForm,
submitOnChange: true,
closeOnSubmit: false
},
actions: {
addAction: this.addAction,
editAction: this.editAction,
removeAction: this.removeAction
}
}
static TABS = {
description: {
active: true,
cssClass: '',
group: 'primary',
id: 'description',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Description'
},
actions: {
active: false,
cssClass: '',
group: 'primary',
id: 'actions',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Actions'
},
settings: {
active: false,
cssClass: '',
group: 'primary',
id: 'settings',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Settings'
}
}
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
context.document = this.document;
context.config = CONFIG.daggerheart;
context.tabs = super._getTabs(this.constructor.TABS);
return context;
}
static async updateForm(event, _, formData) {
await this.document.update(formData.object);
this.render();
}
static async selectActionType() {
const content = await foundry.applications.handlebars.renderTemplate(
"systems/daggerheart/templates/views/actionType.hbs",
{types: SYSTEM.ACTIONS.actionTypes}
),
title = 'Select Action Type',
type = 'form',
data = {};
return Dialog.prompt({
title,
label: title,
content, type,
callback: html => {
const form = html[0].querySelector("form"),
fd = new foundry.applications.ux.FormDataExtended(form);
foundry.utils.mergeObject(data, fd.object, { inplace: true });
// if (!data.name?.trim()) data.name = game.i18n.localize(SYSTEM.ACTIONS.actionTypes[data.type].name);
return data;
},
rejectClose: false
})
}
static async addAction() {
const actionType = await DHItemSheetV2.selectActionType(),
actionIndexes = this.document.system.actions.map(x => x._id.split('-')[2]).sort((a, b) => a - b)
try {
const cls = actionsTypes[actionType?.type] ?? actionsTypes.attack,
action = new cls(
{
// id: `${this.document.id}-Action-${actionIndexes.length > 0 ? actionIndexes[0] + 1 : 1}`
_id: foundry.utils.randomID(),
type: actionType.type,
name: game.i18n.localize(SYSTEM.ACTIONS.actionTypes[actionType.type].name),
...cls.getSourceConfig(this.document)
},
{
parent: this.document
}
);
await this.document.update({ 'system.actions': [...this.document.system.actions, action] });
await new DHActionConfig(this.document.system.actions[this.document.system.actions.length - 1]).render(true);
} catch (error) {
console.log(error)
}
}
static async editAction(_, button) {
const action = this.document.system.actions[button.dataset.index];
await new DHActionConfig(action).render(true);
}
static async removeAction(event, button) {
event.stopPropagation();
await this.document.update({
'system.actions': this.document.system.actions.filter(
(_, index) => index !== Number.parseInt(button.dataset.index)
)
});
}
}
}

View file

@ -1,16 +1,9 @@
import DaggerheartSheet from '../daggerheart-sheet.mjs';
import DHItemSheetV2 from '../item.mjs';
const { ItemSheetV2 } = foundry.applications.sheets;
export default class ArmorSheet extends DaggerheartSheet(ItemSheetV2) {
export default class ArmorSheet extends DHItemSheetV2(ItemSheetV2) {
static DEFAULT_OPTIONS = {
tag: 'form',
classes: ['daggerheart', 'sheet', 'item', 'dh-style', 'armor'],
position: { width: 600 },
form: {
handler: this.updateForm,
submitOnChange: true,
closeOnSubmit: false
},
classes: ['armor'],
dragDrop: [{ dragSelector: null, dropSelector: null }]
};
@ -18,42 +11,13 @@ export default class ArmorSheet extends DaggerheartSheet(ItemSheetV2) {
header: { template: 'systems/daggerheart/templates/sheets/items/armor/header.hbs' },
tabs: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-navigation.hbs' },
description: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-description.hbs' },
actions: {
template: 'systems/daggerheart/templates/sheets/global/tabs/tab-actions.hbs',
scrollable: ['.actions']
},
settings: {
template: 'systems/daggerheart/templates/sheets/items/armor/settings.hbs',
scrollable: ['.settings']
}
};
static TABS = {
description: {
active: true,
cssClass: '',
group: 'primary',
id: 'description',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Description'
},
settings: {
active: false,
cssClass: '',
group: 'primary',
id: 'settings',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Settings'
}
};
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
context.document = this.document;
context.config = CONFIG.daggerheart;
context.tabs = super._getTabs(this.constructor.TABS);
return context;
}
static async updateForm(event, _, formData) {
await this.document.update(formData.object);
this.render();
}
}

View file

@ -1,57 +1,23 @@
import DaggerheartSheet from '../daggerheart-sheet.mjs';
import DHItemSheetV2 from '../item.mjs'
const { ItemSheetV2 } = foundry.applications.sheets;
export default class ConsumableSheet extends DaggerheartSheet(ItemSheetV2) {
export default class ConsumableSheet extends DHItemSheetV2(ItemSheetV2) {
static DEFAULT_OPTIONS = {
tag: 'form',
classes: ['daggerheart', 'sheet', 'item', 'dh-style', 'consumable'],
position: { width: 550 },
form: {
handler: this.updateForm,
submitOnChange: true,
closeOnSubmit: false
}
classes: ['consumable'],
position: { width: 550 }
};
static PARTS = {
header: { template: 'systems/daggerheart/templates/sheets/items/consumable/header.hbs' },
tabs: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-navigation.hbs' },
description: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-description.hbs' },
actions: {
template: 'systems/daggerheart/templates/sheets/global/tabs/tab-actions.hbs',
scrollable: ['.actions']
},
settings: {
template: 'systems/daggerheart/templates/sheets/items/consumable/settings.hbs',
scrollable: ['.settings']
}
};
static TABS = {
description: {
active: true,
cssClass: '',
group: 'primary',
id: 'description',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Description'
},
settings: {
active: false,
cssClass: '',
group: 'primary',
id: 'settings',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Settings'
}
};
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
context.document = this.document;
context.tabs = super._getTabs(this.constructor.TABS);
return context;
}
static async updateForm(event, _, formData) {
await this.document.update(formData.object);
this.render();
}
}

View file

@ -1,23 +1,10 @@
import DaggerheartAction from '../../../data/action.mjs';
import DaggerheartActionConfig from '../../config/Action.mjs';
import DaggerheartSheet from '../daggerheart-sheet.mjs';
import DHItemSheetV2 from '../item.mjs'
const { ItemSheetV2 } = foundry.applications.sheets;
export default class DomainCardSheet extends DaggerheartSheet(ItemSheetV2) {
export default class DomainCardSheet extends DHItemSheetV2(ItemSheetV2) {
static DEFAULT_OPTIONS = {
tag: 'form',
classes: ['daggerheart', 'sheet', 'item', 'dh-style', 'domain-card'],
position: { width: 450, height: 700 },
actions: {
addAction: this.addAction,
editAction: this.editAction,
removeAction: this.removeAction
},
form: {
handler: this.updateForm,
submitOnChange: true,
closeOnSubmit: false
}
classes: ['domain-card'],
position: { width: 450, height: 700 }
};
static PARTS = {
@ -33,74 +20,4 @@ export default class DomainCardSheet extends DaggerheartSheet(ItemSheetV2) {
scrollable: ['.settings']
}
};
static TABS = {
description: {
active: true,
cssClass: '',
group: 'primary',
id: 'description',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Description'
},
actions: {
active: false,
cssClass: '',
group: 'primary',
id: 'actions',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Actions'
},
settings: {
active: false,
cssClass: '',
group: 'primary',
id: 'settings',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Settings'
}
};
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
context.config = CONFIG.daggerheart;
context.tabs = super._getTabs(this.constructor.TABS);
return context;
}
static async updateForm(event, _, formData) {
await this.document.update(formData.object);
this.render();
}
static async addAction() {
const actionIndexes = this.document.system.actions.map(x => x.id.split('-')[2]).sort((a, b) => a - b);
const action = await new DaggerheartAction(
{
id: `${this.document.id}-Action-${actionIndexes.length > 0 ? actionIndexes[0] + 1 : 1}`
},
{
parent: this.document
}
);
await this.document.update({ 'system.actions': [...this.document.system.actions, action] });
await new DaggerheartActionConfig(this.document.system.actions[this.document.system.actions.length - 1]).render(
true
);
}
static async editAction(_, button) {
const action = this.document.system.actions[button.dataset.index];
await new DaggerheartActionConfig(action).render(true);
}
static async removeAction(event, button) {
event.stopPropagation();
await this.document.update({
'system.actions': this.document.system.actions.filter(
(_, index) => index !== Number.parseInt(button.dataset.index)
)
});
}
}

View file

@ -1,9 +1,7 @@
import DaggerheartAction from '../../../data/action.mjs';
import DaggerheartActionConfig from '../../config/Action.mjs';
import DaggerheartSheet from '../daggerheart-sheet.mjs';
import DHItemSheetV2 from '../item.mjs'
const { ItemSheetV2 } = foundry.applications.sheets;
export default class FeatureSheet extends DaggerheartSheet(ItemSheetV2) {
export default class FeatureSheet extends DHItemSheetV2(ItemSheetV2) {
constructor(options = {}) {
super(options);
@ -11,22 +9,13 @@ export default class FeatureSheet extends DaggerheartSheet(ItemSheetV2) {
}
static DEFAULT_OPTIONS = {
tag: 'form',
id: 'daggerheart-feature',
classes: ['daggerheart', 'sheet', 'item', 'dh-style', 'feature'],
classes: ['feature'],
position: { width: 600, height: 600 },
window: { resizable: true },
actions: {
addEffect: this.addEffect,
removeEffect: this.removeEffect,
addAction: this.addAction,
editAction: this.editAction,
removeAction: this.removeAction
},
form: {
handler: this.updateForm,
submitOnChange: true,
closeOnSubmit: false
removeEffect: this.removeEffect
}
};
@ -49,30 +38,7 @@ export default class FeatureSheet extends DaggerheartSheet(ItemSheetV2) {
};
static TABS = {
description: {
active: true,
cssClass: '',
group: 'primary',
id: 'description',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Description'
},
actions: {
active: false,
cssClass: '',
group: 'primary',
id: 'actions',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Actions'
},
settings: {
active: false,
cssClass: '',
group: 'primary',
id: 'settings',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Settings'
},
...super.TABS,
effects: {
active: false,
cssClass: '',
@ -102,11 +68,6 @@ export default class FeatureSheet extends DaggerheartSheet(ItemSheetV2) {
return context;
}
static async updateForm(event, _, formData) {
await this.document.update(formData.object);
this.render();
}
effectSelect(event) {
this.selectedEffectType = event.currentTarget.value;
this.render(true);
@ -130,26 +91,4 @@ export default class FeatureSheet extends DaggerheartSheet(ItemSheetV2) {
const path = `system.effects.-=${button.dataset.effect}`;
await this.item.update({ [path]: null });
}
static async addAction() {
const action = await new DaggerheartAction({ img: this.document.img }, { parent: this.document });
await this.document.update({ 'system.actions': [...this.document.system.actions, action] });
await new DaggerheartActionConfig(this.document.system.actions[this.document.system.actions.length - 1]).render(
true
);
}
static async editAction(_, button) {
const action = this.document.system.actions[button.dataset.index];
await new DaggerheartActionConfig(action).render(true);
}
static async removeAction(event, button) {
event.stopPropagation();
await this.document.update({
'system.actions': this.document.system.actions.filter(
(_, index) => index !== Number.parseInt(button.dataset.index)
)
});
}
}

View file

@ -1,57 +1,23 @@
import DaggerheartSheet from '../daggerheart-sheet.mjs';
import DHItemSheetV2 from '../item.mjs'
const { ItemSheetV2 } = foundry.applications.sheets;
export default class MiscellaneousSheet extends DaggerheartSheet(ItemSheetV2) {
export default class MiscellaneousSheet extends DHItemSheetV2(ItemSheetV2) {
static DEFAULT_OPTIONS = {
tag: 'form',
classes: ['daggerheart', 'sheet', 'item', 'dh-style', 'miscellaneous'],
position: { width: 550 },
form: {
handler: this.updateForm,
submitOnChange: true,
closeOnSubmit: false
}
classes: ['miscellaneous'],
position: { width: 550 }
};
static PARTS = {
header: { template: 'systems/daggerheart/templates/sheets/items/miscellaneous/header.hbs' },
tabs: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-navigation.hbs' },
description: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-description.hbs' },
actions: {
template: 'systems/daggerheart/templates/sheets/global/tabs/tab-actions.hbs',
scrollable: ['.actions']
},
settings: {
template: 'systems/daggerheart/templates/sheets/items/miscellaneous/settings.hbs',
scrollable: ['.settings']
}
};
static TABS = {
description: {
active: true,
cssClass: '',
group: 'primary',
id: 'description',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Description'
},
settings: {
active: false,
cssClass: '',
group: 'primary',
id: 'settings',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Settings'
}
};
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
context.document = this.document;
context.tabs = super._getTabs(this.constructor.TABS);
return context;
}
static async updateForm(event, _, formData) {
await this.document.update(formData.object);
this.render();
}
}

View file

@ -1,58 +1,22 @@
import DaggerheartSheet from '../daggerheart-sheet.mjs';
import DHItemSheetV2 from '../item.mjs'
const { ItemSheetV2 } = foundry.applications.sheets;
export default class WeaponSheet extends DaggerheartSheet(ItemSheetV2) {
export default class WeaponSheet extends DHItemSheetV2(ItemSheetV2) {
static DEFAULT_OPTIONS = {
tag: 'form',
classes: ['daggerheart', 'sheet', 'item', 'dh-style', 'weapon'],
position: { width: 600 },
form: {
handler: this.updateForm,
submitOnChange: true,
closeOnSubmit: false
}
};
classes: ['weapon']
}
static PARTS = {
header: { template: 'systems/daggerheart/templates/sheets/items/weapon/header.hbs' },
tabs: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-navigation.hbs' },
description: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-description.hbs' },
actions: {
template: 'systems/daggerheart/templates/sheets/global/tabs/tab-actions.hbs',
scrollable: ['.actions']
},
settings: {
template: 'systems/daggerheart/templates/sheets/items/weapon/settings.hbs',
scrollable: ['.settings']
}
};
static TABS = {
description: {
active: true,
cssClass: '',
group: 'primary',
id: 'description',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Description'
},
settings: {
active: false,
cssClass: '',
group: 'primary',
id: 'settings',
icon: null,
label: 'DAGGERHEART.Sheets.Feature.Tabs.Settings'
}
};
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
context.document = this.document;
context.config = CONFIG.daggerheart;
context.tabs = super._getTabs(this.constructor.TABS);
return context;
}
static async updateForm(event, _, formData) {
await this.document.update(formData.object);
this.render();
}
}

View file

@ -1,7 +1,43 @@
export const actionTypes = {
attack: {
id: 'attack',
name: 'DAGGERHEART.Actions.Types.Attack.Name',
icon: "fa-swords"
},
spellcast: {
id: 'spellcast',
name: 'DAGGERHEART.Actions.Types.Spellcast.Name',
icon: "fa-book-sparkles"
},
healing: {
id: 'healing',
name: 'DAGGERHEART.Actions.Types.Healing.Name',
icon: "fa-kit-medical"
},
resource: {
id: 'resource',
name: 'DAGGERHEART.Actions.Types.Resource.Name',
icon: "fa-honey-pot"
},
damage: {
id: 'damage',
name: 'DAGGERHEART.Effects.Types.Health.Name'
name: 'DAGGERHEART.Actions.Types.Damage.Name',
icon: "fa-bone-break"
},
summon: {
id: 'summon',
name: 'DAGGERHEART.Actions.Types.Summon.Name',
icon: "fa-ghost"
},
effect: {
id: 'effect',
name: 'DAGGERHEART.Actions.Types.Effect.Name',
icon: "fa-person-rays"
},
macro: {
id: 'macro',
name: 'DAGGERHEART.Actions.Types.Macro.Name',
icon: "fa-scroll"
}
};

View file

@ -1,4 +1,9 @@
export const range = {
self: {
label: 'DAGGERHEART.Range.self.name',
description: 'DAGGERHEART.Range.self.description',
distance: 0
},
melee: {
id: 'melee',
label: 'DAGGERHEART.Range.melee.name',
@ -248,6 +253,11 @@ export const diceTypes = {
d20: 'd20'
};
export const multiplierTypes = {
proficiency: 'Proficiency',
spellcast: 'Spellcast'
};
export const getDiceSoNicePresets = () => {
const { diceSoNice } = game.settings.get(SYSTEM.id, SYSTEM.SETTINGS.gameSettings.appearance);
@ -312,3 +322,18 @@ export const abilityCosts = {
label: 'Stress'
}
};
export const rollTypes = {
weapon: {
id: 'weapon',
label: 'DAGGERHEART.RollTypes.weapon.name'
},
spellcast: {
id: 'spellcast',
label: 'DAGGERHEART.RollTypes.spellcast.name'
},
ability: {
id: 'ability',
label: 'DAGGERHEART.RollTypes.ability.name'
}
}

View file

@ -5,4 +5,5 @@ export { default as DhCombatant } from './combatant.mjs';
export * as actors from './actor/_module.mjs';
export * as items from './item/_module.mjs';
export { actionsTypes } from './action/_module.mjs';
export * as messages from './chat-message/_modules.mjs';

View file

@ -1,34 +0,0 @@
export default class DaggerheartAction extends foundry.abstract.DataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
id: new fields.DocumentIdField(),
name: new fields.StringField({ initial: 'New Action' }),
damage: new fields.SchemaField({
type: new fields.StringField({ choices: SYSTEM.GENERAL.damageTypes, nullable: true, initial: null }),
value: new fields.StringField({})
}),
healing: new fields.SchemaField({
type: new fields.StringField({ choices: SYSTEM.GENERAL.healingTypes, nullable: true, initial: null }),
value: new fields.StringField()
}),
conditions: new fields.ArrayField(
new fields.SchemaField({
name: new fields.StringField(),
icon: new fields.StringField(),
description: new fields.StringField()
})
),
cost: new fields.SchemaField({
type: new fields.StringField({ choices: SYSTEM.GENERAL.abilityCosts, nullable: true, initial: null }),
value: new fields.NumberField({ nullable: true, initial: null })
}),
target: new fields.SchemaField({
type: new fields.StringField({
choices: SYSTEM.ACTIONS.targetTypes,
initial: SYSTEM.ACTIONS.targetTypes.other.id
})
})
};
}
}

View file

@ -0,0 +1,13 @@
import { DHAttackAction, DHBaseAction, DHDamageAction, DHEffectAction, DHHealingAction, DHMacroAction, DHResourceAction, DHSpellCastAction, DHSummonAction } from "./action.mjs";
export const actionsTypes = {
base: DHBaseAction,
attack: DHAttackAction,
spellcast: DHSpellCastAction,
resource: DHResourceAction,
damage: DHDamageAction,
healing: DHHealingAction,
summon: DHSummonAction,
effect: DHEffectAction,
macro: DHMacroAction
}

View file

@ -0,0 +1,380 @@
import { abilities } from '../../config/actorConfig.mjs';
import { DHActionDiceData, DHDamageData, DHDamageField } from './actionDice.mjs';
export default class DHAction extends foundry.abstract.DataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
id: new fields.DocumentIdField(),
name: new fields.StringField({ initial: 'New Action' }),
damage: new fields.SchemaField({
type: new fields.StringField({ choices: SYSTEM.GENERAL.damageTypes, nullable: true, initial: null }),
value: new fields.StringField({})
}),
healing: new fields.SchemaField({
type: new fields.StringField({ choices: SYSTEM.GENERAL.healingTypes, nullable: true, initial: null }),
value: new fields.StringField()
}),
conditions: new fields.ArrayField(
new fields.SchemaField({
name: new fields.StringField(),
icon: new fields.StringField(),
description: new fields.StringField()
})
),
cost: new fields.SchemaField({
type: new fields.StringField({ choices: SYSTEM.GENERAL.abilityCosts, nullable: true, initial: null }),
value: new fields.NumberField({ nullable: true, initial: null })
}),
target: new fields.SchemaField({
type: new fields.StringField({
choices: SYSTEM.ACTIONS.targetTypes,
initial: SYSTEM.ACTIONS.targetTypes.other.id
})
})
};
}
}
const fields = foundry.data.fields;
/*
ToDo
- Apply ActiveEffect => Add to Chat message like Damage Button ?
- Add Drag & Drop for documentUUID field (Macro & Summon)
- Add optionnal Role for Healing ?
- Handle Roll result as part of formula if needed
- Target Check
- Cost Check
- Range Check
- Area of effect and measurement placement
- Auto use costs and action
*/
export class DHBaseAction extends foundry.abstract.DataModel {
static defineSchema() {
return {
_id: new fields.DocumentIdField(),
type: new fields.StringField({ initial: undefined, readonly: true, required: true }),
name: new fields.StringField({ initial: undefined }),
img: new fields.FilePathField({ initial: undefined, categories: ['IMAGE'], base64: false }),
actionType: new fields.StringField({ choices: SYSTEM.ITEM.actionTypes, initial: 'action', nullable: true }),
cost: new fields.ArrayField(
new fields.SchemaField({
type: new fields.StringField({
choices: SYSTEM.GENERAL.abilityCosts,
nullable: false,
required: true,
initial: 'hope'
}),
value: new fields.NumberField({ nullable: true, initial: 1 }),
scalable: new fields.BooleanField({ initial: false }),
step: new fields.NumberField({ nullable: true, initial: null })
})
),
uses: new fields.SchemaField({
value: new fields.NumberField({ nullable: true, initial: null }),
max: new fields.NumberField({ nullable: true, initial: null }),
recovery: new fields.StringField({
choices: SYSTEM.GENERAL.refreshTypes,
initial: null,
nullable: true
})
}),
range: new fields.StringField({
choices: SYSTEM.GENERAL.range,
required: true,
blank: false,
initial: 'self'
})
};
}
prepareData() {}
get index() {
return this.parent.actions.indexOf(this);
}
get item() {
return this.parent.parent;
}
get actor() {
return this.item?.actor;
}
get chatTemplate() {
return 'systems/daggerheart/templates/chat/attack-roll.hbs';
}
static getRollType() {
return 'ability';
}
static getSourceConfig(parent) {
const updateSource = {};
updateSource.img ??= parent?.img ?? parent?.system?.img;
if (parent?.system?.trait) {
updateSource['roll'] = {
type: this.getRollType(),
trait: parent.system.trait
};
}
if (parent?.system?.range) {
updateSource['range'] = parent?.system?.range;
}
return updateSource;
}
async use(event) {
if (this.roll.type && this.roll.trait) {
const modifierValue = this.actor.system.traits[this.roll.trait].value;
const config = {
event: event,
title: this.item.name,
roll: {
modifier: modifierValue,
label: game.i18n.localize(abilities[this.roll.trait].label),
type: this.actionType,
difficulty: this.roll?.difficulty
},
chatMessage: {
template: this.chatTemplate
}
};
if (this.target?.type) config.checkTarget = true;
if (this.damage.parts.length) {
config.damage = {
value: this.damage.parts.map(p => p.getFormula(this.actor)).join(' + '),
type: this.damage.parts[0].type
};
}
if (this.effects.length) {
// Apply Active Effects. In Chat Message ?
}
return this.actor.diceRoll(config);
}
}
}
const extraDefineSchema = (field, option) => {
return {
[field]: {
// damage: new fields.SchemaField({
// parts: new fields.ArrayField(new fields.EmbeddedDataField(DHDamageData))
// }),
damage: new DHDamageField(option),
roll: new fields.SchemaField({
type: new fields.StringField({ nullable: true, initial: null, choices: SYSTEM.GENERAL.rollTypes }),
trait: new fields.StringField({ nullable: true, initial: null, choices: SYSTEM.ACTOR.abilities }),
difficulty: new fields.NumberField({ nullable: true, initial: null, integer: true, min: 0 })
}),
target: new fields.SchemaField({
type: new fields.StringField({
choices: SYSTEM.ACTIONS.targetTypes,
initial: SYSTEM.ACTIONS.targetTypes.other.id
})
}),
effects: new fields.ArrayField( // ActiveEffect
new fields.SchemaField({
_id: new fields.DocumentIdField()
})
)
}[field]
};
};
export class DHAttackAction extends DHBaseAction {
static defineSchema() {
return {
...super.defineSchema(),
...extraDefineSchema('damage', true),
...extraDefineSchema('roll'),
...extraDefineSchema('target'),
...extraDefineSchema('effects')
};
}
static getRollType() {
return 'weapon';
}
prepareData() {
super.prepareData();
if (this.damage.includeBase && !!this.item?.system?.damage) {
const baseDamage = this.getParentDamage();
this.damage.parts.unshift(new DHDamageData(baseDamage));
}
}
getParentDamage() {
return {
multiplier: 'proficiency',
dice: this.item?.system?.damage.value,
bonus: this.item?.system?.damage.bonus ?? 0,
type: this.item?.system?.damage.type,
base: true
};
}
// Temporary until full formula parser
// getDamageFormula() {
// return this.damage.parts.map(p => p.formula).join(' + ');
// }
}
export class DHSpellCastAction extends DHBaseAction {
static defineSchema() {
return {
...super.defineSchema(),
...extraDefineSchema('damage'),
...extraDefineSchema('roll'),
...extraDefineSchema('target'),
...extraDefineSchema('effects')
};
}
static getRollType() {
return 'spellcast';
}
}
export class DHDamageAction extends DHBaseAction {
static defineSchema() {
return {
...super.defineSchema(),
...extraDefineSchema('damage', false),
...extraDefineSchema('target'),
...extraDefineSchema('effects')
};
}
async use(event) {
const formula = this.damage.parts.map(p => p.getFormula(this.actor)).join(' + ');
if (!formula || formula == '') return;
let roll = { formula: formula, total: formula };
if (isNaN(formula)) {
roll = await new Roll(formula).evaluate();
}
const cls = getDocumentClass('ChatMessage');
const msg = new cls({
user: game.user.id,
content: await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/chat/damage-roll.hbs',
{
roll: roll.formula,
total: roll.total,
type: this.damage.parts.map(p => p.type)
}
)
});
cls.create(msg.toObject());
}
}
export class DHHealingAction extends DHBaseAction {
static defineSchema() {
return {
...super.defineSchema(),
healing: new fields.SchemaField({
type: new fields.StringField({
choices: SYSTEM.GENERAL.healingTypes,
required: true,
blank: false,
initial: SYSTEM.GENERAL.healingTypes.health.id,
label: 'Healing'
}),
value: new fields.EmbeddedDataField(DHActionDiceData)
}),
...extraDefineSchema('target'),
...extraDefineSchema('effects')
};
}
async use(event) {
const formula = this.healing.value.getFormula(this.actor);
if (!formula || formula == '') return;
// const roll = await super.use(event);
let roll = { formula: formula, total: formula };
if (isNaN(formula)) {
roll = await new Roll(formula).evaluate();
}
const cls = getDocumentClass('ChatMessage');
const msg = new cls({
user: game.user.id,
content: await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/chat/healing-roll.hbs',
{
roll: roll.formula,
total: roll.total,
type: this.healing.type
}
)
});
cls.create(msg.toObject());
}
get chatTemplate() {
return 'systems/daggerheart/templates/chat/healing-roll.hbs';
}
}
export class DHResourceAction extends DHBaseAction {
static defineSchema() {
return {
...super.defineSchema(),
// ...extraDefineSchema('roll'),
...extraDefineSchema('target'),
...extraDefineSchema('effects'),
resource: new fields.SchemaField({
type: new fields.StringField({ choices: [], blank: true, required: false, initial: "", label: "Resource" }),
value: new fields.NumberField({ initial: 0, label: "Value" })
})
};
}
}
export class DHSummonAction extends DHBaseAction {
static defineSchema() {
return {
...super.defineSchema(),
documentUUID: new fields.StringField({ blank: true, initial: '', placeholder: 'Enter a Creature UUID' })
};
}
}
export class DHEffectAction extends DHBaseAction {
static defineSchema() {
return {
...super.defineSchema(),
...extraDefineSchema('effects')
};
}
}
export class DHMacroAction extends DHBaseAction {
static defineSchema() {
return {
...super.defineSchema(),
documentUUID: new fields.StringField({ blank: true, initial: '', placeholder: 'Enter a macro UUID' })
};
}
async use(event) {
const fixUUID = !this.documentUUID.includes('Macro.') ? `Macro.${this.documentUUID}` : this.documentUUID,
macro = await fromUuid(fixUUID);
try {
if (!macro) throw new Error(`No macro found for the UUID: ${this.documentUUID}.`);
macro.execute();
} catch (error) {
ui.notifications.error(error);
}
}
}

View file

@ -0,0 +1,49 @@
import FormulaField from "../fields/formulaField.mjs";
const fields = foundry.data.fields;
export class DHActionDiceData extends foundry.abstract.DataModel {
/** @override */
static defineSchema() {
return {
multiplier: new fields.StringField({ choices: SYSTEM.GENERAL.multiplierTypes, initial: 'proficiency', label: 'Multiplier' }),
dice: new fields.StringField({ choices: SYSTEM.GENERAL.diceTypes, initial: 'd6', label: 'Formula' }),
bonus: new fields.NumberField({ nullable: true, initial: null, label: 'Bonus' }),
custom: new fields.SchemaField({
enabled: new fields.BooleanField({ label: 'Custom Formula' }),
formula: new FormulaField( { label: 'Formula' } )
})
}
}
getFormula(actor) {
return this.custom.enabled ? this.custom.formula : `${(actor.system[this.multiplier] ?? 1)}${this.dice}${this.bonus ? (this.bonus < 0 ? ` - ${Math.abs(this.bonus)}` : ` + ${this.bonus}`) : ''}`;
}
}
export class DHDamageField extends fields.SchemaField {
constructor(hasBase, options, context={}) {
const damageFields = {
parts: new fields.ArrayField(new fields.EmbeddedDataField(DHDamageData))
}
if(hasBase) damageFields.includeBase = new fields.BooleanField({ initial: true })
super(damageFields, options, context);
}
}
export class DHDamageData extends DHActionDiceData {
/** @override */
static defineSchema() {
return {
...super.defineSchema(),
base: new fields.BooleanField({ initial: false, readonly: true, label: 'Base' }),
type: new fields.StringField({
choices: SYSTEM.GENERAL.damageTypes,
initial: 'physical',
label: 'Type',
nullable: false,
required: true
})
}
}
}

View file

@ -5,17 +5,19 @@ export default class DHAdversaryRoll extends foundry.abstract.TypeDataModel {
return {
title: new fields.StringField(),
origin: new fields.StringField({ required: true }),
roll: new fields.StringField({}),
total: new fields.NumberField({ integer: true }),
dice: new fields.DataField(),
roll: new fields.DataField(),
modifiers: new fields.ArrayField(
new fields.SchemaField({
value: new fields.NumberField({ integer: true }),
label: new fields.StringField({}),
title: new fields.StringField({})
label: new fields.StringField({})
})
),
advantageState: new fields.NumberField({ required: true, choices: [0, 1, 2], initial: 0 }),
dice: new fields.EmbeddedDataField(DhpAdversaryRollDice),
advantageState: new fields.BooleanField({ nullable: true, initial: null }),
advantage: new fields.SchemaField({
dice: new fields.StringField({}),
value: new fields.NumberField({ integer: true })
}),
targets: new fields.ArrayField(
new fields.SchemaField({
id: new fields.StringField({}),
@ -37,42 +39,8 @@ export default class DHAdversaryRoll extends foundry.abstract.TypeDataModel {
}
prepareDerivedData() {
const diceKeys = Object.keys(this.dice.rolls);
const highestDiceIndex =
diceKeys.length < 2
? null
: this.dice.rolls[diceKeys[0]].value > this.dice.rolls[diceKeys[1]].value
? 0
: 1;
if (highestDiceIndex !== null) {
this.dice.rolls = this.dice.rolls.map((roll, index) => ({
...roll,
discarded: this.advantageState === 1 ? index !== highestDiceIndex : index === highestDiceIndex
}));
}
this.targets.forEach(target => {
target.hit = target.difficulty ? this.total >= target.difficulty : this.total >= target.evasion;
});
}
}
class DhpAdversaryRollDice extends foundry.abstract.DataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
type: new fields.StringField({ required: true }),
rolls: new fields.ArrayField(
new fields.SchemaField({
value: new fields.NumberField({ required: true, integer: true }),
discarded: new fields.BooleanField({ initial: false })
})
)
};
}
get rollTotal() {
return this.rolls.reduce((acc, roll) => acc + (!roll.discarded ? roll.value : 0), 0);
}
}

View file

@ -9,7 +9,13 @@ export default class DHDamageRoll extends foundry.abstract.TypeDataModel {
total: new fields.NumberField({ required: true, integer: true }),
type: new fields.StringField({ choices: Object.keys(SYSTEM.GENERAL.damageTypes), integer: false })
}),
dice: new fields.ArrayField(new fields.EmbeddedDataField(DhpDamageDice)),
dice: new fields.ArrayField(
new fields.SchemaField({
type: new fields.StringField({ required: true }),
rolls: new fields.ArrayField(new fields.NumberField({ required: true, integer: true })),
total: new fields.NumberField({ integer: true })
})
),
modifiers: new fields.ArrayField(
new fields.SchemaField({
value: new fields.NumberField({ required: true, integer: true }),
@ -26,18 +32,3 @@ export default class DHDamageRoll extends foundry.abstract.TypeDataModel {
};
}
}
class DhpDamageDice extends foundry.abstract.DataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
type: new fields.StringField({ required: true }),
rolls: new fields.ArrayField(new fields.NumberField({ required: true, integer: true }))
};
}
get rollTotal() {
return this.rolls.reduce((acc, roll) => acc + roll, 0);
}
}

View file

@ -1,4 +1,4 @@
import { DualityRollColor } from "../settings/Appearance.mjs";
import { DualityRollColor } from '../settings/Appearance.mjs';
const fields = foundry.data.fields;
const diceField = () =>
@ -18,18 +18,17 @@ export default class DHDualityRoll extends foundry.abstract.TypeDataModel {
return {
title: new fields.StringField(),
origin: new fields.StringField({ required: true }),
roll: new fields.StringField({}),
roll: new fields.DataField({}),
modifiers: new fields.ArrayField(
new fields.SchemaField({
value: new fields.NumberField({ integer: true }),
label: new fields.StringField({}),
title: new fields.StringField({})
label: new fields.StringField({})
})
),
hope: diceField(),
fear: diceField(),
advantageState: new fields.BooleanField({ nullable: true, initial: null }),
advantage: diceField(),
disadvantage: diceField(),
targets: new fields.ArrayField(
new fields.SchemaField({
id: new fields.StringField({}),
@ -64,15 +63,6 @@ export default class DHDualityRoll extends foundry.abstract.TypeDataModel {
};
}
get total() {
const advantage = this.advantage.value
? this.advantage.value
: this.disadvantage.value
? -this.disadvantage.value
: 0;
return this.diceTotal + advantage + this.modifierTotal.value;
}
get diceTotal() {
return this.hope.value + this.fear.value;
}
@ -112,13 +102,7 @@ export default class DHDualityRoll extends foundry.abstract.TypeDataModel {
}
prepareDerivedData() {
const total = this.total;
this.hope.discarded = this.hope.value < this.fear.value;
this.fear.discarded = this.fear.value < this.hope.value;
this.targets.forEach(target => {
target.hit = target.difficulty ? total >= target.difficulty : total >= target.evasion;
});
}
}

View file

@ -0,0 +1,41 @@
import { actionsTypes } from "../action/_module.mjs";
// Temporary Solution
export default class ActionField extends foundry.data.fields.ObjectField {
getModel(value) {
return actionsTypes[value.type] ?? actionsTypes.attack;
}
/* -------------------------------------------- */
/** @override */
_cleanType(value, options) {
if ( !(typeof value === "object") ) value = {};
const cls = this.getModel(value);
if ( cls ) return cls.cleanData(value, options);
return value;
}
/* -------------------------------------------- */
/** @override */
initialize(value, model, options = {}) {
const cls = this.getModel(value);
if ( cls ) return new cls(value, { parent: model, ...options });
return foundry.utils.deepClone(value);
}
/* -------------------------------------------- */
/**
* Migrate this field's candidate source data.
* @param {object} sourceData Candidate source data of the root model.
* @param {any} fieldData The value of this field within the source data.
*/
migrateSource(sourceData, fieldData) {
const cls = this.getModel(fieldData);
if ( cls ) cls.migrateDataSafe(fieldData);
}
}

View file

@ -19,7 +19,7 @@ export {
DHFeature,
DHMiscellaneous,
DHSubclass,
DHWeapon,
DHWeapon
}
export const config = {

View file

@ -1,5 +1,5 @@
import DaggerheartAction from '../action.mjs';
import BaseDataItem from './base.mjs';
import DHAction from "../action/action.mjs";
import BaseDataItem from "./base.mjs";
export default class DHDomainCard extends BaseDataItem {
/** @inheritDoc */
@ -22,7 +22,7 @@ export default class DHDomainCard extends BaseDataItem {
type: new fields.StringField({ choices: SYSTEM.DOMAIN.cardTypes, required: true, blank: true }),
foundation: new fields.BooleanField({ initial: false }),
inVault: new fields.BooleanField({ initial: false }),
actions: new fields.ArrayField(new fields.EmbeddedDataField(DaggerheartAction))
actions: new fields.ArrayField(new fields.EmbeddedDataField(DHAction))
};
}

View file

@ -1,5 +1,5 @@
import { getTier } from '../../helpers/utils.mjs';
import DaggerheartAction from '../action.mjs';
import DHAction from '../action/action.mjs';
import BaseDataItem from './base.mjs';
export default class DHFeature extends BaseDataItem {
@ -92,7 +92,7 @@ export default class DHFeature extends BaseDataItem {
})
})
),
actions: new fields.ArrayField(new fields.EmbeddedDataField(DaggerheartAction))
actions: new fields.ArrayField(new fields.EmbeddedDataField(DHAction))
};
}

View file

@ -1,14 +1,15 @@
import BaseDataItem from "./base.mjs";
import FormulaField from "../fields/formulaField.mjs";
import BaseDataItem from './base.mjs';
import FormulaField from '../fields/formulaField.mjs';
import ActionField from '../fields/actionField.mjs';
export default class DHWeapon extends BaseDataItem {
/** @inheritDoc */
static get metadata() {
return foundry.utils.mergeObject(super.metadata, {
label: "TYPES.Item.weapon",
type: "weapon",
label: 'TYPES.Item.weapon',
type: 'weapon',
hasDescription: true,
isQuantifiable: true,
isQuantifiable: true
});
}
@ -33,8 +34,9 @@ export default class DHWeapon extends BaseDataItem {
initial: 'physical'
})
}),
feature: new fields.StringField({ choices: SYSTEM.ITEM.weaponFeatures, blank: true }),
// actions: new fields.ArrayField(new fields.EmbeddedDataField(DHAttackAction))
actions: new fields.ArrayField(new ActionField())
};
}
}

View file

@ -3,6 +3,7 @@ import NpcRollSelectionDialog from '../applications/npcRollSelectionDialog.mjs';
import RollSelectionDialog from '../applications/rollSelectionDialog.mjs';
import { GMUpdateEvent, socketEvent } from '../helpers/socket.mjs';
import { setDiceSoNiceForDualityRoll } from '../helpers/utils.mjs';
import DHDualityRoll from '../data/chat-message/dualityRoll.mjs';
export default class DhpActor extends Actor {
async _preCreate(data, options, user) {
@ -123,87 +124,52 @@ export default class DhpActor extends Actor {
});
}
async diceRoll(modifier, shiftKey) {
if (this.type === 'character') {
return await this.dualityRoll(modifier, shiftKey);
} else {
return await this.npcRoll(modifier, shiftKey);
}
}
async npcRoll(modifier, shiftKey) {
let advantage = null;
const modifiers = [
{
value: Number.parseInt(modifier.value),
label: modifier.value >= 0 ? `+${modifier.value}` : `-${modifier.value}`,
title: modifier.title
}
];
if (!shiftKey) {
const dialogClosed = new Promise((resolve, _) => {
new NpcRollSelectionDialog(this.system.experiences, resolve).render(true);
});
const result = await dialogClosed;
advantage = result.advantage;
result.experiences.forEach(x =>
modifiers.push({
value: x.value,
label: x.value >= 0 ? `+${x.value}` : `-${x.value}`,
title: x.description
})
);
}
const roll = Roll.create(
`${advantage === true || advantage === false ? 2 : 1}d20${advantage === true ? 'kh' : advantage === false ? 'kl' : ''} ${modifiers.map(x => `+ ${x.value}`).join(' ')}`
);
let rollResult = await roll.evaluate();
const dice = [];
for (var i = 0; i < rollResult.terms.length; i++) {
const term = rollResult.terms[i];
if (term.faces) {
dice.push({ type: `d${term.faces}`, rolls: term.results.map(x => ({ value: x.result })) });
}
}
// There is Only ever one dice term here
return { roll, dice: dice[0], modifiers, advantageState: advantage === true ? 1 : advantage === false ? 2 : 0 };
}
async dualityRoll(modifier, shiftKey) {
/**
* @param {object} config
* @param {Event} config.event
* @param {string} config.title
* @param {object} config.roll
* @param {number} config.roll.modifier
* @param {boolean} [config.roll.simple=false]
* @param {string} [config.roll.type]
* @param {number} [config.roll.difficulty]
* @param {any} [config.damage]
* @param {object} [config.chatMessage]
* @param {string} config.chatMessage.template
* @param {boolean} [config.chatMessage.mute]
* @param {boolean} [config.checkTarget]
*/
async diceRoll(config) {
let hopeDice = 'd12',
fearDice = 'd12',
advantageDice = null,
disadvantageDice = null;
advantageDice = 'd6',
disadvantageDice = 'd6',
advantage = config.event.altKey ? true : config.event.ctrlKey ? false : null,
targets,
damage = config.damage,
modifiers = this.formatRollModifier(config.roll),
rollConfig,
formula,
hope,
fear;
const modifiers =
modifier.value !== null
? [
{
value: modifier.value ? Number.parseInt(modifier.value) : 0,
label:
modifier.value >= 0
? `${modifier.title} +${modifier.value}`
: `${modifier.title} ${modifier.value}`,
title: modifier.title
}
]
: [];
if (!shiftKey) {
if (!config.event.shiftKey && !config.event.altKey && !config.event.ctrlKey) {
const dialogClosed = new Promise((resolve, _) => {
new RollSelectionDialog(this.system.experiences, this.system.resources.hope.value, resolve).render(
true
);
this.type === 'character'
? new RollSelectionDialog(
this.system.experiences,
this.system.resources.hope.value,
resolve
).render(true)
: new NpcRollSelectionDialog(this.system.experiences, resolve).render(true);
});
const result = await dialogClosed;
(hopeDice = result.hope),
(fearDice = result.fear),
(advantageDice = result.advantage),
(disadvantageDice = result.disadvantage);
result.experiences.forEach(x =>
rollConfig = await dialogClosed;
advantage = rollConfig.advantage;
hopeDice = rollConfig.hope;
fearDice = rollConfig.fear;
rollConfig.experiences.forEach(x =>
modifiers.push({
value: x.value,
label: x.value >= 0 ? `+${x.value}` : `-${x.value}`,
@ -211,60 +177,123 @@ export default class DhpActor extends Actor {
})
);
const automateHope = await game.settings.get(SYSTEM.id, SYSTEM.SETTINGS.gameSettings.Automation.Hope);
if (this.type === 'character') {
const automateHope = await game.settings.get(SYSTEM.id, SYSTEM.SETTINGS.gameSettings.Automation.Hope);
if (automateHope && result.hopeUsed) {
await this.update({
'system.resources.hope.value': this.system.resources.hope.value - result.hopeUsed
});
if (automateHope && result.hopeUsed) {
await this.update({
'system.resources.hope.value': this.system.resources.hope.value - result.hopeUsed
});
}
}
}
const roll = new Roll(
`1${hopeDice} + 1${fearDice}${advantageDice ? ` + 1${advantageDice}` : disadvantageDice ? ` - 1${disadvantageDice}` : ''} ${modifiers.map(x => `+ ${x.value}`).join(' ')}`
);
let rollResult = await roll.evaluate();
setDiceSoNiceForDualityRoll(rollResult, advantageDice, disadvantageDice);
const hope = rollResult.dice[0].results[0].result;
const fear = rollResult.dice[1].results[0].result;
const advantage = advantageDice ? rollResult.dice[2].results[0].result : null;
const disadvantage = disadvantageDice ? rollResult.dice[2].results[0].result : null;
if (disadvantage) {
rollResult = { ...rollResult, total: rollResult.total - Math.max(hope, disadvantage) };
}
if (advantage) {
rollResult = { ...rollResult, total: 'Select Hope Die' };
if (this.type === 'character') {
formula = `1${hopeDice} + 1${fearDice}${advantage === true ? ` + 1d6` : advantage === false ? ` - 1d6` : ''}`;
} else {
formula = `${advantage === true || advantage === false ? 2 : 1}d20${advantage === true ? 'kh' : advantage === false ? 'kl' : ''}`;
}
formula += ` ${modifiers.map(x => `+ ${x.value}`).join(' ')}`;
const roll = await Roll.create(formula).evaluate();
const dice = roll.dice.flatMap(dice => ({
denomination: dice.denomination,
number: dice.number,
total: dice.total,
results: dice.results.map(result => ({ result: result.result, discarded: !result.active }))
}));
const automateHope = await game.settings.get(SYSTEM.id, SYSTEM.SETTINGS.gameSettings.Automation.Hope);
if (automateHope && hope > fear) {
await this.update({
'system.resources.hope.value': Math.min(
this.system.resources.hope.value + 1,
this.system.resources.hope.max
)
});
}
if (automateHope && hope === fear) {
await this.update({
'system.resources': {
'hope.value': Math.min(this.system.resources.hope.value + 1, this.system.resources.hope.max),
'stress.value': Math.max(this.system.resources.stress.value - 1, 0)
if (this.type === 'character') {
setDiceSoNiceForDualityRoll(roll, advantage);
hope = roll.dice[0].results[0].result;
fear = roll.dice[1].results[0].result;
if (
game.settings.get(SYSTEM.id, SYSTEM.SETTINGS.gameSettings.Automation.Hope) &&
config.roll.type === 'action'
) {
if (hope > fear) {
await this.update({
'system.resources.hope.value': Math.min(
this.system.resources.hope.value + 1,
this.system.resources.hope.max
)
});
} else if (hope === fear) {
await this.update({
'system.resources': {
'hope.value': Math.min(
this.system.resources.hope.value + 1,
this.system.resources.hope.max
),
'stress.value': Math.max(this.system.resources.stress.value - 1, 0)
}
});
}
}
}
if (config.checkTarget) {
targets = Array.from(game.user.targets).map(x => {
const target = {
id: x.id,
name: x.actor.name,
img: x.actor.img,
difficulty: x.actor.system.difficulty,
evasion: x.actor.system.evasion?.value
};
target.hit = target.difficulty ? roll.total >= target.difficulty : roll.total >= target.evasion;
return target;
});
}
return {
roll,
rollResult,
hope: { dice: hopeDice, value: hope },
fear: { dice: fearDice, value: fear },
advantage: { dice: advantageDice, value: advantage },
disadvantage: { dice: disadvantageDice, value: disadvantage },
modifiers: modifiers
};
if (config.chatMessage) {
const configRoll = {
title: config.title,
origin: this.id,
dice,
roll,
modifiers: modifiers.filter(x => x.label),
advantageState: advantage
};
if (this.type === 'character') {
configRoll.hope = { dice: hopeDice, value: hope };
configRoll.fear = { dice: fearDice, value: fear };
configRoll.advantage = { dice: advantageDice, value: roll.dice[2]?.results[0].result ?? null };
}
if (damage) configRoll.damage = damage;
if (targets) configRoll.targets = targets;
const systemData =
this.type === 'character' && !config.roll.simple ? new DHDualityRoll(configRoll) : configRoll,
cls = getDocumentClass('ChatMessage'),
msg = new cls({
type: config.chatMessage.type ?? 'dualityRoll',
sound: config.chatMessage.mute ? null : CONFIG.sounds.dice,
system: systemData,
content: config.chatMessage.template,
rolls: [roll]
});
await cls.create(msg.toObject());
}
return roll;
}
formatRollModifier(roll) {
const modifier = roll.modifier !== null ? Number.parseInt(roll.modifier) : null;
return modifier !== null
? [
{
value: modifier,
label: roll.label
? modifier >= 0
? `${roll.label} +${modifier}`
: `${roll.label} ${modifier}`
: null,
title: roll.label
}
]
: [];
}
async damageRoll(title, damage, targets, shiftKey) {
@ -294,7 +323,11 @@ export default class DhpActor extends Actor {
for (var i = 0; i < rollResult.terms.length; i++) {
const term = rollResult.terms[i];
if (term.faces) {
dice.push({ type: `d${term.faces}`, rolls: term.results.map(x => x.result) });
dice.push({
type: `d${term.faces}`,
rolls: term.results.map(x => x.result),
total: term.results.reduce((acc, x) => acc + x.result, 0)
});
} else if (term.operator) {
} else if (term.number) {
const operator = i === 0 ? '' : rollResult.terms[i - 1].operator;

View file

@ -2,6 +2,12 @@ export default class DhpItem extends Item {
prepareData() {
super.prepareData();
}
/** @inheritDoc */
prepareEmbeddedDocuments() {
super.prepareEmbeddedDocuments();
for ( const action of this.system.actions ?? [] ) action.prepareData();
}
/**
* @inheritdoc
@ -90,4 +96,46 @@ export default class DhpItem extends Item {
options
});
}
async selectActionDialog() {
const content = await foundry.applications.handlebars.renderTemplate(
"systems/daggerheart/templates/views/actionSelect.hbs",
{actions: this.system.actions}
),
title = 'Select Action',
type = 'div',
data = {};
return Dialog.prompt({
title,
// label: title,
content, type,
callback: html => {
const form = html[0].querySelector("form"),
fd = new foundry.applications.ux.FormDataExtended(form);
return this.system.actions.find(a => a._id === fd.object.actionId);
},
rejectClose: false
})
}
async use(event) {
const actions = this.system.actions
let response;
if(actions?.length) {
let action = actions[0];
if(actions.length > 1 && !event?.shiftKey) {
// Actions Choice Dialog
action = await this.selectActionDialog();
}
if(action) response = action.use(event);
// Check Target
// If action.roll => Roll Dialog
// Else If action.cost => Cost Dialog
// Then
// Apply Cost
// Apply Effect
}
// Display Item Card in chat
return response;
}
}

View file

@ -9,21 +9,26 @@ export function dualityRollEnricher(match, _options) {
}
export function getDualityMessage(roll) {
const attributeLabel =
roll.attribute && abilities[roll.attribute]
const traitLabel =
roll.trait && abilities[roll.trait]
? game.i18n.format('DAGGERHEART.General.Check', {
check: game.i18n.localize(abilities[roll.attribute].label)
check: game.i18n.localize(abilities[roll.trait].label)
})
: null;
const label = attributeLabel ?? game.i18n.localize('DAGGERHEART.General.Duality');
const label = traitLabel ?? game.i18n.localize('DAGGERHEART.General.Duality');
const dataLabel = traitLabel
? game.i18n.localize(abilities[roll.trait].label)
: game.i18n.localize('DAGGERHEART.General.Duality');
const dualityElement = document.createElement('span');
dualityElement.innerHTML = `
<button class="duality-roll-button"
data-label="${label}"
data-title="${label}"
data-label="${dataLabel}"
data-hope="${roll.hope ?? 'd12'}"
data-fear="${roll.fear ?? 'd12'}"
${roll.attribute && abilities[roll.attribute] ? `data-attribute="${roll.attribute}"` : ''}
${roll.trait && abilities[roll.trait] ? `data-trait="${roll.trait}"` : ''}
${roll.advantage ? 'data-advantage="true"' : ''}
${roll.disadvantage ? 'data-disadvantage="true"' : ''}
>

View file

@ -122,14 +122,16 @@ export const getCommandTarget = () => {
return target;
};
export const setDiceSoNiceForDualityRoll = (rollResult, advantage, disadvantage) => {
export const setDiceSoNiceForDualityRoll = (rollResult, advantageState) => {
const diceSoNicePresets = getDiceSoNicePresets();
rollResult.dice[0].options.appearance = diceSoNicePresets.hope;
rollResult.dice[1].options.appearance = diceSoNicePresets.fear;
if (advantage) {
rollResult.dice[2].options.appearance = diceSoNicePresets.advantage;
} else if (disadvantage) {
rollResult.dice[2].options.appearance = diceSoNicePresets.disadvantage;
if (rollResult.dice[2]) {
if (advantageState === true) {
rollResult.dice[2].options.appearance = diceSoNicePresets.advantage;
} else if (advantageState === false) {
rollResult.dice[2].options.appearance = diceSoNicePresets.disadvantage;
}
}
};
@ -222,3 +224,14 @@ export const getDeleteKeys = (property, innerProperty, innerPropertyDefaultValue
return acc;
}, {});
};
// Fix on Foundry native formula replacement for DH
const nativeReplaceFormulaData = Roll.replaceFormulaData;
Roll.replaceFormulaData = function (formula, data, { missing, warn = false } = {}) {
const terms = [
{ term: 'prof', default: 1 },
{ term: 'cast', default: 1 }
];
formula = terms.reduce((a, c) => a.replaceAll(`@${c.term}`, data[c.term] ?? c.default), formula);
return nativeReplaceFormulaData(formula, data, { missing, warn });
};