Merged with development

This commit is contained in:
WBHarry 2025-09-06 23:23:07 +02:00
commit 19a07139ff
548 changed files with 4997 additions and 2887 deletions

View file

@ -51,11 +51,13 @@ export default class DHAttackAction extends DHDamageAction {
const labels = [];
const { roll, range, damage } = this;
if (roll.trait) labels.push(game.i18n.localize(`DAGGERHEART.CONFIG.Traits.${roll.trait}.short`))
if (roll.trait) labels.push(game.i18n.localize(`DAGGERHEART.CONFIG.Traits.${roll.trait}.short`));
if (range) labels.push(game.i18n.localize(`DAGGERHEART.CONFIG.Range.${range}.short`));
for (const { value, type } of damage.parts) {
const str = Roll.replaceFormulaData(value.getFormula(), this.actor?.getRollData() ?? {});
const useAltDamage = this.actor?.effects?.find(x => x.type === 'horde')?.active;
for (const { value, valueAlt, type } of damage.parts) {
const usedValue = useAltDamage ? valueAlt : value;
const str = Roll.replaceFormulaData(usedValue.getFormula(), this.actor?.getRollData() ?? {});
const icons = Array.from(type)
.map(t => CONFIG.DH.GENERAL.damageTypes[t]?.icon)

View file

@ -1,14 +1,9 @@
import DhpActor from '../../documents/actor.mjs';
import D20RollDialog from '../../applications/dialogs/d20RollDialog.mjs';
import { ActionMixin } from '../fields/actionField.mjs';
import { abilities } from '../../config/actorConfig.mjs';
const fields = foundry.data.fields;
/*
!!! I'm currently refactoring the whole Action thing, it's a WIP !!!
*/
/*
ToDo
- Target Check / Target Picker
@ -20,6 +15,7 @@ const fields = foundry.data.fields;
export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel) {
static extraSchemas = ['cost', 'uses', 'range'];
/** @inheritDoc */
static defineSchema() {
const schemaFields = {
_id: new fields.DocumentIdField({ initial: () => foundry.utils.randomID() }),
@ -37,31 +33,76 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
};
this.extraSchemas.forEach(s => {
let clsField;
if ((clsField = this.getActionField(s))) schemaFields[s] = new clsField();
let clsField = this.getActionField(s);
if (clsField) schemaFields[s] = new clsField();
});
return schemaFields;
}
/**
* Create a Map containing each Action step based on fields define in schema. Ordered by Fields order property.
*
* Each step can be called individually as long as needed config is provided.
* Ex: <action>.workflow.get("damage").execute(config)
* @returns {Map}
*/
defineWorkflow() {
const workflow = new Map();
this.constructor.extraSchemas.forEach(s => {
let clsField = this.constructor.getActionField(s);
if (clsField?.execute) {
workflow.set(s, { order: clsField.order, execute: clsField.execute.bind(this) });
if (s === 'damage')
workflow.set('applyDamage', { order: 75, execute: clsField.applyDamage.bind(this) });
}
});
return new Map([...workflow.entries()].sort(([aKey, aValue], [bKey, bValue]) => aValue.order - bValue.order));
}
/**
* Getter returning the workflow property or creating it the first time the property is called
*/
get workflow() {
if (this.hasOwnProperty('_workflow')) return this._workflow;
const workflow = Object.freeze(this.defineWorkflow());
Object.defineProperty(this, '_workflow', { value: workflow, writable: false });
return workflow;
}
/**
* Get the Field class from ActionFields global config
* @param {string} name Field short name, equal to Action property
* @returns Action Field
*/
static getActionField(name) {
const field = game.system.api.fields.ActionFields[`${name.capitalize()}Field`];
return fields.DataField.isPrototypeOf(field) && field;
}
/** @inheritDoc */
prepareData() {
this.name = this.name || game.i18n.localize(CONFIG.DH.ACTIONS.actionTypes[this.type].name);
this.img = this.img ?? this.parent?.parent?.img;
}
/**
* Get Action ID
*/
get id() {
return this._id;
}
/**
* Return Item the action is attached too.
*/
get item() {
return this.parent.parent;
}
/**
* Return the first Actor parent found.
*/
get actor() {
return this.item instanceof DhpActor
? this.item
@ -74,6 +115,11 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
return 'trait';
}
/**
* Prepare base data based on Action Type & Parent Type
* @param {object} parent
* @returns {object}
*/
static getSourceConfig(parent) {
const updateSource = {};
if (parent?.parent?.type === 'weapon' && this === game.system.api.models.actions.actionsTypes.attack) {
@ -96,6 +142,11 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
return updateSource;
}
/**
* Obtain a data object used to evaluate any dice rolls associated with this particular Action
* @param {object} [data ={}] Optional data object from previous configuration/rolls
* @returns {object}
*/
getRollData(data = {}) {
if (!this.actor) return null;
const actorData = this.actor.getRollData(false);
@ -111,19 +162,30 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
return actorData;
}
async use(event, options = {}) {
/**
* Execute each part of the Action workflow in order, calling a specific event before and after each part.
* @param {object} config Config object usually created from prepareConfig method
*/
async executeWorkflow(config) {
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;
}
}
/**
* Main method to use the Action
* @param {Event} event Event from the button used to trigger the Action
* @returns {object}
*/
async use(event) {
if (!this.actor) throw new Error("An Action can't be used outside of an Actor context.");
if (this.chatDisplay) await this.toChat();
let { byPassRoll } = options,
config = this.prepareConfig(event, byPassRoll);
for (let i = 0; i < this.constructor.extraSchemas.length; i++) {
let clsField = this.constructor.getActionField(this.constructor.extraSchemas[i]);
if (clsField?.prepareConfig) {
const keep = clsField.prepareConfig.call(this, config);
if (config.isFastForward && !keep) return;
}
}
let config = this.prepareConfig(event);
if (!config) return;
if (Hooks.call(`${CONFIG.DH.id}.preUseAction`, this, config) === false) return;
@ -133,274 +195,116 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
if (!config) return;
}
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);
// Execute the Action Worflow in order based of schema fields
await this.executeWorkflow(config);
if (Hooks.call(`${CONFIG.DH.id}.postUseAction`, this, config) === false) return;
return config;
}
/* */
prepareConfig(event, byPass = false) {
const hasRoll = this.getUseHasRoll(byPass);
return {
/**
* Create the basic config common to every action type
* @param {Event} event Event from the button used to trigger the Action
* @returns {object}
*/
prepareBaseConfig(event) {
const config = {
event,
title: `${this.item.name}: ${game.i18n.localize(this.name)}`,
title: `${this.item instanceof CONFIG.Actor.documentClass ? '' : `${this.item.name}: `}${game.i18n.localize(this.name)}`,
source: {
item: this.item._id,
action: this._id,
actor: this.actor.uuid
},
dialog: {
configure: hasRoll
},
type: this.type,
hasRoll: hasRoll,
hasDamage: this.damage?.parts?.length && this.type !== 'healing',
hasHealing: this.damage?.parts?.length && this.type === 'healing',
hasEffect: !!this.effects?.length,
isDirect: !!this.damage?.direct,
dialog: {},
actionType: this.actionType,
hasRoll: this.hasRoll,
hasDamage: this.hasDamage,
hasHealing: this.hasHealing,
hasEffect: this.hasEffect,
hasSave: this.hasSave,
isDirect: !!this.damage?.direct,
selectedRollMode: game.settings.get('core', 'rollMode'),
isFastForward: event.shiftKey,
data: this.getRollData(),
evaluate: hasRoll
evaluate: this.hasRoll
};
DHBaseAction.applyKeybindings(config);
return config;
}
/**
* Create the config for that action used for its workflow
* @param {Event} event Event from the button used to trigger the Action
* @returns {object}
*/
prepareConfig(event) {
const config = this.prepareBaseConfig(event);
for (const clsField of Object.values(this.schema.fields)) {
if (clsField?.prepareConfig) if (clsField.prepareConfig.call(this, config) === false) return false;
}
return config;
}
/**
* Method used to know if a configuration dialog must be shown or not when there is no roll.
* @param {*} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
* @returns {boolean}
*/
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;
}
/**
* Consume Action configured resources & uses.
* That method is only used when we want those resources to be consumed outside of the use method workflow.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
* @param {boolean} successCost
*/
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 this.workflow.get('cost')?.execute(config, successCost);
await this.workflow.get('uses')?.execute(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;
/**
* Set if a configuration dialog must be shown or not if a special keyboard key is pressed.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
*/
static applyKeybindings(config) {
config.dialog.configure ??= !(config.event.shiftKey || config.event.altKey || config.event.ctrlKey);
}
/**
* Getters to know which parts the action is composed of. A field can exist but configured to not be used.
* @returns {boolean} If that part is in the action.
*/
get hasRoll() {
return !!this.roll?.type;
}
get modifiers() {
if (!this.actor) return [];
const modifiers = [];
/** Placeholder for specific bonuses **/
return modifiers;
get hasDamage() {
return this.damage?.parts?.length && this.type !== 'healing';
}
get hasHealing() {
return this.damage?.parts?.length && this.type === 'healing';
}
/* 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({
flags: {
[game.system.id]: {
reactionRolls: {
[targetId]: 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.

View file

@ -1,10 +1,9 @@
import BeastformDialog from '../../applications/dialogs/beastformDialog.mjs';
import DHBaseAction from './baseAction.mjs';
export default class DhBeastformAction extends DHBaseAction {
static extraSchemas = [...super.extraSchemas, 'beastform'];
async use(event, options) {
/* async use(event, options) {
const beastformConfig = this.prepareBeastformConfig();
const abort = await this.handleActiveTransformations();
@ -82,5 +81,5 @@ export default class DhBeastformAction extends DHBaseAction {
beastformEffects.map(x => x.id)
);
return existingEffects;
}
} */
}

View file

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

View file

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

View file

@ -130,7 +130,7 @@ export default class DhpAdversary extends BaseDataActor {
CONFIG.DH.id,
CONFIG.DH.SETTINGS.gameSettings.Automation
).hordeDamage;
if (autoHordeDamage && changes.system?.resources?.hitPoints?.value) {
if (autoHordeDamage && changes.system?.resources?.hitPoints?.value !== undefined) {
const hordeActiveEffect = this.parent.effects.find(x => x.type === 'horde');
if (hordeActiveEffect) {
const halfHP = Math.ceil(this.resources.hitPoints.max / 2);

View file

@ -130,11 +130,16 @@ export default class BaseDataActor extends foundry.abstract.TypeDataModel {
const typeForDefeated = ['character', 'adversary', 'companion'].find(x => x === this.parent.type);
if (defeatedSettings.enabled && typeForDefeated) {
const resource = typeForDefeated === 'companion' ? 'stress' : 'hitPoints';
if (changes.system.resources[resource]) {
const becameMax = changes.system.resources[resource].value === this.resources[resource].max;
const resourceValue = changes.system.resources[resource];
if (
resourceValue &&
this.resources[resource].max &&
resourceValue.value !== this.resources[resource].value
) {
const becameMax = resourceValue.value === this.resources[resource].max;
const wasMax =
this.resources[resource].value === this.resources[resource].max &&
this.resources[resource].value !== changes.system.resources[resource].value;
this.resources[resource].value !== resourceValue.value;
if (becameMax) {
this.parent.toggleDefeated(true);
} else if (wasMax) {

View file

@ -317,7 +317,7 @@ export default class DhCharacter extends BaseDataActor {
}
get multiclass() {
const value = this.parent.items.find(x => x.type === 'Class' && x.system.isMulticlass);
const value = this.parent.items.find(x => x.type === 'class' && x.system.isMulticlass);
const subclass = this.parent.items.find(x => x.type === 'subclass' && x.system.isMulticlass);
return {
@ -443,7 +443,9 @@ export default class DhCharacter extends BaseDataActor {
classFeatures.push(item);
} else if (item.system.originItemType === CONFIG.DH.ITEM.featureTypes.subclass.id) {
if (this.class.subclass) {
const subclassState = this.class.subclass.system.featureState;
const prop = item.system.multiclassOrigin ? 'multiclass' : 'class';
const subclassState = this[prop].subclass?.system?.featureState;
if (!subclassState) continue;
if (
item.system.identifier === CONFIG.DH.ITEM.featureSubTypes.foundation ||

View file

@ -1,9 +1,9 @@
import DHAbilityUse from "./abilityUse.mjs";
import DHActorRoll from "./adversaryRoll.mjs";
import DHAbilityUse from './abilityUse.mjs';
import DHActorRoll from './actorRoll.mjs';
export const config = {
abilityUse: DHAbilityUse,
adversaryRoll: DHActorRoll,
damageRoll: DHActorRoll,
dualityRoll: DHActorRoll
abilityUse: DHAbilityUse,
adversaryRoll: DHActorRoll,
damageRoll: DHActorRoll,
dualityRoll: DHActorRoll
};

View file

@ -55,9 +55,12 @@ export default class DHActorRoll extends foundry.abstract.TypeDataModel {
}
get action() {
const actionItem = this.actionItem;
if (!actionItem || !this.source.action) return null;
return actionItem.system.actionsList?.find(a => a.id === this.source.action);
const actionActor = this.actionActor,
actionItem = this.actionItem;
if (!this.source.action) return null;
if (actionItem) return actionItem.system.actionsList?.find(a => a.id === this.source.action);
else if (actionActor?.system.attack?._id === this.source.action) return actionActor.system.attack;
return null;
}
get targetMode() {
@ -95,7 +98,7 @@ export default class DHActorRoll extends foundry.abstract.TypeDataModel {
}
registerTargetHook() {
if (!this.parent.isAuthor) return;
if (!this.parent.isAuthor || !this.hasTarget) return;
if (this.targetMode && this.parent.targetHook !== null) {
Hooks.off('targetToken', this.parent.targetHook);
return (this.parent.targetHook = null);
@ -113,7 +116,7 @@ export default class DHActorRoll extends foundry.abstract.TypeDataModel {
this.currentTargets = this.getTargetList();
// this.registerTargetHook();
if (this.targetMode === true && this.hasRoll) {
if (this.hasRoll) {
this.targetShort = this.targets.reduce(
(a, c) => {
if (c.hit) a.hit += 1;
@ -127,7 +130,8 @@ export default class DHActorRoll extends foundry.abstract.TypeDataModel {
}
this.canViewSecret = this.parent.speakerActor?.testUserPermission(game.user, 'OBSERVER');
this.canButtonApply = game.user.isGM;
this.canButtonApply = game.user.isGM; //temp
this.isGM = game.user.isGM; //temp
}
getTargetList() {

View file

@ -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';

View file

@ -1,6 +1,13 @@
import BeastformDialog from '../../../applications/dialogs/beastformDialog.mjs';
const fields = foundry.data.fields;
export default class BeastformField extends fields.SchemaField {
/**
* Action Workflow order
*/
static order = 90;
constructor(options = {}, context = {}) {
const beastformFields = {
tierAccess: new fields.SchemaField({
@ -27,4 +34,96 @@ export default class BeastformField extends fields.SchemaField {
};
super(beastformFields, options, context);
}
/**
* Beastform Transformation Action Workflow part.
* Must be called within Action context or similar.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
*/
static async execute(config) {
// Should not be useful anymore here
await BeastformField.handleActiveTransformations.call(this);
const { selected, evolved, hybrid } = await BeastformDialog.configure(config, this.item);
if (!selected) return false;
return await BeastformField.transform.call(this, selected, evolved, hybrid);
}
/**
* 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.actor.effects.find(x => x.type === 'beastform')) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.beastformAlreadyApplied'));
return false;
}
const settingsTiers = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.LevelTiers).tiers;
const actorLevel = this.actor.system.levelData.level.current;
const actorTier =
Object.values(settingsTiers).find(
tier => actorLevel >= tier.levels.start && actorLevel <= tier.levels.end
) ?? 1;
config.tierLimit = this.beastform.tierAccess.exact ?? actorTier;
}
/**
* TODO by Harry
* @param {*} selectedForm
* @param {*} evolvedData
* @param {*} hybridData
* @returns
*/
static async transform(selectedForm, evolvedData, hybridData) {
const formData = evolvedData?.form ? evolvedData.form.toObject() : selectedForm.toObject();
const beastformEffect = formData.effects.find(x => x.type === 'beastform');
if (!beastformEffect) {
ui.notifications.error('DAGGERHEART.UI.Notifications.beastformMissingEffect');
return false;
}
if (evolvedData?.form) {
const evolvedForm = selectedForm.effects.find(x => x.type === 'beastform');
if (!evolvedForm) {
ui.notifications.error('DAGGERHEART.UI.Notifications.beastformMissingEffect');
return false;
}
beastformEffect.changes = [...beastformEffect.changes, ...evolvedForm.changes];
formData.system.features = [...formData.system.features, ...selectedForm.system.features.map(x => x.uuid)];
}
if (selectedForm.system.beastformType === CONFIG.DH.ITEM.beastformTypes.hybrid.id) {
formData.system.advantageOn = Object.values(hybridData.advantages).reduce((advantages, formCategory) => {
Object.keys(formCategory).forEach(advantageKey => {
advantages[advantageKey] = formCategory[advantageKey];
});
return advantages;
}, {});
formData.system.features = [
...formData.system.features,
...Object.values(hybridData.features).flatMap(x => Object.keys(x))
];
}
this.actor.createEmbeddedDocuments('Item', [formData]);
}
/**
* Remove existing beastform effect and return true if there was one
* @returns {boolean}
*/
static async handleActiveTransformations() {
const beastformEffects = this.actor.effects.filter(x => x.type === 'beastform');
const existingEffects = beastformEffects.length > 0;
await this.actor.deleteEmbeddedDocuments(
'ActiveEffect',
beastformEffects.map(x => x.id)
);
return existingEffects;
}
}

View file

@ -1,6 +1,12 @@
const fields = foundry.data.fields;
export default class CostField extends fields.ArrayField {
/**
* Action Workflow order
*/
static order = 150;
/** @inheritDoc */
constructor(options = {}, context = {}) {
const element = new fields.SchemaField({
key: new fields.StringField({
@ -8,7 +14,7 @@ export default class CostField extends fields.ArrayField {
required: true,
initial: 'hope'
}),
keyIsID: new fields.BooleanField(),
itemId: new fields.StringField({ nullable: true, initial: null }),
value: new fields.NumberField({ nullable: true, initial: 1, min: 0 }),
scalable: new fields.BooleanField({ initial: false }),
step: new fields.NumberField({ nullable: true, initial: null }),
@ -20,18 +26,88 @@ export default class CostField extends fields.ArrayField {
super(element, options, context);
}
static prepareConfig(config) {
/**
* Cost Consumption Action Workflow part.
* Consume configured action resources.
* Must be called within Action context or similar.
* @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 execute(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
}
};
if (this.parent?.parent) {
for (var cost of config.costs) {
if (cost.itemId) {
usefulResources[cost.key] = {
value: cost.value,
target: this.parent.parent,
itemId: cost.itemId
};
}
}
}
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,
itemId: resource.itemId
});
return a;
}
}, []);
await actor.modifyResource(resources);
}
/**
* Update Action Workflow config object.
* Must be called within Action context or similar.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
* @returns {boolean} Return false if fast-forwarded and no more uses.
*/
prepareConfig(config) {
const costs = this.cost?.length ? foundry.utils.deepClone(this.cost) : [];
config.costs = CostField.calcCosts.call(this, costs);
const hasCost = CostField.hasCost.call(this, config.costs);
if (config.isFastForward && !hasCost)
return ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.insufficientResources'));
return hasCost;
if (config.dialog.configure === false && !hasCost) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.insufficientResources'));
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 => {
let filteredCosts = costs;
if (this.parent.metadata.isQuantifiable && this.parent.consumeOnUse === false) {
filteredCosts = filteredCosts.filter(c => c.key !== 'quantity');
}
return filteredCosts.map(c => {
c.scale = c.scale ?? 0;
c.step = c.step ?? 1;
c.total = c.value + c.scale * c.step;
@ -40,13 +116,19 @@ export default class CostField extends fields.ArrayField {
c.key === 'fear'
? game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear)
: resources[c.key].isReversed
? resources[c.key].max
? resources[c.key].max - resources[c.key].value
: resources[c.key].value;
if (c.scalable) c.maxStep = Math.floor((c.max - c.value) / c.step);
return c;
});
}
/**
* 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,17 +155,20 @@ 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)
actorResources.hope = foundry.utils.deepClone(this.actor.system.partner.system.resources.hope);
const itemResources = {};
for (let itemResource of costs) {
if (itemResource.keyIsID) {
itemResources[itemResource.key] = {
value: this.parent.resource.value ?? 0,
max: CostField.formatMax.call(this, this.parent?.resource?.max)
};
if (itemResource.itemId) {
itemResources[itemResource.key] = CostField.getItemIdCostResource.bind(this)(itemResource);
}
}
@ -93,6 +178,45 @@ export default class CostField extends fields.ArrayField {
};
}
static getItemIdCostResource(itemResource) {
switch (itemResource.key) {
case CONFIG.DH.GENERAL.itemAbilityCosts.resource.id:
return {
value: this.parent.resource.value ?? 0,
max: CostField.formatMax.call(this, this.parent?.resource?.max)
};
case CONFIG.DH.GENERAL.itemAbilityCosts.quantity.id:
return {
value: this.parent.quantity ?? 0,
max: this.parent.quantity ?? 0
};
default:
return { value: 0, max: 0 };
}
}
static getItemIdCostUpdate(r) {
switch (r.key) {
case CONFIG.DH.GENERAL.itemAbilityCosts.resource.id:
return {
path: 'system.resource.value',
value: r.target.system.resource.value + r.value
};
case CONFIG.DH.GENERAL.itemAbilityCosts.quantity.id:
return {
path: 'system.quantity',
value: r.target.system.quantity + r.value
};
default:
return { path: '', value: undefined };
}
}
/**
*
* @param {*} costs
* @returns
*/
static getRealCosts(costs) {
const realCosts = costs?.length ? costs.filter(c => c.enabled) : [];
let mergedCosts = [];
@ -104,6 +228,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)) {

View file

@ -1,8 +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
*/
static order = 20;
/** @inheritDoc */
constructor(options, context = {}) {
const damageFields = {
parts: new fields.ArrayField(new fields.EmbeddedDataField(DHDamageData)),
@ -14,6 +21,152 @@ export default class DamageField extends fields.SchemaField {
};
super(damageFields, options, context);
}
/**
* Roll Damage/Healing Action Workflow part.
* Must be called within Action context or similar.
* @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.
*/
static async execute(config, messageId = null, force = false) {
if (!this.hasDamage && !this.hasHealing) return;
if (
this.hasRoll &&
DamageField.getAutomation() === CONFIG.DH.SETTINGS.actionAutomationChoices.never.id &&
!force
)
return;
let formulas = this.damage.parts.map(p => ({
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, config);
const damageConfig = {
...config,
roll: formulas,
dialog: {},
data: this.getRollData()
};
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;
damageConfig.source.message = config.message?._id ?? messageId;
damageConfig.directDamage = !!damageConfig.source?.message;
// if(damageConfig.source?.message && game.modules.get('dice-so-nice')?.active)
// await game.dice3d.waitFor3DAnimationByMessageID(damageConfig.source.message);
const damageResult = await CONFIG.Dice.daggerheart.DamageRoll.build(damageConfig);
if (!damageResult) return false;
config.damage = damageResult.damage;
config.message ??= damageConfig.message;
}
/**
* 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.filter(target => target.hit);
if (!config.damage || !targets?.length || (!DamageField.getApplyAutomation() && !force)) return;
for (let target of targets) {
const actor = fromUuidSync(target.actorId);
if (!actor) continue;
if (!config.hasHealing && config.onSave && target.saved?.success === true) {
const mod = CONFIG.DH.ACTIONS.damageOnSave[config.onSave]?.mod ?? 1;
Object.entries(config.damage).forEach(([k, v]) => {
v.total = 0;
v.parts.forEach(part => {
part.total = Math.ceil(part.total * mod);
v.total += part.total;
});
});
}
if (config.hasHealing) actor.takeHealing(config.damage);
else actor.takeDamage(config.damage, config.isDirect);
}
}
/**
* Return value or valueAlt from damage part
* Must be called within Action context or similar.
* @param {object} part Damage Part
* @param {object} data Action getRollData
* @returns Formula value object
*/
static 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;
}
/**
* Prepare formulas for Damage Roll
* Must be called within Action context or similar.
* @param {object[]} formulas Array of formatted formulas object
* @param {object} data Action getRollData
* @returns
*/
static formatFormulas(formulas, data) {
const formattedFormulas = [];
formulas.forEach(formula => {
if (isNaN(formula.formula))
formula.formula = Roll.replaceFormulaData(formula.formula, this.getRollData(data));
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;
}
/**
* 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)
);
}
}
export class DHActionDiceData extends foundry.abstract.DataModel {

View file

@ -1,6 +1,14 @@
import { emitAsGM, GMUpdateEvent } from '../../../systemRegistration/socket.mjs';
const fields = foundry.data.fields;
export default class EffectsField extends fields.ArrayField {
/**
* Action Workflow order
*/
static order = 100;
/** @inheritDoc */
constructor(options = {}, context = {}) {
const element = new fields.SchemaField({
_id: new fields.DocumentIdField(),
@ -8,4 +16,85 @@ export default class EffectsField extends fields.ArrayField {
});
super(element, options, context);
}
/**
* Apply Effects Action Workflow part.
* Must be called within Action context or similar.
* @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.
*/
static 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;
message = config.message = await CONFIG.Dice.daggerheart.DHRoll.toMessage(roll, config);
}
if (EffectsField.getAutomation() || force) {
targets ??= (message.system?.targets ?? config.targets).filter(t => !config.hasRoll || t.hit);
await emitAsGM(GMUpdateEvent.UpdateEffect, EffectsField.applyEffects.bind(this), targets, this.uuid);
// EffectsField.applyEffects.call(this, config.targets.filter(t => !config.hasRoll || t.hit));
}
}
/**
* Apply Action Effects to a list of Targets
* Must be called within Action context or similar.
* @param {object[]} targets Array of formatted targets
*/
static async applyEffects(targets) {
if (!this.effects?.length || !targets?.length) return;
let effects = this.effects;
targets.forEach(async token => {
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 EffectsField.applyEffect(effect, actor);
});
});
}
/**
* Apply an Effect to a target or enable it if already on it
* @param {object} effect Effect object containing ActiveEffect UUID
* @param {object} actor Actor Document
*/
static 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 });
}
/**
* 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)
);
}
}

View file

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

View file

@ -1,7 +1,29 @@
const fields = foundry.data.fields;
export default class MacroField extends fields.DocumentUUIDField {
/**
* Action Workflow order
*/
static order = 70;
/** @inheritDoc */
constructor(context = {}) {
super({ type: "Macro" }, context);
super({ type: 'Macro' }, context);
}
/**
* Macro Action Workflow part.
* Must be called within Action context or similar or similar.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods. Currently not used.
*/
static async execute(config) {
const fixUUID = !this.macro.includes('Macro.') ? `Macro.${this.macro}` : this.macro,
macro = await fromUuid(fixUUID);
try {
if (!macro) throw new Error(`No macro found for the UUID: ${this.macro}.`);
macro.execute();
} catch (error) {
ui.notifications.error(error);
}
}
}

View file

@ -1,17 +1,23 @@
const fields = foundry.data.fields;
export default class RangeField extends fields.StringField {
/** @inheritDoc */
constructor(context = {}) {
const options = {
choices: CONFIG.DH.GENERAL.range,
required: false,
blank: true,
label: "DAGGERHEART.GENERAL.range"
label: 'DAGGERHEART.GENERAL.range'
};
super(options, context);
}
static prepareConfig(config) {
return true;
/**
* 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;
}
}

View file

@ -5,7 +5,12 @@ export class DHActionRollData extends foundry.abstract.DataModel {
static defineSchema() {
return {
type: new fields.StringField({ nullable: true, initial: null, choices: CONFIG.DH.GENERAL.rollTypes }),
trait: new fields.StringField({ nullable: true, initial: null, choices: CONFIG.DH.ACTOR.abilities, label: "DAGGERHEART.GENERAL.Trait.single" }),
trait: new fields.StringField({
nullable: true,
initial: null,
choices: CONFIG.DH.ACTOR.abilities,
label: 'DAGGERHEART.GENERAL.Trait.single'
}),
difficulty: new fields.NumberField({ nullable: true, initial: null, integer: true, min: 0 }),
bonus: new fields.NumberField({ nullable: true, initial: null, integer: true }),
advState: new fields.StringField({
@ -71,29 +76,6 @@ export class DHActionRollData extends foundry.abstract.DataModel {
const modifiers = [];
if (!this.parent?.actor) return modifiers;
switch (this.parent.actor.type) {
case 'character':
const spellcastingTrait =
this.type === 'spellcast'
? (this.parent.actor?.system?.spellcastModifierTrait?.key ?? 'agility')
: null;
const trait =
this.useDefault || !this.trait
? (spellcastingTrait ?? this.parent.item.system.attack?.roll?.trait ?? 'agility')
: this.trait;
if (
this.type === CONFIG.DH.GENERAL.rollTypes.attack.id ||
this.type === CONFIG.DH.GENERAL.rollTypes.trait.id
)
modifiers.push({
label: `DAGGERHEART.CONFIG.Traits.${trait}.name`,
value: this.parent.actor.system.traits[trait].value
});
else if (this.type === CONFIG.DH.GENERAL.rollTypes.spellcast.id)
modifiers.push({
label: `DAGGERHEART.CONFIG.RollTypes.spellcast.name`,
value: this.parent.actor.system.spellcastModifier
});
break;
case 'companion':
case 'adversary':
if (this.type === CONFIG.DH.GENERAL.rollTypes.attack.id)
@ -107,10 +89,79 @@ export class DHActionRollData extends foundry.abstract.DataModel {
}
return modifiers;
}
get rollTrait() {
if (this.parent?.actor?.type !== 'character') return null;
switch (this.type) {
case CONFIG.DH.GENERAL.rollTypes.spellcast.id:
return this.parent.actor?.system?.spellcastModifierTrait?.key ?? 'agility';
case CONFIG.DH.GENERAL.rollTypes.attack.id:
case CONFIG.DH.GENERAL.rollTypes.trait.id:
return this.useDefault || !this.trait
? (this.parent.item.system.attack?.roll?.trait ?? 'agility')
: this.trait;
default:
return null;
}
}
}
export default class RollField extends fields.EmbeddedDataField {
/**
* Action Workflow order
*/
static order = 10;
/** @inheritDoc */
constructor(options, context = {}) {
super(DHActionRollData, options, context);
}
/**
* Roll Action Workflow part.
* Must be called within Action context or similar.
* @param {object} config Object that contains workflow datas. Usually made from Action Fields prepareConfig methods.
*/
static 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;
config.dialog.configure = RollField.getAutomation() ? !config.dialog.configure : config.dialog.configure;
const roll = {
baseModifiers: this.roll.getModifier(),
label: 'Attack',
type: this.roll?.type,
trait: this.roll?.rollTrait,
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;
config.roll = roll;
}
/**
* 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)
);
}
}

View file

@ -1,6 +1,14 @@
import { abilities } from '../../../config/actorConfig.mjs';
const fields = foundry.data.fields;
export default class SaveField extends fields.SchemaField {
/**
* Action Workflow order
*/
static order = 50;
/** @inheritDoc */
constructor(options = {}, context = {}) {
const saveFields = {
trait: new fields.StringField({
@ -16,4 +24,157 @@ export default class SaveField extends fields.SchemaField {
};
super(saveFields, options, context);
}
/**
* Reaction Roll Action Workflow part.
* Must be called within Action context or similar.
* @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.
*/
static 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;
message = config.message = await CONFIG.Dice.daggerheart.DHRoll.toMessage(roll, config);
}
if (SaveField.getAutomation() !== CONFIG.DH.SETTINGS.actionAutomationChoices.never.id || force) {
targets ??= config.targets.filter(t => !config.hasRoll || t.hit);
await SaveField.rollAllSave.call(this, targets, config.event, message);
} else return false;
}
/**
* 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;
return new Promise(resolve => {
const aPromise = [];
targets.forEach(target => {
aPromise.push(
new Promise(async subResolve => {
const actor = fromUuidSync(target.actorId);
if (actor) {
const rollSave =
game.user === actor.owner
? SaveField.rollSave.call(this, actor, event)
: actor.owner.query('reactionRoll', {
actionId: this.uuid,
actorId: actor.uuid,
event,
message
});
const result = await rollSave;
await SaveField.updateSaveMessage.call(this, result, message, target.id);
subResolve();
} else subResolve();
})
);
});
Promise.all(aPromise).then(result => resolve());
});
}
/**
* 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')
: game.i18n.format('DAGGERHEART.UI.Chat.dualityRoll.abilityCheckTitle', {
ability: game.i18n.localize(abilities[this.save.trait]?.label)
}),
rollConfig = {
event,
title,
roll: {
trait: this.save.trait,
difficulty: this.save.difficulty ?? this.actor?.baseSaveDifficulty,
type: 'trait'
},
actionType: 'reaction',
hasRoll: true,
data: actor.getRollData()
};
if (SaveField.getAutomation() === CONFIG.DH.SETTINGS.actionAutomationChoices.always.id)
rollConfig.dialog = { configure: false };
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 async updateSaveMessage(result, message, targetId) {
if (!result) return;
const updateMsg = async 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(async () => await updateMsg(message, targetId, result));
else await 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),
action = await fromUuid(actionId);
if (!actor || !actor?.isOwner) reject();
SaveField.rollSave.call(action, actor, event, message).then(result => resolve(result));
});
}
}

View file

@ -1,6 +1,7 @@
const fields = foundry.data.fields;
export default class TargetField extends fields.SchemaField {
/** @inheritDoc */
constructor(options = {}, context = {}) {
const targetFields = {
type: new fields.StringField({
@ -13,44 +14,66 @@ export default class TargetField extends fields.SchemaField {
super(targetFields, options, context);
}
static prepareConfig(config) {
if (!this.target?.type) return [];
/**
* 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 = [];
}
}
config.targets = targets.map(t => TargetField.formatTarget.call(this, t));
const hasTargets = TargetField.checkTargets.call(this, this.target.amount, config.targets);
if (config.isFastForward && !hasTargets)
return ui.notifications.warn('Too many targets selected for that actions.');
return hasTargets;
if (config.dialog.configure === false && !hasTargets) {
ui.notifications.warn('Too many targets selected for that actions.');
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 &&
actorDisposition === targetDisposition) ||
(this.target.type === CONFIG.DH.GENERAL.targetTypes.hostile.id &&
actorDisposition + targetDisposition === 0)
(type === CONFIG.DH.GENERAL.targetTypes.friendly.id && actorDisposition === targetDisposition) ||
(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,

View file

@ -3,6 +3,12 @@ import FormulaField from '../formulaField.mjs';
const fields = foundry.data.fields;
export default class UsesField extends fields.SchemaField {
/**
* Action Workflow order
*/
static order = 160;
/** @inheritDoc */
constructor(options = {}, context = {}) {
const usesFields = {
value: new fields.NumberField({ nullable: true, initial: null }),
@ -20,16 +26,45 @@ export default class UsesField extends fields.SchemaField {
super(usesFields, options, context);
}
static prepareConfig(config) {
/**
* Uses Consumption Action Workflow part.
* Increment Action spent uses by 1.
* Must be called within Action context or similar or similar.
* @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 execute(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 });
}
/**
* 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.
* @returns {boolean} Return false if fast-forwarded and no more uses.
*/
prepareConfig(config) {
const uses = this.uses?.max ? foundry.utils.deepClone(this.uses) : null;
if (uses && !uses.value) uses.value = 0;
config.uses = uses;
const hasUses = UsesField.hasUses.call(this, config.uses);
if (config.isFastForward && !hasUses)
return ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.actionNoUsesRemaining'));
return hasUses;
if (config.dialog.configure === false && !hasUses) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.actionNoUsesRemaining'));
return hasUses;
}
}
/**
* Prepare Uses object for Action Workflow
* Must be called within Action context.
* @param {object} uses
* @returns {object}
*/
static calcUses(uses) {
if (!uses) return null;
return {
@ -39,6 +74,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;

View file

@ -162,7 +162,6 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel {
for (let f of this.features) {
const fBase = f.item ?? f;
const feature = fBase.system ? fBase : await foundry.utils.fromUuid(fBase.uuid);
const multiclass = this.isMulticlass ? 'multiclass' : null;
features.push(
foundry.utils.mergeObject(
feature.toObject(),
@ -170,7 +169,8 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel {
_stats: { compendiumSource: fBase.uuid },
system: {
originItemType: this.parent.type,
identifier: multiclass ?? (f.item ? f.type : null)
identifier: f.item ? f.type : null,
multiclassOrigin: this.isMulticlass
}
},
{ inplace: false }

View file

@ -49,6 +49,8 @@ export default class DHClass extends BaseDataItem {
suggestedSecondaryWeapon: new ForeignDocumentUUIDField({ type: 'Item' }),
suggestedArmor: new ForeignDocumentUUIDField({ type: 'Item' })
}),
backgroundQuestions: new fields.ArrayField(new fields.StringField(), { initial: ['', '', ''] }),
connections: new fields.ArrayField(new fields.StringField(), { initial: ['', '', ''] }),
isMulticlass: new fields.BooleanField({ initial: false })
};
}
@ -96,6 +98,20 @@ export default class DHClass extends BaseDataItem {
}
}
}
if (!data.system.isMulticlass) {
const addQuestions = (base, questions) => {
return `${base}${questions.map(q => `<p><strong>${q}</strong></p>`).join('<br/>')}`;
};
const backgroundQuestions = data.system.backgroundQuestions.filter(x => x);
const connections = data.system.connections.filter(x => x);
await this.actor.update({
'system.biography': {
background: addQuestions(this.actor.system.biography.background, backgroundQuestions),
connections: addQuestions(this.actor.system.biography.connections, connections)
}
});
}
}
const allowed = await super._preCreate(data, options, user);

View file

@ -1,5 +1,4 @@
import BaseDataItem from './base.mjs';
import { ActionField } from '../fields/actionField.mjs';
export default class DHConsumable extends BaseDataItem {
/** @inheritDoc */
@ -19,7 +18,8 @@ export default class DHConsumable extends BaseDataItem {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
consumeOnUse: new fields.BooleanField({ initial: false })
consumeOnUse: new fields.BooleanField({ initial: true }),
destroyOnEmpty: new fields.BooleanField({ initial: true })
};
}
@ -27,5 +27,4 @@ export default class DHConsumable extends BaseDataItem {
/**@override */
static DEFAULT_ICON = 'systems/daggerheart/assets/icons/documents/items/round-potion.svg';
}

View file

@ -29,6 +29,7 @@ export default class DHFeature extends BaseDataItem {
nullable: true,
initial: null
}),
multiclassOrigin: new fields.BooleanField({ initial: false }),
identifier: new fields.StringField()
};
}

View file

@ -1,3 +1,4 @@
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
import ItemLinkFields from '../fields/itemLinkFields.mjs';
import BaseDataItem from './base.mjs';
@ -25,7 +26,8 @@ export default class DHSubclass extends BaseDataItem {
}),
features: new ItemLinkFields(),
featureState: new fields.NumberField({ required: true, initial: 1, min: 1 }),
isMulticlass: new fields.BooleanField({ initial: false })
isMulticlass: new fields.BooleanField({ initial: false }),
linkedClass: new ForeignDocumentUUIDField({ type: 'Item', nullable: true, initial: null })
};
}
@ -50,8 +52,7 @@ export default class DHSubclass extends BaseDataItem {
async _preCreate(data, options, user) {
if (this.actor?.type === 'character') {
const dataUuid =
data.uuid ?? (data.folder ? `Compendium.daggerheart.subclasses.Item.${data._id}` : `Item.${data._id}`);
const dataUuid = data.uuid ?? data._stats.compendiumSource ?? `Item.${data._id}`;
if (this.actor.system.class.subclass) {
if (this.actor.system.multiclass.subclass) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.subclassesAlreadyPresent'));

View file

@ -1,100 +1,46 @@
import { fearDisplay } from '../../config/generalConfig.mjs';
export default class DhAppearance extends foundry.abstract.DataModel {
static LOCALIZATION_PREFIXES = ['DAGGERHEART.SETTINGS.Appearance'];
static defineSchema() {
const fields = foundry.data.fields;
const { StringField, ColorField, BooleanField, SchemaField } = foundry.data.fields;
// helper to create dice style schema
const diceStyle = ({ fg, bg, outline, edge }) =>
new SchemaField({
foreground: new ColorField({ required: true, initial: fg }),
background: new ColorField({ required: true, initial: bg }),
outline: new ColorField({ required: true, initial: outline }),
edge: new ColorField({ required: true, initial: edge }),
texture: new StringField({ initial: 'astralsea', required: true, blank: false }),
colorset: new StringField({ initial: 'inspired', required: true, blank: false }),
material: new StringField({ initial: 'metal', required: true, blank: false }),
system: new StringField({ initial: 'standard', required: true, blank: false })
});
return {
displayFear: new fields.StringField({
displayFear: new StringField({
required: true,
choices: fearDisplay,
initial: fearDisplay.token.value,
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.displayFear.label'
choices: CONFIG.DH.GENERAL.fearDisplay,
initial: CONFIG.DH.GENERAL.fearDisplay.token.value
}),
diceSoNice: new fields.SchemaField({
hope: new fields.SchemaField({
foreground: new fields.ColorField({ required: true, initial: '#ffffff' }),
background: new fields.ColorField({ required: true, initial: '#ffe760' }),
outline: new fields.ColorField({ required: true, initial: '#000000' }),
edge: new fields.ColorField({ required: true, initial: '#ffffff' }),
texture: new fields.StringField({ initial: 'astralsea' }),
colorset: new fields.StringField({ initial: 'inspired' }),
material: new fields.StringField({ initial: 'metal' }),
system: new fields.StringField({ initial: 'standard' })
}),
fear: new fields.SchemaField({
foreground: new fields.ColorField({ required: true, initial: '#000000' }),
background: new fields.ColorField({ required: true, initial: '#0032b1' }),
outline: new fields.ColorField({ required: true, initial: '#ffffff' }),
edge: new fields.ColorField({ required: true, initial: '#000000' }),
texture: new fields.StringField({ initial: 'astralsea' }),
colorset: new fields.StringField({ initial: 'inspired' }),
material: new fields.StringField({ initial: 'metal' }),
system: new fields.StringField({ initial: 'standard' })
}),
advantage: new fields.SchemaField({
foreground: new fields.ColorField({ required: true, initial: '#ffffff' }),
background: new fields.ColorField({ required: true, initial: '#008000' }),
outline: new fields.ColorField({ required: true, initial: '#000000' }),
edge: new fields.ColorField({ required: true, initial: '#ffffff' }),
texture: new fields.StringField({ initial: 'astralsea' }),
colorset: new fields.StringField({ initial: 'inspired' }),
material: new fields.StringField({ initial: 'metal' }),
system: new fields.StringField({ initial: 'standard' })
}),
disadvantage: new fields.SchemaField({
foreground: new fields.ColorField({ required: true, initial: '#000000' }),
background: new fields.ColorField({ required: true, initial: '#b30000' }),
outline: new fields.ColorField({ required: true, initial: '#ffffff' }),
edge: new fields.ColorField({ required: true, initial: '#000000' }),
texture: new fields.StringField({ initial: 'astralsea' }),
colorset: new fields.StringField({ initial: 'inspired' }),
material: new fields.StringField({ initial: 'metal' }),
system: new fields.StringField({ initial: 'standard' })
})
diceSoNice: new SchemaField({
hope: diceStyle({ fg: '#ffffff', bg: '#ffe760', outline: '#000000', edge: '#ffffff' }),
fear: diceStyle({ fg: '#000000', bg: '#0032b1', outline: '#ffffff', edge: '#000000' }),
advantage: diceStyle({ fg: '#ffffff', bg: '#008000', outline: '#000000', edge: '#ffffff' }),
disadvantage: diceStyle({ fg: '#000000', bg: '#b30000', outline: '#ffffff', edge: '#000000' })
}),
showGenericStatusEffects: new fields.BooleanField({
initial: true,
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.showGenericStatusEffects.label'
extendCharacterDescriptions: new BooleanField(),
extendAdversaryDescriptions: new BooleanField(),
extendEnvironmentDescriptions: new BooleanField(),
extendItemDescriptions: new BooleanField(),
expandRollMessage: new SchemaField({
desc: new BooleanField(),
roll: new BooleanField(),
damage: new BooleanField(),
target: new BooleanField()
}),
extendCharacterDescriptions: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.extendCharacterDescriptions.label'
}),
extendAdversaryDescriptions: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.extendAdversaryDescriptions.label'
}),
extendEnvironmentDescriptions: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.extendEnvironmentDescriptions.label'
}),
extendItemDescriptions: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.extendItemDescriptions.label'
}),
expandRollMessage: new fields.SchemaField({
desc: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.expandRollMessageDesc.label'
}),
roll: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.expandRollMessageRoll.label'
}),
damage: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.expandRollMessageDamage.label'
}),
target: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.expandRollMessageTarget.label'
})
}),
hideAttribution: new fields.BooleanField({
required: true,
initial: false,
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.hideAttribution.label'
})
hideAttribution: new BooleanField(),
showGenericStatusEffects: new BooleanField({ initial: true })
};
}
}

View file

@ -80,6 +80,72 @@ export default class DhAutomation extends foundry.abstract.DataModel {
initial: CONFIG.DH.GENERAL.defeatedConditions.defeated.id,
label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.companionDefault.label'
})
}),
roll: new fields.SchemaField({
roll: new fields.SchemaField({
gm: new fields.BooleanField({
required: true,
initial: false,
label: 'DAGGERHEART.GENERAL.gm'
}),
players: new fields.BooleanField({
required: true,
initial: false,
label: 'DAGGERHEART.GENERAL.player.plurial'
})
}),
damage: new fields.SchemaField({
gm: new fields.StringField({
required: true,
initial: 'never',
choices: CONFIG.DH.SETTINGS.actionAutomationChoices,
label: 'DAGGERHEART.GENERAL.gm'
}),
players: new fields.StringField({
required: true,
initial: 'never',
choices: CONFIG.DH.SETTINGS.actionAutomationChoices,
label: 'DAGGERHEART.GENERAL.player.plurial'
})
}),
save: new fields.SchemaField({
gm: new fields.StringField({
required: true,
initial: 'never',
choices: CONFIG.DH.SETTINGS.actionAutomationChoices,
label: 'DAGGERHEART.GENERAL.gm'
}),
players: new fields.StringField({
required: true,
initial: 'never',
choices: CONFIG.DH.SETTINGS.actionAutomationChoices,
label: 'DAGGERHEART.GENERAL.player.plurial'
})
}),
damageApply: new fields.SchemaField({
gm: new fields.BooleanField({
required: true,
initial: false,
label: 'DAGGERHEART.GENERAL.gm'
}),
players: new fields.BooleanField({
required: true,
initial: false,
label: 'DAGGERHEART.GENERAL.player.plurial'
})
}),
effect: new fields.SchemaField({
gm: new fields.BooleanField({
required: true,
initial: false,
label: 'DAGGERHEART.GENERAL.gm'
}),
players: new fields.BooleanField({
required: true,
initial: false,
label: 'DAGGERHEART.GENERAL.player.plurial'
})
})
})
};
}