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

@ -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())
};
}
}