/**
* Base configuration application for advancements that can be extended by other types to implement custom
* editing interfaces.
*
* @param {Advancement} advancement The advancement item being edited.
* @param {object} [options={}] Additional options passed to FormApplication.
* @param {string} [options.dropKeyPath=null] Path within advancement configuration where dropped items are stored.
* If populated, will enable default drop & delete behavior.
*/
class AdvancementConfig extends FormApplication {
constructor(advancement, options={}) {
super(advancement, options);
this.#advancementId = advancement.id;
this.item = advancement.item;
}
/* -------------------------------------------- */
/**
* The ID of the advancement being created or edited.
* @type {string}
*/
#advancementId;
/* -------------------------------------------- */
/**
* Parent item to which this advancement belongs.
* @type {Item5e}
*/
item;
/* -------------------------------------------- */
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "advancement", "dialog"],
template: "systems/dnd5e/templates/advancement/advancement-config.hbs",
width: 400,
height: "auto",
submitOnChange: true,
closeOnSubmit: false,
dropKeyPath: null
});
}
/* -------------------------------------------- */
/**
* The advancement being created or edited.
* @type {Advancement}
*/
get advancement() {
return this.item.advancement.byId[this.#advancementId];
}
/* -------------------------------------------- */
/** @inheritDoc */
get title() {
const type = this.advancement.constructor.metadata.title;
return `${game.i18n.format("DND5E.AdvancementConfigureTitle", { item: this.item.name })}: ${type}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
await super.close(options);
delete this.advancement.apps[this.appId];
}
/* -------------------------------------------- */
/** @inheritdoc */
getData() {
const levels = Object.fromEntries(Array.fromRange(CONFIG.DND5E.maxLevel + 1).map(l => [l, l]));
if ( ["class", "subclass"].includes(this.item.type) ) delete levels[0];
else levels[0] = game.i18n.localize("DND5E.AdvancementLevelAnyHeader");
const context = {
CONFIG: CONFIG.DND5E,
...this.advancement.toObject(false),
src: this.advancement.toObject(),
default: {
title: this.advancement.constructor.metadata.title,
icon: this.advancement.constructor.metadata.icon
},
levels,
showClassRestrictions: this.item.type === "class",
showLevelSelector: !this.advancement.constructor.metadata.multiLevel
};
return context;
}
/* -------------------------------------------- */
/**
* Perform any changes to configuration data before it is saved to the advancement.
* @param {object} configuration Configuration object.
* @returns {object} Modified configuration.
*/
async prepareConfigurationUpdate(configuration) {
return configuration;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
// Remove an item from the list
if ( this.options.dropKeyPath ) html.on("click", "[data-action='delete']", this._onItemDelete.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
render(force=false, options={}) {
this.advancement.apps[this.appId] = this;
return super.render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
let updates = foundry.utils.expandObject(formData);
if ( updates.configuration ) updates.configuration = await this.prepareConfigurationUpdate(updates.configuration);
await this.advancement.update(updates);
}
/* -------------------------------------------- */
/**
* Helper method to take an object and apply updates that remove any empty keys.
* @param {object} object Object to be cleaned.
* @returns {object} Copy of object with only non false-ish values included and others marked
* using `-=` syntax to be removed by update process.
* @protected
*/
static _cleanedObject(object) {
return Object.entries(object).reduce((obj, [key, value]) => {
if ( value ) obj[key] = value;
else obj[`-=${key}`] = null;
return obj;
}, {});
}
/* -------------------------------------------- */
/* Drag & Drop for Item Pools */
/* -------------------------------------------- */
/**
* Handle deleting an existing Item entry from the Advancement.
* @param {Event} event The originating click event.
* @returns {Promise} The updated parent Item after the application re-renders.
* @protected
*/
async _onItemDelete(event) {
event.preventDefault();
const uuidToDelete = event.currentTarget.closest("[data-item-uuid]")?.dataset.itemUuid;
if ( !uuidToDelete ) return;
const items = foundry.utils.getProperty(this.advancement.configuration, this.options.dropKeyPath);
const updates = { configuration: await this.prepareConfigurationUpdate({
[this.options.dropKeyPath]: items.filter(uuid => uuid !== uuidToDelete)
}) };
await this.advancement.update(updates);
}
/* -------------------------------------------- */
/** @inheritdoc */
_canDragDrop() {
return this.isEditable;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
if ( !this.options.dropKeyPath ) throw new Error(
"AdvancementConfig#options.dropKeyPath must be configured or #_onDrop must be overridden to support"
+ " drag and drop on advancement config items."
);
// Try to extract the data
const data = TextEditor.getDragEventData(event);
if ( data?.type !== "Item" ) return false;
const item = await Item.implementation.fromDropData(data);
try {
this._validateDroppedItem(event, item);
} catch(err) {
return ui.notifications.error(err.message);
}
const existingItems = foundry.utils.getProperty(this.advancement.configuration, this.options.dropKeyPath);
// Abort if this uuid is the parent item
if ( item.uuid === this.item.uuid ) {
return ui.notifications.error(game.i18n.localize("DND5E.AdvancementItemGrantRecursiveWarning"));
}
// Abort if this uuid exists already
if ( existingItems.includes(item.uuid) ) {
return ui.notifications.warn(game.i18n.localize("DND5E.AdvancementItemGrantDuplicateWarning"));
}
await this.advancement.update({[`configuration.${this.options.dropKeyPath}`]: [...existingItems, item.uuid]});
}
/* -------------------------------------------- */
/**
* Called when an item is dropped to validate the Item before it is saved. An error should be thrown
* if the item is invalid.
* @param {Event} event Triggering drop event.
* @param {Item5e} item The materialized Item that was dropped.
* @throws An error if the item is invalid.
* @protected
*/
_validateDroppedItem(event, item) {}
}
/**
* Base class for the advancement interface displayed by the advancement prompt that should be subclassed by
* individual advancement types.
*
* @param {Item5e} item Item to which the advancement belongs.
* @param {string} advancementId ID of the advancement this flow modifies.
* @param {number} level Level for which to configure this flow.
* @param {object} [options={}] Application rendering options.
*/
class AdvancementFlow extends FormApplication {
constructor(item, advancementId, level, options={}) {
super({}, options);
/**
* The item that houses the Advancement.
* @type {Item5e}
*/
this.item = item;
/**
* ID of the advancement this flow modifies.
* @type {string}
* @private
*/
this._advancementId = advancementId;
/**
* Level for which to configure this flow.
* @type {number}
*/
this.level = level;
/**
* Data retained by the advancement manager during a reverse step. If restoring data using Advancement#restore,
* this data should be used when displaying the flow's form.
* @type {object|null}
*/
this.retainedData = null;
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "systems/dnd5e/templates/advancement/advancement-flow.hbs",
popOut: false
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get id() {
return `actor-${this.advancement.item.id}-advancement-${this.advancement.id}-${this.level}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return this.advancement.title;
}
/* -------------------------------------------- */
/**
* The Advancement object this flow modifies.
* @type {Advancement|null}
*/
get advancement() {
return this.item.advancement?.byId[this._advancementId] ?? null;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData() {
return {
appId: this.id,
advancement: this.advancement,
type: this.advancement.constructor.typeName,
title: this.title,
summary: this.advancement.summaryForLevel(this.level),
level: this.level
};
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
await this.advancement.apply(this.level, formData);
}
}
/**
* Data field that selects the appropriate advancement data model if available, otherwise defaults to generic
* `ObjectField` to prevent issues with custom advancement types that aren't currently loaded.
*/
class AdvancementField extends foundry.data.fields.ObjectField {
/**
* Get the BaseAdvancement definition for the specified advancement type.
* @param {string} type The Advancement type.
* @returns {typeof BaseAdvancement|null} The BaseAdvancement class, or null.
*/
getModelForType(type) {
return CONFIG.DND5E.advancementTypes[type] ?? null;
}
/* -------------------------------------------- */
/** @inheritdoc */
_cleanType(value, options) {
if ( !(typeof value === "object") ) value = {};
const cls = this.getModelForType(value.type);
if ( cls ) return cls.cleanData(value, options);
return value;
}
/* -------------------------------------------- */
/** @inheritdoc */
initialize(value, model, options={}) {
const cls = this.getModelForType(value.type);
if ( cls ) return new cls(value, {parent: model, ...options});
return foundry.utils.deepClone(value);
}
}
/* -------------------------------------------- */
/**
* Data field that automatically selects the Advancement-specific configuration or value data models.
*
* @param {Advancement} advancementType Advancement class to which this field belongs.
*/
class AdvancementDataField extends foundry.data.fields.ObjectField {
constructor(advancementType, options={}) {
super(options);
this.advancementType = advancementType;
}
/* -------------------------------------------- */
/** @inheritdoc */
static get _defaults() {
return foundry.utils.mergeObject(super._defaults, {required: true});
}
/**
* Get the DataModel definition for the specified field as defined in metadata.
* @returns {typeof DataModel|null} The DataModel class, or null.
*/
getModel() {
return this.advancementType.metadata?.dataModels?.[this.name];
}
/* -------------------------------------------- */
/**
* Get the defaults object for the specified field as defined in metadata.
* @returns {object}
*/
getDefaults() {
return this.advancementType.metadata?.defaults?.[this.name] ?? {};
}
/* -------------------------------------------- */
/** @inheritdoc */
_cleanType(value, options) {
if ( !(typeof value === "object") ) value = {};
// Use a defined DataModel
const cls = this.getModel();
if ( cls ) return cls.cleanData(value, options);
if ( options.partial ) return value;
// Use the defined defaults
const defaults = this.getDefaults();
return foundry.utils.mergeObject(defaults, value, {inplace: false});
}
/* -------------------------------------------- */
/** @inheritdoc */
initialize(value, model, options={}) {
const cls = this.getModel();
if ( cls ) return new cls(value, {parent: model, ...options});
return foundry.utils.deepClone(value);
}
}
/* -------------------------------------------- */
/**
* @typedef {StringFieldOptions} FormulaFieldOptions
* @property {boolean} [deterministic=false] Is this formula not allowed to have dice values?
*/
/**
* Special case StringField which represents a formula.
*
* @param {FormulaFieldOptions} [options={}] Options which configure the behavior of the field.
* @property {boolean} deterministic=false Is this formula not allowed to have dice values?
*/
class FormulaField extends foundry.data.fields.StringField {
/** @inheritdoc */
static get _defaults() {
return foundry.utils.mergeObject(super._defaults, {
deterministic: false
});
}
/* -------------------------------------------- */
/** @inheritdoc */
_validateType(value) {
if ( this.options.deterministic ) {
const roll = new Roll(value);
if ( !roll.isDeterministic ) throw new Error("must not contain dice terms");
Roll.safeEval(roll.formula);
}
else Roll.validate(value);
super._validateType(value);
}
}
/* -------------------------------------------- */
/**
* Special case StringField that includes automatic validation for identifiers.
*/
class IdentifierField extends foundry.data.fields.StringField {
/** @override */
_validateType(value) {
if ( !dnd5e.utils.validators.isValidIdentifier(value) ) {
throw new Error(game.i18n.localize("DND5E.IdentifierError"));
}
}
}
/* -------------------------------------------- */
/**
* @callback MappingFieldInitialValueBuilder
* @param {string} key The key within the object where this new value is being generated.
* @param {*} initial The generic initial data provided by the contained model.
* @param {object} existing Any existing mapping data.
* @returns {object} Value to use as default for this key.
*/
/**
* @typedef {DataFieldOptions} MappingFieldOptions
* @property {string[]} [initialKeys] Keys that will be created if no data is provided.
* @property {MappingFieldInitialValueBuilder} [initialValue] Function to calculate the initial value for a key.
* @property {boolean} [initialKeysOnly=false] Should the keys in the initialized data be limited to the keys provided
* by `options.initialKeys`?
*/
/**
* A subclass of ObjectField that represents a mapping of keys to the provided DataField type.
*
* @param {DataField} model The class of DataField which should be embedded in this field.
* @param {MappingFieldOptions} [options={}] Options which configure the behavior of the field.
* @property {string[]} [initialKeys] Keys that will be created if no data is provided.
* @property {MappingFieldInitialValueBuilder} [initialValue] Function to calculate the initial value for a key.
* @property {boolean} [initialKeysOnly=false] Should the keys in the initialized data be limited to the keys provided
* by `options.initialKeys`?
*/
class MappingField extends foundry.data.fields.ObjectField {
constructor(model, options) {
if ( !(model instanceof foundry.data.fields.DataField) ) {
throw new Error("MappingField must have a DataField as its contained element");
}
super(options);
/**
* The embedded DataField definition which is contained in this field.
* @type {DataField}
*/
this.model = model;
}
/* -------------------------------------------- */
/** @inheritdoc */
static get _defaults() {
return foundry.utils.mergeObject(super._defaults, {
initialKeys: null,
initialValue: null,
initialKeysOnly: false
});
}
/* -------------------------------------------- */
/** @inheritdoc */
_cleanType(value, options) {
Object.entries(value).forEach(([k, v]) => value[k] = this.model.clean(v, options));
return value;
}
/* -------------------------------------------- */
/** @inheritdoc */
getInitialValue(data) {
let keys = this.initialKeys;
const initial = super.getInitialValue(data);
if ( !keys || !foundry.utils.isEmpty(initial) ) return initial;
if ( !(keys instanceof Array) ) keys = Object.keys(keys);
for ( const key of keys ) initial[key] = this._getInitialValueForKey(key);
return initial;
}
/* -------------------------------------------- */
/**
* Get the initial value for the provided key.
* @param {string} key Key within the object being built.
* @param {object} [object] Any existing mapping data.
* @returns {*} Initial value based on provided field type.
*/
_getInitialValueForKey(key, object) {
const initial = this.model.getInitialValue();
return this.initialValue?.(key, initial, object) ?? initial;
}
/* -------------------------------------------- */
/** @override */
_validateType(value, options={}) {
if ( foundry.utils.getType(value) !== "Object" ) throw new Error("must be an Object");
const errors = this._validateValues(value, options);
if ( !foundry.utils.isEmpty(errors) ) throw new foundry.data.fields.ModelValidationError(errors);
}
/* -------------------------------------------- */
/**
* Validate each value of the object.
* @param {object} value The object to validate.
* @param {object} options Validation options.
* @returns {Object} An object of value-specific errors by key.
*/
_validateValues(value, options) {
const errors = {};
for ( const [k, v] of Object.entries(value) ) {
const error = this.model.validate(v, options);
if ( error ) errors[k] = error;
}
return errors;
}
/* -------------------------------------------- */
/** @override */
initialize(value, model, options={}) {
if ( !value ) return value;
const obj = {};
const initialKeys = (this.initialKeys instanceof Array) ? this.initialKeys : Object.keys(this.initialKeys ?? {});
const keys = this.initialKeysOnly ? initialKeys : Object.keys(value);
for ( const key of keys ) {
const data = value[key] ?? this._getInitialValueForKey(key, value);
obj[key] = this.model.initialize(data, model, options);
}
return obj;
}
/* -------------------------------------------- */
/** @inheritdoc */
_getField(path) {
if ( path.length === 0 ) return this;
else if ( path.length === 1 ) return this.model;
path.shift();
return this.model._getField(path);
}
}
var fields = /*#__PURE__*/Object.freeze({
__proto__: null,
AdvancementDataField: AdvancementDataField,
AdvancementField: AdvancementField,
FormulaField: FormulaField,
IdentifierField: IdentifierField,
MappingField: MappingField
});
class BaseAdvancement extends foundry.abstract.DataModel {
/**
* Name of this advancement type that will be stored in config and used for lookups.
* @type {string}
* @protected
*/
static get typeName() {
return this.name.replace(/Advancement$/, "");
}
/* -------------------------------------------- */
static defineSchema() {
return {
_id: new foundry.data.fields.DocumentIdField({initial: () => foundry.utils.randomID()}),
type: new foundry.data.fields.StringField({
required: true, initial: this.typeName, validate: v => v === this.typeName,
validationError: `must be the same as the Advancement type name ${this.typeName}`
}),
configuration: new AdvancementDataField(this, {required: true}),
value: new AdvancementDataField(this, {required: true}),
level: new foundry.data.fields.NumberField({
integer: true, initial: this.metadata?.multiLevel ? undefined : 1, min: 0, label: "DND5E.Level"
}),
title: new foundry.data.fields.StringField({initial: undefined, label: "DND5E.AdvancementCustomTitle"}),
icon: new foundry.data.fields.FilePathField({
initial: undefined, categories: ["IMAGE"], label: "DND5E.AdvancementCustomIcon"
}),
classRestriction: new foundry.data.fields.StringField({
initial: undefined, choices: ["primary", "secondary"], label: "DND5E.AdvancementClassRestriction"
})
};
}
/* -------------------------------------------- */
/** @inheritdoc */
toObject(source=true) {
if ( !source ) return super.toObject(source);
const clone = foundry.utils.deepClone(this._source);
// Remove any undefined keys from the source data
Object.keys(clone).filter(k => clone[k] === undefined).forEach(k => delete clone[k]);
return clone;
}
}
/**
* Error that can be thrown during the advancement update preparation process.
*/
class AdvancementError extends Error {
constructor(...args) {
super(...args);
this.name = "AdvancementError";
}
}
/**
* Abstract base class which various advancement types can subclass.
* @param {Item5e} item Item to which this advancement belongs.
* @param {object} [data={}] Raw data stored in the advancement object.
* @param {object} [options={}] Options which affect DataModel construction.
* @abstract
*/
class Advancement extends BaseAdvancement {
constructor(data, {parent=null, ...options}={}) {
if ( parent instanceof Item ) parent = parent.system;
super(data, {parent, ...options});
/**
* A collection of Application instances which should be re-rendered whenever this document is updated.
* The keys of this object are the application ids and the values are Application instances. Each
* Application in this object will have its render method called by {@link Document#render}.
* @type {Object}
*/
Object.defineProperty(this, "apps", {
value: {},
writable: false,
enumerable: false
});
}
/* -------------------------------------------- */
/** @inheritdoc */
_initialize(options) {
super._initialize(options);
return this.prepareData();
}
static ERROR = AdvancementError;
/* -------------------------------------------- */
/**
* Information on how an advancement type is configured.
*
* @typedef {object} AdvancementMetadata
* @property {object} dataModels
* @property {DataModel} configuration Data model used for validating configuration data.
* @property {DataModel} value Data model used for validating value data.
* @property {number} order Number used to determine default sorting order of advancement items.
* @property {string} icon Icon used for this advancement type if no user icon is specified.
* @property {string} title Title to be displayed if no user title is specified.
* @property {string} hint Description of this type shown in the advancement selection dialog.
* @property {boolean} multiLevel Can this advancement affect more than one level? If this is set to true,
* the level selection control in the configuration window is hidden and the
* advancement should provide its own implementation of `Advancement#levels`
* and potentially its own level configuration interface.
* @property {Set} validItemTypes Set of types to which this advancement can be added.
* @property {object} apps
* @property {*} apps.config Subclass of AdvancementConfig that allows for editing of this advancement type.
* @property {*} apps.flow Subclass of AdvancementFlow that is displayed while fulfilling this advancement.
*/
/**
* Configuration information for this advancement type.
* @type {AdvancementMetadata}
*/
static get metadata() {
return {
order: 100,
icon: "icons/svg/upgrade.svg",
title: game.i18n.localize("DND5E.AdvancementTitle"),
hint: "",
multiLevel: false,
validItemTypes: new Set(["background", "class", "subclass"]),
apps: {
config: AdvancementConfig,
flow: AdvancementFlow
}
};
}
/* -------------------------------------------- */
/* Instance Properties */
/* -------------------------------------------- */
/**
* Unique identifier for this advancement within its item.
* @type {string}
*/
get id() {
return this._id;
}
/* -------------------------------------------- */
/**
* Globally unique identifier for this advancement.
* @type {string}
*/
get uuid() {
return `${this.item.uuid}.Advancement.${this.id}`;
}
/* -------------------------------------------- */
/**
* Item to which this advancement belongs.
* @type {Item5e}
*/
get item() {
return this.parent.parent;
}
/* -------------------------------------------- */
/**
* Actor to which this advancement's item belongs, if the item is embedded.
* @type {Actor5e|null}
*/
get actor() {
return this.item.parent ?? null;
}
/* -------------------------------------------- */
/**
* List of levels in which this advancement object should be displayed. Will be a list of class levels if this
* advancement is being applied to classes or subclasses, otherwise a list of character levels.
* @returns {number[]}
*/
get levels() {
return this.level !== undefined ? [this.level] : [];
}
/* -------------------------------------------- */
/**
* Should this advancement be applied to a class based on its class restriction setting? This will always return
* true for advancements that are not within an embedded class item.
* @type {boolean}
* @protected
*/
get appliesToClass() {
const originalClass = this.item.isOriginalClass;
return (originalClass === null) || !this.classRestriction
|| (this.classRestriction === "primary" && originalClass)
|| (this.classRestriction === "secondary" && !originalClass);
}
/* -------------------------------------------- */
/* Preparation Methods */
/* -------------------------------------------- */
/**
* Prepare data for the Advancement.
*/
prepareData() {
this.title = this.title || this.constructor.metadata.title;
this.icon = this.icon || this.constructor.metadata.icon;
}
/* -------------------------------------------- */
/* Display Methods */
/* -------------------------------------------- */
/**
* Has the player made choices for this advancement at the specified level?
* @param {number} level Level for which to check configuration.
* @returns {boolean} Have any available choices been made?
*/
configuredForLevel(level) {
return true;
}
/* -------------------------------------------- */
/**
* Value used for sorting this advancement at a certain level.
* @param {number} level Level for which this entry is being sorted.
* @returns {string} String that can be used for sorting.
*/
sortingValueForLevel(level) {
return `${this.constructor.metadata.order.paddedString(4)} ${this.titleForLevel(level)}`;
}
/* -------------------------------------------- */
/**
* Title displayed in advancement list for a specific level.
* @param {number} level Level for which to generate a title.
* @param {object} [options={}]
* @param {object} [options.configMode=false] Is the advancement's item sheet in configuration mode? When in
* config mode, the choices already made on this actor should not
* be displayed.
* @returns {string} HTML title with any level-specific information.
*/
titleForLevel(level, { configMode=false }={}) {
return this.title;
}
/* -------------------------------------------- */
/**
* Summary content displayed beneath the title in the advancement list.
* @param {number} level Level for which to generate the summary.
* @param {object} [options={}]
* @param {object} [options.configMode=false] Is the advancement's item sheet in configuration mode? When in
* config mode, the choices already made on this actor should not
* be displayed.
* @returns {string} HTML content of the summary.
*/
summaryForLevel(level, { configMode=false }={}) {
return "";
}
/* -------------------------------------------- */
/**
* Render all of the Application instances which are connected to this advancement.
* @param {boolean} [force=false] Force rendering
* @param {object} [context={}] Optional context
*/
render(force=false, context={}) {
for ( const app of Object.values(this.apps) ) app.render(force, context);
}
/* -------------------------------------------- */
/* Editing Methods */
/* -------------------------------------------- */
/**
* Update this advancement.
* @param {object} updates Updates to apply to this advancement.
* @returns {Promise} This advancement after updates have been applied.
*/
async update(updates) {
await this.item.updateAdvancement(this.id, updates);
return this;
}
/* -------------------------------------------- */
/**
* Update this advancement's data on the item without performing a database commit.
* @param {object} updates Updates to apply to this advancement.
* @returns {Advancement} This advancement after updates have been applied.
*/
updateSource(updates) {
super.updateSource(updates);
return this;
}
/* -------------------------------------------- */
/**
* Can an advancement of this type be added to the provided item?
* @param {Item5e} item Item to check against.
* @returns {boolean} Should this be enabled as an option on the `AdvancementSelection` dialog?
*/
static availableForItem(item) {
return true;
}
/* -------------------------------------------- */
/**
* Serialize salient information for this Advancement when dragging it.
* @returns {object} An object of drag data.
*/
toDragData() {
const dragData = { type: "Advancement" };
if ( this.id ) dragData.uuid = this.uuid;
else dragData.data = this.toObject();
return dragData;
}
/* -------------------------------------------- */
/* Application Methods */
/* -------------------------------------------- */
/**
* Locally apply this advancement to the actor.
* @param {number} level Level being advanced.
* @param {object} data Data from the advancement form.
* @abstract
*/
async apply(level, data) { }
/* -------------------------------------------- */
/**
* Locally apply this advancement from stored data, if possible. If stored data can not be restored for any reason,
* throw an AdvancementError to display the advancement flow UI.
* @param {number} level Level being advanced.
* @param {object} data Data from `Advancement#reverse` needed to restore this advancement.
* @abstract
*/
async restore(level, data) { }
/* -------------------------------------------- */
/**
* Locally remove this advancement's changes from the actor.
* @param {number} level Level being removed.
* @returns {object} Data that can be passed to the `Advancement#restore` method to restore this reversal.
* @abstract
*/
async reverse(level) { }
}
/**
* Configuration application for hit points.
*/
class HitPointsConfig extends AdvancementConfig {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "systems/dnd5e/templates/advancement/hit-points-config.hbs"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData() {
return foundry.utils.mergeObject(super.getData(), {
hitDie: this.advancement.hitDie
});
}
}
/**
* Inline application that presents hit points selection upon level up.
*/
class HitPointsFlow extends AdvancementFlow {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "systems/dnd5e/templates/advancement/hit-points-flow.hbs"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData() {
const source = this.retainedData ?? this.advancement.value;
const value = source[this.level];
// If value is empty, `useAverage` should default to the value selected at the previous level
let useAverage = value === "avg";
if ( !value ) {
const lastValue = source[this.level - 1];
if ( lastValue === "avg" ) useAverage = true;
}
return foundry.utils.mergeObject(super.getData(), {
isFirstClassLevel: (this.level === 1) && this.advancement.item.isOriginalClass,
hitDie: this.advancement.hitDie,
dieValue: this.advancement.hitDieValue,
data: {
value: Number.isInteger(value) ? value : "",
useAverage
}
});
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
this.form.querySelector(".averageCheckbox")?.addEventListener("change", event => {
this.form.querySelector(".rollResult").disabled = event.target.checked;
this.form.querySelector(".rollButton").disabled = event.target.checked;
this._updateRollResult();
});
this.form.querySelector(".rollButton")?.addEventListener("click", async () => {
const roll = await this.advancement.actor.rollClassHitPoints(this.advancement.item);
this.form.querySelector(".rollResult").value = roll.total;
});
this._updateRollResult();
}
/* -------------------------------------------- */
/**
* Update the roll result display when the average result is taken.
* @protected
*/
_updateRollResult() {
if ( !this.form.elements.useAverage?.checked ) return;
this.form.elements.value.value = (this.advancement.hitDieValue / 2) + 1;
}
/* -------------------------------------------- */
/** @inheritdoc */
_updateObject(event, formData) {
let value;
if ( formData.useMax ) value = "max";
else if ( formData.useAverage ) value = "avg";
else if ( Number.isInteger(formData.value) ) value = parseInt(formData.value);
if ( value !== undefined ) return this.advancement.apply(this.level, { [this.level]: value });
this.form.querySelector(".rollResult")?.classList.add("error");
const errorType = formData.value ? "Invalid" : "Empty";
throw new Advancement.ERROR(game.i18n.localize(`DND5E.AdvancementHitPoints${errorType}Error`));
}
}
/* -------------------------------------------- */
/* Formulas */
/* -------------------------------------------- */
/**
* Convert a bonus value to a simple integer for displaying on the sheet.
* @param {number|string|null} bonus Bonus formula.
* @param {object} [data={}] Data to use for replacing @ strings.
* @returns {number} Simplified bonus as an integer.
* @protected
*/
function simplifyBonus(bonus, data={}) {
if ( !bonus ) return 0;
if ( Number.isNumeric(bonus) ) return Number(bonus);
try {
const roll = new Roll(bonus, data);
return roll.isDeterministic ? Roll.safeEval(roll.formula) : 0;
} catch(error) {
console.error(error);
return 0;
}
}
/* -------------------------------------------- */
/* Object Helpers */
/* -------------------------------------------- */
/**
* Sort the provided object by its values or by an inner sortKey.
* @param {object} obj The object to sort.
* @param {string} [sortKey] An inner key upon which to sort.
* @returns {object} A copy of the original object that has been sorted.
*/
function sortObjectEntries(obj, sortKey) {
let sorted = Object.entries(obj);
if ( sortKey ) sorted = sorted.sort((a, b) => a[1][sortKey].localeCompare(b[1][sortKey]));
else sorted = sorted.sort((a, b) => a[1].localeCompare(b[1]));
return Object.fromEntries(sorted);
}
/* -------------------------------------------- */
/**
* Retrieve the indexed data for a Document using its UUID. Will never return a result for embedded documents.
* @param {string} uuid The UUID of the Document index to retrieve.
* @returns {object} Document's index if one could be found.
*/
function indexFromUuid(uuid) {
const parts = uuid.split(".");
let index;
// Compendium Documents
if ( parts[0] === "Compendium" ) {
const [, scope, packName, id] = parts;
const pack = game.packs.get(`${scope}.${packName}`);
index = pack?.index.get(id);
}
// World Documents
else if ( parts.length < 3 ) {
const [docName, id] = parts;
const collection = CONFIG[docName].collection.instance;
index = collection.get(id);
}
return index || null;
}
/* -------------------------------------------- */
/**
* Creates an HTML document link for the provided UUID.
* @param {string} uuid UUID for which to produce the link.
* @returns {string} Link to the item or empty string if item wasn't found.
*/
function linkForUuid(uuid) {
return TextEditor._createContentLink(["", "UUID", uuid]).outerHTML;
}
/* -------------------------------------------- */
/* Validators */
/* -------------------------------------------- */
/**
* Ensure the provided string contains only the characters allowed in identifiers.
* @param {string} identifier
* @returns {boolean}
*/
function isValidIdentifier(identifier) {
return /^([a-z0-9_-]+)$/i.test(identifier);
}
const validators = {
isValidIdentifier: isValidIdentifier
};
/* -------------------------------------------- */
/* Handlebars Template Helpers */
/* -------------------------------------------- */
/**
* Define a set of template paths to pre-load. Pre-loaded templates are compiled and cached for fast access when
* rendering. These paths will also be available as Handlebars partials by using the file name
* (e.g. "dnd5e.actor-traits").
* @returns {Promise}
*/
async function preloadHandlebarsTemplates() {
const partials = [
// Shared Partials
"systems/dnd5e/templates/actors/parts/active-effects.hbs",
"systems/dnd5e/templates/apps/parts/trait-list.hbs",
// Actor Sheet Partials
"systems/dnd5e/templates/actors/parts/actor-traits.hbs",
"systems/dnd5e/templates/actors/parts/actor-inventory.hbs",
"systems/dnd5e/templates/actors/parts/actor-features.hbs",
"systems/dnd5e/templates/actors/parts/actor-spellbook.hbs",
"systems/dnd5e/templates/actors/parts/actor-warnings.hbs",
// Item Sheet Partials
"systems/dnd5e/templates/items/parts/item-action.hbs",
"systems/dnd5e/templates/items/parts/item-activation.hbs",
"systems/dnd5e/templates/items/parts/item-advancement.hbs",
"systems/dnd5e/templates/items/parts/item-description.hbs",
"systems/dnd5e/templates/items/parts/item-mountable.hbs",
"systems/dnd5e/templates/items/parts/item-spellcasting.hbs",
"systems/dnd5e/templates/items/parts/item-summary.hbs",
// Journal Partials
"systems/dnd5e/templates/journal/parts/journal-table.hbs",
// Advancement Partials
"systems/dnd5e/templates/advancement/parts/advancement-controls.hbs",
"systems/dnd5e/templates/advancement/parts/advancement-spell-config.hbs"
];
const paths = {};
for ( const path of partials ) {
paths[path.replace(".hbs", ".html")] = path;
paths[`dnd5e.${path.split("/").pop().replace(".hbs", "")}`] = path;
}
return loadTemplates(paths);
}
/* -------------------------------------------- */
/**
* A helper that fetch the appropriate item context from root and adds it to the first block parameter.
* @param {object} context Current evaluation context.
* @param {object} options Handlebars options.
* @returns {string}
*/
function itemContext(context, options) {
if ( arguments.length !== 2 ) throw new Error("#dnd5e-itemContext requires exactly one argument");
if ( foundry.utils.getType(context) === "function" ) context = context.call(this);
const ctx = options.data.root.itemContext?.[context.id];
if ( !ctx ) {
const inverse = options.inverse(this);
if ( inverse ) return options.inverse(this);
}
return options.fn(context, { data: options.data, blockParams: [ctx] });
}
/* -------------------------------------------- */
/**
* Register custom Handlebars helpers used by 5e.
*/
function registerHandlebarsHelpers() {
Handlebars.registerHelper({
getProperty: foundry.utils.getProperty,
"dnd5e-linkForUuid": linkForUuid,
"dnd5e-itemContext": itemContext
});
}
/* -------------------------------------------- */
/* Config Pre-Localization */
/* -------------------------------------------- */
/**
* Storage for pre-localization configuration.
* @type {object}
* @private
*/
const _preLocalizationRegistrations = {};
/**
* Mark the provided config key to be pre-localized during the init stage.
* @param {string} configKeyPath Key path within `CONFIG.DND5E` to localize.
* @param {object} [options={}]
* @param {string} [options.key] If each entry in the config enum is an object,
* localize and sort using this property.
* @param {string[]} [options.keys=[]] Array of localization keys. First key listed will be used for sorting
* if multiple are provided.
* @param {boolean} [options.sort=false] Sort this config enum, using the key if set.
*/
function preLocalize(configKeyPath, { key, keys=[], sort=false }={}) {
if ( key ) keys.unshift(key);
_preLocalizationRegistrations[configKeyPath] = { keys, sort };
}
/* -------------------------------------------- */
/**
* Execute previously defined pre-localization tasks on the provided config object.
* @param {object} config The `CONFIG.DND5E` object to localize and sort. *Will be mutated.*
*/
function performPreLocalization(config) {
for ( const [keyPath, settings] of Object.entries(_preLocalizationRegistrations) ) {
const target = foundry.utils.getProperty(config, keyPath);
_localizeObject(target, settings.keys);
if ( settings.sort ) foundry.utils.setProperty(config, keyPath, sortObjectEntries(target, settings.keys[0]));
}
}
/* -------------------------------------------- */
/**
* Localize the values of a configuration object by translating them in-place.
* @param {object} obj The configuration object to localize.
* @param {string[]} [keys] List of inner keys that should be localized if this is an object.
* @private
*/
function _localizeObject(obj, keys) {
for ( const [k, v] of Object.entries(obj) ) {
const type = typeof v;
if ( type === "string" ) {
obj[k] = game.i18n.localize(v);
continue;
}
if ( type !== "object" ) {
console.error(new Error(
`Pre-localized configuration values must be a string or object, ${type} found for "${k}" instead.`
));
continue;
}
if ( !keys?.length ) {
console.error(new Error(
"Localization keys must be provided for pre-localizing when target is an object."
));
continue;
}
for ( const key of keys ) {
if ( !v[key] ) continue;
v[key] = game.i18n.localize(v[key]);
}
}
}
/* -------------------------------------------- */
/* Migration */
/* -------------------------------------------- */
/**
* Synchronize the spells for all Actors in some collection with source data from an Item compendium pack.
* @param {CompendiumCollection} actorPack An Actor compendium pack which will be updated
* @param {CompendiumCollection} spellsPack An Item compendium pack which provides source data for spells
* @returns {Promise}
*/
async function synchronizeActorSpells(actorPack, spellsPack) {
// Load all actors and spells
const actors = await actorPack.getDocuments();
const spells = await spellsPack.getDocuments();
const spellsMap = spells.reduce((obj, item) => {
obj[item.name] = item;
return obj;
}, {});
// Unlock the pack
await actorPack.configure({locked: false});
// Iterate over actors
SceneNavigation.displayProgressBar({label: "Synchronizing Spell Data", pct: 0});
for ( const [i, actor] of actors.entries() ) {
const {toDelete, toCreate} = _synchronizeActorSpells(actor, spellsMap);
if ( toDelete.length ) await actor.deleteEmbeddedDocuments("Item", toDelete);
if ( toCreate.length ) await actor.createEmbeddedDocuments("Item", toCreate, {keepId: true});
console.debug(`${actor.name} | Synchronized ${toCreate.length} spells`);
SceneNavigation.displayProgressBar({label: actor.name, pct: ((i / actors.length) * 100).toFixed(0)});
}
// Re-lock the pack
await actorPack.configure({locked: true});
SceneNavigation.displayProgressBar({label: "Synchronizing Spell Data", pct: 100});
}
/* -------------------------------------------- */
/**
* A helper function to synchronize spell data for a specific Actor.
* @param {Actor5e} actor
* @param {Object} spellsMap
* @returns {{toDelete: string[], toCreate: object[]}}
* @private
*/
function _synchronizeActorSpells(actor, spellsMap) {
const spells = actor.itemTypes.spell;
const toDelete = [];
const toCreate = [];
if ( !spells.length ) return {toDelete, toCreate};
for ( const spell of spells ) {
const source = spellsMap[spell.name];
if ( !source ) {
console.warn(`${actor.name} | ${spell.name} | Does not exist in spells compendium pack`);
continue;
}
// Combine source data with the preparation and uses data from the actor
const spellData = source.toObject();
const {preparation, uses, save} = spell.toObject().system;
Object.assign(spellData.system, {preparation, uses});
spellData.system.save.dc = save.dc;
foundry.utils.setProperty(spellData, "flags.core.sourceId", source.uuid);
// Record spells to be deleted and created
toDelete.push(spell.id);
toCreate.push(spellData);
}
return {toDelete, toCreate};
}
var utils = /*#__PURE__*/Object.freeze({
__proto__: null,
indexFromUuid: indexFromUuid,
linkForUuid: linkForUuid,
performPreLocalization: performPreLocalization,
preLocalize: preLocalize,
preloadHandlebarsTemplates: preloadHandlebarsTemplates,
registerHandlebarsHelpers: registerHandlebarsHelpers,
simplifyBonus: simplifyBonus,
sortObjectEntries: sortObjectEntries,
synchronizeActorSpells: synchronizeActorSpells,
validators: validators
});
/**
* Advancement that presents the player with the option to roll hit points at each level or select the average value.
* Keeps track of player hit point rolls or selection for each class level. **Can only be added to classes and each
* class can only have one.**
*/
class HitPointsAdvancement extends Advancement {
/** @inheritdoc */
static get metadata() {
return foundry.utils.mergeObject(super.metadata, {
order: 10,
icon: "systems/dnd5e/icons/svg/hit-points.svg",
title: game.i18n.localize("DND5E.AdvancementHitPointsTitle"),
hint: game.i18n.localize("DND5E.AdvancementHitPointsHint"),
multiLevel: true,
validItemTypes: new Set(["class"]),
apps: {
config: HitPointsConfig,
flow: HitPointsFlow
}
});
}
/* -------------------------------------------- */
/* Instance Properties */
/* -------------------------------------------- */
/** @inheritdoc */
get levels() {
return Array.fromRange(CONFIG.DND5E.maxLevel + 1).slice(1);
}
/* -------------------------------------------- */
/**
* Shortcut to the hit die used by the class.
* @returns {string}
*/
get hitDie() {
return this.item.system.hitDice;
}
/* -------------------------------------------- */
/**
* The face value of the hit die used.
* @returns {number}
*/
get hitDieValue() {
return Number(this.hitDie.substring(1));
}
/* -------------------------------------------- */
/* Display Methods */
/* -------------------------------------------- */
/** @inheritdoc */
configuredForLevel(level) {
return this.valueForLevel(level) !== null;
}
/* -------------------------------------------- */
/** @inheritdoc */
titleForLevel(level, { configMode=false }={}) {
const hp = this.valueForLevel(level);
if ( !hp || configMode ) return this.title;
return `${this.title}: ${hp} `;
}
/* -------------------------------------------- */
/**
* Hit points given at the provided level.
* @param {number} level Level for which to get hit points.
* @returns {number|null} Hit points for level or null if none have been taken.
*/
valueForLevel(level) {
return this.constructor.valueForLevel(this.value, this.hitDieValue, level);
}
/* -------------------------------------------- */
/**
* Hit points given at the provided level.
* @param {object} data Contents of `value` used to determine this value.
* @param {number} hitDieValue Face value of the hit die used by this advancement.
* @param {number} level Level for which to get hit points.
* @returns {number|null} Hit points for level or null if none have been taken.
*/
static valueForLevel(data, hitDieValue, level) {
const value = data[level];
if ( !value ) return null;
if ( value === "max" ) return hitDieValue;
if ( value === "avg" ) return (hitDieValue / 2) + 1;
return value;
}
/* -------------------------------------------- */
/**
* Total hit points provided by this advancement.
* @returns {number} Hit points currently selected.
*/
total() {
return Object.keys(this.value).reduce((total, level) => total + this.valueForLevel(parseInt(level)), 0);
}
/* -------------------------------------------- */
/**
* Total hit points taking the provided ability modifier into account, with a minimum of 1 per level.
* @param {number} mod Modifier to add per level.
* @returns {number} Total hit points plus modifier.
*/
getAdjustedTotal(mod) {
return Object.keys(this.value).reduce((total, level) => {
return total + Math.max(this.valueForLevel(parseInt(level)) + mod, 1);
}, 0);
}
/* -------------------------------------------- */
/* Editing Methods */
/* -------------------------------------------- */
/** @inheritdoc */
static availableForItem(item) {
return !item.advancement.byType.HitPoints?.length;
}
/* -------------------------------------------- */
/* Application Methods */
/* -------------------------------------------- */
/**
* Add the ability modifier and any bonuses to the provided hit points value to get the number to apply.
* @param {number} value Hit points taken at a given level.
* @returns {number} Hit points adjusted with ability modifier and per-level bonuses.
*/
#getApplicableValue(value) {
const abilityId = CONFIG.DND5E.hitPointsAbility || "con";
value = Math.max(value + (this.actor.system.abilities[abilityId]?.mod ?? 0), 1);
value += simplifyBonus(this.actor.system.attributes.hp.bonuses.level, this.actor.getRollData());
return value;
}
/* -------------------------------------------- */
/** @inheritdoc */
apply(level, data) {
let value = this.constructor.valueForLevel(data, this.hitDieValue, level);
if ( value === undefined ) return;
this.actor.updateSource({
"system.attributes.hp.value": this.actor.system.attributes.hp.value + this.#getApplicableValue(value)
});
this.updateSource({ value: data });
}
/* -------------------------------------------- */
/** @inheritdoc */
restore(level, data) {
this.apply(level, data);
}
/* -------------------------------------------- */
/** @inheritdoc */
reverse(level) {
let value = this.valueForLevel(level);
if ( value === undefined ) return;
this.actor.updateSource({
"system.attributes.hp.value": this.actor.system.attributes.hp.value - this.#getApplicableValue(value)
});
const source = { [level]: this.value[level] };
this.updateSource({ [`value.-=${level}`]: null });
return source;
}
}
/**
* Configuration application for item grants.
*/
class ItemGrantConfig extends AdvancementConfig {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "advancement", "item-grant"],
dragDrop: [{ dropSelector: ".drop-target" }],
dropKeyPath: "items",
template: "systems/dnd5e/templates/advancement/item-grant-config.hbs"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
context.showSpellConfig = context.configuration.items.map(uuid => fromUuidSync(uuid)).some(i => i?.type === "spell");
return context;
}
/* -------------------------------------------- */
/** @inheritdoc */
_validateDroppedItem(event, item) {
this.advancement._validateItemType(item);
}
}
/**
* Inline application that presents the player with a list of items to be added.
*/
class ItemGrantFlow extends AdvancementFlow {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "systems/dnd5e/templates/advancement/item-grant-flow.hbs"
});
}
/* -------------------------------------------- */
/**
* Produce the rendering context for this flow.
* @returns {object}
*/
async getContext() {
const config = this.advancement.configuration.items;
const added = this.retainedData?.items.map(i => foundry.utils.getProperty(i, "flags.dnd5e.sourceId"))
?? this.advancement.value.added;
const checked = new Set(Object.values(added ?? {}));
return {
optional: this.advancement.configuration.optional,
items: (await Promise.all(config.map(uuid => fromUuid(uuid)))).reduce((arr, item) => {
if ( !item ) return arr;
item.checked = added ? checked.has(item.uuid) : true;
arr.push(item);
return arr;
}, [])
};
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
return foundry.utils.mergeObject(super.getData(options), await this.getContext());
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("a[data-uuid]").click(this._onClickFeature.bind(this));
}
/* -------------------------------------------- */
/**
* Handle clicking on a feature during item grant to preview the feature.
* @param {MouseEvent} event The triggering event.
* @protected
*/
async _onClickFeature(event) {
event.preventDefault();
const uuid = event.currentTarget.dataset.uuid;
const item = await fromUuid(uuid);
item?.sheet.render(true);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
const retainedData = this.retainedData?.items.reduce((obj, i) => {
obj[foundry.utils.getProperty(i, "flags.dnd5e.sourceId")] = i;
return obj;
}, {});
await this.advancement.apply(this.level, formData, retainedData);
}
}
class SpellConfigurationData extends foundry.abstract.DataModel {
static defineSchema() {
return {
ability: new foundry.data.fields.StringField({label: "DND5E.AbilityModifier"}),
preparation: new foundry.data.fields.StringField({label: "DND5E.SpellPreparationMode"}),
uses: new foundry.data.fields.SchemaField({
max: new FormulaField({deterministic: true, label: "DND5E.UsesMax"}),
per: new foundry.data.fields.StringField({label: "DND5E.UsesPeriod"})
}, {label: "DND5E.LimitedUses"})
};
}
/* -------------------------------------------- */
/**
* Changes that this spell configuration indicates should be performed on spells.
* @type {object}
*/
get spellChanges() {
const updates = {};
if ( this.ability ) updates["system.ability"] = this.ability;
if ( this.preparation ) updates["system.preparation.mode"] = this.preparation;
if ( this.uses.max && this.uses.per ) {
updates["system.uses.max"] = this.uses.max;
updates["system.uses.per"] = this.uses.per;
if ( Number.isNumeric(this.uses.max) ) updates["system.uses.value"] = parseInt(this.uses.max);
else {
try {
const rollData = this.parent.parent.actor.getRollData({ deterministic: true });
const formula = Roll.replaceFormulaData(this.uses.max, rollData, {missing: 0});
updates["system.uses.value"] = Roll.safeEval(formula);
} catch(e) { }
}
}
return updates;
}
}
class ItemGrantConfigurationData extends foundry.abstract.DataModel {
/** @inheritdoc */
static defineSchema() {
return {
items: new foundry.data.fields.ArrayField(new foundry.data.fields.StringField(), {
required: true, label: "DOCUMENT.Items"
}),
optional: new foundry.data.fields.BooleanField({
required: true, label: "DND5E.AdvancementItemGrantOptional", hint: "DND5E.AdvancementItemGrantOptionalHint"
}),
spell: new foundry.data.fields.EmbeddedDataField(SpellConfigurationData, {
required: true, nullable: true, initial: null
})
};
}
}
/**
* Advancement that automatically grants one or more items to the player. Presents the player with the option of
* skipping any or all of the items.
*/
class ItemGrantAdvancement extends Advancement {
/** @inheritdoc */
static get metadata() {
return foundry.utils.mergeObject(super.metadata, {
dataModels: {
configuration: ItemGrantConfigurationData
},
order: 40,
icon: "systems/dnd5e/icons/svg/item-grant.svg",
title: game.i18n.localize("DND5E.AdvancementItemGrantTitle"),
hint: game.i18n.localize("DND5E.AdvancementItemGrantHint"),
apps: {
config: ItemGrantConfig,
flow: ItemGrantFlow
}
});
}
/* -------------------------------------------- */
/**
* The item types that are supported in Item Grant.
* @type {Set}
*/
static VALID_TYPES = new Set(["feat", "spell", "consumable", "backpack", "equipment", "loot", "tool", "weapon"]);
/* -------------------------------------------- */
/* Display Methods */
/* -------------------------------------------- */
/** @inheritdoc */
configuredForLevel(level) {
return !foundry.utils.isEmpty(this.value);
}
/* -------------------------------------------- */
/** @inheritdoc */
summaryForLevel(level, { configMode=false }={}) {
// Link to compendium items
if ( !this.value.added || configMode ) {
return this.configuration.items.reduce((html, uuid) => html + dnd5e.utils.linkForUuid(uuid), "");
}
// Link to items on the actor
else {
return Object.keys(this.value.added).map(id => {
const item = this.actor.items.get(id);
return item?.toAnchor({classes: ["content-link"]}).outerHTML ?? "";
}).join("");
}
}
/* -------------------------------------------- */
/* Application Methods */
/* -------------------------------------------- */
/**
* Location where the added items are stored for the specified level.
* @param {number} level Level being advanced.
* @returns {string}
*/
storagePath(level) {
return "value.added";
}
/* -------------------------------------------- */
/**
* Locally apply this advancement to the actor.
* @param {number} level Level being advanced.
* @param {object} data Data from the advancement form.
* @param {object} [retainedData={}] Item data grouped by UUID. If present, this data will be used rather than
* fetching new data from the source.
*/
async apply(level, data, retainedData={}) {
const items = [];
const updates = {};
const spellChanges = this.configuration.spell?.spellChanges ?? {};
for ( const [uuid, selected] of Object.entries(data) ) {
if ( !selected ) continue;
let itemData = retainedData[uuid];
if ( !itemData ) {
const source = await fromUuid(uuid);
if ( !source ) continue;
itemData = source.clone({
_id: foundry.utils.randomID(),
"flags.dnd5e.sourceId": uuid,
"flags.dnd5e.advancementOrigin": `${this.item.id}.${this.id}`
}, {keepId: true}).toObject();
}
if ( itemData.type === "spell" ) foundry.utils.mergeObject(itemData, spellChanges);
items.push(itemData);
updates[itemData._id] = uuid;
}
this.actor.updateSource({items});
this.updateSource({[this.storagePath(level)]: updates});
}
/* -------------------------------------------- */
/** @inheritdoc */
restore(level, data) {
const updates = {};
for ( const item of data.items ) {
this.actor.updateSource({items: [item]});
updates[item._id] = item.flags.dnd5e.sourceId;
}
this.updateSource({[this.storagePath(level)]: updates});
}
/* -------------------------------------------- */
/** @inheritdoc */
reverse(level) {
const items = [];
const keyPath = this.storagePath(level);
for ( const id of Object.keys(foundry.utils.getProperty(this, keyPath) ?? {}) ) {
const item = this.actor.items.get(id);
if ( item ) items.push(item.toObject());
this.actor.items.delete(id);
}
this.updateSource({[keyPath.replace(/\.([\w\d]+)$/, ".-=$1")]: null});
return { items };
}
/* -------------------------------------------- */
/**
* Verify that the provided item can be used with this advancement based on the configuration.
* @param {Item5e} item Item that needs to be tested.
* @param {object} config
* @param {boolean} [config.strict=true] Should an error be thrown when an invalid type is encountered?
* @returns {boolean} Is this type valid?
* @throws An error if the item is invalid and strict is `true`.
*/
_validateItemType(item, { strict=true }={}) {
if ( this.constructor.VALID_TYPES.has(item.type) ) return true;
const type = game.i18n.localize(CONFIG.Item.typeLabels[item.type]);
if ( strict ) throw new Error(game.i18n.format("DND5E.AdvancementItemTypeInvalidWarning", {type}));
return false;
}
}
/**
* Configuration application for item choices.
*/
class ItemChoiceConfig extends AdvancementConfig {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "advancement", "item-choice", "two-column"],
dragDrop: [{ dropSelector: ".drop-target" }],
dropKeyPath: "pool",
template: "systems/dnd5e/templates/advancement/item-choice-config.hbs",
width: 540
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = {
...super.getData(options),
showSpellConfig: this.advancement.configuration.type === "spell",
validTypes: this.advancement.constructor.VALID_TYPES.reduce((obj, type) => {
obj[type] = game.i18n.localize(CONFIG.Item.typeLabels[type]);
return obj;
}, {})
};
if ( this.advancement.configuration.type === "feat" ) {
const selectedType = CONFIG.DND5E.featureTypes[this.advancement.configuration.restriction.type];
context.typeRestriction = {
typeLabel: game.i18n.localize("DND5E.ItemFeatureType"),
typeOptions: CONFIG.DND5E.featureTypes,
subtypeLabel: game.i18n.format("DND5E.ItemFeatureSubtype", {category: selectedType?.label}),
subtypeOptions: selectedType?.subtypes
};
}
return context;
}
/* -------------------------------------------- */
/** @inheritdoc */
async prepareConfigurationUpdate(configuration) {
if ( configuration.choices ) configuration.choices = this.constructor._cleanedObject(configuration.choices);
// Ensure items are still valid if type restriction or spell restriction are changed
const pool = [];
for ( const uuid of (configuration.pool ?? this.advancement.configuration.pool) ) {
if ( this.advancement._validateItemType(await fromUuid(uuid), {
type: configuration.type, restriction: configuration.restriction ?? {}, strict: false
}) ) pool.push(uuid);
}
configuration.pool = pool;
return configuration;
}
/* -------------------------------------------- */
/** @inheritdoc */
_validateDroppedItem(event, item) {
this.advancement._validateItemType(item);
}
}
/**
* Object describing the proficiency for a specific ability or skill.
*
* @param {number} proficiency Actor's flat proficiency bonus based on their current level.
* @param {number} multiplier Value by which to multiply the actor's base proficiency value.
* @param {boolean} [roundDown] Should half-values be rounded up or down?
*/
class Proficiency {
constructor(proficiency, multiplier, roundDown=true) {
/**
* Base proficiency value of the actor.
* @type {number}
* @private
*/
this._baseProficiency = Number(proficiency ?? 0);
/**
* Value by which to multiply the actor's base proficiency value.
* @type {number}
*/
this.multiplier = Number(multiplier ?? 0);
/**
* Direction decimal results should be rounded ("up" or "down").
* @type {string}
*/
this.rounding = roundDown ? "down" : "up";
}
/* -------------------------------------------- */
/**
* Calculate an actor's proficiency modifier based on level or CR.
* @param {number} level Level or CR To use for calculating proficiency modifier.
* @returns {number} Proficiency modifier.
*/
static calculateMod(level) {
return Math.floor((level + 7) / 4);
}
/* -------------------------------------------- */
/**
* Flat proficiency value regardless of proficiency mode.
* @type {number}
*/
get flat() {
const roundMethod = (this.rounding === "down") ? Math.floor : Math.ceil;
return roundMethod(this.multiplier * this._baseProficiency);
}
/* -------------------------------------------- */
/**
* Dice-based proficiency value regardless of proficiency mode.
* @type {string}
*/
get dice() {
if ( (this._baseProficiency === 0) || (this.multiplier === 0) ) return "0";
const roundTerm = (this.rounding === "down") ? "floor" : "ceil";
if ( this.multiplier === 0.5 ) {
return `${roundTerm}(1d${this._baseProficiency * 2} / 2)`;
} else {
return `${this.multiplier}d${this._baseProficiency * 2}`;
}
}
/* -------------------------------------------- */
/**
* Either flat or dice proficiency term based on configured setting.
* @type {string}
*/
get term() {
return (game.settings.get("dnd5e", "proficiencyModifier") === "dice") ? this.dice : String(this.flat);
}
/* -------------------------------------------- */
/**
* Whether the proficiency is greater than zero.
* @type {boolean}
*/
get hasProficiency() {
return (this._baseProficiency > 0) && (this.multiplier > 0);
}
/* -------------------------------------------- */
/**
* Override the default `toString` method to return flat proficiency for backwards compatibility in formula.
* @returns {string} Flat proficiency value.
*/
toString() {
return this.term;
}
}
/* -------------------------------------------- */
/* D20 Roll */
/* -------------------------------------------- */
/**
* Configuration data for a D20 roll.
*
* @typedef {object} D20RollConfiguration
*
* @property {string[]} [parts=[]] The dice roll component parts, excluding the initial d20.
* @property {object} [data={}] Data that will be used when parsing this roll.
* @property {Event} [event] The triggering event for this roll.
*
* ## D20 Properties
* @property {boolean} [advantage] Apply advantage to this roll (unless overridden by modifier keys or dialog)?
* @property {boolean} [disadvantage] Apply disadvantage to this roll (unless overridden by modifier keys or dialog)?
* @property {number|null} [critical=20] The value of the d20 result which represents a critical success,
* `null` will prevent critical successes.
* @property {number|null} [fumble=1] The value of the d20 result which represents a critical failure,
* `null` will prevent critical failures.
* @property {number} [targetValue] The value of the d20 result which should represent a successful roll.
*
* ## Flags
* @property {boolean} [elvenAccuracy] Allow Elven Accuracy to modify this roll?
* @property {boolean} [halflingLucky] Allow Halfling Luck to modify this roll?
* @property {boolean} [reliableTalent] Allow Reliable Talent to modify this roll?
*
* ## Roll Configuration Dialog
* @property {boolean} [fastForward] Should the roll configuration dialog be skipped?
* @property {boolean} [chooseModifier=false] If the configuration dialog is shown, should the ability modifier be
* configurable within that interface?
* @property {string} [template] The HTML template used to display the roll configuration dialog.
* @property {string} [title] Title of the roll configuration dialog.
* @property {object} [dialogOptions] Additional options passed to the roll configuration dialog.
*
* ## Chat Message
* @property {boolean} [chatMessage=true] Should a chat message be created for this roll?
* @property {object} [messageData={}] Additional data which is applied to the created chat message.
* @property {string} [rollMode] Value of `CONST.DICE_ROLL_MODES` to apply as default for the chat message.
* @property {object} [flavor] Flavor text to use in the created chat message.
*/
/**
* A standardized helper function for managing core 5e d20 rolls.
* Holding SHIFT, ALT, or CTRL when the attack is rolled will "fast-forward".
* This chooses the default options of a normal attack with no bonus, Advantage, or Disadvantage respectively
*
* @param {D20RollConfiguration} configuration Configuration data for the D20 roll.
* @returns {Promise} The evaluated D20Roll, or null if the workflow was cancelled.
*/
async function d20Roll({
parts=[], data={}, event,
advantage, disadvantage, critical=20, fumble=1, targetValue,
elvenAccuracy, halflingLucky, reliableTalent,
fastForward, chooseModifier=false, template, title, dialogOptions,
chatMessage=true, messageData={}, rollMode, flavor
}={}) {
// Handle input arguments
const formula = ["1d20"].concat(parts).join(" + ");
const {advantageMode, isFF} = CONFIG.Dice.D20Roll.determineAdvantageMode({
advantage, disadvantage, fastForward, event
});
const defaultRollMode = rollMode || game.settings.get("core", "rollMode");
if ( chooseModifier && !isFF ) {
data.mod = "@mod";
if ( "abilityCheckBonus" in data ) data.abilityCheckBonus = "@abilityCheckBonus";
}
// Construct the D20Roll instance
const roll = new CONFIG.Dice.D20Roll(formula, data, {
flavor: flavor || title,
advantageMode,
defaultRollMode,
rollMode,
critical,
fumble,
targetValue,
elvenAccuracy,
halflingLucky,
reliableTalent
});
// Prompt a Dialog to further configure the D20Roll
if ( !isFF ) {
const configured = await roll.configureDialog({
title,
chooseModifier,
defaultRollMode,
defaultAction: advantageMode,
defaultAbility: data?.item?.ability || data?.defaultAbility,
template
}, dialogOptions);
if ( configured === null ) return null;
} else roll.options.rollMode ??= defaultRollMode;
// Evaluate the configured roll
await roll.evaluate({async: true});
// Create a Chat Message
if ( roll && chatMessage ) await roll.toMessage(messageData);
return roll;
}
/* -------------------------------------------- */
/* Damage Roll */
/* -------------------------------------------- */
/**
* Configuration data for a damage roll.
*
* @typedef {object} DamageRollConfiguration
*
* @property {string[]} [parts=[]] The dice roll component parts.
* @property {object} [data={}] Data that will be used when parsing this roll.
* @property {Event} [event] The triggering event for this roll.
*
* ## Critical Handling
* @property {boolean} [allowCritical=true] Is this damage roll allowed to be rolled as critical?
* @property {boolean} [critical] Apply critical to this roll (unless overridden by modifier key or dialog)?
* @property {number} [criticalBonusDice] A number of bonus damage dice that are added for critical hits.
* @property {number} [criticalMultiplier] Multiplier to use when calculating critical damage.
* @property {boolean} [multiplyNumeric] Should numeric terms be multiplied when this roll criticals?
* @property {boolean} [powerfulCritical] Should the critical dice be maximized rather than rolled?
* @property {string} [criticalBonusDamage] An extra damage term that is applied only on a critical hit.
*
* ## Roll Configuration Dialog
* @property {boolean} [fastForward] Should the roll configuration dialog be skipped?
* @property {string} [template] The HTML template used to render the roll configuration dialog.
* @property {string} [title] Title of the roll configuration dialog.
* @property {object} [dialogOptions] Additional options passed to the roll configuration dialog.
*
* ## Chat Message
* @property {boolean} [chatMessage=true] Should a chat message be created for this roll?
* @property {object} [messageData={}] Additional data which is applied to the created chat message.
* @property {string} [rollMode] Value of `CONST.DICE_ROLL_MODES` to apply as default for the chat message.
* @property {string} [flavor] Flavor text to use in the created chat message.
*/
/**
* A standardized helper function for managing core 5e damage rolls.
* Holding SHIFT, ALT, or CTRL when the attack is rolled will "fast-forward".
* This chooses the default options of a normal attack with no bonus, Critical, or no bonus respectively
*
* @param {DamageRollConfiguration} configuration Configuration data for the Damage roll.
* @returns {Promise} The evaluated DamageRoll, or null if the workflow was canceled.
*/
async function damageRoll({
parts=[], data={}, event,
allowCritical=true, critical, criticalBonusDice, criticalMultiplier,
multiplyNumeric, powerfulCritical, criticalBonusDamage,
fastForward, template, title, dialogOptions,
chatMessage=true, messageData={}, rollMode, flavor
}={}) {
// Handle input arguments
const defaultRollMode = rollMode || game.settings.get("core", "rollMode");
// Construct the DamageRoll instance
const formula = parts.join(" + ");
const {isCritical, isFF} = _determineCriticalMode({critical, fastForward, event});
const roll = new CONFIG.Dice.DamageRoll(formula, data, {
flavor: flavor || title,
rollMode,
critical: isFF ? isCritical : false,
criticalBonusDice,
criticalMultiplier,
criticalBonusDamage,
multiplyNumeric: multiplyNumeric ?? game.settings.get("dnd5e", "criticalDamageModifiers"),
powerfulCritical: powerfulCritical ?? game.settings.get("dnd5e", "criticalDamageMaxDice")
});
// Prompt a Dialog to further configure the DamageRoll
if ( !isFF ) {
const configured = await roll.configureDialog({
title,
defaultRollMode: defaultRollMode,
defaultCritical: isCritical,
template,
allowCritical
}, dialogOptions);
if ( configured === null ) return null;
}
// Evaluate the configured roll
await roll.evaluate({async: true});
// Create a Chat Message
if ( roll && chatMessage ) await roll.toMessage(messageData);
return roll;
}
/* -------------------------------------------- */
/**
* Determines whether this d20 roll should be fast-forwarded, and whether advantage or disadvantage should be applied
* @param {object} [config]
* @param {Event} [config.event] Event that triggered the roll.
* @param {boolean} [config.critical] Is this roll treated as a critical by default?
* @param {boolean} [config.fastForward] Should the roll dialog be skipped?
* @returns {{isFF: boolean, isCritical: boolean}} Whether the roll is fast-forward, and whether it is a critical hit
*/
function _determineCriticalMode({event, critical=false, fastForward}={}) {
const isFF = fastForward ?? (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey));
if ( event?.altKey ) critical = true;
return {isFF: !!isFF, isCritical: critical};
}
/**
* A helper Dialog subclass for rolling Hit Dice on short rest.
*
* @param {Actor5e} actor Actor that is taking the short rest.
* @param {object} [dialogData={}] An object of dialog data which configures how the modal window is rendered.
* @param {object} [options={}] Dialog rendering options.
*/
class ShortRestDialog extends Dialog {
constructor(actor, dialogData={}, options={}) {
super(dialogData, options);
/**
* Store a reference to the Actor document which is resting
* @type {Actor}
*/
this.actor = actor;
/**
* Track the most recently used HD denomination for re-rendering the form
* @type {string}
*/
this._denom = null;
}
/* -------------------------------------------- */
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "systems/dnd5e/templates/apps/short-rest.hbs",
classes: ["dnd5e", "dialog"]
});
}
/* -------------------------------------------- */
/** @inheritDoc */
getData() {
const data = super.getData();
// Determine Hit Dice
data.availableHD = this.actor.items.reduce((hd, item) => {
if ( item.type === "class" ) {
const {levels, hitDice, hitDiceUsed} = item.system;
const denom = hitDice ?? "d6";
const available = parseInt(levels ?? 1) - parseInt(hitDiceUsed ?? 0);
hd[denom] = denom in hd ? hd[denom] + available : available;
}
return hd;
}, {});
data.canRoll = this.actor.system.attributes.hd > 0;
data.denomination = this._denom;
// Determine rest type
const variant = game.settings.get("dnd5e", "restVariant");
data.promptNewDay = variant !== "epic"; // It's never a new day when only resting 1 minute
data.newDay = false; // It may be a new day, but not by default
return data;
}
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
let btn = html.find("#roll-hd");
btn.click(this._onRollHitDie.bind(this));
}
/* -------------------------------------------- */
/**
* Handle rolling a Hit Die as part of a Short Rest action
* @param {Event} event The triggering click event
* @protected
*/
async _onRollHitDie(event) {
event.preventDefault();
const btn = event.currentTarget;
this._denom = btn.form.hd.value;
await this.actor.rollHitDie(this._denom);
this.render();
}
/* -------------------------------------------- */
/**
* A helper constructor function which displays the Short Rest dialog and returns a Promise once it's workflow has
* been resolved.
* @param {object} [options={}]
* @param {Actor5e} [options.actor] Actor that is taking the short rest.
* @returns {Promise} Promise that resolves when the rest is completed or rejects when canceled.
*/
static async shortRestDialog({ actor }={}) {
return new Promise((resolve, reject) => {
const dlg = new this(actor, {
title: `${game.i18n.localize("DND5E.ShortRest")}: ${actor.name}`,
buttons: {
rest: {
icon: ' ',
label: game.i18n.localize("DND5E.Rest"),
callback: html => {
let newDay = false;
if ( game.settings.get("dnd5e", "restVariant") !== "epic" ) {
newDay = html.find('input[name="newDay"]')[0].checked;
}
resolve(newDay);
}
},
cancel: {
icon: ' ',
label: game.i18n.localize("Cancel"),
callback: reject
}
},
close: reject
});
dlg.render(true);
});
}
}
/**
* A helper Dialog subclass for completing a long rest.
*
* @param {Actor5e} actor Actor that is taking the long rest.
* @param {object} [dialogData={}] An object of dialog data which configures how the modal window is rendered.
* @param {object} [options={}] Dialog rendering options.
*/
class LongRestDialog extends Dialog {
constructor(actor, dialogData={}, options={}) {
super(dialogData, options);
this.actor = actor;
}
/* -------------------------------------------- */
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "systems/dnd5e/templates/apps/long-rest.hbs",
classes: ["dnd5e", "dialog"]
});
}
/* -------------------------------------------- */
/** @inheritDoc */
getData() {
const data = super.getData();
const variant = game.settings.get("dnd5e", "restVariant");
data.promptNewDay = variant !== "gritty"; // It's always a new day when resting 1 week
data.newDay = variant === "normal"; // It's probably a new day when resting normally (8 hours)
return data;
}
/* -------------------------------------------- */
/**
* A helper constructor function which displays the Long Rest confirmation dialog and returns a Promise once it's
* workflow has been resolved.
* @param {object} [options={}]
* @param {Actor5e} [options.actor] Actor that is taking the long rest.
* @returns {Promise} Promise that resolves when the rest is completed or rejects when canceled.
*/
static async longRestDialog({ actor } = {}) {
return new Promise((resolve, reject) => {
const dlg = new this(actor, {
title: `${game.i18n.localize("DND5E.LongRest")}: ${actor.name}`,
buttons: {
rest: {
icon: ' ',
label: game.i18n.localize("DND5E.Rest"),
callback: html => {
let newDay = true;
if (game.settings.get("dnd5e", "restVariant") !== "gritty") {
newDay = html.find('input[name="newDay"]')[0].checked;
}
resolve(newDay);
}
},
cancel: {
icon: ' ',
label: game.i18n.localize("Cancel"),
callback: reject
}
},
default: "rest",
close: reject
});
dlg.render(true);
});
}
}
/**
* Cached version of the base items compendia indices with the needed subtype fields.
* @type {object}
* @private
*/
const _cachedIndices = {};
/* -------------------------------------------- */
/* Trait Lists */
/* -------------------------------------------- */
/**
* Get the key path to the specified trait on an actor.
* @param {string} trait Trait as defined in `CONFIG.DND5E.traits`.
* @returns {string} Key path to this trait's object within an actor's system data.
*/
function actorKeyPath(trait) {
const traitConfig = CONFIG.DND5E.traits[trait];
if ( traitConfig.actorKeyPath ) return traitConfig.actorKeyPath;
return `traits.${trait}`;
}
/* -------------------------------------------- */
/**
* Fetch the categories object for the specified trait.
* @param {string} trait Trait as defined in `CONFIG.DND5E.traits`.
* @returns {object} Trait categories defined within `CONFIG.DND5E`.
*/
function categories(trait) {
const traitConfig = CONFIG.DND5E.traits[trait];
return CONFIG.DND5E[traitConfig.configKey ?? trait];
}
/* -------------------------------------------- */
/**
* Get a list of choices for a specific trait.
* @param {string} trait Trait as defined in `CONFIG.DND5E.traits`.
* @param {Set} [chosen=[]] Optional list of keys to be marked as chosen.
* @returns {object} Object mapping proficiency ids to choice objects.
*/
async function choices(trait, chosen=new Set()) {
const traitConfig = CONFIG.DND5E.traits[trait];
if ( foundry.utils.getType(chosen) === "Array" ) chosen = new Set(chosen);
let data = Object.entries(categories(trait)).reduce((obj, [key, label]) => {
obj[key] = { label, chosen: chosen.has(key) };
return obj;
}, {});
if ( traitConfig.children ) {
for ( const [categoryKey, childrenKey] of Object.entries(traitConfig.children) ) {
const children = CONFIG.DND5E[childrenKey];
if ( !children || !data[categoryKey] ) continue;
data[categoryKey].children = Object.entries(children).reduce((obj, [key, label]) => {
obj[key] = { label, chosen: chosen.has(key) };
return obj;
}, {});
}
}
if ( traitConfig.subtypes ) {
const keyPath = `system.${traitConfig.subtypes.keyPath}`;
const map = CONFIG.DND5E[`${trait}ProficienciesMap`];
// Merge all IDs lists together
const ids = traitConfig.subtypes.ids.reduce((obj, key) => {
if ( CONFIG.DND5E[key] ) Object.assign(obj, CONFIG.DND5E[key]);
return obj;
}, {});
// Fetch base items for all IDs
const baseItems = await Promise.all(Object.entries(ids).map(async ([key, id]) => {
const index = await getBaseItem(id);
return [key, index];
}));
// Sort base items as children of categories based on subtypes
for ( const [key, index] of baseItems ) {
if ( !index ) continue;
// Get the proper subtype, using proficiency map if needed
let type = foundry.utils.getProperty(index, keyPath);
if ( map?.[type] ) type = map[type];
const entry = { label: index.name, chosen: chosen.has(key) };
// No category for this type, add at top level
if ( !data[type] ) data[key] = entry;
// Add as child to appropriate category
else {
data[type].children ??= {};
data[type].children[key] = entry;
}
}
}
// Sort Categories
if ( traitConfig.sortCategories ) data = dnd5e.utils.sortObjectEntries(data, "label");
// Sort Children
for ( const category of Object.values(data) ) {
if ( !category.children ) continue;
category.children = dnd5e.utils.sortObjectEntries(category.children, "label");
}
return data;
}
/* -------------------------------------------- */
/**
* Fetch an item for the provided ID. If the provided ID contains a compendium pack name
* it will be fetched from that pack, otherwise it will be fetched from the compendium defined
* in `DND5E.sourcePacks.ITEMS`.
* @param {string} identifier Simple ID or compendium name and ID separated by a dot.
* @param {object} [options]
* @param {boolean} [options.indexOnly] If set to true, only the index data will be fetched (will never return
* Promise).
* @param {boolean} [options.fullItem] If set to true, the full item will be returned as long as `indexOnly` is
* false.
* @returns {Promise|object} Promise for a `Document` if `indexOnly` is false & `fullItem` is true,
* otherwise else a simple object containing the minimal index data.
*/
function getBaseItem(identifier, { indexOnly=false, fullItem=false }={}) {
let pack = CONFIG.DND5E.sourcePacks.ITEMS;
let [scope, collection, id] = identifier.split(".");
if ( scope && collection ) pack = `${scope}.${collection}`;
if ( !id ) id = identifier;
const packObject = game.packs.get(pack);
// Full Item5e document required, always async.
if ( fullItem && !indexOnly ) return packObject?.getDocument(id);
const cache = _cachedIndices[pack];
const loading = cache instanceof Promise;
// Return extended index if cached, otherwise normal index, guaranteed to never be async.
if ( indexOnly ) {
const index = packObject?.index.get(id);
return loading ? index : cache?.[id] ?? index;
}
// Returned cached version of extended index if available.
if ( loading ) return cache.then(() => _cachedIndices[pack][id]);
else if ( cache ) return cache[id];
if ( !packObject ) return;
// Build the extended index and return a promise for the data
const promise = packObject.getIndex({ fields: traitIndexFields() }).then(index => {
const store = index.reduce((obj, entry) => {
obj[entry._id] = entry;
return obj;
}, {});
_cachedIndices[pack] = store;
return store[id];
});
_cachedIndices[pack] = promise;
return promise;
}
/* -------------------------------------------- */
/**
* List of fields on items that should be indexed for retrieving subtypes.
* @returns {string[]} Index list to pass to `Compendium#getIndex`.
* @protected
*/
function traitIndexFields() {
const fields = [];
for ( const traitConfig of Object.values(CONFIG.DND5E.traits) ) {
if ( !traitConfig.subtypes ) continue;
fields.push(`system.${traitConfig.subtypes.keyPath}`);
}
return fields;
}
/* -------------------------------------------- */
/* Localized Formatting Methods */
/* -------------------------------------------- */
/**
* Get the localized label for a specific trait type.
* @param {string} trait Trait as defined in `CONFIG.DND5E.traits`.
* @param {number} [count] Count used to determine pluralization. If no count is provided, will default to
* the 'other' pluralization.
* @returns {string} Localized label.
*/
function traitLabel(trait, count) {
let typeCap;
if ( trait.length === 2 ) typeCap = trait.toUpperCase();
else typeCap = trait.capitalize();
const pluralRule = ( count !== undefined ) ? new Intl.PluralRules(game.i18n.lang).select(count) : "other";
return game.i18n.localize(`DND5E.Trait${typeCap}Plural.${pluralRule}`);
}
/* -------------------------------------------- */
/**
* Retrieve the proper display label for the provided key.
* @param {string} trait Trait as defined in `CONFIG.DND5E.traits`.
* @param {string} key Key for which to generate the label.
* @returns {string} Retrieved label.
*/
function keyLabel(trait, key) {
const traitConfig = CONFIG.DND5E.traits[trait];
if ( categories(trait)[key] ) {
const category = categories(trait)[key];
if ( !traitConfig.labelKey ) return category;
return foundry.utils.getProperty(category, traitConfig.labelKey);
}
for ( const childrenKey of Object.values(traitConfig.children ?? {}) ) {
if ( CONFIG.DND5E[childrenKey]?.[key] ) return CONFIG.DND5E[childrenKey]?.[key];
}
for ( const idsKey of traitConfig.subtypes?.ids ?? [] ) {
if ( !CONFIG.DND5E[idsKey]?.[key] ) continue;
const index = getBaseItem(CONFIG.DND5E[idsKey][key], { indexOnly: true });
if ( index ) return index.name;
else break;
}
return key;
}
/* -------------------------------------------- */
/**
* Create a human readable description of the provided choice.
* @param {string} trait Trait as defined in `CONFIG.DND5E.traits`.
* @param {TraitChoice} choice Data for a specific choice.
* @returns {string}
*/
function choiceLabel(trait, choice) {
// Select from any trait values
if ( !choice.pool ) {
return game.i18n.format("DND5E.TraitConfigChooseAny", {
count: choice.count,
type: traitLabel(trait, choice.count).toLowerCase()
});
}
// Select from a list of options
const choices = choice.pool.map(key => keyLabel(trait, key));
const listFormatter = new Intl.ListFormat(game.i18n.lang, { type: "disjunction" });
return game.i18n.format("DND5E.TraitConfigChooseList", {
count: choice.count,
list: listFormatter.format(choices)
});
}
var trait = /*#__PURE__*/Object.freeze({
__proto__: null,
actorKeyPath: actorKeyPath,
categories: categories,
choiceLabel: choiceLabel,
choices: choices,
getBaseItem: getBaseItem,
keyLabel: keyLabel,
traitIndexFields: traitIndexFields,
traitLabel: traitLabel
});
/**
* Extend the base Actor class to implement additional system-specific logic.
*/
class Actor5e extends Actor {
/**
* The data source for Actor5e.classes allowing it to be lazily computed.
* @type {Object}
* @private
*/
_classes;
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* A mapping of classes belonging to this Actor.
* @type {Object}
*/
get classes() {
if ( this._classes !== undefined ) return this._classes;
if ( !["character", "npc"].includes(this.type) ) return this._classes = {};
return this._classes = this.items.filter(item => item.type === "class").reduce((obj, cls) => {
obj[cls.identifier] = cls;
return obj;
}, {});
}
/* -------------------------------------------- */
/**
* Is this Actor currently polymorphed into some other creature?
* @type {boolean}
*/
get isPolymorphed() {
return this.getFlag("dnd5e", "isPolymorphed") || false;
}
/* -------------------------------------------- */
/**
* The Actor's currently equipped armor, if any.
* @type {Item5e|null}
*/
get armor() {
return this.system.attributes.ac.equippedArmor ?? null;
}
/* -------------------------------------------- */
/**
* The Actor's currently equipped shield, if any.
* @type {Item5e|null}
*/
get shield() {
return this.system.attributes.ac.equippedShield ?? null;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
_initializeSource(source, options={}) {
source = super._initializeSource(source, options);
if ( !source._id || !options.pack || dnd5e.moduleArt.suppressArt ) return source;
const uuid = `Compendium.${options.pack}.${source._id}`;
const art = game.dnd5e.moduleArt.map.get(uuid);
if ( art?.actor || art?.token ) {
if ( art.actor ) source.img = art.actor;
if ( typeof art.token === "string" ) source.prototypeToken.texture.src = art.token;
else if ( art.token ) foundry.utils.mergeObject(source.prototypeToken, art.token);
const biography = source.system.details?.biography;
if ( art.credit && biography ) {
if ( typeof biography.value !== "string" ) biography.value = "";
biography.value += `${art.credit}
`;
}
}
return source;
}
/* -------------------------------------------- */
/** @inheritDoc */
prepareData() {
// Do not attempt to prepare non-system types.
if ( !game.template.Actor.types.includes(this.type) ) return;
this._classes = undefined;
this._preparationWarnings = [];
super.prepareData();
this.items.forEach(item => item.prepareFinalAttributes());
}
/* -------------------------------------------- */
/** @inheritDoc */
prepareBaseData() {
// Delegate preparation to type-subclass
if ( this.type === "group" ) { // Eventually other types will also support this
return this.system._prepareBaseData();
}
this._prepareBaseArmorClass();
// Type-specific preparation
switch ( this.type ) {
case "character":
return this._prepareCharacterData();
case "npc":
return this._prepareNPCData();
case "vehicle":
return this._prepareVehicleData();
}
}
/* --------------------------------------------- */
/** @inheritDoc */
applyActiveEffects() {
this._prepareScaleValues();
// The Active Effects do not have access to their parent at preparation time, so we wait until this stage to
// determine whether they are suppressed or not.
this.effects.forEach(e => e.determineSuppression());
return super.applyActiveEffects();
}
/* -------------------------------------------- */
/** @inheritDoc */
prepareDerivedData() {
// Delegate preparation to type-subclass
if ( this.type === "group" ) { // Eventually other types will also support this
return this.system._prepareDerivedData();
}
const flags = this.flags.dnd5e || {};
this.labels = {};
// Retrieve data for polymorphed actors
let originalSaves = null;
let originalSkills = null;
if ( this.isPolymorphed ) {
const transformOptions = flags.transformOptions;
const original = game.actors?.get(flags.originalActor);
if ( original ) {
if ( transformOptions.mergeSaves ) originalSaves = original.system.abilities;
if ( transformOptions.mergeSkills ) originalSkills = original.system.skills;
}
}
// Prepare abilities, skills, & everything else
const globalBonuses = this.system.bonuses?.abilities ?? {};
const rollData = this.getRollData();
const checkBonus = simplifyBonus(globalBonuses?.check, rollData);
this._prepareAbilities(rollData, globalBonuses, checkBonus, originalSaves);
this._prepareSkills(rollData, globalBonuses, checkBonus, originalSkills);
this._prepareTools(rollData, globalBonuses, checkBonus);
this._prepareArmorClass();
this._prepareEncumbrance();
this._prepareHitPoints(rollData);
this._prepareInitiative(rollData, checkBonus);
this._prepareSpellcasting();
}
/* -------------------------------------------- */
/**
* Return the amount of experience required to gain a certain character level.
* @param {number} level The desired level.
* @returns {number} The XP required.
*/
getLevelExp(level) {
const levels = CONFIG.DND5E.CHARACTER_EXP_LEVELS;
return levels[Math.min(level, levels.length - 1)];
}
/* -------------------------------------------- */
/**
* Return the amount of experience granted by killing a creature of a certain CR.
* @param {number} cr The creature's challenge rating.
* @returns {number} The amount of experience granted per kill.
*/
getCRExp(cr) {
if ( cr < 1.0 ) return Math.max(200 * cr, 10);
return CONFIG.DND5E.CR_EXP_LEVELS[cr];
}
/* -------------------------------------------- */
/**
* @inheritdoc
* @param {object} [options]
* @param {boolean} [options.deterministic] Whether to force deterministic values for data properties that could be
* either a die term or a flat term.
*/
getRollData({ deterministic=false }={}) {
const data = {...super.getRollData()};
if ( this.type === "group" ) return data;
data.prof = new Proficiency(this.system.attributes.prof, 1);
if ( deterministic ) data.prof = data.prof.flat;
data.attributes = foundry.utils.deepClone(data.attributes);
data.attributes.spellmod = data.abilities[data.attributes.spellcasting || "int"]?.mod ?? 0;
data.classes = {};
for ( const [identifier, cls] of Object.entries(this.classes) ) {
data.classes[identifier] = {...cls.system};
if ( cls.subclass ) data.classes[identifier].subclass = cls.subclass.system;
}
return data;
}
/* -------------------------------------------- */
/* Base Data Preparation Helpers */
/* -------------------------------------------- */
/**
* Initialize derived AC fields for Active Effects to target.
* Mutates the system.attributes.ac object.
* @protected
*/
_prepareBaseArmorClass() {
const ac = this.system.attributes.ac;
ac.armor = 10;
ac.shield = ac.bonus = ac.cover = 0;
}
/* -------------------------------------------- */
/**
* Derive any values that have been scaled by the Advancement system.
* Mutates the value of the `system.scale` object.
* @protected
*/
_prepareScaleValues() {
this.system.scale = Object.entries(this.classes).reduce((scale, [identifier, cls]) => {
scale[identifier] = cls.scaleValues;
if ( cls.subclass ) scale[cls.subclass.identifier] = cls.subclass.scaleValues;
return scale;
}, {});
}
/* -------------------------------------------- */
/**
* Perform any Character specific preparation.
* Mutates several aspects of the system data object.
* @protected
*/
_prepareCharacterData() {
this.system.details.level = 0;
this.system.attributes.hd = 0;
this.system.attributes.attunement.value = 0;
for ( const item of this.items ) {
// Class levels & hit dice
if ( item.type === "class" ) {
const classLevels = parseInt(item.system.levels) || 1;
this.system.details.level += classLevels;
this.system.attributes.hd += classLevels - (parseInt(item.system.hitDiceUsed) || 0);
}
// Attuned items
else if ( item.system.attunement === CONFIG.DND5E.attunementTypes.ATTUNED ) {
this.system.attributes.attunement.value += 1;
}
}
// Character proficiency bonus
this.system.attributes.prof = Proficiency.calculateMod(this.system.details.level);
// Experience required for next level
const xp = this.system.details.xp;
xp.max = this.getLevelExp(this.system.details.level || 1);
const prior = this.getLevelExp(this.system.details.level - 1 || 0);
const required = xp.max - prior;
const pct = Math.round((xp.value - prior) * 100 / required);
xp.pct = Math.clamped(pct, 0, 100);
}
/* -------------------------------------------- */
/**
* Perform any NPC specific preparation.
* Mutates several aspects of the system data object.
* @protected
*/
_prepareNPCData() {
const cr = this.system.details.cr;
// Attuned items
this.system.attributes.attunement.value = this.items.filter(i => {
return i.system.attunement === CONFIG.DND5E.attunementTypes.ATTUNED;
}).length;
// Kill Experience
this.system.details.xp ??= {};
this.system.details.xp.value = this.getCRExp(cr);
// Proficiency
this.system.attributes.prof = Proficiency.calculateMod(Math.max(cr, 1));
// Spellcaster Level
if ( this.system.attributes.spellcasting && !Number.isNumeric(this.system.details.spellLevel) ) {
this.system.details.spellLevel = Math.max(cr, 1);
}
}
/* -------------------------------------------- */
/**
* Perform any Vehicle specific preparation.
* Mutates several aspects of the system data object.
* @protected
*/
_prepareVehicleData() {
this.system.attributes.prof = 0;
}
/* -------------------------------------------- */
/* Derived Data Preparation Helpers */
/* -------------------------------------------- */
/**
* Prepare abilities.
* @param {object} bonusData Data produced by `getRollData` to be applied to bonus formulas.
* @param {object} globalBonuses Global bonus data.
* @param {number} checkBonus Global ability check bonus.
* @param {object} originalSaves A transformed actor's original actor's abilities.
* @protected
*/
_prepareAbilities(bonusData, globalBonuses, checkBonus, originalSaves) {
const flags = this.flags.dnd5e ?? {};
const dcBonus = simplifyBonus(this.system.bonuses?.spell?.dc, bonusData);
const saveBonus = simplifyBonus(globalBonuses.save, bonusData);
for ( const [id, abl] of Object.entries(this.system.abilities) ) {
if ( flags.diamondSoul ) abl.proficient = 1; // Diamond Soul is proficient in all saves
abl.mod = Math.floor((abl.value - 10) / 2);
const isRA = this._isRemarkableAthlete(id);
abl.checkProf = new Proficiency(this.system.attributes.prof, (isRA || flags.jackOfAllTrades) ? 0.5 : 0, !isRA);
const saveBonusAbl = simplifyBonus(abl.bonuses?.save, bonusData);
abl.saveBonus = saveBonusAbl + saveBonus;
abl.saveProf = new Proficiency(this.system.attributes.prof, abl.proficient);
const checkBonusAbl = simplifyBonus(abl.bonuses?.check, bonusData);
abl.checkBonus = checkBonusAbl + checkBonus;
abl.save = abl.mod + abl.saveBonus;
if ( Number.isNumeric(abl.saveProf.term) ) abl.save += abl.saveProf.flat;
abl.dc = 8 + abl.mod + this.system.attributes.prof + dcBonus;
// If we merged saves when transforming, take the highest bonus here.
if ( originalSaves && abl.proficient ) abl.save = Math.max(abl.save, originalSaves[id].save);
}
}
/* -------------------------------------------- */
/**
* Prepare skill checks. Mutates the values of system.skills.
* @param {object} bonusData Data produced by `getRollData` to be applied to bonus formulas.
* @param {object} globalBonuses Global bonus data.
* @param {number} checkBonus Global ability check bonus.
* @param {object} originalSkills A transformed actor's original actor's skills.
* @protected
*/
_prepareSkills(bonusData, globalBonuses, checkBonus, originalSkills) {
if ( this.type === "vehicle" ) return;
const flags = this.flags.dnd5e ?? {};
// Skill modifiers
const feats = CONFIG.DND5E.characterFlags;
const skillBonus = simplifyBonus(globalBonuses.skill, bonusData);
for ( const [id, skl] of Object.entries(this.system.skills) ) {
const ability = this.system.abilities[skl.ability];
const baseBonus = simplifyBonus(skl.bonuses?.check, bonusData);
let roundDown = true;
// Remarkable Athlete
if ( this._isRemarkableAthlete(skl.ability) && (skl.value < 0.5) ) {
skl.value = 0.5;
roundDown = false;
}
// Jack of All Trades
else if ( flags.jackOfAllTrades && (skl.value < 0.5) ) {
skl.value = 0.5;
}
// Polymorph Skill Proficiencies
if ( originalSkills ) {
skl.value = Math.max(skl.value, originalSkills[id].value);
}
// Compute modifier
const checkBonusAbl = simplifyBonus(ability?.bonuses?.check, bonusData);
skl.bonus = baseBonus + checkBonus + checkBonusAbl + skillBonus;
skl.mod = ability?.mod ?? 0;
skl.prof = new Proficiency(this.system.attributes.prof, skl.value, roundDown);
skl.proficient = skl.value;
skl.total = skl.mod + skl.bonus;
if ( Number.isNumeric(skl.prof.term) ) skl.total += skl.prof.flat;
// Compute passive bonus
const passive = flags.observantFeat && (feats.observantFeat.skills.includes(id)) ? 5 : 0;
const passiveBonus = simplifyBonus(skl.bonuses?.passive, bonusData);
skl.passive = 10 + skl.mod + skl.bonus + skl.prof.flat + passive + passiveBonus;
}
}
/* -------------------------------------------- */
/**
* Prepare tool checks. Mutates the values of system.tools.
* @param {object} bonusData Data produced by `getRollData` to be applied to bonus formulae.
* @param {object} globalBonuses Global bonus data.
* @param {number} checkBonus Global ability check bonus.
* @protected
*/
_prepareTools(bonusData, globalBonuses, checkBonus) {
if ( this.type === "vehicle" ) return;
const flags = this.flags.dnd5e ?? {};
for ( const tool of Object.values(this.system.tools) ) {
const ability = this.system.abilities[tool.ability];
const baseBonus = simplifyBonus(tool.bonuses.check, bonusData);
let roundDown = true;
// Remarkable Athlete.
if ( this._isRemarkableAthlete(tool.ability) && (tool.value < 0.5) ) {
tool.value = 0.5;
roundDown = false;
}
// Jack of All Trades.
else if ( flags.jackOfAllTrades && (tool.value < 0.5) ) tool.value = 0.5;
const checkBonusAbl = simplifyBonus(ability?.bonuses?.check, bonusData);
tool.bonus = baseBonus + checkBonus + checkBonusAbl;
tool.mod = ability?.mod ?? 0;
tool.prof = new Proficiency(this.system.attributes.prof, tool.value, roundDown);
tool.total = tool.mod + tool.bonus;
if ( Number.isNumeric(tool.prof.term) ) tool.total += tool.prof.flat;
}
}
/* -------------------------------------------- */
/**
* Prepare a character's AC value from their equipped armor and shield.
* Mutates the value of the `system.attributes.ac` object.
*/
_prepareArmorClass() {
const ac = this.system.attributes.ac;
// Apply automatic migrations for older data structures
let cfg = CONFIG.DND5E.armorClasses[ac.calc];
if ( !cfg ) {
ac.calc = "flat";
if ( Number.isNumeric(ac.value) ) ac.flat = Number(ac.value);
cfg = CONFIG.DND5E.armorClasses.flat;
}
// Identify Equipped Items
const armorTypes = new Set(Object.keys(CONFIG.DND5E.armorTypes));
const {armors, shields} = this.itemTypes.equipment.reduce((obj, equip) => {
const armor = equip.system.armor;
if ( !equip.system.equipped || !armorTypes.has(armor?.type) ) return obj;
if ( armor.type === "shield" ) obj.shields.push(equip);
else obj.armors.push(equip);
return obj;
}, {armors: [], shields: []});
// Determine base AC
switch ( ac.calc ) {
// Flat AC (no additional bonuses)
case "flat":
ac.value = Number(ac.flat);
return;
// Natural AC (includes bonuses)
case "natural":
ac.base = Number(ac.flat);
break;
default:
let formula = ac.calc === "custom" ? ac.formula : cfg.formula;
if ( armors.length ) {
if ( armors.length > 1 ) this._preparationWarnings.push({
message: game.i18n.localize("DND5E.WarnMultipleArmor"), type: "warning"
});
const armorData = armors[0].system.armor;
const isHeavy = armorData.type === "heavy";
ac.armor = armorData.value ?? ac.armor;
ac.dex = isHeavy ? 0 : Math.min(armorData.dex ?? Infinity, this.system.abilities.dex?.mod ?? 0);
ac.equippedArmor = armors[0];
}
else ac.dex = this.system.abilities.dex?.mod ?? 0;
const rollData = this.getRollData({ deterministic: true });
rollData.attributes.ac = ac;
try {
const replaced = Roll.replaceFormulaData(formula, rollData);
ac.base = Roll.safeEval(replaced);
} catch(err) {
this._preparationWarnings.push({
message: game.i18n.localize("DND5E.WarnBadACFormula"), link: "armor", type: "error"
});
const replaced = Roll.replaceFormulaData(CONFIG.DND5E.armorClasses.default.formula, rollData);
ac.base = Roll.safeEval(replaced);
}
break;
}
// Equipped Shield
if ( shields.length ) {
if ( shields.length > 1 ) this._preparationWarnings.push({
message: game.i18n.localize("DND5E.WarnMultipleShields"), type: "warning"
});
ac.shield = shields[0].system.armor.value ?? 0;
ac.equippedShield = shields[0];
}
// Compute total AC and return
ac.value = ac.base + ac.shield + ac.bonus + ac.cover;
}
/* -------------------------------------------- */
/**
* Prepare the level and percentage of encumbrance for an Actor.
* Optionally include the weight of carried currency by applying the standard rule from the PHB pg. 143.
* Mutates the value of the `system.attributes.encumbrance` object.
* @protected
*/
_prepareEncumbrance() {
const encumbrance = this.system.attributes.encumbrance ??= {};
// Get the total weight from items
const physicalItems = ["weapon", "equipment", "consumable", "tool", "backpack", "loot"];
let weight = this.items.reduce((weight, i) => {
if ( !physicalItems.includes(i.type) ) return weight;
const q = i.system.quantity || 0;
const w = i.system.weight || 0;
return weight + (q * w);
}, 0);
// [Optional] add Currency Weight (for non-transformed actors)
const currency = this.system.currency;
if ( game.settings.get("dnd5e", "currencyWeight") && currency ) {
const numCoins = Object.values(currency).reduce((val, denom) => val + Math.max(denom, 0), 0);
const currencyPerWeight = game.settings.get("dnd5e", "metricWeightUnits")
? CONFIG.DND5E.encumbrance.currencyPerWeight.metric
: CONFIG.DND5E.encumbrance.currencyPerWeight.imperial;
weight += numCoins / currencyPerWeight;
}
// Determine the Encumbrance size class
let mod = {tiny: 0.5, sm: 1, med: 1, lg: 2, huge: 4, grg: 8}[this.system.traits.size] || 1;
if ( this.flags.dnd5e?.powerfulBuild ) mod = Math.min(mod * 2, 8);
const strengthMultiplier = game.settings.get("dnd5e", "metricWeightUnits")
? CONFIG.DND5E.encumbrance.strMultiplier.metric
: CONFIG.DND5E.encumbrance.strMultiplier.imperial;
// Populate final Encumbrance values
encumbrance.value = weight.toNearest(0.1);
encumbrance.max = ((this.system.abilities.str?.value ?? 10) * strengthMultiplier * mod).toNearest(0.1);
encumbrance.pct = Math.clamped((encumbrance.value * 100) / encumbrance.max, 0, 100);
encumbrance.encumbered = encumbrance.pct > (200 / 3);
}
/* -------------------------------------------- */
/**
* Prepare hit points for characters.
* @param {object} rollData Data produced by `getRollData` to be applied to bonus formulas.
* @protected
*/
_prepareHitPoints(rollData) {
if ( this.type !== "character" || (this.system._source.attributes.hp.max !== null) ) return;
const hp = this.system.attributes.hp;
const abilityId = CONFIG.DND5E.hitPointsAbility || "con";
const abilityMod = (this.system.abilities[abilityId]?.mod ?? 0);
const base = Object.values(this.classes).reduce((total, item) => {
const advancement = item.advancement.byType.HitPoints?.[0];
return total + (advancement?.getAdjustedTotal(abilityMod) ?? 0);
}, 0);
const levelBonus = simplifyBonus(hp.bonuses.level, rollData) * this.system.details.level;
const overallBonus = simplifyBonus(hp.bonuses.overall, rollData);
hp.max = base + levelBonus + overallBonus;
}
/* -------------------------------------------- */
/**
* Prepare the initiative data for an actor.
* Mutates the value of the system.attributes.init object.
* @param {object} bonusData Data produced by getRollData to be applied to bonus formulas
* @param {number} globalCheckBonus Global ability check bonus
* @protected
*/
_prepareInitiative(bonusData, globalCheckBonus=0) {
const init = this.system.attributes.init ??= {};
const flags = this.flags.dnd5e || {};
// Compute initiative modifier
const abilityId = init.ability || CONFIG.DND5E.initiativeAbility;
const ability = this.system.abilities?.[abilityId] || {};
init.mod = ability.mod ?? 0;
// Initiative proficiency
const prof = this.system.attributes.prof ?? 0;
const ra = flags.remarkableAthlete && ["str", "dex", "con"].includes(abilityId);
init.prof = new Proficiency(prof, (flags.jackOfAllTrades || ra) ? 0.5 : 0, !ra);
// Total initiative includes all numeric terms
const initBonus = simplifyBonus(init.bonus, bonusData);
const abilityBonus = simplifyBonus(ability.bonuses?.check, bonusData);
init.total = init.mod + initBonus + abilityBonus + globalCheckBonus
+ (flags.initiativeAlert ? 5 : 0)
+ (Number.isNumeric(init.prof.term) ? init.prof.flat : 0);
}
/* -------------------------------------------- */
/* Spellcasting Preparation */
/* -------------------------------------------- */
/**
* Prepare data related to the spell-casting capabilities of the Actor.
* Mutates the value of the system.spells object.
* @protected
*/
_prepareSpellcasting() {
if ( !this.system.spells ) return;
// Spellcasting DC
const spellcastingAbility = this.system.abilities[this.system.attributes.spellcasting];
this.system.attributes.spelldc = spellcastingAbility ? spellcastingAbility.dc : 8 + this.system.attributes.prof;
// Translate the list of classes into spellcasting progression
const progression = { slot: 0, pact: 0 };
const types = {};
// NPCs don't get spell levels from classes
if ( this.type === "npc" ) {
progression.slot = this.system.details.spellLevel ?? 0;
types.leveled = 1;
}
else {
// Grab all classes with spellcasting
const classes = this.items.filter(cls => {
if ( cls.type !== "class" ) return false;
const type = cls.spellcasting.type;
if ( !type ) return false;
types[type] ??= 0;
types[type] += 1;
return true;
});
for ( const cls of classes ) this.constructor.computeClassProgression(
progression, cls, { actor: this, count: types[cls.spellcasting.type] }
);
}
for ( const type of Object.keys(CONFIG.DND5E.spellcastingTypes) ) {
this.constructor.prepareSpellcastingSlots(this.system.spells, type, progression, { actor: this });
}
}
/* -------------------------------------------- */
/**
* Contribute to the actor's spellcasting progression.
* @param {object} progression Spellcasting progression data. *Will be mutated.*
* @param {Item5e} cls Class for whom this progression is being computed.
* @param {object} [config={}]
* @param {Actor5e|null} [config.actor] Actor for whom the data is being prepared.
* @param {SpellcastingDescription} [config.spellcasting] Spellcasting descriptive object.
* @param {number} [config.count=1] Number of classes with this type of spellcasting.
*/
static computeClassProgression(progression, cls, {actor, spellcasting, count=1}={}) {
const type = cls.spellcasting.type;
spellcasting = spellcasting ?? cls.spellcasting;
/**
* A hook event that fires while computing the spellcasting progression for each class on each actor.
* The actual hook names include the spellcasting type (e.g. `dnd5e.computeLeveledProgression`).
* @param {object} progression Spellcasting progression data. *Will be mutated.*
* @param {Actor5e|null} [actor] Actor for whom the data is being prepared.
* @param {Item5e} cls Class for whom this progression is being computed.
* @param {SpellcastingDescription} spellcasting Spellcasting descriptive object.
* @param {number} count Number of classes with this type of spellcasting.
* @returns {boolean} Explicitly return false to prevent default progression from being calculated.
* @function dnd5e.computeSpellcastingProgression
* @memberof hookEvents
*/
const allowed = Hooks.call(
`dnd5e.compute${type.capitalize()}Progression`, progression, actor, cls, spellcasting, count
);
if ( allowed && (type === "pact") ) {
this.computePactProgression(progression, actor, cls, spellcasting, count);
} else if ( allowed && (type === "leveled") ) {
this.computeLeveledProgression(progression, actor, cls, spellcasting, count);
}
}
/* -------------------------------------------- */
/**
* Contribute to the actor's spellcasting progression for a class with leveled spellcasting.
* @param {object} progression Spellcasting progression data. *Will be mutated.*
* @param {Actor5e} actor Actor for whom the data is being prepared.
* @param {Item5e} cls Class for whom this progression is being computed.
* @param {SpellcastingDescription} spellcasting Spellcasting descriptive object.
* @param {number} count Number of classes with this type of spellcasting.
*/
static computeLeveledProgression(progression, actor, cls, spellcasting, count) {
const prog = CONFIG.DND5E.spellcastingTypes.leveled.progression[spellcasting.progression];
if ( !prog ) return;
const rounding = prog.roundUp ? Math.ceil : Math.floor;
progression.slot += rounding(spellcasting.levels / prog.divisor ?? 1);
// Single-classed, non-full progression rounds up, rather than down.
if ( (count === 1) && (prog.divisor > 1) && progression.slot ) {
progression.slot = Math.ceil(spellcasting.levels / prog.divisor);
}
}
/* -------------------------------------------- */
/**
* Contribute to the actor's spellcasting progression for a class with pact spellcasting.
* @param {object} progression Spellcasting progression data. *Will be mutated.*
* @param {Actor5e} actor Actor for whom the data is being prepared.
* @param {Item5e} cls Class for whom this progression is being computed.
* @param {SpellcastingDescription} spellcasting Spellcasting descriptive object.
* @param {number} count Number of classes with this type of spellcasting.
*/
static computePactProgression(progression, actor, cls, spellcasting, count) {
progression.pact += spellcasting.levels;
}
/* -------------------------------------------- */
/**
* Prepare actor's spell slots using progression data.
* @param {object} spells The `data.spells` object within actor's data. *Will be mutated.*
* @param {string} type Type of spellcasting slots being prepared.
* @param {object} progression Spellcasting progression data.
* @param {object} [config]
* @param {Actor5e} [config.actor] Actor for whom the data is being prepared.
*/
static prepareSpellcastingSlots(spells, type, progression, {actor}={}) {
/**
* A hook event that fires to convert the provided spellcasting progression into spell slots.
* The actual hook names include the spellcasting type (e.g. `dnd5e.prepareLeveledSlots`).
* @param {object} spells The `data.spells` object within actor's data. *Will be mutated.*
* @param {Actor5e} actor Actor for whom the data is being prepared.
* @param {object} progression Spellcasting progression data.
* @returns {boolean} Explicitly return false to prevent default preparation from being performed.
* @function dnd5e.prepareSpellcastingSlots
* @memberof hookEvents
*/
const allowed = Hooks.call(`dnd5e.prepare${type.capitalize()}Slots`, spells, actor, progression);
if ( allowed && (type === "pact") ) this.preparePactSlots(spells, actor, progression);
else if ( allowed && (type === "leveled") ) this.prepareLeveledSlots(spells, actor, progression);
}
/* -------------------------------------------- */
/**
* Prepare leveled spell slots using progression data.
* @param {object} spells The `data.spells` object within actor's data. *Will be mutated.*
* @param {Actor5e} actor Actor for whom the data is being prepared.
* @param {object} progression Spellcasting progression data.
*/
static prepareLeveledSlots(spells, actor, progression) {
const levels = Math.clamped(progression.slot, 0, CONFIG.DND5E.maxLevel);
const slots = CONFIG.DND5E.SPELL_SLOT_TABLE[Math.min(levels, CONFIG.DND5E.SPELL_SLOT_TABLE.length) - 1] ?? [];
for ( const [n, slot] of Object.entries(spells) ) {
const level = parseInt(n.slice(-1));
if ( Number.isNaN(level) ) continue;
slot.max = Number.isNumeric(slot.override) ? Math.max(parseInt(slot.override), 0) : slots[level - 1] ?? 0;
slot.value = parseInt(slot.value); // TODO: DataModels should remove the need for this
}
}
/* -------------------------------------------- */
/**
* Prepare pact spell slots using progression data.
* @param {object} spells The `data.spells` object within actor's data. *Will be mutated.*
* @param {Actor5e} actor Actor for whom the data is being prepared.
* @param {object} progression Spellcasting progression data.
*/
static preparePactSlots(spells, actor, progression) {
// Pact spell data:
// - pact.level: Slot level for pact casting
// - pact.max: Total number of pact slots
// - pact.value: Currently available pact slots
// - pact.override: Override number of available spell slots
let pactLevel = Math.clamped(progression.pact, 0, CONFIG.DND5E.maxLevel);
spells.pact ??= {};
const override = Number.isNumeric(spells.pact.override) ? parseInt(spells.pact.override) : null;
// Pact slot override
if ( (pactLevel === 0) && (actor.type === "npc") && (override !== null) ) {
pactLevel = actor.system.details.spellLevel;
}
// TODO: Allow pact level and slot count to be configured
if ( pactLevel > 0 ) {
spells.pact.level = Math.ceil(Math.min(10, pactLevel) / 2); // TODO: Allow custom max pact level
if ( override === null ) {
spells.pact.max = Math.max(1, Math.min(pactLevel, 2), Math.min(pactLevel - 8, 3), Math.min(pactLevel - 13, 4));
} else {
spells.pact.max = Math.max(override, 1);
}
spells.pact.value = Math.min(spells.pact.value, spells.pact.max);
}
else {
spells.pact.max = override || 0;
spells.pact.level = spells.pact.max > 0 ? 1 : 0;
}
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
async _preCreate(data, options, user) {
await super._preCreate(data, options, user);
const sourceId = this.getFlag("core", "sourceId");
if ( sourceId?.startsWith("Compendium.") ) return;
// Configure prototype token settings
if ( "size" in (this.system.traits || {}) ) {
const s = CONFIG.DND5E.tokenSizes[this.system.traits.size || "med"];
const prototypeToken = {width: s, height: s};
if ( this.type === "character" ) Object.assign(prototypeToken, {
sight: { enabled: true }, actorLink: true, disposition: 1
});
this.updateSource({prototypeToken});
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _preUpdate(changed, options, user) {
await super._preUpdate(changed, options, user);
// Apply changes in Actor size to Token width/height
if ( "size" in (this.system.traits || {}) ) {
const newSize = foundry.utils.getProperty(changed, "system.traits.size");
if ( newSize && (newSize !== this.system.traits?.size) ) {
let size = CONFIG.DND5E.tokenSizes[newSize];
if ( !foundry.utils.hasProperty(changed, "prototypeToken.width") ) {
changed.prototypeToken ||= {};
changed.prototypeToken.height = size;
changed.prototypeToken.width = size;
}
}
}
// Reset death save counters
if ( "hp" in (this.system.attributes || {}) ) {
const isDead = this.system.attributes.hp.value <= 0;
if ( isDead && (foundry.utils.getProperty(changed, "system.attributes.hp.value") > 0) ) {
foundry.utils.setProperty(changed, "system.attributes.death.success", 0);
foundry.utils.setProperty(changed, "system.attributes.death.failure", 0);
}
}
}
/* -------------------------------------------- */
/**
* Assign a class item as the original class for the Actor based on which class has the most levels.
* @returns {Promise} Instance of the updated actor.
* @protected
*/
_assignPrimaryClass() {
const classes = this.itemTypes.class.sort((a, b) => b.system.levels - a.system.levels);
const newPC = classes[0]?.id || "";
return this.update({"system.details.originalClass": newPC});
}
/* -------------------------------------------- */
/* Gameplay Mechanics */
/* -------------------------------------------- */
/** @override */
async modifyTokenAttribute(attribute, value, isDelta, isBar) {
if ( attribute === "attributes.hp" ) {
const hp = this.system.attributes.hp;
const delta = isDelta ? (-1 * value) : (hp.value + hp.temp) - value;
return this.applyDamage(delta);
}
return super.modifyTokenAttribute(attribute, value, isDelta, isBar);
}
/* -------------------------------------------- */
/**
* Apply a certain amount of damage or healing to the health pool for Actor
* @param {number} amount An amount of damage (positive) or healing (negative) to sustain
* @param {number} multiplier A multiplier which allows for resistance, vulnerability, or healing
* @returns {Promise} A Promise which resolves once the damage has been applied
*/
async applyDamage(amount=0, multiplier=1) {
amount = Math.floor(parseInt(amount) * multiplier);
const hp = this.system.attributes.hp;
if ( !hp ) return this; // Group actors don't have HP at the moment
// Deduct damage from temp HP first
const tmp = parseInt(hp.temp) || 0;
const dt = amount > 0 ? Math.min(tmp, amount) : 0;
// Remaining goes to health
const tmpMax = parseInt(hp.tempmax) || 0;
const dh = Math.clamped(hp.value - (amount - dt), 0, Math.max(0, hp.max + tmpMax));
// Update the Actor
const updates = {
"system.attributes.hp.temp": tmp - dt,
"system.attributes.hp.value": dh
};
// Delegate damage application to a hook
// TODO replace this in the future with a better modifyTokenAttribute function in the core
const allowed = Hooks.call("modifyTokenAttribute", {
attribute: "attributes.hp",
value: amount,
isDelta: false,
isBar: true
}, updates);
return allowed !== false ? this.update(updates, {dhp: -amount}) : this;
}
/* -------------------------------------------- */
/**
* Apply a certain amount of temporary hit point, but only if it's more than the actor currently has.
* @param {number} amount An amount of temporary hit points to set
* @returns {Promise} A Promise which resolves once the temp HP has been applied
*/
async applyTempHP(amount=0) {
amount = parseInt(amount);
const hp = this.system.attributes.hp;
// Update the actor if the new amount is greater than the current
const tmp = parseInt(hp.temp) || 0;
return amount > tmp ? this.update({"system.attributes.hp.temp": amount}) : this;
}
/* -------------------------------------------- */
/**
* Get a color used to represent the current hit points of an Actor.
* @param {number} current The current HP value
* @param {number} max The maximum HP value
* @returns {Color} The color used to represent the HP percentage
*/
static getHPColor(current, max) {
const pct = Math.clamped(current, 0, max) / max;
return Color.fromRGB([(1-(pct/2)), pct, 0]);
}
/* -------------------------------------------- */
/**
* Determine whether the provided ability is usable for remarkable athlete.
* @param {string} ability Ability type to check.
* @returns {boolean} Whether the actor has the remarkable athlete flag and the ability is physical.
* @private
*/
_isRemarkableAthlete(ability) {
return this.getFlag("dnd5e", "remarkableAthlete")
&& CONFIG.DND5E.characterFlags.remarkableAthlete.abilities.includes(ability);
}
/* -------------------------------------------- */
/* Rolling */
/* -------------------------------------------- */
/**
* Roll a Skill Check
* Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus
* @param {string} skillId The skill id (e.g. "ins")
* @param {object} options Options which configure how the skill check is rolled
* @returns {Promise} A Promise which resolves to the created Roll instance
*/
async rollSkill(skillId, options={}) {
const skl = this.system.skills[skillId];
const abl = this.system.abilities[skl.ability];
const globalBonuses = this.system.bonuses?.abilities ?? {};
const parts = ["@mod", "@abilityCheckBonus"];
const data = this.getRollData();
// Add ability modifier
data.mod = skl.mod;
data.defaultAbility = skl.ability;
// Include proficiency bonus
if ( skl.prof.hasProficiency ) {
parts.push("@prof");
data.prof = skl.prof.term;
}
// Global ability check bonus
if ( globalBonuses.check ) {
parts.push("@checkBonus");
data.checkBonus = Roll.replaceFormulaData(globalBonuses.check, data);
}
// Ability-specific check bonus
if ( abl?.bonuses?.check ) data.abilityCheckBonus = Roll.replaceFormulaData(abl.bonuses.check, data);
else data.abilityCheckBonus = 0;
// Skill-specific skill bonus
if ( skl.bonuses?.check ) {
const checkBonusKey = `${skillId}CheckBonus`;
parts.push(`@${checkBonusKey}`);
data[checkBonusKey] = Roll.replaceFormulaData(skl.bonuses.check, data);
}
// Global skill check bonus
if ( globalBonuses.skill ) {
parts.push("@skillBonus");
data.skillBonus = Roll.replaceFormulaData(globalBonuses.skill, data);
}
// Reliable Talent applies to any skill check we have full or better proficiency in
const reliableTalent = (skl.value >= 1 && this.getFlag("dnd5e", "reliableTalent"));
// Roll and return
const flavor = game.i18n.format("DND5E.SkillPromptTitle", {skill: CONFIG.DND5E.skills[skillId]?.label ?? ""});
const rollData = foundry.utils.mergeObject({
data: data,
title: `${flavor}: ${this.name}`,
flavor,
chooseModifier: true,
halflingLucky: this.getFlag("dnd5e", "halflingLucky"),
reliableTalent,
messageData: {
speaker: options.speaker || ChatMessage.getSpeaker({actor: this}),
"flags.dnd5e.roll": {type: "skill", skillId }
}
}, options);
rollData.parts = parts.concat(options.parts ?? []);
/**
* A hook event that fires before a skill check is rolled for an Actor.
* @function dnd5e.preRollSkill
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the skill check is being rolled.
* @param {D20RollConfiguration} config Configuration data for the pending roll.
* @param {string} skillId ID of the skill being rolled as defined in `DND5E.skills`.
* @returns {boolean} Explicitly return `false` to prevent skill check from being rolled.
*/
if ( Hooks.call("dnd5e.preRollSkill", this, rollData, skillId) === false ) return;
const roll = await d20Roll(rollData);
/**
* A hook event that fires after a skill check has been rolled for an Actor.
* @function dnd5e.rollSkill
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the skill check has been rolled.
* @param {D20Roll} roll The resulting roll.
* @param {string} skillId ID of the skill that was rolled as defined in `DND5E.skills`.
*/
if ( roll ) Hooks.callAll("dnd5e.rollSkill", this, roll, skillId);
return roll;
}
/* -------------------------------------------- */
/**
* Roll a Tool Check.
* Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonuses.
* @param {string} toolId The identifier of the tool being rolled.
* @param {object} options Options which configure how the tool check is rolled.
* @returns {Promise} A Promise which resolves to the created Roll instance.
*/
async rollToolCheck(toolId, options={}) {
// Prepare roll data.
const tool = this.system.tools[toolId];
const ability = this.system.abilities[tool?.ability ?? "int"];
const globalBonuses = this.system.bonuses?.abilities ?? {};
const parts = ["@mod", "@abilityCheckBonus"];
const data = this.getRollData();
// Add ability modifier.
data.mod = tool?.mod ?? 0;
data.defaultAbility = options.ability || (tool?.ability ?? "int");
// Add proficiency.
if ( tool?.prof.hasProficiency ) {
parts.push("@prof");
data.prof = tool.prof.term;
}
// Global ability check bonus.
if ( globalBonuses.check ) {
parts.push("@checkBonus");
data.checkBonus = Roll.replaceFormulaData(globalBonuses.check, data);
}
// Ability-specific check bonus.
if ( ability?.bonuses.check ) data.abilityCheckBonus = Roll.replaceFormulaData(ability.bonuses.check, data);
else data.abilityCheckBonus = 0;
// Tool-specific check bonus.
if ( tool?.bonuses.check || options.bonus ) {
parts.push("@toolBonus");
const bonus = [];
if ( tool?.bonuses.check ) bonus.push(Roll.replaceFormulaData(tool.bonuses.check, data));
if ( options.bonus ) bonus.push(Roll.replaceFormulaData(options.bonus, data));
data.toolBonus = bonus.join(" + ");
}
const flavor = game.i18n.format("DND5E.ToolPromptTitle", {tool: keyLabel("tool", toolId) ?? ""});
const rollData = foundry.utils.mergeObject({
data, flavor,
title: `${flavor}: ${this.name}`,
chooseModifier: true,
halflingLucky: this.getFlag("dnd5e", "halflingLucky"),
messageData: {
speaker: options.speaker || ChatMessage.implementation.getSpeaker({actor: this}),
"flags.dnd5e.roll": {type: "tool", toolId}
}
}, options);
rollData.parts = parts.concat(options.parts ?? []);
/**
* A hook event that fires before a tool check is rolled for an Actor.
* @function dnd5e.preRollRool
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the tool check is being rolled.
* @param {D20RollConfiguration} config Configuration data for the pending roll.
* @param {string} toolId Identifier of the tool being rolled.
* @returns {boolean} Explicitly return `false` to prevent skill check from being rolled.
*/
if ( Hooks.call("dnd5e.preRollToolCheck", this, rollData, toolId) === false ) return;
const roll = await d20Roll(rollData);
/**
* A hook event that fires after a tool check has been rolled for an Actor.
* @function dnd5e.rollTool
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the tool check has been rolled.
* @param {D20Roll} roll The resulting roll.
* @param {string} toolId Identifier of the tool that was rolled.
*/
if ( roll ) Hooks.callAll("dnd5e.rollToolCheck", this, roll, toolId);
return roll;
}
/* -------------------------------------------- */
/**
* Roll a generic ability test or saving throw.
* Prompt the user for input on which variety of roll they want to do.
* @param {string} abilityId The ability id (e.g. "str")
* @param {object} options Options which configure how ability tests or saving throws are rolled
*/
rollAbility(abilityId, options={}) {
const label = CONFIG.DND5E.abilities[abilityId]?.label ?? "";
new Dialog({
title: `${game.i18n.format("DND5E.AbilityPromptTitle", {ability: label})}: ${this.name}`,
content: `${game.i18n.format("DND5E.AbilityPromptText", {ability: label})}
`,
buttons: {
test: {
label: game.i18n.localize("DND5E.ActionAbil"),
callback: () => this.rollAbilityTest(abilityId, options)
},
save: {
label: game.i18n.localize("DND5E.ActionSave"),
callback: () => this.rollAbilitySave(abilityId, options)
}
}
}).render(true);
}
/* -------------------------------------------- */
/**
* Roll an Ability Test
* Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus
* @param {string} abilityId The ability ID (e.g. "str")
* @param {object} options Options which configure how ability tests are rolled
* @returns {Promise} A Promise which resolves to the created Roll instance
*/
async rollAbilityTest(abilityId, options={}) {
const label = CONFIG.DND5E.abilities[abilityId]?.label ?? "";
const abl = this.system.abilities[abilityId];
const globalBonuses = this.system.bonuses?.abilities ?? {};
const parts = [];
const data = this.getRollData();
// Add ability modifier
parts.push("@mod");
data.mod = abl?.mod ?? 0;
// Include proficiency bonus
if ( abl?.checkProf.hasProficiency ) {
parts.push("@prof");
data.prof = abl.checkProf.term;
}
// Add ability-specific check bonus
if ( abl?.bonuses?.check ) {
const checkBonusKey = `${abilityId}CheckBonus`;
parts.push(`@${checkBonusKey}`);
data[checkBonusKey] = Roll.replaceFormulaData(abl.bonuses.check, data);
}
// Add global actor bonus
if ( globalBonuses.check ) {
parts.push("@checkBonus");
data.checkBonus = Roll.replaceFormulaData(globalBonuses.check, data);
}
// Roll and return
const flavor = game.i18n.format("DND5E.AbilityPromptTitle", {ability: label});
const rollData = foundry.utils.mergeObject({
data,
title: `${flavor}: ${this.name}`,
flavor,
halflingLucky: this.getFlag("dnd5e", "halflingLucky"),
messageData: {
speaker: options.speaker || ChatMessage.getSpeaker({actor: this}),
"flags.dnd5e.roll": {type: "ability", abilityId }
}
}, options);
rollData.parts = parts.concat(options.parts ?? []);
/**
* A hook event that fires before an ability test is rolled for an Actor.
* @function dnd5e.preRollAbilityTest
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the ability test is being rolled.
* @param {D20RollConfiguration} config Configuration data for the pending roll.
* @param {string} abilityId ID of the ability being rolled as defined in `DND5E.abilities`.
* @returns {boolean} Explicitly return `false` to prevent ability test from being rolled.
*/
if ( Hooks.call("dnd5e.preRollAbilityTest", this, rollData, abilityId) === false ) return;
const roll = await d20Roll(rollData);
/**
* A hook event that fires after an ability test has been rolled for an Actor.
* @function dnd5e.rollAbilityTest
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the ability test has been rolled.
* @param {D20Roll} roll The resulting roll.
* @param {string} abilityId ID of the ability that was rolled as defined in `DND5E.abilities`.
*/
if ( roll ) Hooks.callAll("dnd5e.rollAbilityTest", this, roll, abilityId);
return roll;
}
/* -------------------------------------------- */
/**
* Roll an Ability Saving Throw
* Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus
* @param {string} abilityId The ability ID (e.g. "str")
* @param {object} options Options which configure how ability tests are rolled
* @returns {Promise} A Promise which resolves to the created Roll instance
*/
async rollAbilitySave(abilityId, options={}) {
const label = CONFIG.DND5E.abilities[abilityId]?.label ?? "";
const abl = this.system.abilities[abilityId];
const globalBonuses = this.system.bonuses?.abilities ?? {};
const parts = [];
const data = this.getRollData();
// Add ability modifier
parts.push("@mod");
data.mod = abl?.mod ?? 0;
// Include proficiency bonus
if ( abl?.saveProf.hasProficiency ) {
parts.push("@prof");
data.prof = abl.saveProf.term;
}
// Include ability-specific saving throw bonus
if ( abl?.bonuses?.save ) {
const saveBonusKey = `${abilityId}SaveBonus`;
parts.push(`@${saveBonusKey}`);
data[saveBonusKey] = Roll.replaceFormulaData(abl.bonuses.save, data);
}
// Include a global actor ability save bonus
if ( globalBonuses.save ) {
parts.push("@saveBonus");
data.saveBonus = Roll.replaceFormulaData(globalBonuses.save, data);
}
// Roll and return
const flavor = game.i18n.format("DND5E.SavePromptTitle", {ability: label});
const rollData = foundry.utils.mergeObject({
data,
title: `${flavor}: ${this.name}`,
flavor,
halflingLucky: this.getFlag("dnd5e", "halflingLucky"),
messageData: {
speaker: options.speaker || ChatMessage.getSpeaker({actor: this}),
"flags.dnd5e.roll": {type: "save", abilityId }
}
}, options);
rollData.parts = parts.concat(options.parts ?? []);
/**
* A hook event that fires before an ability save is rolled for an Actor.
* @function dnd5e.preRollAbilitySave
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the ability save is being rolled.
* @param {D20RollConfiguration} config Configuration data for the pending roll.
* @param {string} abilityId ID of the ability being rolled as defined in `DND5E.abilities`.
* @returns {boolean} Explicitly return `false` to prevent ability save from being rolled.
*/
if ( Hooks.call("dnd5e.preRollAbilitySave", this, rollData, abilityId) === false ) return;
const roll = await d20Roll(rollData);
/**
* A hook event that fires after an ability save has been rolled for an Actor.
* @function dnd5e.rollAbilitySave
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the ability save has been rolled.
* @param {D20Roll} roll The resulting roll.
* @param {string} abilityId ID of the ability that was rolled as defined in `DND5E.abilities`.
*/
if ( roll ) Hooks.callAll("dnd5e.rollAbilitySave", this, roll, abilityId);
return roll;
}
/* -------------------------------------------- */
/**
* Perform a death saving throw, rolling a d20 plus any global save bonuses
* @param {object} options Additional options which modify the roll
* @returns {Promise} A Promise which resolves to the Roll instance
*/
async rollDeathSave(options={}) {
const death = this.system.attributes.death;
// Display a warning if we are not at zero HP or if we already have reached 3
if ( (this.system.attributes.hp.value > 0) || (death.failure >= 3) || (death.success >= 3) ) {
ui.notifications.warn(game.i18n.localize("DND5E.DeathSaveUnnecessary"));
return null;
}
// Evaluate a global saving throw bonus
const speaker = options.speaker || ChatMessage.getSpeaker({actor: this});
const globalBonuses = this.system.bonuses?.abilities ?? {};
const parts = [];
const data = this.getRollData();
// Diamond Soul adds proficiency
if ( this.getFlag("dnd5e", "diamondSoul") ) {
parts.push("@prof");
data.prof = new Proficiency(this.system.attributes.prof, 1).term;
}
// Include a global actor ability save bonus
if ( globalBonuses.save ) {
parts.push("@saveBonus");
data.saveBonus = Roll.replaceFormulaData(globalBonuses.save, data);
}
// Evaluate the roll
const flavor = game.i18n.localize("DND5E.DeathSavingThrow");
const rollData = foundry.utils.mergeObject({
data,
title: `${flavor}: ${this.name}`,
flavor,
halflingLucky: this.getFlag("dnd5e", "halflingLucky"),
targetValue: 10,
messageData: {
speaker: speaker,
"flags.dnd5e.roll": {type: "death"}
}
}, options);
rollData.parts = parts.concat(options.parts ?? []);
/**
* A hook event that fires before a death saving throw is rolled for an Actor.
* @function dnd5e.preRollDeathSave
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the death saving throw is being rolled.
* @param {D20RollConfiguration} config Configuration data for the pending roll.
* @returns {boolean} Explicitly return `false` to prevent death saving throw from being rolled.
*/
if ( Hooks.call("dnd5e.preRollDeathSave", this, rollData) === false ) return;
const roll = await d20Roll(rollData);
if ( !roll ) return null;
// Take action depending on the result
const details = {};
// Save success
if ( roll.total >= (roll.options.targetValue ?? 10) ) {
let successes = (death.success || 0) + 1;
// Critical Success = revive with 1hp
if ( roll.isCritical ) {
details.updates = {
"system.attributes.death.success": 0,
"system.attributes.death.failure": 0,
"system.attributes.hp.value": 1
};
details.chatString = "DND5E.DeathSaveCriticalSuccess";
}
// 3 Successes = survive and reset checks
else if ( successes === 3 ) {
details.updates = {
"system.attributes.death.success": 0,
"system.attributes.death.failure": 0
};
details.chatString = "DND5E.DeathSaveSuccess";
}
// Increment successes
else details.updates = {"system.attributes.death.success": Math.clamped(successes, 0, 3)};
}
// Save failure
else {
let failures = (death.failure || 0) + (roll.isFumble ? 2 : 1);
details.updates = {"system.attributes.death.failure": Math.clamped(failures, 0, 3)};
if ( failures >= 3 ) { // 3 Failures = death
details.chatString = "DND5E.DeathSaveFailure";
}
}
/**
* A hook event that fires after a death saving throw has been rolled for an Actor, but before
* updates have been performed.
* @function dnd5e.rollDeathSave
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the death saving throw has been rolled.
* @param {D20Roll} roll The resulting roll.
* @param {object} details
* @param {object} details.updates Updates that will be applied to the actor as a result of this save.
* @param {string} details.chatString Localizable string displayed in the create chat message. If not set, then
* no chat message will be displayed.
* @returns {boolean} Explicitly return `false` to prevent updates from being performed.
*/
if ( Hooks.call("dnd5e.rollDeathSave", this, roll, details) === false ) return roll;
if ( !foundry.utils.isEmpty(details.updates) ) await this.update(details.updates);
// Display success/failure chat message
if ( details.chatString ) {
let chatData = { content: game.i18n.format(details.chatString, {name: this.name}), speaker };
ChatMessage.applyRollMode(chatData, roll.options.rollMode);
await ChatMessage.create(chatData);
}
// Return the rolled result
return roll;
}
/* -------------------------------------------- */
/**
* Get an un-evaluated D20Roll instance used to roll initiative for this Actor.
* @param {object} [options] Options which modify the roll
* @param {D20Roll.ADV_MODE} [options.advantageMode] A specific advantage mode to apply
* @param {string} [options.flavor] Special flavor text to apply
* @returns {D20Roll} The constructed but unevaluated D20Roll
*/
getInitiativeRoll(options={}) {
// Use a temporarily cached initiative roll
if ( this._cachedInitiativeRoll ) return this._cachedInitiativeRoll.clone();
// Obtain required data
const init = this.system.attributes?.init;
const abilityId = init?.ability || CONFIG.DND5E.initiativeAbility;
const data = this.getRollData();
const flags = this.flags.dnd5e || {};
if ( flags.initiativeAdv ) options.advantageMode ??= dnd5e.dice.D20Roll.ADV_MODE.ADVANTAGE;
// Standard initiative formula
const parts = ["1d20"];
// Special initiative bonuses
if ( init ) {
parts.push(init.mod);
if ( init.prof.term !== "0" ) {
parts.push("@prof");
data.prof = init.prof.term;
}
if ( init.bonus ) {
parts.push("@bonus");
data.bonus = Roll.replaceFormulaData(init.bonus, data);
}
}
// Ability check bonuses
if ( "abilities" in this.system ) {
const abilityBonus = this.system.abilities[abilityId]?.bonuses?.check;
if ( abilityBonus ) {
parts.push("@abilityBonus");
data.abilityBonus = Roll.replaceFormulaData(abilityBonus, data);
}
}
// Global check bonus
if ( "bonuses" in this.system ) {
const globalCheckBonus = this.system.bonuses.abilities?.check;
if ( globalCheckBonus ) {
parts.push("@globalBonus");
data.globalBonus = Roll.replaceFormulaData(globalCheckBonus, data);
}
}
// Alert feat
if ( flags.initiativeAlert ) {
parts.push("@alertBonus");
data.alertBonus = 5;
}
// Ability score tiebreaker
const tiebreaker = game.settings.get("dnd5e", "initiativeDexTiebreaker");
if ( tiebreaker && ("abilities" in this.system) ) {
const abilityValue = this.system.abilities[abilityId]?.value;
if ( Number.isNumeric(abilityValue) ) parts.push(String(abilityValue / 100));
}
options = foundry.utils.mergeObject({
flavor: options.flavor ?? game.i18n.localize("DND5E.Initiative"),
halflingLucky: flags.halflingLucky ?? false,
critical: null,
fumble: null
}, options);
// Create the d20 roll
const formula = parts.join(" + ");
return new CONFIG.Dice.D20Roll(formula, data, options);
}
/* -------------------------------------------- */
/**
* Roll initiative for this Actor with a dialog that provides an opportunity to elect advantage or other bonuses.
* @param {object} [rollOptions] Options forwarded to the Actor#getInitiativeRoll method
* @returns {Promise} A promise which resolves once initiative has been rolled for the Actor
*/
async rollInitiativeDialog(rollOptions={}) {
// Create and configure the Initiative roll
const roll = this.getInitiativeRoll(rollOptions);
const choice = await roll.configureDialog({
defaultRollMode: game.settings.get("core", "rollMode"),
title: `${game.i18n.localize("DND5E.InitiativeRoll")}: ${this.name}`,
chooseModifier: false,
defaultAction: rollOptions.advantageMode ?? dnd5e.dice.D20Roll.ADV_MODE.NORMAL
});
if ( choice === null ) return; // Closed dialog
// Temporarily cache the configured roll and use it to roll initiative for the Actor
this._cachedInitiativeRoll = roll;
await this.rollInitiative({createCombatants: true});
delete this._cachedInitiativeRoll;
}
/* -------------------------------------------- */
/** @inheritdoc */
async rollInitiative(options={}) {
/**
* A hook event that fires before initiative is rolled for an Actor.
* @function dnd5e.preRollInitiative
* @memberof hookEvents
* @param {Actor5e} actor The Actor that is rolling initiative.
* @param {D20Roll} roll The initiative roll.
*/
if ( Hooks.call("dnd5e.preRollInitiative", this, this._cachedInitiativeRoll) === false ) return;
const combat = await super.rollInitiative(options);
const combatants = this.isToken ? this.getActiveTokens(false, true).reduce((arr, t) => {
const combatant = game.combat.getCombatantByToken(t.id);
if ( combatant ) arr.push(combatant);
return arr;
}, []) : [game.combat.getCombatantByActor(this.id)];
/**
* A hook event that fires after an Actor has rolled for initiative.
* @function dnd5e.rollInitiative
* @memberof hookEvents
* @param {Actor5e} actor The Actor that rolled initiative.
* @param {Combatant[]} combatants The associated Combatants in the Combat.
*/
Hooks.callAll("dnd5e.rollInitiative", this, combatants);
return combat;
}
/* -------------------------------------------- */
/**
* Roll a hit die of the appropriate type, gaining hit points equal to the die roll plus your CON modifier.
* @param {string} [denomination] The hit denomination of hit die to roll. Example "d8".
* If no denomination is provided, the first available HD will be used
* @param {object} options Additional options which modify the roll.
* @returns {Promise} The created Roll instance, or null if no hit die was rolled
*/
async rollHitDie(denomination, options={}) {
// If no denomination was provided, choose the first available
let cls = null;
if ( !denomination ) {
cls = this.itemTypes.class.find(c => c.system.hitDiceUsed < c.system.levels);
if ( !cls ) return null;
denomination = cls.system.hitDice;
}
// Otherwise, locate a class (if any) which has an available hit die of the requested denomination
else cls = this.items.find(i => {
return (i.system.hitDice === denomination) && ((i.system.hitDiceUsed || 0) < (i.system.levels || 1));
});
// If no class is available, display an error notification
if ( !cls ) {
ui.notifications.error(game.i18n.format("DND5E.HitDiceWarn", {name: this.name, formula: denomination}));
return null;
}
// Prepare roll data
const flavor = game.i18n.localize("DND5E.HitDiceRoll");
const rollConfig = foundry.utils.mergeObject({
formula: `max(0, 1${denomination} + @abilities.con.mod)`,
data: this.getRollData(),
chatMessage: true,
messageData: {
speaker: ChatMessage.getSpeaker({actor: this}),
flavor,
title: `${flavor}: ${this.name}`,
rollMode: game.settings.get("core", "rollMode"),
"flags.dnd5e.roll": {type: "hitDie"}
}
}, options);
/**
* A hook event that fires before a hit die is rolled for an Actor.
* @function dnd5e.preRollHitDie
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the hit die is to be rolled.
* @param {object} config Configuration data for the pending roll.
* @param {string} config.formula Formula that will be rolled.
* @param {object} config.data Data used when evaluating the roll.
* @param {boolean} config.chatMessage Should a chat message be created for this roll?
* @param {object} config.messageData Data used to create the chat message.
* @param {string} denomination Size of hit die to be rolled.
* @returns {boolean} Explicitly return `false` to prevent hit die from being rolled.
*/
if ( Hooks.call("dnd5e.preRollHitDie", this, rollConfig, denomination) === false ) return;
const roll = await new Roll(rollConfig.formula, rollConfig.data).roll({async: true});
if ( rollConfig.chatMessage ) roll.toMessage(rollConfig.messageData);
const hp = this.system.attributes.hp;
const dhp = Math.min(Math.max(0, hp.max + (hp.tempmax ?? 0)) - hp.value, roll.total);
const updates = {
actor: {"system.attributes.hp.value": hp.value + dhp},
class: {"system.hitDiceUsed": cls.system.hitDiceUsed + 1}
};
/**
* A hook event that fires after a hit die has been rolled for an Actor, but before updates have been performed.
* @function dnd5e.rollHitDie
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the hit die has been rolled.
* @param {Roll} roll The resulting roll.
* @param {object} updates
* @param {object} updates.actor Updates that will be applied to the actor.
* @param {object} updates.class Updates that will be applied to the class.
* @returns {boolean} Explicitly return `false` to prevent updates from being performed.
*/
if ( Hooks.call("dnd5e.rollHitDie", this, roll, updates) === false ) return roll;
// Re-evaluate dhp in the event that it was changed in the previous hook
const updateOptions = { dhp: (updates.actor?.["system.attributes.hp.value"] ?? hp.value) - hp.value };
// Perform updates
if ( !foundry.utils.isEmpty(updates.actor) ) await this.update(updates.actor, updateOptions);
if ( !foundry.utils.isEmpty(updates.class) ) await cls.update(updates.class);
return roll;
}
/* -------------------------------------------- */
/**
* Roll hit points for a specific class as part of a level-up workflow.
* @param {Item5e} item The class item whose hit dice to roll.
* @param {object} options
* @param {boolean} [options.chatMessage=true] Display the chat message for this roll.
* @returns {Promise} The completed roll.
* @see {@link dnd5e.preRollClassHitPoints}
*/
async rollClassHitPoints(item, { chatMessage=true }={}) {
if ( item.type !== "class" ) throw new Error("Hit points can only be rolled for a class item.");
const rollData = {
formula: `1${item.system.hitDice}`,
data: item.getRollData(),
chatMessage
};
const flavor = game.i18n.format("DND5E.AdvancementHitPointsRollMessage", { class: item.name });
const messageData = {
title: `${flavor}: ${this.name}`,
flavor,
speaker: ChatMessage.getSpeaker({ actor: this }),
"flags.dnd5e.roll": { type: "hitPoints" }
};
/**
* A hook event that fires before hit points are rolled for a character's class.
* @function dnd5e.preRollClassHitPoints
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the hit points are being rolled.
* @param {Item5e} item The class item whose hit dice will be rolled.
* @param {object} rollData
* @param {string} rollData.formula The string formula to parse.
* @param {object} rollData.data The data object against which to parse attributes within the formula.
* @param {object} messageData The data object to use when creating the message.
*/
Hooks.callAll("dnd5e.preRollClassHitPoints", this, item, rollData, messageData);
const roll = new Roll(rollData.formula, rollData.data);
await roll.evaluate({async: true});
/**
* A hook event that fires after hit points haven been rolled for a character's class.
* @function dnd5e.rollClassHitPoints
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the hit points have been rolled.
* @param {Roll} roll The resulting roll.
*/
Hooks.callAll("dnd5e.rollClassHitPoints", this, roll);
if ( rollData.chatMessage ) await roll.toMessage(messageData);
return roll;
}
/* -------------------------------------------- */
/**
* Roll hit points for an NPC based on the HP formula.
* @param {object} options
* @param {boolean} [options.chatMessage=true] Display the chat message for this roll.
* @returns {Promise} The completed roll.
* @see {@link dnd5e.preRollNPCHitPoints}
*/
async rollNPCHitPoints({ chatMessage=true }={}) {
if ( this.type !== "npc" ) throw new Error("NPC hit points can only be rolled for NPCs");
const rollData = {
formula: this.system.attributes.hp.formula,
data: this.getRollData(),
chatMessage
};
const flavor = game.i18n.format("DND5E.HPFormulaRollMessage");
const messageData = {
title: `${flavor}: ${this.name}`,
flavor,
speaker: ChatMessage.getSpeaker({ actor: this }),
"flags.dnd5e.roll": { type: "hitPoints" }
};
/**
* A hook event that fires before hit points are rolled for an NPC.
* @function dnd5e.preRollNPCHitPoints
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the hit points are being rolled.
* @param {object} rollData
* @param {string} rollData.formula The string formula to parse.
* @param {object} rollData.data The data object against which to parse attributes within the formula.
* @param {object} messageData The data object to use when creating the message.
*/
Hooks.callAll("dnd5e.preRollNPCHitPoints", this, rollData, messageData);
const roll = new Roll(rollData.formula, rollData.data);
await roll.evaluate({async: true});
/**
* A hook event that fires after hit points are rolled for an NPC.
* @function dnd5e.rollNPCHitPoints
* @memberof hookEvents
* @param {Actor5e} actor Actor for which the hit points have been rolled.
* @param {Roll} roll The resulting roll.
*/
Hooks.callAll("dnd5e.rollNPCHitPoints", this, roll);
if ( rollData.chatMessage ) await roll.toMessage(messageData);
return roll;
}
/* -------------------------------------------- */
/* Resting */
/* -------------------------------------------- */
/**
* Configuration options for a rest.
*
* @typedef {object} RestConfiguration
* @property {boolean} dialog Present a dialog window which allows for rolling hit dice as part of the
* Short Rest and selecting whether a new day has occurred.
* @property {boolean} chat Should a chat message be created to summarize the results of the rest?
* @property {boolean} newDay Does this rest carry over to a new day?
* @property {boolean} [autoHD] Should hit dice be spent automatically during a short rest?
* @property {number} [autoHDThreshold] How many hit points should be missing before hit dice are
* automatically spent during a short rest.
*/
/**
* Results from a rest operation.
*
* @typedef {object} RestResult
* @property {number} dhp Hit points recovered during the rest.
* @property {number} dhd Hit dice recovered or spent during the rest.
* @property {object} updateData Updates applied to the actor.
* @property {object[]} updateItems Updates applied to actor's items.
* @property {boolean} longRest Whether the rest type was a long rest.
* @property {boolean} newDay Whether a new day occurred during the rest.
* @property {Roll[]} rolls Any rolls that occurred during the rest process, not including hit dice.
*/
/* -------------------------------------------- */
/**
* Take a short rest, possibly spending hit dice and recovering resources, item uses, and pact slots.
* @param {RestConfiguration} [config] Configuration options for a short rest.
* @returns {Promise} A Promise which resolves once the short rest workflow has completed.
*/
async shortRest(config={}) {
config = foundry.utils.mergeObject({
dialog: true, chat: true, newDay: false, autoHD: false, autoHDThreshold: 3
}, config);
/**
* A hook event that fires before a short rest is started.
* @function dnd5e.preShortRest
* @memberof hookEvents
* @param {Actor5e} actor The actor that is being rested.
* @param {RestConfiguration} config Configuration options for the rest.
* @returns {boolean} Explicitly return `false` to prevent the rest from being started.
*/
if ( Hooks.call("dnd5e.preShortRest", this, config) === false ) return;
// Take note of the initial hit points and number of hit dice the Actor has
const hd0 = this.system.attributes.hd;
const hp0 = this.system.attributes.hp.value;
// Display a Dialog for rolling hit dice
if ( config.dialog ) {
try { config.newDay = await ShortRestDialog.shortRestDialog({actor: this, canRoll: hd0 > 0});
} catch(err) { return; }
}
// Automatically spend hit dice
else if ( config.autoHD ) await this.autoSpendHitDice({ threshold: config.autoHDThreshold });
// Return the rest result
const dhd = this.system.attributes.hd - hd0;
const dhp = this.system.attributes.hp.value - hp0;
return this._rest(config.chat, config.newDay, false, dhd, dhp);
}
/* -------------------------------------------- */
/**
* Take a long rest, recovering hit points, hit dice, resources, item uses, and spell slots.
* @param {RestConfiguration} [config] Configuration options for a long rest.
* @returns {Promise} A Promise which resolves once the long rest workflow has completed.
*/
async longRest(config={}) {
config = foundry.utils.mergeObject({
dialog: true, chat: true, newDay: true
}, config);
/**
* A hook event that fires before a long rest is started.
* @function dnd5e.preLongRest
* @memberof hookEvents
* @param {Actor5e} actor The actor that is being rested.
* @param {RestConfiguration} config Configuration options for the rest.
* @returns {boolean} Explicitly return `false` to prevent the rest from being started.
*/
if ( Hooks.call("dnd5e.preLongRest", this, config) === false ) return;
if ( config.dialog ) {
try { config.newDay = await LongRestDialog.longRestDialog({actor: this}); }
catch(err) { return; }
}
return this._rest(config.chat, config.newDay, true);
}
/* -------------------------------------------- */
/**
* Perform all of the changes needed for a short or long rest.
*
* @param {boolean} chat Summarize the results of the rest workflow as a chat message.
* @param {boolean} newDay Has a new day occurred during this rest?
* @param {boolean} longRest Is this a long rest?
* @param {number} [dhd=0] Number of hit dice spent during so far during the rest.
* @param {number} [dhp=0] Number of hit points recovered so far during the rest.
* @returns {Promise} Consolidated results of the rest workflow.
* @private
*/
async _rest(chat, newDay, longRest, dhd=0, dhp=0) {
let hitPointsRecovered = 0;
let hitPointUpdates = {};
let hitDiceRecovered = 0;
let hitDiceUpdates = [];
const rolls = [];
// Recover hit points & hit dice on long rest
if ( longRest ) {
({ updates: hitPointUpdates, hitPointsRecovered } = this._getRestHitPointRecovery());
({ updates: hitDiceUpdates, hitDiceRecovered } = this._getRestHitDiceRecovery());
}
// Figure out the rest of the changes
const result = {
dhd: dhd + hitDiceRecovered,
dhp: dhp + hitPointsRecovered,
updateData: {
...hitPointUpdates,
...this._getRestResourceRecovery({ recoverShortRestResources: !longRest, recoverLongRestResources: longRest }),
...this._getRestSpellRecovery({ recoverSpells: longRest })
},
updateItems: [
...hitDiceUpdates,
...(await this._getRestItemUsesRecovery({ recoverLongRestUses: longRest, recoverDailyUses: newDay, rolls }))
],
longRest,
newDay
};
result.rolls = rolls;
/**
* A hook event that fires after rest result is calculated, but before any updates are performed.
* @function dnd5e.preRestCompleted
* @memberof hookEvents
* @param {Actor5e} actor The actor that is being rested.
* @param {RestResult} result Details on the rest to be completed.
* @returns {boolean} Explicitly return `false` to prevent the rest updates from being performed.
*/
if ( Hooks.call("dnd5e.preRestCompleted", this, result) === false ) return result;
// Perform updates
await this.update(result.updateData);
await this.updateEmbeddedDocuments("Item", result.updateItems);
// Display a Chat Message summarizing the rest effects
if ( chat ) await this._displayRestResultMessage(result, longRest);
/**
* A hook event that fires when the rest process is completed for an actor.
* @function dnd5e.restCompleted
* @memberof hookEvents
* @param {Actor5e} actor The actor that just completed resting.
* @param {RestResult} result Details on the rest completed.
*/
Hooks.callAll("dnd5e.restCompleted", this, result);
// Return data summarizing the rest effects
return result;
}
/* -------------------------------------------- */
/**
* Display a chat message with the result of a rest.
*
* @param {RestResult} result Result of the rest operation.
* @param {boolean} [longRest=false] Is this a long rest?
* @returns {Promise} Chat message that was created.
* @protected
*/
async _displayRestResultMessage(result, longRest=false) {
const { dhd, dhp, newDay } = result;
const diceRestored = dhd !== 0;
const healthRestored = dhp !== 0;
const length = longRest ? "Long" : "Short";
// Summarize the rest duration
let restFlavor;
switch (game.settings.get("dnd5e", "restVariant")) {
case "normal":
restFlavor = (longRest && newDay) ? "DND5E.LongRestOvernight" : `DND5E.${length}RestNormal`;
break;
case "gritty":
restFlavor = (!longRest && newDay) ? "DND5E.ShortRestOvernight" : `DND5E.${length}RestGritty`;
break;
case "epic":
restFlavor = `DND5E.${length}RestEpic`;
break;
}
// Determine the chat message to display
let message;
if ( diceRestored && healthRestored ) message = `DND5E.${length}RestResult`;
else if ( longRest && !diceRestored && healthRestored ) message = "DND5E.LongRestResultHitPoints";
else if ( longRest && diceRestored && !healthRestored ) message = "DND5E.LongRestResultHitDice";
else message = `DND5E.${length}RestResultShort`;
// Create a chat message
let chatData = {
user: game.user.id,
speaker: {actor: this, alias: this.name},
flavor: game.i18n.localize(restFlavor),
rolls: result.rolls,
content: game.i18n.format(message, {
name: this.name,
dice: longRest ? dhd : -dhd,
health: dhp
})
};
ChatMessage.applyRollMode(chatData, game.settings.get("core", "rollMode"));
return ChatMessage.create(chatData);
}
/* -------------------------------------------- */
/**
* Automatically spend hit dice to recover hit points up to a certain threshold.
* @param {object} [options]
* @param {number} [options.threshold=3] A number of missing hit points which would trigger an automatic HD roll.
* @returns {Promise} Number of hit dice spent.
*/
async autoSpendHitDice({ threshold=3 }={}) {
const hp = this.system.attributes.hp;
const max = Math.max(0, hp.max + hp.tempmax);
let diceRolled = 0;
while ( (this.system.attributes.hp.value + threshold) <= max ) {
const r = await this.rollHitDie();
if ( r === null ) break;
diceRolled += 1;
}
return diceRolled;
}
/* -------------------------------------------- */
/**
* Recovers actor hit points and eliminates any temp HP.
* @param {object} [options]
* @param {boolean} [options.recoverTemp=true] Reset temp HP to zero.
* @param {boolean} [options.recoverTempMax=true] Reset temp max HP to zero.
* @returns {object} Updates to the actor and change in hit points.
* @protected
*/
_getRestHitPointRecovery({recoverTemp=true, recoverTempMax=true}={}) {
const hp = this.system.attributes.hp;
let max = hp.max;
let updates = {};
if ( recoverTempMax ) updates["system.attributes.hp.tempmax"] = 0;
else max = Math.max(0, max + (hp.tempmax || 0));
updates["system.attributes.hp.value"] = max;
if ( recoverTemp ) updates["system.attributes.hp.temp"] = 0;
return { updates, hitPointsRecovered: max - hp.value };
}
/* -------------------------------------------- */
/**
* Recovers actor resources.
* @param {object} [options]
* @param {boolean} [options.recoverShortRestResources=true] Recover resources that recharge on a short rest.
* @param {boolean} [options.recoverLongRestResources=true] Recover resources that recharge on a long rest.
* @returns {object} Updates to the actor.
* @protected
*/
_getRestResourceRecovery({recoverShortRestResources=true, recoverLongRestResources=true}={}) {
let updates = {};
for ( let [k, r] of Object.entries(this.system.resources) ) {
if ( Number.isNumeric(r.max) && ((recoverShortRestResources && r.sr) || (recoverLongRestResources && r.lr)) ) {
updates[`system.resources.${k}.value`] = Number(r.max);
}
}
return updates;
}
/* -------------------------------------------- */
/**
* Recovers spell slots and pact slots.
* @param {object} [options]
* @param {boolean} [options.recoverPact=true] Recover all expended pact slots.
* @param {boolean} [options.recoverSpells=true] Recover all expended spell slots.
* @returns {object} Updates to the actor.
* @protected
*/
_getRestSpellRecovery({recoverPact=true, recoverSpells=true}={}) {
const spells = this.system.spells;
let updates = {};
if ( recoverPact ) {
const pact = spells.pact;
updates["system.spells.pact.value"] = pact.override || pact.max;
}
if ( recoverSpells ) {
for ( let [k, v] of Object.entries(spells) ) {
updates[`system.spells.${k}.value`] = Number.isNumeric(v.override) ? v.override : (v.max ?? 0);
}
}
return updates;
}
/* -------------------------------------------- */
/**
* Recovers class hit dice during a long rest.
*
* @param {object} [options]
* @param {number} [options.maxHitDice] Maximum number of hit dice to recover.
* @returns {object} Array of item updates and number of hit dice recovered.
* @protected
*/
_getRestHitDiceRecovery({maxHitDice}={}) {
// Determine the number of hit dice which may be recovered
if ( maxHitDice === undefined ) maxHitDice = Math.max(Math.floor(this.system.details.level / 2), 1);
// Sort classes which can recover HD, assuming players prefer recovering larger HD first.
const sortedClasses = Object.values(this.classes).sort((a, b) => {
return (parseInt(b.system.hitDice.slice(1)) || 0) - (parseInt(a.system.hitDice.slice(1)) || 0);
});
// Update hit dice usage
let updates = [];
let hitDiceRecovered = 0;
for ( let item of sortedClasses ) {
const hitDiceUsed = item.system.hitDiceUsed;
if ( (hitDiceRecovered < maxHitDice) && (hitDiceUsed > 0) ) {
let delta = Math.min(hitDiceUsed || 0, maxHitDice - hitDiceRecovered);
hitDiceRecovered += delta;
updates.push({_id: item.id, "system.hitDiceUsed": hitDiceUsed - delta});
}
}
return { updates, hitDiceRecovered };
}
/* -------------------------------------------- */
/**
* Recovers item uses during short or long rests.
* @param {object} [options]
* @param {boolean} [options.recoverShortRestUses=true] Recover uses for items that recharge after a short rest.
* @param {boolean} [options.recoverLongRestUses=true] Recover uses for items that recharge after a long rest.
* @param {boolean} [options.recoverDailyUses=true] Recover uses for items that recharge on a new day.
* @param {Roll[]} [options.rolls] Rolls that have been performed as part of this rest.
* @returns {Promise} Array of item updates.
* @protected
*/
async _getRestItemUsesRecovery({recoverShortRestUses=true, recoverLongRestUses=true,
recoverDailyUses=true, rolls}={}) {
let recovery = [];
if ( recoverShortRestUses ) recovery.push("sr");
if ( recoverLongRestUses ) recovery.push("lr");
if ( recoverDailyUses ) recovery.push("day");
let updates = [];
for ( let item of this.items ) {
const uses = item.system.uses;
if ( recovery.includes(uses?.per) ) {
updates.push({_id: item.id, "system.uses.value": uses.max});
}
if ( recoverLongRestUses && item.system.recharge?.value ) {
updates.push({_id: item.id, "system.recharge.charged": true});
}
// Items that roll to gain charges on a new day
if ( recoverDailyUses && uses?.recovery && (uses?.per === "charges") ) {
const roll = new Roll(uses.recovery, item.getRollData());
if ( recoverLongRestUses && (game.settings.get("dnd5e", "restVariant") === "gritty") ) {
roll.alter(7, 0, {multiplyNumeric: true});
}
let total = 0;
try {
total = (await roll.evaluate({async: true})).total;
} catch(err) {
ui.notifications.warn(game.i18n.format("DND5E.ItemRecoveryFormulaWarning", {
name: item.name,
formula: uses.recovery
}));
}
const newValue = Math.clamped(uses.value + total, 0, uses.max);
if ( newValue !== uses.value ) {
const diff = newValue - uses.value;
const isMax = newValue === uses.max;
const locKey = `DND5E.Item${diff < 0 ? "Loss" : "Recovery"}Roll${isMax ? "Max" : ""}`;
updates.push({_id: item.id, "system.uses.value": newValue});
rolls.push(roll);
await roll.toMessage({
user: game.user.id,
speaker: {actor: this, alias: this.name},
flavor: game.i18n.format(locKey, {name: item.name, count: Math.abs(diff)})
});
}
}
}
return updates;
}
/* -------------------------------------------- */
/* Conversion & Transformation */
/* -------------------------------------------- */
/**
* Convert all carried currency to the highest possible denomination using configured conversion rates.
* See CONFIG.DND5E.currencies for configuration.
* @returns {Promise}
*/
convertCurrency() {
const currency = foundry.utils.deepClone(this.system.currency);
const currencies = Object.entries(CONFIG.DND5E.currencies);
currencies.sort((a, b) => a[1].conversion - b[1].conversion);
// Count total converted units of the base currency
let basis = currencies.reduce((change, [denomination, config]) => {
if ( !config.conversion ) return change;
return change + (currency[denomination] / config.conversion);
}, 0);
// Convert base units into the highest denomination possible
for ( const [denomination, config] of currencies) {
if ( !config.conversion ) continue;
const amount = Math.floor(basis * config.conversion);
currency[denomination] = amount;
basis -= (amount / config.conversion);
}
// Save the updated currency object
return this.update({"system.currency": currency});
}
/* -------------------------------------------- */
/**
* Options that determine what properties of the original actor are kept and which are replaced with
* the target actor.
*
* @typedef {object} TransformationOptions
* @property {boolean} [keepPhysical=false] Keep physical abilities (str, dex, con)
* @property {boolean} [keepMental=false] Keep mental abilities (int, wis, cha)
* @property {boolean} [keepSaves=false] Keep saving throw proficiencies
* @property {boolean} [keepSkills=false] Keep skill proficiencies
* @property {boolean} [mergeSaves=false] Take the maximum of the save proficiencies
* @property {boolean} [mergeSkills=false] Take the maximum of the skill proficiencies
* @property {boolean} [keepClass=false] Keep proficiency bonus
* @property {boolean} [keepFeats=false] Keep features
* @property {boolean} [keepSpells=false] Keep spells
* @property {boolean} [keepItems=false] Keep items
* @property {boolean} [keepBio=false] Keep biography
* @property {boolean} [keepVision=false] Keep vision
* @property {boolean} [keepSelf=false] Keep self
* @property {boolean} [keepAE=false] Keep all effects
* @property {boolean} [keepOriginAE=true] Keep effects which originate on this actor
* @property {boolean} [keepOtherOriginAE=true] Keep effects which originate on another actor
* @property {boolean} [keepSpellAE=true] Keep effects which originate from actors spells
* @property {boolean} [keepFeatAE=true] Keep effects which originate from actors features
* @property {boolean} [keepEquipmentAE=true] Keep effects which originate on actors equipment
* @property {boolean} [keepClassAE=true] Keep effects which originate from actors class/subclass
* @property {boolean} [keepBackgroundAE=true] Keep effects which originate from actors background
* @property {boolean} [transformTokens=true] Transform linked tokens too
*/
/**
* Transform this Actor into another one.
*
* @param {Actor5e} target The target Actor.
* @param {TransformationOptions} [options={}] Options that determine how the transformation is performed.
* @param {boolean} [options.renderSheet=true] Render the sheet of the transformed actor after the polymorph
* @returns {Promise>|null} Updated token if the transformation was performed.
*/
async transformInto(target, { keepPhysical=false, keepMental=false, keepSaves=false, keepSkills=false,
mergeSaves=false, mergeSkills=false, keepClass=false, keepFeats=false, keepSpells=false, keepItems=false,
keepBio=false, keepVision=false, keepSelf=false, keepAE=false, keepOriginAE=true, keepOtherOriginAE=true,
keepSpellAE=true, keepEquipmentAE=true, keepFeatAE=true, keepClassAE=true, keepBackgroundAE=true,
transformTokens=true}={}, {renderSheet=true}={}) {
// Ensure the player is allowed to polymorph
const allowed = game.settings.get("dnd5e", "allowPolymorphing");
if ( !allowed && !game.user.isGM ) {
return ui.notifications.warn(game.i18n.localize("DND5E.PolymorphWarn"));
}
// Get the original Actor data and the new source data
const o = this.toObject();
o.flags.dnd5e = o.flags.dnd5e || {};
o.flags.dnd5e.transformOptions = {mergeSkills, mergeSaves};
const source = target.toObject();
if ( keepSelf ) {
o.img = source.img;
o.name = `${o.name} (${game.i18n.localize("DND5E.PolymorphSelf")})`;
}
// Prepare new data to merge from the source
const d = foundry.utils.mergeObject({
type: o.type, // Remain the same actor type
name: `${o.name} (${source.name})`, // Append the new shape to your old name
system: source.system, // Get the systemdata model of your new form
items: source.items, // Get the items of your new form
effects: o.effects.concat(source.effects), // Combine active effects from both forms
img: source.img, // New appearance
ownership: o.ownership, // Use the original actor permissions
folder: o.folder, // Be displayed in the same sidebar folder
flags: o.flags, // Use the original actor flags
prototypeToken: { name: `${o.name} (${source.name})`, texture: {}, sight: {}, detectionModes: [] } // Set a new empty token
}, keepSelf ? o : {}); // Keeps most of original actor
// Specifically delete some data attributes
delete d.system.resources; // Don't change your resource pools
delete d.system.currency; // Don't lose currency
delete d.system.bonuses; // Don't lose global bonuses
// Specific additional adjustments
d.system.details.alignment = o.system.details.alignment; // Don't change alignment
d.system.attributes.exhaustion = o.system.attributes.exhaustion; // Keep your prior exhaustion level
d.system.attributes.inspiration = o.system.attributes.inspiration; // Keep inspiration
d.system.spells = o.system.spells; // Keep spell slots
d.system.attributes.ac.flat = target.system.attributes.ac.value; // Override AC
// Token appearance updates
for ( const k of ["width", "height", "alpha", "lockRotation"] ) {
d.prototypeToken[k] = source.prototypeToken[k];
}
for ( const k of ["offsetX", "offsetY", "scaleX", "scaleY", "src", "tint"] ) {
d.prototypeToken.texture[k] = source.prototypeToken.texture[k];
}
for ( const k of ["bar1", "bar2", "displayBars", "displayName", "disposition", "rotation", "elevation"] ) {
d.prototypeToken[k] = o.prototypeToken[k];
}
if ( !keepSelf ) {
const sightSource = keepVision ? o.prototypeToken : source.prototypeToken;
for ( const k of ["range", "angle", "visionMode", "color", "attenuation", "brightness", "saturation", "contrast", "enabled"] ) {
d.prototypeToken.sight[k] = sightSource.sight[k];
}
d.prototypeToken.detectionModes = sightSource.detectionModes;
// Transfer ability scores
const abilities = d.system.abilities;
for ( let k of Object.keys(abilities) ) {
const oa = o.system.abilities[k];
const prof = abilities[k].proficient;
const type = CONFIG.DND5E.abilities[k]?.type;
if ( keepPhysical && (type === "physical") ) abilities[k] = oa;
else if ( keepMental && (type === "mental") ) abilities[k] = oa;
if ( keepSaves ) abilities[k].proficient = oa.proficient;
else if ( mergeSaves ) abilities[k].proficient = Math.max(prof, oa.proficient);
}
// Transfer skills
if ( keepSkills ) d.system.skills = o.system.skills;
else if ( mergeSkills ) {
for ( let [k, s] of Object.entries(d.system.skills) ) {
s.value = Math.max(s.value, o.system.skills[k].value);
}
}
// Keep specific items from the original data
d.items = d.items.concat(o.items.filter(i => {
if ( ["class", "subclass"].includes(i.type) ) return keepClass;
else if ( i.type === "feat" ) return keepFeats;
else if ( i.type === "spell" ) return keepSpells;
else return keepItems;
}));
// Transfer classes for NPCs
if ( !keepClass && d.system.details.cr ) {
const cls = new dnd5e.dataModels.item.ClassData({levels: d.system.details.cr});
d.items.push({
type: "class",
name: game.i18n.localize("DND5E.PolymorphTmpClass"),
system: cls.toObject()
});
}
// Keep biography
if ( keepBio ) d.system.details.biography = o.system.details.biography;
// Keep senses
if ( keepVision ) d.system.traits.senses = o.system.traits.senses;
// Remove active effects
const oEffects = foundry.utils.deepClone(d.effects);
const originEffectIds = new Set(oEffects.filter(effect => {
return !effect.origin || effect.origin === this.uuid;
}).map(e => e._id));
d.effects = d.effects.filter(e => {
if ( keepAE ) return true;
const origin = e.origin?.startsWith("Actor") || e.origin?.startsWith("Item") ? fromUuidSync(e.origin) : {};
const originIsSelf = origin?.parent?.uuid === this.uuid;
const isOriginEffect = originEffectIds.has(e._id);
if ( isOriginEffect ) return keepOriginAE;
if ( !isOriginEffect && !originIsSelf ) return keepOtherOriginAE;
if ( origin.type === "spell" ) return keepSpellAE;
if ( origin.type === "feat" ) return keepFeatAE;
if ( origin.type === "background" ) return keepBackgroundAE;
if ( ["subclass", "class"].includes(origin.type) ) return keepClassAE;
if ( ["equipment", "weapon", "tool", "loot", "backpack"].includes(origin.type) ) return keepEquipmentAE;
return true;
});
}
// Set a random image if source is configured that way
if ( source.prototypeToken.randomImg ) {
const images = await target.getTokenImages();
d.prototypeToken.texture.src = images[Math.floor(Math.random() * images.length)];
}
// Set new data flags
if ( !this.isPolymorphed || !d.flags.dnd5e.originalActor ) d.flags.dnd5e.originalActor = this.id;
d.flags.dnd5e.isPolymorphed = true;
// Gather previous actor data
const previousActorIds = this.getFlag("dnd5e", "previousActorIds") || [];
previousActorIds.push(this._id);
foundry.utils.setProperty(d.flags, "dnd5e.previousActorIds", previousActorIds);
// Update unlinked Tokens, and grab a copy of any actorData adjustments to re-apply
if ( this.isToken ) {
const tokenData = d.prototypeToken;
delete d.prototypeToken;
let previousActorData;
if ( game.dnd5e.isV10 ) {
tokenData.actorData = d;
previousActorData = this.token.toObject().actorData;
} else {
tokenData.delta = d;
previousActorData = this.token.delta.toObject();
}
foundry.utils.setProperty(tokenData, "flags.dnd5e.previousActorData", previousActorData);
await this.sheet?.close();
const update = await this.token.update(tokenData);
if ( renderSheet ) this.sheet?.render(true);
return update;
}
// Close sheet for non-transformed Actor
await this.sheet?.close();
/**
* A hook event that fires just before the actor is transformed.
* @function dnd5e.transformActor
* @memberof hookEvents
* @param {Actor5e} actor The original actor before transformation.
* @param {Actor5e} target The target actor into which to transform.
* @param {object} data The data that will be used to create the new transformed actor.
* @param {TransformationOptions} options Options that determine how the transformation is performed.
* @param {object} [options]
*/
Hooks.callAll("dnd5e.transformActor", this, target, d, {
keepPhysical, keepMental, keepSaves, keepSkills, mergeSaves, mergeSkills, keepClass, keepFeats, keepSpells,
keepItems, keepBio, keepVision, keepSelf, keepAE, keepOriginAE, keepOtherOriginAE, keepSpellAE,
keepEquipmentAE, keepFeatAE, keepClassAE, keepBackgroundAE, transformTokens
}, {renderSheet});
// Create new Actor with transformed data
const newActor = await this.constructor.create(d, {renderSheet});
// Update placed Token instances
if ( !transformTokens ) return;
const tokens = this.getActiveTokens(true);
const updates = tokens.map(t => {
const newTokenData = foundry.utils.deepClone(d.prototypeToken);
newTokenData._id = t.id;
newTokenData.actorId = newActor.id;
newTokenData.actorLink = true;
const dOriginalActor = foundry.utils.getProperty(d, "flags.dnd5e.originalActor");
foundry.utils.setProperty(newTokenData, "flags.dnd5e.originalActor", dOriginalActor);
foundry.utils.setProperty(newTokenData, "flags.dnd5e.isPolymorphed", true);
return newTokenData;
});
return canvas.scene?.updateEmbeddedDocuments("Token", updates);
}
/* -------------------------------------------- */
/**
* If this actor was transformed with transformTokens enabled, then its
* active tokens need to be returned to their original state. If not, then
* we can safely just delete this actor.
* @param {object} [options]
* @param {boolean} [options.renderSheet=true] Render Sheet after revert the transformation.
* @returns {Promise|null} Original actor if it was reverted.
*/
async revertOriginalForm({renderSheet=true}={}) {
if ( !this.isPolymorphed ) return;
if ( !this.isOwner ) return ui.notifications.warn(game.i18n.localize("DND5E.PolymorphRevertWarn"));
/**
* A hook event that fires just before the actor is reverted to original form.
* @function dnd5e.revertOriginalForm
* @memberof hookEvents
* @param {Actor} this The original actor before transformation.
* @param {object} [options]
*/
Hooks.callAll("dnd5e.revertOriginalForm", this, {renderSheet});
const previousActorIds = this.getFlag("dnd5e", "previousActorIds") ?? [];
const isOriginalActor = !previousActorIds.length;
const isRendered = this.sheet.rendered;
// Obtain a reference to the original actor
const original = game.actors.get(this.getFlag("dnd5e", "originalActor"));
// If we are reverting an unlinked token, grab the previous actorData, and create a new token
if ( this.isToken ) {
const baseActor = original ? original : game.actors.get(this.token.actorId);
if ( !baseActor ) {
ui.notifications.warn(game.i18n.format("DND5E.PolymorphRevertNoOriginalActorWarn", {
reference: this.getFlag("dnd5e", "originalActor")
}));
return;
}
const prototypeTokenData = await baseActor.getTokenDocument();
const actorData = this.token.getFlag("dnd5e", "previousActorData");
const tokenUpdate = this.token.toObject();
if ( game.dnd5e.isV10 ) tokenUpdate.actorData = actorData ?? {};
else {
actorData._id = tokenUpdate.delta._id;
tokenUpdate.delta = actorData;
}
for ( const k of ["width", "height", "alpha", "lockRotation", "name"] ) {
tokenUpdate[k] = prototypeTokenData[k];
}
for ( const k of ["offsetX", "offsetY", "scaleX", "scaleY", "src", "tint"] ) {
tokenUpdate.texture[k] = prototypeTokenData.texture[k];
}
tokenUpdate.sight = prototypeTokenData.sight;
tokenUpdate.detectionModes = prototypeTokenData.detectionModes;
await this.sheet.close();
await canvas.scene?.deleteEmbeddedDocuments("Token", [this.token._id]);
const token = await TokenDocument.implementation.create(tokenUpdate, {
parent: canvas.scene, keepId: true, render: true
});
if ( isOriginalActor ) {
await this.unsetFlag("dnd5e", "isPolymorphed");
await this.unsetFlag("dnd5e", "previousActorIds");
await this.token.unsetFlag("dnd5e", "previousActorData");
}
if ( isRendered && renderSheet ) token.actor?.sheet?.render(true);
return token;
}
if ( !original ) {
ui.notifications.warn(game.i18n.format("DND5E.PolymorphRevertNoOriginalActorWarn", {
reference: this.getFlag("dnd5e", "originalActor")
}));
return;
}
// Get the Tokens which represent this actor
if ( canvas.ready ) {
const tokens = this.getActiveTokens(true);
const tokenData = await original.getTokenDocument();
const tokenUpdates = tokens.map(t => {
const update = duplicate(tokenData);
update._id = t.id;
delete update.x;
delete update.y;
return update;
});
await canvas.scene.updateEmbeddedDocuments("Token", tokenUpdates);
}
if ( isOriginalActor ) {
await this.unsetFlag("dnd5e", "isPolymorphed");
await this.unsetFlag("dnd5e", "previousActorIds");
}
// Delete the polymorphed version(s) of the actor, if possible
if ( game.user.isGM ) {
const idsToDelete = previousActorIds.filter(id =>
id !== original.id // Is not original Actor Id
&& game.actors?.get(id) // Actor still exists
).concat([this.id]); // Add this id
await Actor.implementation.deleteDocuments(idsToDelete);
} else if ( isRendered ) {
this.sheet?.close();
}
if ( isRendered && renderSheet ) original.sheet?.render(isRendered);
return original;
}
/* -------------------------------------------- */
/**
* Add additional system-specific sidebar directory context menu options for Actor documents
* @param {jQuery} html The sidebar HTML
* @param {Array} entryOptions The default array of context menu options
*/
static addDirectoryContextOptions(html, entryOptions) {
entryOptions.push({
name: "DND5E.PolymorphRestoreTransformation",
icon: ' ',
callback: li => {
const actor = game.actors.get(li.data("documentId"));
return actor.revertOriginalForm();
},
condition: li => {
const allowed = game.settings.get("dnd5e", "allowPolymorphing");
if ( !allowed && !game.user.isGM ) return false;
const actor = game.actors.get(li.data("documentId"));
return actor && actor.isPolymorphed;
}
});
}
/* -------------------------------------------- */
/**
* Format a type object into a string.
* @param {object} typeData The type data to convert to a string.
* @returns {string}
*/
static formatCreatureType(typeData) {
if ( typeof typeData === "string" ) return typeData; // Backwards compatibility
let localizedType;
if ( typeData.value === "custom" ) {
localizedType = typeData.custom;
} else {
let code = CONFIG.DND5E.creatureTypes[typeData.value];
localizedType = game.i18n.localize(typeData.swarm ? `${code}Pl` : code);
}
let type = localizedType;
if ( typeData.swarm ) {
type = game.i18n.format("DND5E.CreatureSwarmPhrase", {
size: game.i18n.localize(CONFIG.DND5E.actorSizes[typeData.swarm]),
type: localizedType
});
}
if (typeData.subtype) type = `${type} (${typeData.subtype})`;
return type;
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
_onUpdate(data, options, userId) {
super._onUpdate(data, options, userId);
this._displayScrollingDamage(options.dhp);
}
/* -------------------------------------------- */
/**
* Display changes to health as scrolling combat text.
* Adapt the font size relative to the Actor's HP total to emphasize more significant blows.
* @param {number} dhp The change in hit points that was applied
* @private
*/
_displayScrollingDamage(dhp) {
if ( !dhp ) return;
dhp = Number(dhp);
const tokens = this.isToken ? [this.token?.object] : this.getActiveTokens(true);
for ( const t of tokens ) {
const pct = Math.clamped(Math.abs(dhp) / this.system.attributes.hp.max, 0, 1);
canvas.interface.createScrollingText(t.center, dhp.signedString(), {
anchor: CONST.TEXT_ANCHOR_POINTS.TOP,
fontSize: 16 + (32 * pct), // Range between [16, 48]
fill: CONFIG.DND5E.tokenHPColors[dhp < 0 ? "damage" : "healing"],
stroke: 0x000000,
strokeThickness: 4,
jitter: 0.25
});
}
}
}
/**
* Inline application that presents the player with a choice of items.
*/
class ItemChoiceFlow extends ItemGrantFlow {
/**
* Set of selected UUIDs.
* @type {Set}
*/
selected;
/**
* Cached items from the advancement's pool.
* @type {Item5e[]}
*/
pool;
/**
* List of dropped items.
* @type {Item5e[]}
*/
dropped;
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
dragDrop: [{ dropSelector: ".drop-target" }],
template: "systems/dnd5e/templates/advancement/item-choice-flow.hbs"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async getContext() {
this.selected ??= new Set(
this.retainedData?.items.map(i => foundry.utils.getProperty(i, "flags.dnd5e.sourceId"))
?? Object.values(this.advancement.value[this.level] ?? {})
);
this.pool ??= await Promise.all(this.advancement.configuration.pool.map(uuid => fromUuid(uuid)));
if ( !this.dropped ) {
this.dropped = [];
for ( const data of this.retainedData?.items ?? [] ) {
const uuid = foundry.utils.getProperty(data, "flags.dnd5e.sourceId");
if ( this.pool.find(i => uuid === i.uuid) ) continue;
const item = await fromUuid(uuid);
item.dropped = true;
this.dropped.push(item);
}
}
const max = this.advancement.configuration.choices[this.level];
const choices = { max, current: this.selected.size, full: this.selected.size >= max };
const previousLevels = {};
const previouslySelected = new Set();
for ( const [level, data] of Object.entries(this.advancement.value.added ?? {}) ) {
if ( level > this.level ) continue;
previousLevels[level] = await Promise.all(Object.values(data).map(uuid => fromUuid(uuid)));
Object.values(data).forEach(uuid => previouslySelected.add(uuid));
}
const items = [...this.pool, ...this.dropped].reduce((items, i) => {
i.checked = this.selected.has(i.uuid);
i.disabled = !i.checked && choices.full;
if ( !previouslySelected.has(i.uuid) ) items.push(i);
return items;
}, []);
return { choices, items, previousLevels };
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".item-delete").click(this._onItemDelete.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
_onChangeInput(event) {
if ( event.target.checked ) this.selected.add(event.target.name);
else this.selected.delete(event.target.name);
this.render();
}
/* -------------------------------------------- */
/**
* Handle deleting a dropped item.
* @param {Event} event The originating click event.
* @protected
*/
async _onItemDelete(event) {
event.preventDefault();
const uuidToDelete = event.currentTarget.closest(".item-name")?.querySelector("input")?.name;
if ( !uuidToDelete ) return;
this.dropped.findSplice(i => i.uuid === uuidToDelete);
this.selected.delete(uuidToDelete);
this.render();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
if ( this.selected.size >= this.advancement.configuration.choices[this.level] ) return false;
// Try to extract the data
let data;
try {
data = JSON.parse(event.dataTransfer.getData("text/plain"));
} catch(err) {
return false;
}
if ( data.type !== "Item" ) return false;
const item = await Item.implementation.fromDropData(data);
try {
this.advancement._validateItemType(item);
} catch(err) {
return ui.notifications.error(err.message);
}
// If the item is already been marked as selected, no need to go further
if ( this.selected.has(item.uuid) ) return false;
// Check to ensure the dropped item hasn't been selected at a lower level
for ( const [level, data] of Object.entries(this.advancement.value.added ?? {}) ) {
if ( level >= this.level ) continue;
if ( Object.values(data).includes(item.uuid) ) {
return ui.notifications.error(game.i18n.localize("DND5E.AdvancementItemChoicePreviouslyChosenWarning"));
}
}
// If spell level is restricted to available level, ensure the spell is of the appropriate level
const spellLevel = this.advancement.configuration.restriction.level;
if ( (this.advancement.configuration.type === "spell") && spellLevel === "available" ) {
const maxSlot = this._maxSpellSlotLevel();
if ( item.system.level > maxSlot ) return ui.notifications.error(game.i18n.format(
"DND5E.AdvancementItemChoiceSpellLevelAvailableWarning", { level: CONFIG.DND5E.spellLevels[maxSlot] }
));
}
// Mark the item as selected
this.selected.add(item.uuid);
// If the item doesn't already exist in the pool, add it
if ( !this.pool.find(i => i.uuid === item.uuid) ) {
this.dropped.push(item);
item.dropped = true;
}
this.render();
}
/* -------------------------------------------- */
/**
* Determine the maximum spell slot level for the actor to which this advancement is being applied.
* @returns {number}
*/
_maxSpellSlotLevel() {
const spellcasting = this.advancement.item.spellcasting;
let spells;
// For advancements on classes or subclasses, use the largest slot available for that class
if ( spellcasting ) {
const progression = { slot: 0, pact: {} };
const maxSpellLevel = CONFIG.DND5E.SPELL_SLOT_TABLE[CONFIG.DND5E.SPELL_SLOT_TABLE.length - 1].length;
spells = Object.fromEntries(Array.fromRange(maxSpellLevel, 1).map(l => [`spell${l}`, {}]));
Actor5e.computeClassProgression(progression, this.advancement.item, { spellcasting });
Actor5e.prepareSpellcastingSlots(spells, spellcasting.type, progression);
}
// For all other items, use the largest slot possible
else spells = this.advancement.actor.system.spells;
const largestSlot = Object.entries(spells).reduce((slot, [key, data]) => {
if ( data.max === 0 ) return slot;
const level = parseInt(key.replace("spell", ""));
if ( !Number.isNaN(level) && level > slot ) return level;
return slot;
}, -1);
return Math.max(spells.pact?.level ?? 0, largestSlot);
}
}
class ItemChoiceConfigurationData extends foundry.abstract.DataModel {
static defineSchema() {
return {
hint: new foundry.data.fields.StringField({label: "DND5E.AdvancementHint"}),
choices: new MappingField(new foundry.data.fields.NumberField(), {
hint: "DND5E.AdvancementItemChoiceLevelsHint"
}),
allowDrops: new foundry.data.fields.BooleanField({
initial: true, label: "DND5E.AdvancementConfigureAllowDrops",
hint: "DND5E.AdvancementConfigureAllowDropsHint"
}),
type: new foundry.data.fields.StringField({
blank: false, nullable: true, initial: null,
label: "DND5E.AdvancementItemChoiceType", hint: "DND5E.AdvancementItemChoiceTypeHint"
}),
pool: new foundry.data.fields.ArrayField(new foundry.data.fields.StringField(), {label: "DOCUMENT.Items"}),
spell: new foundry.data.fields.EmbeddedDataField(SpellConfigurationData, {nullable: true, initial: null}),
restriction: new foundry.data.fields.SchemaField({
type: new foundry.data.fields.StringField({label: "DND5E.Type"}),
subtype: new foundry.data.fields.StringField({label: "DND5E.Subtype"}),
level: new foundry.data.fields.StringField({label: "DND5E.SpellLevel"})
})
};
}
}
/**
* Advancement that presents the player with a choice of multiple items that they can take. Keeps track of which
* items were selected at which levels.
*/
class ItemChoiceAdvancement extends ItemGrantAdvancement {
/** @inheritdoc */
static get metadata() {
return foundry.utils.mergeObject(super.metadata, {
dataModels: {
configuration: ItemChoiceConfigurationData
},
order: 50,
icon: "systems/dnd5e/icons/svg/item-choice.svg",
title: game.i18n.localize("DND5E.AdvancementItemChoiceTitle"),
hint: game.i18n.localize("DND5E.AdvancementItemChoiceHint"),
multiLevel: true,
apps: {
config: ItemChoiceConfig,
flow: ItemChoiceFlow
}
});
}
/* -------------------------------------------- */
/* Instance Properties */
/* -------------------------------------------- */
/** @inheritdoc */
get levels() {
return Array.from(Object.keys(this.configuration.choices));
}
/* -------------------------------------------- */
/* Display Methods */
/* -------------------------------------------- */
/** @inheritdoc */
configuredForLevel(level) {
return this.value.added?.[level] !== undefined;
}
/* -------------------------------------------- */
/** @inheritdoc */
titleForLevel(level, { configMode=false }={}) {
return `${this.title} (${game.i18n.localize("DND5E.AdvancementChoices")}) `;
}
/* -------------------------------------------- */
/** @inheritdoc */
summaryForLevel(level, { configMode=false }={}) {
const items = this.value.added?.[level];
if ( !items || configMode ) return "";
return Object.values(items).reduce((html, uuid) => html + game.dnd5e.utils.linkForUuid(uuid), "");
}
/* -------------------------------------------- */
/* Application Methods */
/* -------------------------------------------- */
/** @inheritdoc */
storagePath(level) {
return `value.added.${level}`;
}
/* -------------------------------------------- */
/**
* Verify that the provided item can be used with this advancement based on the configuration.
* @param {Item5e} item Item that needs to be tested.
* @param {object} config
* @param {string} config.type Type restriction on this advancement.
* @param {object} config.restriction Additional restrictions to be applied.
* @param {boolean} [config.strict=true] Should an error be thrown when an invalid type is encountered?
* @returns {boolean} Is this type valid?
* @throws An error if the item is invalid and strict is `true`.
*/
_validateItemType(item, { type, restriction, strict=true }={}) {
super._validateItemType(item, { strict });
type ??= this.configuration.type;
restriction ??= this.configuration.restriction;
// Type restriction is set and the item type does not match the selected type
if ( type && (type !== item.type) ) {
const typeLabel = game.i18n.localize(CONFIG.Item.typeLabels[restriction]);
if ( strict ) throw new Error(game.i18n.format("DND5E.AdvancementItemChoiceTypeWarning", {type: typeLabel}));
return false;
}
// If additional type restrictions applied, make sure they are valid
if ( (type === "feat") && restriction.type ) {
const typeConfig = CONFIG.DND5E.featureTypes[restriction.type];
const subtype = typeConfig.subtypes?.[restriction.subtype];
let errorLabel;
if ( restriction.type !== item.system.type.value ) errorLabel = typeConfig.label;
else if ( subtype && (restriction.subtype !== item.system.type.subtype) ) errorLabel = subtype;
if ( errorLabel ) {
if ( strict ) throw new Error(game.i18n.format("DND5E.AdvancementItemChoiceTypeWarning", {type: errorLabel}));
return false;
}
}
// If spell level is restricted, ensure the spell is of the appropriate level
const l = parseInt(restriction.level);
if ( (type === "spell") && !Number.isNaN(l) && (item.system.level !== l) ) {
const level = CONFIG.DND5E.spellLevels[l];
if ( strict ) throw new Error(game.i18n.format("DND5E.AdvancementItemChoiceSpellLevelSpecificWarning", {level}));
return false;
}
return true;
}
}
/**
* Data model for the Scale Value advancement type.
*
* @property {string} identifier Identifier used to select this scale value in roll formulas.
* @property {string} type Type of data represented by this scale value.
* @property {object} [distance]
* @property {string} [distance.units] If distance type is selected, the units each value uses.
* @property {Object} scale Scale values for each level. Value format is determined by type.
*/
class ScaleValueConfigurationData extends foundry.abstract.DataModel {
/** @inheritdoc */
static defineSchema() {
return {
identifier: new IdentifierField({required: true, label: "DND5E.Identifier"}),
type: new foundry.data.fields.StringField({
required: true, initial: "string", choices: TYPES, label: "DND5E.AdvancementScaleValueTypeLabel"
}),
distance: new foundry.data.fields.SchemaField({
units: new foundry.data.fields.StringField({required: true, label: "DND5E.MovementUnits"})
}),
scale: new MappingField(new ScaleValueEntryField(), {required: true})
};
}
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
super.migrateData(source);
if ( source.type === "numeric" ) source.type = "number";
Object.values(source.scale ?? {}).forEach(v => TYPES[source.type].migrateData(v));
}
}
/**
* Data field that automatically selects the appropriate ScaleValueType based on the selected type.
*/
class ScaleValueEntryField extends foundry.data.fields.ObjectField {
/** @override */
_cleanType(value, options) {
if ( !(typeof value === "object") ) value = {};
// Use a defined DataModel
const cls = TYPES[options.source?.type];
if ( cls ) return cls.cleanData(value, options);
return value;
}
/* -------------------------------------------- */
/** @override */
initialize(value, model) {
const cls = TYPES[model.type];
if ( !value || !cls ) return value;
return new cls(value, {parent: model});
}
/* -------------------------------------------- */
/** @override */
toObject(value) {
return value.toObject(false);
}
}
/**
* Base scale value data type that stores generic string values.
*
* @property {string} value String value.
*/
class ScaleValueType extends foundry.abstract.DataModel {
/** @inheritdoc */
static defineSchema() {
return {
value: new foundry.data.fields.StringField({required: true})
};
}
/* -------------------------------------------- */
/**
* Information on how a scale value of this type is configured.
*
* @typedef {object} ScaleValueTypeMetadata
* @property {string} label Name of this type.
* @property {string} hint Hint for this type shown in the scale value configuration.
* @property {boolean} isNumeric When using the default editing interface, should numeric inputs be used?
*/
/**
* Configuration information for this scale value type.
* @type {ScaleValueTypeMetadata}
*/
static get metadata() {
return {
label: "DND5E.AdvancementScaleValueTypeString",
hint: "DND5E.AdvancementScaleValueTypeHintString",
isNumeric: false
};
}
/* -------------------------------------------- */
/**
* Attempt to convert another scale value type to this one.
* @param {ScaleValueType} original Original type to attempt to convert.
* @param {object} [options] Options which affect DataModel construction.
* @returns {ScaleValueType|null}
*/
static convertFrom(original, options) {
return new this({value: original.formula}, options);
}
/* -------------------------------------------- */
/**
* This scale value prepared to be used in roll formulas.
* @type {string|null}
*/
get formula() { return this.value; }
/* -------------------------------------------- */
/**
* This scale value formatted for display.
* @type {string|null}
*/
get display() { return this.formula; }
/* -------------------------------------------- */
/**
* Shortcut to the prepared value when used in roll formulas.
* @returns {string}
*/
toString() {
return this.formula;
}
}
/**
* Scale value data type that stores numeric values.
*
* @property {number} value Numeric value.
*/
class ScaleValueTypeNumber extends ScaleValueType {
/** @inheritdoc */
static defineSchema() {
return {
value: new foundry.data.fields.NumberField({required: true})
};
}
/* -------------------------------------------- */
/** @inheritdoc */
static get metadata() {
return foundry.utils.mergeObject(super.metadata, {
label: "DND5E.AdvancementScaleValueTypeNumber",
hint: "DND5E.AdvancementScaleValueTypeHintNumber",
isNumeric: true
});
}
/* -------------------------------------------- */
/** @inheritdoc */
static convertFrom(original, options) {
const value = Number(original.formula);
if ( Number.isNaN(value) ) return null;
return new this({value}, options);
}
}
/**
* Scale value data type that stores challenge ratings.
*
* @property {number} value CR value.
*/
class ScaleValueTypeCR extends ScaleValueTypeNumber {
/** @inheritdoc */
static defineSchema() {
return {
value: new foundry.data.fields.NumberField({required: true, min: 0})
// TODO: Add CR validator
};
}
/* -------------------------------------------- */
/** @inheritdoc */
static get metadata() {
return foundry.utils.mergeObject(super.metadata, {
label: "DND5E.AdvancementScaleValueTypeCR",
hint: "DND5E.AdvancementScaleValueTypeHintCR"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get display() {
switch ( this.value ) {
case 0.125: return "⅛";
case 0.25: return "¼";
case 0.5: return "½";
default: return super.display;
}
}
}
/**
* Scale value data type that stores dice values.
*
* @property {number} number Number of dice.
* @property {number} faces Die faces.
*/
class ScaleValueTypeDice extends ScaleValueType {
/** @inheritdoc */
static defineSchema() {
return {
number: new foundry.data.fields.NumberField({nullable: true, integer: true, positive: true}),
faces: new foundry.data.fields.NumberField({required: true, integer: true, positive: true})
};
}
/* -------------------------------------------- */
/** @inheritdoc */
static get metadata() {
return foundry.utils.mergeObject(super.metadata, {
label: "DND5E.AdvancementScaleValueTypeDice",
hint: "DND5E.AdvancementScaleValueTypeHintDice"
});
}
/* -------------------------------------------- */
/**
* List of die faces that can be chosen.
* @type {number[]}
*/
static FACES = [2, 3, 4, 6, 8, 10, 12, 20, 100];
/* -------------------------------------------- */
/** @inheritdoc */
static convertFrom(original, options) {
const [number, faces] = (original.formula ?? "").split("d");
if ( !faces || !Number.isNumeric(number) || !Number.isNumeric(faces) ) return null;
return new this({number: Number(number) || null, faces: Number(faces)}, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
get formula() {
if ( !this.faces ) return null;
return `${this.number ?? ""}${this.die}`;
}
/* -------------------------------------------- */
/**
* The die value to be rolled with the leading "d" (e.g. "d4").
* @type {string}
*/
get die() {
if ( !this.faces ) return "";
return `d${this.faces}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
if ( source.n ) source.number = source.n;
if ( source.die ) source.faces = source.die;
}
}
/**
* Scale value data type that stores distance values.
*
* @property {number} value Numeric value.
*/
class ScaleValueTypeDistance extends ScaleValueTypeNumber {
/** @inheritdoc */
static get metadata() {
return foundry.utils.mergeObject(super.metadata, {
label: "DND5E.AdvancementScaleValueTypeDistance",
hint: "DND5E.AdvancementScaleValueTypeHintDistance"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get display() {
return `${this.value} ${CONFIG.DND5E.movementUnits[this.parent.configuration.distance?.units ?? "ft"]}`;
}
}
/**
* The available types of scaling value.
* @enum {ScaleValueType}
*/
const TYPES = {
string: ScaleValueType,
number: ScaleValueTypeNumber,
cr: ScaleValueTypeCR,
dice: ScaleValueTypeDice,
distance: ScaleValueTypeDistance
};
var scaleValue = /*#__PURE__*/Object.freeze({
__proto__: null,
ScaleValueConfigurationData: ScaleValueConfigurationData,
ScaleValueEntryField: ScaleValueEntryField,
ScaleValueType: ScaleValueType,
ScaleValueTypeCR: ScaleValueTypeCR,
ScaleValueTypeDice: ScaleValueTypeDice,
ScaleValueTypeDistance: ScaleValueTypeDistance,
ScaleValueTypeNumber: ScaleValueTypeNumber,
TYPES: TYPES
});
/**
* Configuration application for scale values.
*/
class ScaleValueConfig extends AdvancementConfig {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "advancement", "scale-value", "two-column"],
template: "systems/dnd5e/templates/advancement/scale-value-config.hbs",
width: 540
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData() {
const config = this.advancement.configuration;
const type = TYPES[config.type];
return foundry.utils.mergeObject(super.getData(), {
classIdentifier: this.item.identifier,
previewIdentifier: config.identifier || this.advancement.title?.slugify()
|| this.advancement.constructor.metadata.title.slugify(),
type: type.metadata,
types: Object.fromEntries(
Object.entries(TYPES).map(([key, d]) => [key, game.i18n.localize(d.metadata.label)])
),
faces: Object.fromEntries(TYPES.dice.FACES.map(die => [die, `d${die}`])),
levels: this._prepareLevelData(),
movementUnits: CONFIG.DND5E.movementUnits
});
}
/* -------------------------------------------- */
/**
* Prepare the data to display at each of the scale levels.
* @returns {object}
* @protected
*/
_prepareLevelData() {
let lastValue = null;
return Array.fromRange(CONFIG.DND5E.maxLevel + 1).slice(1).reduce((obj, level) => {
obj[level] = { placeholder: this._formatPlaceholder(lastValue), value: null };
const value = this.advancement.configuration.scale[level];
if ( value ) {
this._mergeScaleValues(value, lastValue);
obj[level].className = "new-scale-value";
obj[level].value = value;
lastValue = value;
}
return obj;
}, {});
}
/* -------------------------------------------- */
/**
* Formats the placeholder for this scale value.
* @param {*} placeholder
* @returns {object}
* @protected
*/
_formatPlaceholder(placeholder) {
if ( this.advancement.configuration.type === "dice" ) {
return { number: placeholder?.number ?? "", faces: placeholder?.faces ? `d${placeholder.faces}` : "" };
}
return { value: placeholder?.value ?? "" };
}
/* -------------------------------------------- */
/**
* For scale values with multiple properties, have missing properties inherit from earlier filled-in values.
* @param {*} value The primary value.
* @param {*} lastValue The previous value.
*/
_mergeScaleValues(value, lastValue) {
for ( const k of Object.keys(lastValue ?? {}) ) {
if ( value[k] == null ) value[k] = lastValue[k];
}
}
/* -------------------------------------------- */
/** @inheritdoc */
static _cleanedObject(object) {
return Object.entries(object).reduce((obj, [key, value]) => {
if ( Object.keys(value ?? {}).some(k => value[k]) ) obj[key] = value;
else obj[`-=${key}`] = null;
return obj;
}, {});
}
/* -------------------------------------------- */
/** @inheritdoc */
prepareConfigurationUpdate(configuration) {
// Ensure multiple values in a row are not the same
let lastValue = null;
for ( const [lvl, value] of Object.entries(configuration.scale) ) {
if ( this.advancement.testEquality(lastValue, value) ) configuration.scale[lvl] = null;
else if ( Object.keys(value ?? {}).some(k => value[k]) ) {
this._mergeScaleValues(value, lastValue);
lastValue = value;
}
}
configuration.scale = this.constructor._cleanedObject(configuration.scale);
return configuration;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
this.form.querySelector("input[name='title']").addEventListener("input", this._onChangeTitle.bind(this));
this.form.querySelector(".identifier-hint-copy").addEventListener("click", this._onIdentifierHintCopy.bind(this));
}
/* -------------------------------------------- */
/**
* Copies the full scale identifier hint to the clipboard.
* @param {Event} event The triggering click event.
* @protected
*/
_onIdentifierHintCopy(event) {
const data = this.getData();
game.clipboard.copyPlainText(`@scale.${data.classIdentifier}.${data.previewIdentifier}`);
game.tooltip.activate(event.target, {text: game.i18n.localize("DND5E.IdentifierCopied"), direction: "UP"});
}
/* -------------------------------------------- */
/**
* If no identifier is manually entered, slugify the custom title and display as placeholder.
* @param {Event} event Change event to the title input.
*/
_onChangeTitle(event) {
const slug = (event.target.value || this.advancement.constructor.metadata.title).slugify();
this.form.querySelector("input[name='configuration.identifier']").placeholder = slug;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
const updates = foundry.utils.expandObject(formData);
const typeChange = "configuration.type" in formData;
if ( typeChange && (updates.configuration.type !== this.advancement.configuration.type) ) {
// Clear existing scale value data to prevent error during type update
await this.advancement.update(Array.fromRange(CONFIG.DND5E.maxLevel, 1).reduce((obj, lvl) => {
obj[`configuration.scale.-=${lvl}`] = null;
return obj;
}, {}));
updates.configuration.scale ??= {};
const OriginalType = TYPES[this.advancement.configuration.type];
const NewType = TYPES[updates.configuration.type];
for ( const [lvl, data] of Object.entries(updates.configuration.scale) ) {
const original = new OriginalType(data, { parent: this.advancement });
updates.configuration.scale[lvl] = NewType.convertFrom(original)?.toObject();
}
}
return super._updateObject(event, foundry.utils.flattenObject(updates));
}
}
/**
* Inline application that displays any changes to a scale value.
*/
class ScaleValueFlow extends AdvancementFlow {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "systems/dnd5e/templates/advancement/scale-value-flow.hbs"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData() {
return foundry.utils.mergeObject(super.getData(), {
initial: this.advancement.valueForLevel(this.level - 1)?.display,
final: this.advancement.valueForLevel(this.level).display
});
}
}
/**
* Advancement that represents a value that scales with class level. **Can only be added to classes or subclasses.**
*/
class ScaleValueAdvancement extends Advancement {
/** @inheritdoc */
static get metadata() {
return foundry.utils.mergeObject(super.metadata, {
dataModels: {
configuration: ScaleValueConfigurationData
},
order: 60,
icon: "systems/dnd5e/icons/svg/scale-value.svg",
title: game.i18n.localize("DND5E.AdvancementScaleValueTitle"),
hint: game.i18n.localize("DND5E.AdvancementScaleValueHint"),
multiLevel: true,
validItemTypes: new Set(["class", "subclass"]),
apps: {
config: ScaleValueConfig,
flow: ScaleValueFlow
}
});
}
/* -------------------------------------------- */
/**
* The available types of scaling value.
* @enum {ScaleValueType}
*/
static TYPES = TYPES;
/* -------------------------------------------- */
/* Instance Properties */
/* -------------------------------------------- */
/** @inheritdoc */
get levels() {
return Array.from(Object.keys(this.configuration.scale).map(l => Number(l)));
}
/* -------------------------------------------- */
/**
* Identifier for this scale value, either manual value or the slugified title.
* @type {string}
*/
get identifier() {
return this.configuration.identifier || this.title.slugify();
}
/* -------------------------------------------- */
/* Display Methods */
/* -------------------------------------------- */
/** @inheritdoc */
titleForLevel(level, { configMode=false }={}) {
const value = this.valueForLevel(level)?.display;
if ( !value ) return this.title;
return `${this.title}: ${value} `;
}
/* -------------------------------------------- */
/**
* Scale value for the given level.
* @param {number} level Level for which to get the scale value.
* @returns {ScaleValueType} Scale value at the given level or null if none exists.
*/
valueForLevel(level) {
const key = Object.keys(this.configuration.scale).reverse().find(l => Number(l) <= level);
const data = this.configuration.scale[key];
const TypeClass = this.constructor.TYPES[this.configuration.type];
if ( !data || !TypeClass ) return null;
return new TypeClass(data, { parent: this });
}
/* -------------------------------------------- */
/**
* Compare two scaling values and determine if they are equal.
* @param {*} a
* @param {*} b
* @returns {boolean}
*/
testEquality(a, b) {
const keys = Object.keys(a ?? {});
if ( keys.length !== Object.keys(b ?? {}).length ) return false;
for ( const k of keys ) {
if ( a[k] !== b[k] ) return false;
}
return true;
}
}
var _module$a = /*#__PURE__*/Object.freeze({
__proto__: null,
Advancement: Advancement,
HitPointsAdvancement: HitPointsAdvancement,
ItemChoiceAdvancement: ItemChoiceAdvancement,
ItemGrantAdvancement: ItemGrantAdvancement,
ScaleValueAdvancement: ScaleValueAdvancement
});
// Namespace Configuration Values
const DND5E = {};
// ASCII Artwork
DND5E.ASCII = `_______________________________
______ ______ _____ _____
| _ \\___ | _ \\ ___| ___|
| | | ( _ ) | | | |___ \\| |__
| | | / _ \\/\\ | | | \\ \\ __|
| |/ / (_> < |/ //\\__/ / |___
|___/ \\___/\\/___/ \\____/\\____/
_______________________________`;
/**
* Configuration data for abilities.
*
* @typedef {object} AbilityConfiguration
* @property {string} label Localized label.
* @property {string} abbreviation Localized abbreviation.
* @property {string} [type] Whether this is a "physical" or "mental" ability.
* @property {Object} [defaults] Default values for this ability based on actor type.
* If a string is used, the system will attempt to fetch.
* the value of the specified ability.
*/
/**
* The set of Ability Scores used within the system.
* @enum {AbilityConfiguration}
*/
DND5E.abilities = {
str: {
label: "DND5E.AbilityStr",
abbreviation: "DND5E.AbilityStrAbbr",
type: "physical"
},
dex: {
label: "DND5E.AbilityDex",
abbreviation: "DND5E.AbilityDexAbbr",
type: "physical"
},
con: {
label: "DND5E.AbilityCon",
abbreviation: "DND5E.AbilityConAbbr",
type: "physical"
},
int: {
label: "DND5E.AbilityInt",
abbreviation: "DND5E.AbilityIntAbbr",
type: "mental",
defaults: { vehicle: 0 }
},
wis: {
label: "DND5E.AbilityWis",
abbreviation: "DND5E.AbilityWisAbbr",
type: "mental",
defaults: { vehicle: 0 }
},
cha: {
label: "DND5E.AbilityCha",
abbreviation: "DND5E.AbilityChaAbbr",
type: "mental",
defaults: { vehicle: 0 }
},
hon: {
label: "DND5E.AbilityHon",
abbreviation: "DND5E.AbilityHonAbbr",
type: "mental",
defaults: { npc: "cha", vehicle: 0 }
},
san: {
label: "DND5E.AbilitySan",
abbreviation: "DND5E.AbilitySanAbbr",
type: "mental",
defaults: { npc: "wis", vehicle: 0 }
}
};
preLocalize("abilities", { keys: ["label", "abbreviation"] });
patchConfig("abilities", "label", { since: 2.2, until: 2.4 });
Object.defineProperty(DND5E, "abilityAbbreviations", {
get() {
foundry.utils.logCompatibilityWarning(
"The `abilityAbbreviations` configuration object has been merged with `abilities`.",
{ since: "DnD5e 2.2", until: "DnD5e 2.4" }
);
return Object.fromEntries(Object.entries(DND5E.abilities).map(([k, v]) => [k, v.abbreviation]));
}
});
/**
* Configure which ability score is used as the default modifier for initiative rolls.
* @type {string}
*/
DND5E.initiativeAbility = "dex";
/**
* Configure which ability score is used when calculating hit points per level.
* @type {string}
*/
DND5E.hitPointsAbility = "con";
/* -------------------------------------------- */
/**
* Configuration data for skills.
*
* @typedef {object} SkillConfiguration
* @property {string} label Localized label.
* @property {string} ability Key for the default ability used by this skill.
*/
/**
* The set of skill which can be trained with their default ability scores.
* @enum {SkillConfiguration}
*/
DND5E.skills = {
acr: { label: "DND5E.SkillAcr", ability: "dex" },
ani: { label: "DND5E.SkillAni", ability: "wis" },
arc: { label: "DND5E.SkillArc", ability: "int" },
ath: { label: "DND5E.SkillAth", ability: "str" },
dec: { label: "DND5E.SkillDec", ability: "cha" },
his: { label: "DND5E.SkillHis", ability: "int" },
ins: { label: "DND5E.SkillIns", ability: "wis" },
itm: { label: "DND5E.SkillItm", ability: "cha" },
inv: { label: "DND5E.SkillInv", ability: "int" },
med: { label: "DND5E.SkillMed", ability: "wis" },
nat: { label: "DND5E.SkillNat", ability: "int" },
prc: { label: "DND5E.SkillPrc", ability: "wis" },
prf: { label: "DND5E.SkillPrf", ability: "cha" },
per: { label: "DND5E.SkillPer", ability: "cha" },
rel: { label: "DND5E.SkillRel", ability: "int" },
slt: { label: "DND5E.SkillSlt", ability: "dex" },
ste: { label: "DND5E.SkillSte", ability: "dex" },
sur: { label: "DND5E.SkillSur", ability: "wis" }
};
preLocalize("skills", { key: "label", sort: true });
/* -------------------------------------------- */
/**
* Character alignment options.
* @enum {string}
*/
DND5E.alignments = {
lg: "DND5E.AlignmentLG",
ng: "DND5E.AlignmentNG",
cg: "DND5E.AlignmentCG",
ln: "DND5E.AlignmentLN",
tn: "DND5E.AlignmentTN",
cn: "DND5E.AlignmentCN",
le: "DND5E.AlignmentLE",
ne: "DND5E.AlignmentNE",
ce: "DND5E.AlignmentCE"
};
preLocalize("alignments");
/* -------------------------------------------- */
/**
* An enumeration of item attunement types.
* @enum {number}
*/
DND5E.attunementTypes = {
NONE: 0,
REQUIRED: 1,
ATTUNED: 2
};
/**
* An enumeration of item attunement states.
* @type {{"0": string, "1": string, "2": string}}
*/
DND5E.attunements = {
0: "DND5E.AttunementNone",
1: "DND5E.AttunementRequired",
2: "DND5E.AttunementAttuned"
};
preLocalize("attunements");
/* -------------------------------------------- */
/**
* General weapon categories.
* @enum {string}
*/
DND5E.weaponProficiencies = {
sim: "DND5E.WeaponSimpleProficiency",
mar: "DND5E.WeaponMartialProficiency"
};
preLocalize("weaponProficiencies");
/**
* A mapping between `DND5E.weaponTypes` and `DND5E.weaponProficiencies` that
* is used to determine if character has proficiency when adding an item.
* @enum {(boolean|string)}
*/
DND5E.weaponProficienciesMap = {
natural: true,
simpleM: "sim",
simpleR: "sim",
martialM: "mar",
martialR: "mar"
};
/**
* The basic weapon types in 5e. This enables specific weapon proficiencies or
* starting equipment provided by classes and backgrounds.
* @enum {string}
*/
DND5E.weaponIds = {
battleaxe: "I0WocDSuNpGJayPb",
blowgun: "wNWK6yJMHG9ANqQV",
club: "nfIRTECQIG81CvM4",
dagger: "0E565kQUBmndJ1a2",
dart: "3rCO8MTIdPGSW6IJ",
flail: "UrH3sMdnUDckIHJ6",
glaive: "rOG1OM2ihgPjOvFW",
greataxe: "1Lxk6kmoRhG8qQ0u",
greatclub: "QRCsxkCwWNwswL9o",
greatsword: "xMkP8BmFzElcsMaR",
halberd: "DMejWAc8r8YvDPP1",
handaxe: "eO7Fbv5WBk5zvGOc",
handcrossbow: "qaSro7kFhxD6INbZ",
heavycrossbow: "RmP0mYRn2J7K26rX",
javelin: "DWLMnODrnHn8IbAG",
lance: "RnuxdHUAIgxccVwj",
lightcrossbow: "ddWvQRLmnnIS0eLF",
lighthammer: "XVK6TOL4sGItssAE",
longbow: "3cymOVja8jXbzrdT",
longsword: "10ZP2Bu3vnCuYMIB",
mace: "Ajyq6nGwF7FtLhDQ",
maul: "DizirD7eqjh8n95A",
morningstar: "dX8AxCh9o0A9CkT3",
net: "aEiM49V8vWpWw7rU",
pike: "tC0kcqZT9HHAO0PD",
quarterstaff: "g2dWN7PQiMRYWzyk",
rapier: "Tobce1hexTnDk4sV",
scimitar: "fbC0Mg1a73wdFbqO",
shortsword: "osLzOwQdPtrK3rQH",
sickle: "i4NeNZ30ycwPDHMx",
spear: "OG4nBBydvmfWYXIk",
shortbow: "GJv6WkD7D2J6rP6M",
sling: "3gynWO9sN4OLGMWD",
trident: "F65ANO66ckP8FDMa",
warpick: "2YdfjN1PIIrSHZii",
warhammer: "F0Df164Xv1gWcYt0",
whip: "QKTyxoO0YDnAsbYe"
};
/* -------------------------------------------- */
/**
* The categories into which Tool items can be grouped.
*
* @enum {string}
*/
DND5E.toolTypes = {
art: "DND5E.ToolArtisans",
game: "DND5E.ToolGamingSet",
music: "DND5E.ToolMusicalInstrument"
};
preLocalize("toolTypes", { sort: true });
/**
* The categories of tool proficiencies that a character can gain.
*
* @enum {string}
*/
DND5E.toolProficiencies = {
...DND5E.toolTypes,
vehicle: "DND5E.ToolVehicle"
};
preLocalize("toolProficiencies", { sort: true });
/**
* The basic tool types in 5e. This enables specific tool proficiencies or
* starting equipment provided by classes and backgrounds.
* @enum {string}
*/
DND5E.toolIds = {
alchemist: "SztwZhbhZeCqyAes",
bagpipes: "yxHi57T5mmVt0oDr",
brewer: "Y9S75go1hLMXUD48",
calligrapher: "jhjo20QoiD5exf09",
card: "YwlHI3BVJapz4a3E",
carpenter: "8NS6MSOdXtUqD7Ib",
cartographer: "fC0lFK8P4RuhpfaU",
chess: "23y8FvWKf9YLcnBL",
cobbler: "hM84pZnpCqKfi8XH",
cook: "Gflnp29aEv5Lc1ZM",
dice: "iBuTM09KD9IoM5L8",
disg: "IBhDAr7WkhWPYLVn",
drum: "69Dpr25pf4BjkHKb",
dulcimer: "NtdDkjmpdIMiX7I2",
flute: "eJOrPcAz9EcquyRQ",
forg: "cG3m4YlHfbQlLEOx",
glassblower: "rTbVrNcwApnuTz5E",
herb: "i89okN7GFTWHsvPy",
horn: "aa9KuBy4dst7WIW9",
jeweler: "YfBwELTgPFHmQdHh",
leatherworker: "PUMfwyVUbtyxgYbD",
lute: "qBydtUUIkv520DT7",
lyre: "EwG1EtmbgR3bM68U",
mason: "skUih6tBvcBbORzA",
navg: "YHCmjsiXxZ9UdUhU",
painter: "ccm5xlWhx74d6lsK",
panflute: "G5m5gYIx9VAUWC3J",
pois: "il2GNi8C0DvGLL9P",
potter: "hJS8yEVkqgJjwfWa",
shawm: "G3cqbejJpfB91VhP",
smith: "KndVe2insuctjIaj",
thief: "woWZ1sO5IUVGzo58",
tinker: "0d08g1i5WXnNrCNA",
viol: "baoe3U5BfMMMxhCU",
weaver: "ap9prThUB2y9lDyj",
woodcarver: "xKErqkLo4ASYr5EP"
};
/* -------------------------------------------- */
/**
* Time periods that accept a numeric value.
* @enum {string}
*/
DND5E.scalarTimePeriods = {
turn: "DND5E.TimeTurn",
round: "DND5E.TimeRound",
minute: "DND5E.TimeMinute",
hour: "DND5E.TimeHour",
day: "DND5E.TimeDay",
month: "DND5E.TimeMonth",
year: "DND5E.TimeYear"
};
preLocalize("scalarTimePeriods");
/* -------------------------------------------- */
/**
* Time periods for spells that don't have a defined ending.
* @enum {string}
*/
DND5E.permanentTimePeriods = {
disp: "DND5E.TimeDisp",
dstr: "DND5E.TimeDispTrig",
perm: "DND5E.TimePerm"
};
preLocalize("permanentTimePeriods");
/* -------------------------------------------- */
/**
* Time periods that don't accept a numeric value.
* @enum {string}
*/
DND5E.specialTimePeriods = {
inst: "DND5E.TimeInst",
spec: "DND5E.Special"
};
preLocalize("specialTimePeriods");
/* -------------------------------------------- */
/**
* The various lengths of time over which effects can occur.
* @enum {string}
*/
DND5E.timePeriods = {
...DND5E.specialTimePeriods,
...DND5E.permanentTimePeriods,
...DND5E.scalarTimePeriods
};
preLocalize("timePeriods");
/* -------------------------------------------- */
/**
* Various ways in which an item or ability can be activated.
* @enum {string}
*/
DND5E.abilityActivationTypes = {
action: "DND5E.Action",
bonus: "DND5E.BonusAction",
reaction: "DND5E.Reaction",
minute: DND5E.timePeriods.minute,
hour: DND5E.timePeriods.hour,
day: DND5E.timePeriods.day,
special: DND5E.timePeriods.spec,
legendary: "DND5E.LegendaryActionLabel",
mythic: "DND5E.MythicActionLabel",
lair: "DND5E.LairActionLabel",
crew: "DND5E.VehicleCrewAction"
};
preLocalize("abilityActivationTypes");
/* -------------------------------------------- */
/**
* Different things that an ability can consume upon use.
* @enum {string}
*/
DND5E.abilityConsumptionTypes = {
ammo: "DND5E.ConsumeAmmunition",
attribute: "DND5E.ConsumeAttribute",
hitDice: "DND5E.ConsumeHitDice",
material: "DND5E.ConsumeMaterial",
charges: "DND5E.ConsumeCharges"
};
preLocalize("abilityConsumptionTypes", { sort: true });
/* -------------------------------------------- */
/**
* Creature sizes.
* @enum {string}
*/
DND5E.actorSizes = {
tiny: "DND5E.SizeTiny",
sm: "DND5E.SizeSmall",
med: "DND5E.SizeMedium",
lg: "DND5E.SizeLarge",
huge: "DND5E.SizeHuge",
grg: "DND5E.SizeGargantuan"
};
preLocalize("actorSizes");
/**
* Default token image size for the values of `DND5E.actorSizes`.
* @enum {number}
*/
DND5E.tokenSizes = {
tiny: 0.5,
sm: 1,
med: 1,
lg: 2,
huge: 3,
grg: 4
};
/**
* Colors used to visualize temporary and temporary maximum HP in token health bars.
* @enum {number}
*/
DND5E.tokenHPColors = {
damage: 0xFF0000,
healing: 0x00FF00,
temp: 0x66CCFF,
tempmax: 0x440066,
negmax: 0x550000
};
/* -------------------------------------------- */
/**
* Default types of creatures.
* *Note: Not pre-localized to allow for easy fetching of pluralized forms.*
* @enum {string}
*/
DND5E.creatureTypes = {
aberration: "DND5E.CreatureAberration",
beast: "DND5E.CreatureBeast",
celestial: "DND5E.CreatureCelestial",
construct: "DND5E.CreatureConstruct",
dragon: "DND5E.CreatureDragon",
elemental: "DND5E.CreatureElemental",
fey: "DND5E.CreatureFey",
fiend: "DND5E.CreatureFiend",
giant: "DND5E.CreatureGiant",
humanoid: "DND5E.CreatureHumanoid",
monstrosity: "DND5E.CreatureMonstrosity",
ooze: "DND5E.CreatureOoze",
plant: "DND5E.CreaturePlant",
undead: "DND5E.CreatureUndead"
};
/* -------------------------------------------- */
/**
* Classification types for item action types.
* @enum {string}
*/
DND5E.itemActionTypes = {
mwak: "DND5E.ActionMWAK",
rwak: "DND5E.ActionRWAK",
msak: "DND5E.ActionMSAK",
rsak: "DND5E.ActionRSAK",
save: "DND5E.ActionSave",
heal: "DND5E.ActionHeal",
abil: "DND5E.ActionAbil",
util: "DND5E.ActionUtil",
other: "DND5E.ActionOther"
};
preLocalize("itemActionTypes");
/* -------------------------------------------- */
/**
* Different ways in which item capacity can be limited.
* @enum {string}
*/
DND5E.itemCapacityTypes = {
items: "DND5E.ItemContainerCapacityItems",
weight: "DND5E.ItemContainerCapacityWeight"
};
preLocalize("itemCapacityTypes", { sort: true });
/* -------------------------------------------- */
/**
* List of various item rarities.
* @enum {string}
*/
DND5E.itemRarity = {
common: "DND5E.ItemRarityCommon",
uncommon: "DND5E.ItemRarityUncommon",
rare: "DND5E.ItemRarityRare",
veryRare: "DND5E.ItemRarityVeryRare",
legendary: "DND5E.ItemRarityLegendary",
artifact: "DND5E.ItemRarityArtifact"
};
preLocalize("itemRarity");
/* -------------------------------------------- */
/**
* Enumerate the lengths of time over which an item can have limited use ability.
* @enum {string}
*/
DND5E.limitedUsePeriods = {
sr: "DND5E.ShortRest",
lr: "DND5E.LongRest",
day: "DND5E.Day",
charges: "DND5E.Charges"
};
preLocalize("limitedUsePeriods");
/* -------------------------------------------- */
/**
* Specific equipment types that modify base AC.
* @enum {string}
*/
DND5E.armorTypes = {
light: "DND5E.EquipmentLight",
medium: "DND5E.EquipmentMedium",
heavy: "DND5E.EquipmentHeavy",
natural: "DND5E.EquipmentNatural",
shield: "DND5E.EquipmentShield"
};
preLocalize("armorTypes");
/* -------------------------------------------- */
/**
* Equipment types that aren't armor.
* @enum {string}
*/
DND5E.miscEquipmentTypes = {
clothing: "DND5E.EquipmentClothing",
trinket: "DND5E.EquipmentTrinket",
vehicle: "DND5E.EquipmentVehicle"
};
preLocalize("miscEquipmentTypes", { sort: true });
/* -------------------------------------------- */
/**
* The set of equipment types for armor, clothing, and other objects which can be worn by the character.
* @enum {string}
*/
DND5E.equipmentTypes = {
...DND5E.miscEquipmentTypes,
...DND5E.armorTypes
};
preLocalize("equipmentTypes", { sort: true });
/* -------------------------------------------- */
/**
* The various types of vehicles in which characters can be proficient.
* @enum {string}
*/
DND5E.vehicleTypes = {
air: "DND5E.VehicleTypeAir",
land: "DND5E.VehicleTypeLand",
space: "DND5E.VehicleTypeSpace",
water: "DND5E.VehicleTypeWater"
};
preLocalize("vehicleTypes", { sort: true });
/* -------------------------------------------- */
/**
* The set of Armor Proficiencies which a character may have.
* @type {object}
*/
DND5E.armorProficiencies = {
lgt: DND5E.equipmentTypes.light,
med: DND5E.equipmentTypes.medium,
hvy: DND5E.equipmentTypes.heavy,
shl: "DND5E.EquipmentShieldProficiency"
};
preLocalize("armorProficiencies");
/**
* A mapping between `DND5E.equipmentTypes` and `DND5E.armorProficiencies` that
* is used to determine if character has proficiency when adding an item.
* @enum {(boolean|string)}
*/
DND5E.armorProficienciesMap = {
natural: true,
clothing: true,
light: "lgt",
medium: "med",
heavy: "hvy",
shield: "shl"
};
/**
* The basic armor types in 5e. This enables specific armor proficiencies,
* automated AC calculation in NPCs, and starting equipment.
* @enum {string}
*/
DND5E.armorIds = {
breastplate: "SK2HATQ4abKUlV8i",
chainmail: "rLMflzmxpe8JGTOA",
chainshirt: "p2zChy24ZJdVqMSH",
halfplate: "vsgmACFYINloIdPm",
hide: "n1V07puo0RQxPGuF",
leather: "WwdpHLXGX5r8uZu5",
padded: "GtKV1b5uqFQqpEni",
plate: "OjkIqlW2UpgFcjZa",
ringmail: "nsXZejlmgalj4he9",
scalemail: "XmnlF5fgIO3tg6TG",
splint: "cKpJmsJmU8YaiuqG",
studded: "TIV3B1vbrVHIhQAm"
};
/**
* The basic shield in 5e.
* @enum {string}
*/
DND5E.shieldIds = {
shield: "sSs3hSzkKBMNBgTs"
};
/**
* Common armor class calculations.
* @enum {{ label: string, [formula]: string }}
*/
DND5E.armorClasses = {
flat: {
label: "DND5E.ArmorClassFlat",
formula: "@attributes.ac.flat"
},
natural: {
label: "DND5E.ArmorClassNatural",
formula: "@attributes.ac.flat"
},
default: {
label: "DND5E.ArmorClassEquipment",
formula: "@attributes.ac.armor + @attributes.ac.dex"
},
mage: {
label: "DND5E.ArmorClassMage",
formula: "13 + @abilities.dex.mod"
},
draconic: {
label: "DND5E.ArmorClassDraconic",
formula: "13 + @abilities.dex.mod"
},
unarmoredMonk: {
label: "DND5E.ArmorClassUnarmoredMonk",
formula: "10 + @abilities.dex.mod + @abilities.wis.mod"
},
unarmoredBarb: {
label: "DND5E.ArmorClassUnarmoredBarbarian",
formula: "10 + @abilities.dex.mod + @abilities.con.mod"
},
custom: {
label: "DND5E.ArmorClassCustom"
}
};
preLocalize("armorClasses", { key: "label" });
/* -------------------------------------------- */
/**
* Enumerate the valid consumable types which are recognized by the system.
* @enum {string}
*/
DND5E.consumableTypes = {
ammo: "DND5E.ConsumableAmmo",
potion: "DND5E.ConsumablePotion",
poison: "DND5E.ConsumablePoison",
food: "DND5E.ConsumableFood",
scroll: "DND5E.ConsumableScroll",
wand: "DND5E.ConsumableWand",
rod: "DND5E.ConsumableRod",
trinket: "DND5E.ConsumableTrinket"
};
preLocalize("consumableTypes", { sort: true });
/* -------------------------------------------- */
/**
* Configuration data for an item with the "feature" type.
*
* @typedef {object} FeatureTypeConfiguration
* @property {string} label Localized label for this type.
* @property {Object} [subtypes] Enum containing localized labels for subtypes.
*/
/**
* Types of "features" items.
* @enum {FeatureTypeConfiguration}
*/
DND5E.featureTypes = {
background: {
label: "DND5E.Feature.Background"
},
class: {
label: "DND5E.Feature.Class",
subtypes: {
artificerInfusion: "DND5E.ClassFeature.ArtificerInfusion",
channelDivinity: "DND5E.ClassFeature.ChannelDivinity",
defensiveTactic: "DND5E.ClassFeature.DefensiveTactic",
eldritchInvocation: "DND5E.ClassFeature.EldritchInvocation",
elementalDiscipline: "DND5E.ClassFeature.ElementalDiscipline",
fightingStyle: "DND5E.ClassFeature.FightingStyle",
huntersPrey: "DND5E.ClassFeature.HuntersPrey",
ki: "DND5E.ClassFeature.Ki",
maneuver: "DND5E.ClassFeature.Maneuver",
metamagic: "DND5E.ClassFeature.Metamagic",
multiattack: "DND5E.ClassFeature.Multiattack",
pact: "DND5E.ClassFeature.PactBoon",
psionicPower: "DND5E.ClassFeature.PsionicPower",
rune: "DND5E.ClassFeature.Rune",
superiorHuntersDefense: "DND5E.ClassFeature.SuperiorHuntersDefense"
}
},
monster: {
label: "DND5E.Feature.Monster"
},
race: {
label: "DND5E.Feature.Race"
},
feat: {
label: "DND5E.Feature.Feat"
}
};
preLocalize("featureTypes", { key: "label" });
preLocalize("featureTypes.class.subtypes", { sort: true });
/* -------------------------------------------- */
/**
* @typedef {object} CurrencyConfiguration
* @property {string} label Localized label for the currency.
* @property {string} abbreviation Localized abbreviation for the currency.
* @property {number} conversion Number by which this currency should be multiplied to arrive at a standard value.
*/
/**
* The valid currency denominations with localized labels, abbreviations, and conversions.
* The conversion number defines how many of that currency are equal to one GP.
* @enum {CurrencyConfiguration}
*/
DND5E.currencies = {
pp: {
label: "DND5E.CurrencyPP",
abbreviation: "DND5E.CurrencyAbbrPP",
conversion: 0.1
},
gp: {
label: "DND5E.CurrencyGP",
abbreviation: "DND5E.CurrencyAbbrGP",
conversion: 1
},
ep: {
label: "DND5E.CurrencyEP",
abbreviation: "DND5E.CurrencyAbbrEP",
conversion: 2
},
sp: {
label: "DND5E.CurrencySP",
abbreviation: "DND5E.CurrencyAbbrSP",
conversion: 10
},
cp: {
label: "DND5E.CurrencyCP",
abbreviation: "DND5E.CurrencyAbbrCP",
conversion: 100
}
};
preLocalize("currencies", { keys: ["label", "abbreviation"] });
/* -------------------------------------------- */
/* Damage Types */
/* -------------------------------------------- */
/**
* Types of damage that are considered physical.
* @enum {string}
*/
DND5E.physicalDamageTypes = {
bludgeoning: "DND5E.DamageBludgeoning",
piercing: "DND5E.DamagePiercing",
slashing: "DND5E.DamageSlashing"
};
preLocalize("physicalDamageTypes", { sort: true });
/* -------------------------------------------- */
/**
* Types of damage the can be caused by abilities.
* @enum {string}
*/
DND5E.damageTypes = {
...DND5E.physicalDamageTypes,
acid: "DND5E.DamageAcid",
cold: "DND5E.DamageCold",
fire: "DND5E.DamageFire",
force: "DND5E.DamageForce",
lightning: "DND5E.DamageLightning",
necrotic: "DND5E.DamageNecrotic",
poison: "DND5E.DamagePoison",
psychic: "DND5E.DamagePsychic",
radiant: "DND5E.DamageRadiant",
thunder: "DND5E.DamageThunder"
};
preLocalize("damageTypes", { sort: true });
/* -------------------------------------------- */
/**
* Types of damage to which an actor can possess resistance, immunity, or vulnerability.
* @enum {string}
* @deprecated
*/
DND5E.damageResistanceTypes = {
...DND5E.damageTypes,
physical: "DND5E.DamagePhysical"
};
preLocalize("damageResistanceTypes", { sort: true });
/* -------------------------------------------- */
/* Movement */
/* -------------------------------------------- */
/**
* Different types of healing that can be applied using abilities.
* @enum {string}
*/
DND5E.healingTypes = {
healing: "DND5E.Healing",
temphp: "DND5E.HealingTemp"
};
preLocalize("healingTypes");
/* -------------------------------------------- */
/**
* The valid units of measure for movement distances in the game system.
* By default this uses the imperial units of feet and miles.
* @enum {string}
*/
DND5E.movementTypes = {
burrow: "DND5E.MovementBurrow",
climb: "DND5E.MovementClimb",
fly: "DND5E.MovementFly",
swim: "DND5E.MovementSwim",
walk: "DND5E.MovementWalk"
};
preLocalize("movementTypes", { sort: true });
/* -------------------------------------------- */
/* Measurement */
/* -------------------------------------------- */
/**
* The valid units of measure for movement distances in the game system.
* By default this uses the imperial units of feet and miles.
* @enum {string}
*/
DND5E.movementUnits = {
ft: "DND5E.DistFt",
mi: "DND5E.DistMi",
m: "DND5E.DistM",
km: "DND5E.DistKm"
};
preLocalize("movementUnits");
/* -------------------------------------------- */
/**
* The types of range that are used for measuring actions and effects.
* @enum {string}
*/
DND5E.rangeTypes = {
self: "DND5E.DistSelf",
touch: "DND5E.DistTouch",
spec: "DND5E.Special",
any: "DND5E.DistAny"
};
preLocalize("rangeTypes");
/* -------------------------------------------- */
/**
* The valid units of measure for the range of an action or effect. A combination of `DND5E.movementUnits` and
* `DND5E.rangeUnits`.
* @enum {string}
*/
DND5E.distanceUnits = {
...DND5E.movementUnits,
...DND5E.rangeTypes
};
preLocalize("distanceUnits");
/* -------------------------------------------- */
/**
* Configure aspects of encumbrance calculation so that it could be configured by modules.
* @enum {{ imperial: number, metric: number }}
*/
DND5E.encumbrance = {
currencyPerWeight: {
imperial: 50,
metric: 110
},
strMultiplier: {
imperial: 15,
metric: 6.8
},
vehicleWeightMultiplier: {
imperial: 2000, // 2000 lbs in an imperial ton
metric: 1000 // 1000 kg in a metric ton
}
};
/* -------------------------------------------- */
/* Targeting */
/* -------------------------------------------- */
/**
* Targeting types that apply to one or more distinct targets.
* @enum {string}
*/
DND5E.individualTargetTypes = {
self: "DND5E.TargetSelf",
ally: "DND5E.TargetAlly",
enemy: "DND5E.TargetEnemy",
creature: "DND5E.TargetCreature",
object: "DND5E.TargetObject",
space: "DND5E.TargetSpace"
};
preLocalize("individualTargetTypes");
/* -------------------------------------------- */
/**
* Information needed to represent different area of effect target types.
*
* @typedef {object} AreaTargetDefinition
* @property {string} label Localized label for this type.
* @property {string} template Type of `MeasuredTemplate` create for this target type.
*/
/**
* Targeting types that cover an area.
* @enum {AreaTargetDefinition}
*/
DND5E.areaTargetTypes = {
radius: {
label: "DND5E.TargetRadius",
template: "circle"
},
sphere: {
label: "DND5E.TargetSphere",
template: "circle"
},
cylinder: {
label: "DND5E.TargetCylinder",
template: "circle"
},
cone: {
label: "DND5E.TargetCone",
template: "cone"
},
square: {
label: "DND5E.TargetSquare",
template: "rect"
},
cube: {
label: "DND5E.TargetCube",
template: "rect"
},
line: {
label: "DND5E.TargetLine",
template: "ray"
},
wall: {
label: "DND5E.TargetWall",
template: "ray"
}
};
preLocalize("areaTargetTypes", { key: "label", sort: true });
/* -------------------------------------------- */
/**
* The types of single or area targets which can be applied to abilities.
* @enum {string}
*/
DND5E.targetTypes = {
...DND5E.individualTargetTypes,
...Object.fromEntries(Object.entries(DND5E.areaTargetTypes).map(([k, v]) => [k, v.label]))
};
preLocalize("targetTypes", { sort: true });
/* -------------------------------------------- */
/**
* Denominations of hit dice which can apply to classes.
* @type {string[]}
*/
DND5E.hitDieTypes = ["d4", "d6", "d8", "d10", "d12"];
/* -------------------------------------------- */
/**
* The set of possible sensory perception types which an Actor may have.
* @enum {string}
*/
DND5E.senses = {
blindsight: "DND5E.SenseBlindsight",
darkvision: "DND5E.SenseDarkvision",
tremorsense: "DND5E.SenseTremorsense",
truesight: "DND5E.SenseTruesight"
};
preLocalize("senses", { sort: true });
/* -------------------------------------------- */
/* Spellcasting */
/* -------------------------------------------- */
/**
* Define the standard slot progression by character level.
* The entries of this array represent the spell slot progression for a full spell-caster.
* @type {number[][]}
*/
DND5E.SPELL_SLOT_TABLE = [
[2],
[3],
[4, 2],
[4, 3],
[4, 3, 2],
[4, 3, 3],
[4, 3, 3, 1],
[4, 3, 3, 2],
[4, 3, 3, 3, 1],
[4, 3, 3, 3, 2],
[4, 3, 3, 3, 2, 1],
[4, 3, 3, 3, 2, 1],
[4, 3, 3, 3, 2, 1, 1],
[4, 3, 3, 3, 2, 1, 1],
[4, 3, 3, 3, 2, 1, 1, 1],
[4, 3, 3, 3, 2, 1, 1, 1],
[4, 3, 3, 3, 2, 1, 1, 1, 1],
[4, 3, 3, 3, 3, 1, 1, 1, 1],
[4, 3, 3, 3, 3, 2, 1, 1, 1],
[4, 3, 3, 3, 3, 2, 2, 1, 1]
];
/* -------------------------------------------- */
/**
* Various different ways a spell can be prepared.
*/
DND5E.spellPreparationModes = {
prepared: "DND5E.SpellPrepPrepared",
pact: "DND5E.PactMagic",
always: "DND5E.SpellPrepAlways",
atwill: "DND5E.SpellPrepAtWill",
innate: "DND5E.SpellPrepInnate"
};
preLocalize("spellPreparationModes");
/* -------------------------------------------- */
/**
* Subset of `DND5E.spellPreparationModes` that consume spell slots.
* @type {boolean[]}
*/
DND5E.spellUpcastModes = ["always", "pact", "prepared"];
/* -------------------------------------------- */
/**
* Configuration data for different types of spellcasting supported.
*
* @typedef {object} SpellcastingTypeConfiguration
* @property {string} label Localized label.
* @property {Object} [progression] Any progression modes for this type.
*/
/**
* Configuration data for a spellcasting progression mode.
*
* @typedef {object} SpellcastingProgressionConfiguration
* @property {string} label Localized label.
* @property {number} [divisor=1] Value by which the class levels are divided to determine spellcasting level.
* @property {boolean} [roundUp=false] Should fractional values should be rounded up by default?
*/
/**
* Different spellcasting types and their progression.
* @type {SpellcastingTypeConfiguration}
*/
DND5E.spellcastingTypes = {
leveled: {
label: "DND5E.SpellProgLeveled",
progression: {
full: {
label: "DND5E.SpellProgFull",
divisor: 1
},
half: {
label: "DND5E.SpellProgHalf",
divisor: 2
},
third: {
label: "DND5E.SpellProgThird",
divisor: 3
},
artificer: {
label: "DND5E.SpellProgArt",
divisor: 2,
roundUp: true
}
}
},
pact: {
label: "DND5E.SpellProgPact"
}
};
preLocalize("spellcastingTypes", { key: "label", sort: true });
preLocalize("spellcastingTypes.leveled.progression", { key: "label" });
/* -------------------------------------------- */
/**
* Ways in which a class can contribute to spellcasting levels.
* @enum {string}
*/
DND5E.spellProgression = {
none: "DND5E.SpellNone",
full: "DND5E.SpellProgFull",
half: "DND5E.SpellProgHalf",
third: "DND5E.SpellProgThird",
pact: "DND5E.SpellProgPact",
artificer: "DND5E.SpellProgArt"
};
preLocalize("spellProgression", { key: "label" });
/* -------------------------------------------- */
/**
* Valid spell levels.
* @enum {string}
*/
DND5E.spellLevels = {
0: "DND5E.SpellLevel0",
1: "DND5E.SpellLevel1",
2: "DND5E.SpellLevel2",
3: "DND5E.SpellLevel3",
4: "DND5E.SpellLevel4",
5: "DND5E.SpellLevel5",
6: "DND5E.SpellLevel6",
7: "DND5E.SpellLevel7",
8: "DND5E.SpellLevel8",
9: "DND5E.SpellLevel9"
};
preLocalize("spellLevels");
/* -------------------------------------------- */
/**
* The available choices for how spell damage scaling may be computed.
* @enum {string}
*/
DND5E.spellScalingModes = {
none: "DND5E.SpellNone",
cantrip: "DND5E.SpellCantrip",
level: "DND5E.SpellLevel"
};
preLocalize("spellScalingModes", { sort: true });
/* -------------------------------------------- */
/**
* Types of components that can be required when casting a spell.
* @enum {object}
*/
DND5E.spellComponents = {
vocal: {
label: "DND5E.ComponentVerbal",
abbr: "DND5E.ComponentVerbalAbbr"
},
somatic: {
label: "DND5E.ComponentSomatic",
abbr: "DND5E.ComponentSomaticAbbr"
},
material: {
label: "DND5E.ComponentMaterial",
abbr: "DND5E.ComponentMaterialAbbr"
}
};
preLocalize("spellComponents", {keys: ["label", "abbr"]});
/* -------------------------------------------- */
/**
* Supplementary rules keywords that inform a spell's use.
* @enum {object}
*/
DND5E.spellTags = {
concentration: {
label: "DND5E.Concentration",
abbr: "DND5E.ConcentrationAbbr"
},
ritual: {
label: "DND5E.Ritual",
abbr: "DND5E.RitualAbbr"
}
};
preLocalize("spellTags", {keys: ["label", "abbr"]});
/* -------------------------------------------- */
/**
* Schools to which a spell can belong.
* @enum {string}
*/
DND5E.spellSchools = {
abj: "DND5E.SchoolAbj",
con: "DND5E.SchoolCon",
div: "DND5E.SchoolDiv",
enc: "DND5E.SchoolEnc",
evo: "DND5E.SchoolEvo",
ill: "DND5E.SchoolIll",
nec: "DND5E.SchoolNec",
trs: "DND5E.SchoolTrs"
};
preLocalize("spellSchools", { sort: true });
/* -------------------------------------------- */
/**
* Spell scroll item ID within the `DND5E.sourcePacks` compendium for each level.
* @enum {string}
*/
DND5E.spellScrollIds = {
0: "rQ6sO7HDWzqMhSI3",
1: "9GSfMg0VOA2b4uFN",
2: "XdDp6CKh9qEvPTuS",
3: "hqVKZie7x9w3Kqds",
4: "DM7hzgL836ZyUFB1",
5: "wa1VF8TXHmkrrR35",
6: "tI3rWx4bxefNCexS",
7: "mtyw4NS1s7j2EJaD",
8: "aOrinPg7yuDZEuWr",
9: "O4YbkJkLlnsgUszZ"
};
/* -------------------------------------------- */
/* Weapon Details */
/* -------------------------------------------- */
/**
* The set of types which a weapon item can take.
* @enum {string}
*/
DND5E.weaponTypes = {
simpleM: "DND5E.WeaponSimpleM",
simpleR: "DND5E.WeaponSimpleR",
martialM: "DND5E.WeaponMartialM",
martialR: "DND5E.WeaponMartialR",
natural: "DND5E.WeaponNatural",
improv: "DND5E.WeaponImprov",
siege: "DND5E.WeaponSiege"
};
preLocalize("weaponTypes");
/* -------------------------------------------- */
/**
* A subset of weapon properties that determine the physical characteristics of the weapon.
* These properties are used for determining physical resistance bypasses.
* @enum {string}
*/
DND5E.physicalWeaponProperties = {
ada: "DND5E.WeaponPropertiesAda",
mgc: "DND5E.WeaponPropertiesMgc",
sil: "DND5E.WeaponPropertiesSil"
};
preLocalize("physicalWeaponProperties", { sort: true });
/* -------------------------------------------- */
/**
* The set of weapon property flags which can exist on a weapon.
* @enum {string}
*/
DND5E.weaponProperties = {
...DND5E.physicalWeaponProperties,
amm: "DND5E.WeaponPropertiesAmm",
fin: "DND5E.WeaponPropertiesFin",
fir: "DND5E.WeaponPropertiesFir",
foc: "DND5E.WeaponPropertiesFoc",
hvy: "DND5E.WeaponPropertiesHvy",
lgt: "DND5E.WeaponPropertiesLgt",
lod: "DND5E.WeaponPropertiesLod",
rch: "DND5E.WeaponPropertiesRch",
rel: "DND5E.WeaponPropertiesRel",
ret: "DND5E.WeaponPropertiesRet",
spc: "DND5E.WeaponPropertiesSpc",
thr: "DND5E.WeaponPropertiesThr",
two: "DND5E.WeaponPropertiesTwo",
ver: "DND5E.WeaponPropertiesVer"
};
preLocalize("weaponProperties", { sort: true });
/* -------------------------------------------- */
/**
* Compendium packs used for localized items.
* @enum {string}
*/
DND5E.sourcePacks = {
ITEMS: "dnd5e.items"
};
/* -------------------------------------------- */
/**
* Settings to configure how actors are merged when polymorphing is applied.
* @enum {string}
*/
DND5E.polymorphSettings = {
keepPhysical: "DND5E.PolymorphKeepPhysical",
keepMental: "DND5E.PolymorphKeepMental",
keepSaves: "DND5E.PolymorphKeepSaves",
keepSkills: "DND5E.PolymorphKeepSkills",
mergeSaves: "DND5E.PolymorphMergeSaves",
mergeSkills: "DND5E.PolymorphMergeSkills",
keepClass: "DND5E.PolymorphKeepClass",
keepFeats: "DND5E.PolymorphKeepFeats",
keepSpells: "DND5E.PolymorphKeepSpells",
keepItems: "DND5E.PolymorphKeepItems",
keepBio: "DND5E.PolymorphKeepBio",
keepVision: "DND5E.PolymorphKeepVision",
keepSelf: "DND5E.PolymorphKeepSelf"
};
preLocalize("polymorphSettings", { sort: true });
/**
* Settings to configure how actors are effects are merged when polymorphing is applied.
* @enum {string}
*/
DND5E.polymorphEffectSettings = {
keepAE: "DND5E.PolymorphKeepAE",
keepOtherOriginAE: "DND5E.PolymorphKeepOtherOriginAE",
keepOriginAE: "DND5E.PolymorphKeepOriginAE",
keepEquipmentAE: "DND5E.PolymorphKeepEquipmentAE",
keepFeatAE: "DND5E.PolymorphKeepFeatureAE",
keepSpellAE: "DND5E.PolymorphKeepSpellAE",
keepClassAE: "DND5E.PolymorphKeepClassAE",
keepBackgroundAE: "DND5E.PolymorphKeepBackgroundAE"
};
preLocalize("polymorphEffectSettings", { sort: true });
/**
* Settings to configure how actors are merged when preset polymorphing is applied.
* @enum {object}
*/
DND5E.transformationPresets = {
wildshape: {
icon: ' ',
label: "DND5E.PolymorphWildShape",
options: {
keepBio: true,
keepClass: true,
keepMental: true,
mergeSaves: true,
mergeSkills: true,
keepEquipmentAE: false
}
},
polymorph: {
icon: ' ',
label: "DND5E.Polymorph",
options: {
keepEquipmentAE: false,
keepClassAE: false,
keepFeatAE: false,
keepBackgroundAE: false
}
},
polymorphSelf: {
icon: ' ',
label: "DND5E.PolymorphSelf",
options: {
keepSelf: true
}
}
};
preLocalize("transformationPresets", { sort: true, keys: ["label"] });
/* -------------------------------------------- */
/**
* Skill, ability, and tool proficiency levels.
* The key for each level represents its proficiency multiplier.
* @enum {string}
*/
DND5E.proficiencyLevels = {
0: "DND5E.NotProficient",
1: "DND5E.Proficient",
0.5: "DND5E.HalfProficient",
2: "DND5E.Expertise"
};
preLocalize("proficiencyLevels");
/* -------------------------------------------- */
/**
* The amount of cover provided by an object. In cases where multiple pieces
* of cover are in play, we take the highest value.
* @enum {string}
*/
DND5E.cover = {
0: "DND5E.None",
.5: "DND5E.CoverHalf",
.75: "DND5E.CoverThreeQuarters",
1: "DND5E.CoverTotal"
};
preLocalize("cover");
/* -------------------------------------------- */
/**
* A selection of actor attributes that can be tracked on token resource bars.
* @type {string[]}
* @deprecated since v10
*/
DND5E.trackableAttributes = [
"attributes.ac.value", "attributes.init.bonus", "attributes.movement", "attributes.senses", "attributes.spelldc",
"attributes.spellLevel", "details.cr", "details.spellLevel", "details.xp.value", "skills.*.passive",
"abilities.*.value"
];
/* -------------------------------------------- */
/**
* A selection of actor and item attributes that are valid targets for item resource consumption.
* @type {string[]}
*/
DND5E.consumableResources = [
"item.quantity", "item.weight", "item.duration.value", "currency", "details.xp.value", "abilities.*.value",
"attributes.senses", "attributes.movement", "attributes.ac.flat", "item.armor.value", "item.target", "item.range",
"item.save.dc"
];
/* -------------------------------------------- */
/**
* Conditions that can affect an actor.
* @enum {string}
*/
DND5E.conditionTypes = {
blinded: "DND5E.ConBlinded",
charmed: "DND5E.ConCharmed",
deafened: "DND5E.ConDeafened",
diseased: "DND5E.ConDiseased",
exhaustion: "DND5E.ConExhaustion",
frightened: "DND5E.ConFrightened",
grappled: "DND5E.ConGrappled",
incapacitated: "DND5E.ConIncapacitated",
invisible: "DND5E.ConInvisible",
paralyzed: "DND5E.ConParalyzed",
petrified: "DND5E.ConPetrified",
poisoned: "DND5E.ConPoisoned",
prone: "DND5E.ConProne",
restrained: "DND5E.ConRestrained",
stunned: "DND5E.ConStunned",
unconscious: "DND5E.ConUnconscious"
};
preLocalize("conditionTypes", { sort: true });
/**
* Languages a character can learn.
* @enum {string}
*/
DND5E.languages = {
common: "DND5E.LanguagesCommon",
aarakocra: "DND5E.LanguagesAarakocra",
abyssal: "DND5E.LanguagesAbyssal",
aquan: "DND5E.LanguagesAquan",
auran: "DND5E.LanguagesAuran",
celestial: "DND5E.LanguagesCelestial",
deep: "DND5E.LanguagesDeepSpeech",
draconic: "DND5E.LanguagesDraconic",
druidic: "DND5E.LanguagesDruidic",
dwarvish: "DND5E.LanguagesDwarvish",
elvish: "DND5E.LanguagesElvish",
giant: "DND5E.LanguagesGiant",
gith: "DND5E.LanguagesGith",
gnomish: "DND5E.LanguagesGnomish",
goblin: "DND5E.LanguagesGoblin",
gnoll: "DND5E.LanguagesGnoll",
halfling: "DND5E.LanguagesHalfling",
ignan: "DND5E.LanguagesIgnan",
infernal: "DND5E.LanguagesInfernal",
orc: "DND5E.LanguagesOrc",
primordial: "DND5E.LanguagesPrimordial",
sylvan: "DND5E.LanguagesSylvan",
terran: "DND5E.LanguagesTerran",
cant: "DND5E.LanguagesThievesCant",
undercommon: "DND5E.LanguagesUndercommon"
};
preLocalize("languages", { sort: true });
/**
* Maximum allowed character level.
* @type {number}
*/
DND5E.maxLevel = 20;
/**
* XP required to achieve each character level.
* @type {number[]}
*/
DND5E.CHARACTER_EXP_LEVELS = [
0, 300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, 85000, 100000,
120000, 140000, 165000, 195000, 225000, 265000, 305000, 355000
];
/**
* XP granted for each challenge rating.
* @type {number[]}
*/
DND5E.CR_EXP_LEVELS = [
10, 200, 450, 700, 1100, 1800, 2300, 2900, 3900, 5000, 5900, 7200, 8400, 10000, 11500, 13000, 15000, 18000,
20000, 22000, 25000, 33000, 41000, 50000, 62000, 75000, 90000, 105000, 120000, 135000, 155000
];
/**
* @typedef {object} CharacterFlagConfig
* @property {string} name
* @property {string} hint
* @property {string} section
* @property {typeof boolean|string|number} type
* @property {string} placeholder
* @property {string[]} [abilities]
* @property {Object} [choices]
* @property {string[]} [skills]
*/
/* -------------------------------------------- */
/**
* Trait configuration information.
*
* @typedef {object} TraitConfiguration
* @property {string} label Localization key for the trait name.
* @property {string} [actorKeyPath] If the trait doesn't directly map to an entry as `traits.[key]`, where is
* this trait's data stored on the actor?
* @property {string} [configKey] If the list of trait options doesn't match the name of the trait, where can
* the options be found within `CONFIG.DND5E`?
* @property {string} [labelKey] If config is an enum of objects, where can the label be found?
* @property {object} [subtypes] Configuration for traits that take some sort of base item.
* @property {string} [subtypes.keyPath] Path to subtype value on base items, should match a category key.
* @property {string[]} [subtypes.ids] Key for base item ID objects within `CONFIG.DND5E`.
* @property {object} [children] Mapping of category key to an object defining its children.
* @property {boolean} [sortCategories] Whether top-level categories should be sorted.
*/
/**
* Configurable traits on actors.
* @enum {TraitConfiguration}
*/
DND5E.traits = {
saves: {
label: "DND5E.ClassSaves",
configKey: "abilities",
labelKey: "label"
},
skills: {
label: "DND5E.TraitSkillProf",
labelKey: "label"
},
languages: {
label: "DND5E.Languages"
},
di: {
label: "DND5E.DamImm",
configKey: "damageTypes"
},
dr: {
label: "DND5E.DamRes",
configKey: "damageTypes"
},
dv: {
label: "DND5E.DamVuln",
configKey: "damageTypes"
},
ci: {
label: "DND5E.ConImm",
configKey: "conditionTypes"
},
weapon: {
label: "DND5E.TraitWeaponProf",
actorKeyPath: "traits.weaponProf",
configKey: "weaponProficiencies",
subtypes: { keyPath: "weaponType", ids: ["weaponIds"] }
},
armor: {
label: "DND5E.TraitArmorProf",
actorKeyPath: "traits.armorProf",
configKey: "armorProficiencies",
subtypes: { keyPath: "armor.type", ids: ["armorIds", "shieldIds"] }
},
tool: {
label: "DND5E.TraitToolProf",
actorKeyPath: "tools",
configKey: "toolProficiencies",
subtypes: { keyPath: "toolType", ids: ["toolIds"] },
children: { vehicle: "vehicleTypes" },
sortCategories: true
}
};
preLocalize("traits", { key: "label" });
/* -------------------------------------------- */
/**
* Special character flags.
* @enum {CharacterFlagConfig}
*/
DND5E.characterFlags = {
diamondSoul: {
name: "DND5E.FlagsDiamondSoul",
hint: "DND5E.FlagsDiamondSoulHint",
section: "DND5E.Feats",
type: Boolean
},
elvenAccuracy: {
name: "DND5E.FlagsElvenAccuracy",
hint: "DND5E.FlagsElvenAccuracyHint",
section: "DND5E.RacialTraits",
abilities: ["dex", "int", "wis", "cha"],
type: Boolean
},
halflingLucky: {
name: "DND5E.FlagsHalflingLucky",
hint: "DND5E.FlagsHalflingLuckyHint",
section: "DND5E.RacialTraits",
type: Boolean
},
initiativeAdv: {
name: "DND5E.FlagsInitiativeAdv",
hint: "DND5E.FlagsInitiativeAdvHint",
section: "DND5E.Feats",
type: Boolean
},
initiativeAlert: {
name: "DND5E.FlagsAlert",
hint: "DND5E.FlagsAlertHint",
section: "DND5E.Feats",
type: Boolean
},
jackOfAllTrades: {
name: "DND5E.FlagsJOAT",
hint: "DND5E.FlagsJOATHint",
section: "DND5E.Feats",
type: Boolean
},
observantFeat: {
name: "DND5E.FlagsObservant",
hint: "DND5E.FlagsObservantHint",
skills: ["prc", "inv"],
section: "DND5E.Feats",
type: Boolean
},
powerfulBuild: {
name: "DND5E.FlagsPowerfulBuild",
hint: "DND5E.FlagsPowerfulBuildHint",
section: "DND5E.RacialTraits",
type: Boolean
},
reliableTalent: {
name: "DND5E.FlagsReliableTalent",
hint: "DND5E.FlagsReliableTalentHint",
section: "DND5E.Feats",
type: Boolean
},
remarkableAthlete: {
name: "DND5E.FlagsRemarkableAthlete",
hint: "DND5E.FlagsRemarkableAthleteHint",
abilities: ["str", "dex", "con"],
section: "DND5E.Feats",
type: Boolean
},
weaponCriticalThreshold: {
name: "DND5E.FlagsWeaponCritThreshold",
hint: "DND5E.FlagsWeaponCritThresholdHint",
section: "DND5E.Feats",
type: Number,
placeholder: 20
},
spellCriticalThreshold: {
name: "DND5E.FlagsSpellCritThreshold",
hint: "DND5E.FlagsSpellCritThresholdHint",
section: "DND5E.Feats",
type: Number,
placeholder: 20
},
meleeCriticalDamageDice: {
name: "DND5E.FlagsMeleeCriticalDice",
hint: "DND5E.FlagsMeleeCriticalDiceHint",
section: "DND5E.Feats",
type: Number,
placeholder: 0
}
};
preLocalize("characterFlags", { keys: ["name", "hint", "section"] });
/**
* Flags allowed on actors. Any flags not in the list may be deleted during a migration.
* @type {string[]}
*/
DND5E.allowedActorFlags = ["isPolymorphed", "originalActor"].concat(Object.keys(DND5E.characterFlags));
/* -------------------------------------------- */
/**
* Advancement types that can be added to items.
* @enum {*}
*/
DND5E.advancementTypes = {
HitPoints: HitPointsAdvancement,
ItemChoice: ItemChoiceAdvancement,
ItemGrant: ItemGrantAdvancement,
ScaleValue: ScaleValueAdvancement
};
/* -------------------------------------------- */
/**
* Patch an existing config enum to allow conversion from string values to object values without
* breaking existing modules that are expecting strings.
* @param {string} key Key within DND5E that has been replaced with an enum of objects.
* @param {string} fallbackKey Key within the new config object from which to get the fallback value.
* @param {object} [options] Additional options passed through to logCompatibilityWarning.
*/
function patchConfig(key, fallbackKey, options) {
/** @override */
function toString() {
const message = `The value of CONFIG.DND5E.${key} has been changed to an object.`
+` The former value can be acccessed from .${fallbackKey}.`;
foundry.utils.logCompatibilityWarning(message, options);
return this[fallbackKey];
}
Object.values(DND5E[key]).forEach(o => o.toString = toString);
}
/**
* @typedef {object} ModuleArtInfo
* @property {string} actor The path to the actor's portrait image.
* @property {string|object} token The path to the token image, or a richer object specifying additional token
* adjustments.
*/
/**
* A class responsible for managing module-provided art in compendia.
*/
class ModuleArt {
constructor() {
/**
* The stored map of actor UUIDs to their art information.
* @type {Map}
*/
Object.defineProperty(this, "map", {value: new Map(), writable: false});
}
/* -------------------------------------------- */
/**
* Set to true to temporarily prevent actors from loading module art.
* @type {boolean}
*/
suppressArt = false;
/* -------------------------------------------- */
/**
* Register any art mapping information included in active modules.
* @returns {Promise}
*/
async registerModuleArt() {
this.map.clear();
for ( const module of game.modules ) {
const flags = module.flags?.[module.id];
const artPath = this.constructor.getModuleArtPath(module);
if ( !artPath ) continue;
try {
const mapping = await foundry.utils.fetchJsonWithTimeout(artPath);
await this.#parseArtMapping(module.id, mapping, flags["dnd5e-art-credit"]);
} catch( e ) {
console.error(e);
}
}
// Load system mapping.
try {
const mapping = await foundry.utils.fetchJsonWithTimeout("systems/dnd5e/json/fa-token-mapping.json");
const credit = `
Token artwork by
Forgotten Adventures .
`;
await this.#parseArtMapping(game.system.id, mapping, credit);
} catch( e ) {
console.error(e);
}
}
/* -------------------------------------------- */
/**
* Parse a provided module art mapping and store it for reference later.
* @param {string} moduleId The module ID.
* @param {object} mapping A mapping containing pack names, a list of actor IDs, and paths to the art provided by
* the module for them.
* @param {string} [credit] An optional credit line to attach to the Actor's biography.
* @returns {Promise}
*/
async #parseArtMapping(moduleId, mapping, credit) {
let settings = game.settings.get("dnd5e", "moduleArtConfiguration")?.[moduleId];
settings ??= {portraits: true, tokens: true};
for ( const [packName, actors] of Object.entries(mapping) ) {
const pack = game.packs.get(packName);
if ( !pack ) continue;
for ( let [actorId, info] of Object.entries(actors) ) {
const entry = pack.index.get(actorId);
if ( !entry || !(settings.portraits || settings.tokens) ) continue;
if ( settings.portraits ) entry.img = info.actor;
else delete info.actor;
if ( !settings.tokens ) delete info.token;
if ( credit ) info.credit = credit;
const uuid = `Compendium.${packName}.${actorId}`;
info = foundry.utils.mergeObject(this.map.get(uuid) ?? {}, info, {inplace: false});
this.map.set(`Compendium.${packName}.${actorId}`, info);
}
}
}
/* -------------------------------------------- */
/**
* If a module provides art, return the path to is JSON mapping.
* @param {Module} module The module.
* @returns {string|null}
*/
static getModuleArtPath(module) {
const flags = module.flags?.[module.id];
const artPath = flags?.["dnd5e-art"];
if ( !artPath || !module.active ) return null;
return artPath;
}
}
/**
* A class responsible for allowing GMs to configure art provided by installed modules.
*/
class ModuleArtConfig extends FormApplication {
/** @inheritdoc */
constructor(object={}, options={}) {
object = foundry.utils.mergeObject(game.settings.get("dnd5e", "moduleArtConfiguration"), object, {inplace: false});
super(object, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("DND5E.ModuleArtConfigL"),
id: "module-art-config",
template: "systems/dnd5e/templates/apps/module-art-config.html",
popOut: true,
width: 600,
height: "auto"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
context.config = [];
for ( const module of game.modules ) {
if ( !ModuleArt.getModuleArtPath(module) ) continue;
const settings = this.object[module.id] ?? {portraits: true, tokens: true};
context.config.push({label: module.title, id: module.id, ...settings});
}
context.config.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
context.config.unshift({label: game.system.title, id: game.system.id, ...this.object.dnd5e});
return context;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
await game.settings.set("dnd5e", "moduleArtConfiguration", foundry.utils.expandObject(formData));
return SettingsConfig.reloadConfirm({world: true});
}
}
/**
* Register all of the system's settings.
*/
function registerSystemSettings() {
// Internal System Migration Version
game.settings.register("dnd5e", "systemMigrationVersion", {
name: "System Migration Version",
scope: "world",
config: false,
type: String,
default: ""
});
// Rest Recovery Rules
game.settings.register("dnd5e", "restVariant", {
name: "SETTINGS.5eRestN",
hint: "SETTINGS.5eRestL",
scope: "world",
config: true,
default: "normal",
type: String,
choices: {
normal: "SETTINGS.5eRestPHB",
gritty: "SETTINGS.5eRestGritty",
epic: "SETTINGS.5eRestEpic"
}
});
// Diagonal Movement Rule
game.settings.register("dnd5e", "diagonalMovement", {
name: "SETTINGS.5eDiagN",
hint: "SETTINGS.5eDiagL",
scope: "world",
config: true,
default: "555",
type: String,
choices: {
555: "SETTINGS.5eDiagPHB",
5105: "SETTINGS.5eDiagDMG",
EUCL: "SETTINGS.5eDiagEuclidean"
},
onChange: rule => canvas.grid.diagonalRule = rule
});
// Proficiency modifier type
game.settings.register("dnd5e", "proficiencyModifier", {
name: "SETTINGS.5eProfN",
hint: "SETTINGS.5eProfL",
scope: "world",
config: true,
default: "bonus",
type: String,
choices: {
bonus: "SETTINGS.5eProfBonus",
dice: "SETTINGS.5eProfDice"
}
});
// Use Honor ability score
game.settings.register("dnd5e", "honorScore", {
name: "SETTINGS.5eHonorN",
hint: "SETTINGS.5eHonorL",
scope: "world",
config: true,
default: false,
type: Boolean,
requiresReload: true
});
// Use Sanity ability score
game.settings.register("dnd5e", "sanityScore", {
name: "SETTINGS.5eSanityN",
hint: "SETTINGS.5eSanityL",
scope: "world",
config: true,
default: false,
type: Boolean,
requiresReload: true
});
// Apply Dexterity as Initiative Tiebreaker
game.settings.register("dnd5e", "initiativeDexTiebreaker", {
name: "SETTINGS.5eInitTBN",
hint: "SETTINGS.5eInitTBL",
scope: "world",
config: true,
default: false,
type: Boolean
});
// Record Currency Weight
game.settings.register("dnd5e", "currencyWeight", {
name: "SETTINGS.5eCurWtN",
hint: "SETTINGS.5eCurWtL",
scope: "world",
config: true,
default: true,
type: Boolean
});
// Disable Experience Tracking
game.settings.register("dnd5e", "disableExperienceTracking", {
name: "SETTINGS.5eNoExpN",
hint: "SETTINGS.5eNoExpL",
scope: "world",
config: true,
default: false,
type: Boolean
});
// Disable Advancements
game.settings.register("dnd5e", "disableAdvancements", {
name: "SETTINGS.5eNoAdvancementsN",
hint: "SETTINGS.5eNoAdvancementsL",
scope: "world",
config: true,
default: false,
type: Boolean
});
// Collapse Item Cards (by default)
game.settings.register("dnd5e", "autoCollapseItemCards", {
name: "SETTINGS.5eAutoCollapseCardN",
hint: "SETTINGS.5eAutoCollapseCardL",
scope: "client",
config: true,
default: false,
type: Boolean,
onChange: s => {
ui.chat.render();
}
});
// Allow Polymorphing
game.settings.register("dnd5e", "allowPolymorphing", {
name: "SETTINGS.5eAllowPolymorphingN",
hint: "SETTINGS.5eAllowPolymorphingL",
scope: "world",
config: true,
default: false,
type: Boolean
});
// Polymorph Settings
game.settings.register("dnd5e", "polymorphSettings", {
scope: "client",
default: {
keepPhysical: false,
keepMental: false,
keepSaves: false,
keepSkills: false,
mergeSaves: false,
mergeSkills: false,
keepClass: false,
keepFeats: false,
keepSpells: false,
keepItems: false,
keepBio: false,
keepVision: true,
keepSelf: false,
keepAE: false,
keepOriginAE: true,
keepOtherOriginAE: true,
keepFeatAE: true,
keepSpellAE: true,
keepEquipmentAE: true,
keepClassAE: true,
keepBackgroundAE: true,
transformTokens: true
}
});
// Metric Unit Weights
game.settings.register("dnd5e", "metricWeightUnits", {
name: "SETTINGS.5eMetricN",
hint: "SETTINGS.5eMetricL",
scope: "world",
config: true,
type: Boolean,
default: false
});
// Critical Damage Modifiers
game.settings.register("dnd5e", "criticalDamageModifiers", {
name: "SETTINGS.5eCriticalModifiersN",
hint: "SETTINGS.5eCriticalModifiersL",
scope: "world",
config: true,
type: Boolean,
default: false
});
// Critical Damage Maximize
game.settings.register("dnd5e", "criticalDamageMaxDice", {
name: "SETTINGS.5eCriticalMaxDiceN",
hint: "SETTINGS.5eCriticalMaxDiceL",
scope: "world",
config: true,
type: Boolean,
default: false
});
// Strict validation
game.settings.register("dnd5e", "strictValidation", {
scope: "world",
config: false,
type: Boolean,
default: true
});
// Dynamic art.
game.settings.registerMenu("dnd5e", "moduleArtConfiguration", {
name: "DND5E.ModuleArtConfigN",
label: "DND5E.ModuleArtConfigL",
hint: "DND5E.ModuleArtConfigH",
icon: "fa-solid fa-palette",
type: ModuleArtConfig,
restricted: true
});
game.settings.register("dnd5e", "moduleArtConfiguration", {
name: "Module Art Configuration",
scope: "world",
config: false,
type: Object,
default: {
dnd5e: {
portraits: true,
tokens: true
}
}
});
}
/**
* Extend the base ActiveEffect class to implement system-specific logic.
*/
class ActiveEffect5e extends ActiveEffect {
/**
* Is this active effect currently suppressed?
* @type {boolean}
*/
isSuppressed = false;
/* --------------------------------------------- */
/** @inheritdoc */
apply(actor, change) {
if ( this.isSuppressed ) return null;
if ( change.key.startsWith("flags.dnd5e.") ) change = this._prepareFlagChange(actor, change);
return super.apply(actor, change);
}
/* -------------------------------------------- */
/** @inheritdoc */
_applyAdd(actor, change, current, delta, changes) {
if ( current instanceof Set ) {
if ( Array.isArray(delta) ) delta.forEach(item => current.add(item));
else current.add(delta);
return;
}
super._applyAdd(actor, change, current, delta, changes);
}
/* -------------------------------------------- */
/** @inheritdoc */
_applyOverride(actor, change, current, delta, changes) {
if ( current instanceof Set ) {
current.clear();
if ( Array.isArray(delta) ) delta.forEach(item => current.add(item));
else current.add(delta);
return;
}
return super._applyOverride(actor, change, current, delta, changes);
}
/* --------------------------------------------- */
/**
* Transform the data type of the change to match the type expected for flags.
* @param {Actor5e} actor The Actor to whom this effect should be applied.
* @param {EffectChangeData} change The change being applied.
* @returns {EffectChangeData} The change with altered types if necessary.
*/
_prepareFlagChange(actor, change) {
const { key, value } = change;
const data = CONFIG.DND5E.characterFlags[key.replace("flags.dnd5e.", "")];
if ( !data ) return change;
// Set flag to initial value if it isn't present
const current = foundry.utils.getProperty(actor, key) ?? null;
if ( current === null ) {
let initialValue = null;
if ( data.placeholder ) initialValue = data.placeholder;
else if ( data.type === Boolean ) initialValue = false;
else if ( data.type === Number ) initialValue = 0;
foundry.utils.setProperty(actor, key, initialValue);
}
// Coerce change data into the correct type
if ( data.type === Boolean ) {
if ( value === "false" ) change.value = false;
else change.value = Boolean(value);
}
return change;
}
/* --------------------------------------------- */
/**
* Determine whether this Active Effect is suppressed or not.
*/
determineSuppression() {
this.isSuppressed = false;
if ( this.disabled || (this.parent.documentName !== "Actor") ) return;
const parts = this.origin?.split(".") ?? [];
const [parentType, parentId, documentType, documentId, syntheticItem, syntheticItemId] = parts;
let item;
// Case 1: This is a linked or sidebar actor
if ( parentType === "Actor" ) {
if ( (parentId !== this.parent.id) || (documentType !== "Item") ) return;
item = this.parent.items.get(documentId);
}
// Case 2: This is a synthetic actor on the scene
else if ( parentType === "Scene" ) {
if ( (documentId !== this.parent.token?.id) || (syntheticItem !== "Item") ) return;
item = this.parent.items.get(syntheticItemId);
}
if ( !item ) return;
this.isSuppressed = item.areEffectsSuppressed;
}
/* --------------------------------------------- */
/**
* Manage Active Effect instances through the Actor Sheet via effect control buttons.
* @param {MouseEvent} event The left-click event on the effect control
* @param {Actor5e|Item5e} owner The owning document which manages this effect
* @returns {Promise|null} Promise that resolves when the changes are complete.
*/
static onManageActiveEffect(event, owner) {
event.preventDefault();
const a = event.currentTarget;
const li = a.closest("li");
const effect = li.dataset.effectId ? owner.effects.get(li.dataset.effectId) : null;
switch ( a.dataset.action ) {
case "create":
return owner.createEmbeddedDocuments("ActiveEffect", [{
label: game.i18n.localize("DND5E.EffectNew"),
icon: "icons/svg/aura.svg",
origin: owner.uuid,
"duration.rounds": li.dataset.effectType === "temporary" ? 1 : undefined,
disabled: li.dataset.effectType === "inactive"
}]);
case "edit":
return effect.sheet.render(true);
case "delete":
return effect.delete();
case "toggle":
return effect.update({disabled: !effect.disabled});
}
}
/* --------------------------------------------- */
/**
* Prepare the data structure for Active Effects which are currently applied to an Actor or Item.
* @param {ActiveEffect5e[]} effects The array of Active Effect instances to prepare sheet data for
* @returns {object} Data for rendering
*/
static prepareActiveEffectCategories(effects) {
// Define effect header categories
const categories = {
temporary: {
type: "temporary",
label: game.i18n.localize("DND5E.EffectTemporary"),
effects: []
},
passive: {
type: "passive",
label: game.i18n.localize("DND5E.EffectPassive"),
effects: []
},
inactive: {
type: "inactive",
label: game.i18n.localize("DND5E.EffectInactive"),
effects: []
},
suppressed: {
type: "suppressed",
label: game.i18n.localize("DND5E.EffectUnavailable"),
effects: [],
info: [game.i18n.localize("DND5E.EffectUnavailableInfo")]
}
};
// Iterate over active effects, classifying them into categories
for ( let e of effects ) {
if ( game.dnd5e.isV10 ) e._getSourceName(); // Trigger a lookup for the source name
if ( e.isSuppressed ) categories.suppressed.effects.push(e);
else if ( e.disabled ) categories.inactive.effects.push(e);
else if ( e.isTemporary ) categories.temporary.effects.push(e);
else categories.passive.effects.push(e);
}
categories.suppressed.hidden = !categories.suppressed.effects.length;
return categories;
}
}
/**
* A standardized helper function for simplifying the constant parts of a multipart roll formula.
*
* @param {string} formula The original roll formula.
* @param {object} [options] Formatting options.
* @param {boolean} [options.preserveFlavor=false] Preserve flavor text in the simplified formula.
*
* @returns {string} The resulting simplified formula.
*/
function simplifyRollFormula(formula, { preserveFlavor=false } = {}) {
// Create a new roll and verify that the formula is valid before attempting simplification.
let roll;
try { roll = new Roll(formula); }
catch(err) { console.warn(`Unable to simplify formula '${formula}': ${err}`); }
Roll.validate(roll.formula);
// Optionally strip flavor annotations.
if ( !preserveFlavor ) roll.terms = Roll.parse(roll.formula.replace(RollTerm.FLAVOR_REGEXP, ""));
// Perform arithmetic simplification on the existing roll terms.
roll.terms = _simplifyOperatorTerms(roll.terms);
// If the formula contains multiplication or division we cannot easily simplify
if ( /[*/]/.test(roll.formula) ) {
if ( roll.isDeterministic && !/d\(/.test(roll.formula) && (!/\[/.test(roll.formula) || !preserveFlavor) ) {
return Roll.safeEval(roll.formula).toString();
}
else return roll.constructor.getFormula(roll.terms);
}
// Flatten the roll formula and eliminate string terms.
roll.terms = _expandParentheticalTerms(roll.terms);
roll.terms = Roll.simplifyTerms(roll.terms);
// Group terms by type and perform simplifications on various types of roll term.
let { poolTerms, diceTerms, mathTerms, numericTerms } = _groupTermsByType(roll.terms);
numericTerms = _simplifyNumericTerms(numericTerms ?? []);
diceTerms = _simplifyDiceTerms(diceTerms ?? []);
// Recombine the terms into a single term array and remove an initial + operator if present.
const simplifiedTerms = [diceTerms, poolTerms, mathTerms, numericTerms].flat().filter(Boolean);
if ( simplifiedTerms[0]?.operator === "+" ) simplifiedTerms.shift();
return roll.constructor.getFormula(simplifiedTerms);
}
/* -------------------------------------------- */
/**
* A helper function to perform arithmetic simplification and remove redundant operator terms.
* @param {RollTerm[]} terms An array of roll terms.
* @returns {RollTerm[]} A new array of roll terms with redundant operators removed.
*/
function _simplifyOperatorTerms(terms) {
return terms.reduce((acc, term) => {
const prior = acc[acc.length - 1];
const ops = new Set([prior?.operator, term.operator]);
// If one of the terms is not an operator, add the current term as is.
if ( ops.has(undefined) ) acc.push(term);
// Replace consecutive "+ -" operators with a "-" operator.
else if ( (ops.has("+")) && (ops.has("-")) ) acc.splice(-1, 1, new OperatorTerm({ operator: "-" }));
// Replace double "-" operators with a "+" operator.
else if ( (ops.has("-")) && (ops.size === 1) ) acc.splice(-1, 1, new OperatorTerm({ operator: "+" }));
// Don't include "+" operators that directly follow "+", "*", or "/". Otherwise, add the term as is.
else if ( !ops.has("+") ) acc.push(term);
return acc;
}, []);
}
/* -------------------------------------------- */
/**
* A helper function for combining unannotated numeric terms in an array into a single numeric term.
* @param {object[]} terms An array of roll terms.
* @returns {object[]} A new array of terms with unannotated numeric terms combined into one.
*/
function _simplifyNumericTerms(terms) {
const simplified = [];
const { annotated, unannotated } = _separateAnnotatedTerms(terms);
// Combine the unannotated numerical bonuses into a single new NumericTerm.
if ( unannotated.length ) {
const staticBonus = Roll.safeEval(Roll.getFormula(unannotated));
if ( staticBonus === 0 ) return [...annotated];
// If the staticBonus is greater than 0, add a "+" operator so the formula remains valid.
if ( staticBonus > 0 ) simplified.push(new OperatorTerm({ operator: "+"}));
simplified.push(new NumericTerm({ number: staticBonus} ));
}
return [...simplified, ...annotated];
}
/* -------------------------------------------- */
/**
* A helper function to group dice of the same size and sign into single dice terms.
* @param {object[]} terms An array of DiceTerms and associated OperatorTerms.
* @returns {object[]} A new array of simplified dice terms.
*/
function _simplifyDiceTerms(terms) {
const { annotated, unannotated } = _separateAnnotatedTerms(terms);
// Split the unannotated terms into different die sizes and signs
const diceQuantities = unannotated.reduce((obj, curr, i) => {
if ( curr instanceof OperatorTerm ) return obj;
const key = `${unannotated[i - 1].operator}${curr.faces}`;
obj[key] = (obj[key] ?? 0) + curr.number;
return obj;
}, {});
// Add new die and operator terms to simplified for each die size and sign
const simplified = Object.entries(diceQuantities).flatMap(([key, number]) => ([
new OperatorTerm({ operator: key.charAt(0) }),
new Die({ number, faces: parseInt(key.slice(1)) })
]));
return [...simplified, ...annotated];
}
/* -------------------------------------------- */
/**
* A helper function to extract the contents of parenthetical terms into their own terms.
* @param {object[]} terms An array of roll terms.
* @returns {object[]} A new array of terms with no parenthetical terms.
*/
function _expandParentheticalTerms(terms) {
terms = terms.reduce((acc, term) => {
if ( term instanceof ParentheticalTerm ) {
if ( term.isDeterministic ) term = new NumericTerm({ number: Roll.safeEval(term.term) });
else {
const subterms = new Roll(term.term).terms;
term = _expandParentheticalTerms(subterms);
}
}
acc.push(term);
return acc;
}, []);
return _simplifyOperatorTerms(terms.flat());
}
/* -------------------------------------------- */
/**
* A helper function to group terms into PoolTerms, DiceTerms, MathTerms, and NumericTerms.
* MathTerms are included as NumericTerms if they are deterministic.
* @param {RollTerm[]} terms An array of roll terms.
* @returns {object} An object mapping term types to arrays containing roll terms of that type.
*/
function _groupTermsByType(terms) {
// Add an initial operator so that terms can be rearranged arbitrarily.
if ( !(terms[0] instanceof OperatorTerm) ) terms.unshift(new OperatorTerm({ operator: "+" }));
return terms.reduce((obj, term, i) => {
let type;
if ( term instanceof DiceTerm ) type = DiceTerm;
else if ( (term instanceof MathTerm) && (term.isDeterministic) ) type = NumericTerm;
else type = term.constructor;
const key = `${type.name.charAt(0).toLowerCase()}${type.name.substring(1)}s`;
// Push the term and the preceding OperatorTerm.
(obj[key] = obj[key] ?? []).push(terms[i - 1], term);
return obj;
}, {});
}
/* -------------------------------------------- */
/**
* A helper function to separate annotated terms from unannotated terms.
* @param {object[]} terms An array of DiceTerms and associated OperatorTerms.
* @returns {Array | Array[]} A pair of term arrays, one containing annotated terms.
*/
function _separateAnnotatedTerms(terms) {
return terms.reduce((obj, curr, i) => {
if ( curr instanceof OperatorTerm ) return obj;
obj[curr.flavor ? "annotated" : "unannotated"].push(terms[i - 1], curr);
return obj;
}, { annotated: [], unannotated: [] });
}
/**
* A specialized Dialog subclass for ability usage.
*
* @param {Item5e} item Item that is being used.
* @param {object} [dialogData={}] An object of dialog data which configures how the modal window is rendered.
* @param {object} [options={}] Dialog rendering options.
*/
class AbilityUseDialog extends Dialog {
constructor(item, dialogData={}, options={}) {
super(dialogData, options);
this.options.classes = ["dnd5e", "dialog"];
/**
* Store a reference to the Item document being used
* @type {Item5e}
*/
this.item = item;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* A constructor function which displays the Spell Cast Dialog app for a given Actor and Item.
* Returns a Promise which resolves to the dialog FormData once the workflow has been completed.
* @param {Item5e} item Item being used.
* @returns {Promise} Promise that is resolved when the use dialog is acted upon.
*/
static async create(item) {
if ( !item.isOwned ) throw new Error("You cannot display an ability usage dialog for an unowned item");
// Prepare data
const uses = item.system.uses ?? {};
const resource = item.system.consume ?? {};
const quantity = item.system.quantity ?? 0;
const recharge = item.system.recharge ?? {};
const recharges = !!recharge.value;
const sufficientUses = (quantity > 0 && !uses.value) || uses.value > 0;
// Prepare dialog form data
const data = {
item: item,
title: game.i18n.format("DND5E.AbilityUseHint", {type: game.i18n.localize(CONFIG.Item.typeLabels[item.type]), name: item.name}),
note: this._getAbilityUseNote(item, uses, recharge),
consumeSpellSlot: false,
consumeRecharge: recharges,
consumeResource: resource.target && (!item.hasAttack || (resource.type !== "ammo")),
consumeUses: uses.per && (uses.max > 0),
canUse: recharges ? recharge.charged : sufficientUses,
createTemplate: game.user.can("TEMPLATE_CREATE") && item.hasAreaTarget,
errors: []
};
if ( item.type === "spell" ) this._getSpellData(item.actor.system, item.system, data);
// Render the ability usage template
const html = await renderTemplate("systems/dnd5e/templates/apps/ability-use.hbs", data);
// Create the Dialog and return data as a Promise
const icon = data.isSpell ? "fa-magic" : "fa-fist-raised";
const label = game.i18n.localize(`DND5E.AbilityUse${data.isSpell ? "Cast" : "Use"}`);
return new Promise(resolve => {
const dlg = new this(item, {
title: `${item.name}: ${game.i18n.localize("DND5E.AbilityUseConfig")}`,
content: html,
buttons: {
use: {
icon: ` `,
label: label,
callback: html => {
const fd = new FormDataExtended(html[0].querySelector("form"));
resolve(fd.object);
}
}
},
default: "use",
close: () => resolve(null)
});
dlg.render(true);
});
}
/* -------------------------------------------- */
/* Helpers */
/* -------------------------------------------- */
/**
* Get dialog data related to limited spell slots.
* @param {object} actorData System data from the actor using the spell.
* @param {object} itemData System data from the spell being used.
* @param {object} data Data for the dialog being presented.
* @returns {object} Modified dialog data.
* @private
*/
static _getSpellData(actorData, itemData, data) {
// Determine whether the spell may be up-cast
const lvl = itemData.level;
const consumeSpellSlot = (lvl > 0) && CONFIG.DND5E.spellUpcastModes.includes(itemData.preparation.mode);
// If can't upcast, return early and don't bother calculating available spell slots
if (!consumeSpellSlot) {
return foundry.utils.mergeObject(data, { isSpell: true, consumeSpellSlot });
}
// Determine the levels which are feasible
let lmax = 0;
const spellLevels = Array.fromRange(10).reduce((arr, i) => {
if ( i < lvl ) return arr;
const label = CONFIG.DND5E.spellLevels[i];
const l = actorData.spells[`spell${i}`] || {max: 0, override: null};
let max = parseInt(l.override || l.max || 0);
let slots = Math.clamped(parseInt(l.value || 0), 0, max);
if ( max > 0 ) lmax = i;
arr.push({
level: i,
label: i > 0 ? game.i18n.format("DND5E.SpellLevelSlot", {level: label, n: slots}) : label,
canCast: max > 0,
hasSlots: slots > 0
});
return arr;
}, []).filter(sl => sl.level <= lmax);
// If this character has pact slots, present them as an option for casting the spell.
const pact = actorData.spells.pact;
if (pact.level >= lvl) {
spellLevels.push({
level: "pact",
label: `${game.i18n.format("DND5E.SpellLevelPact", {level: pact.level, n: pact.value})}`,
canCast: true,
hasSlots: pact.value > 0
});
}
const canCast = spellLevels.some(l => l.hasSlots);
if ( !canCast ) data.errors.push(game.i18n.format("DND5E.SpellCastNoSlots", {
level: CONFIG.DND5E.spellLevels[lvl],
name: data.item.name
}));
// Merge spell casting data
return foundry.utils.mergeObject(data, { isSpell: true, consumeSpellSlot, spellLevels });
}
/* -------------------------------------------- */
/**
* Get the ability usage note that is displayed.
* @param {object} item Data for the item being used.
* @param {{value: number, max: number, per: string}} uses Object uses and recovery configuration.
* @param {{charged: boolean, value: string}} recharge Object recharge configuration.
* @returns {string} Localized string indicating available uses.
* @private
*/
static _getAbilityUseNote(item, uses, recharge) {
// Zero quantity
const quantity = item.system.quantity;
if ( quantity <= 0 ) return game.i18n.localize("DND5E.AbilityUseUnavailableHint");
// Abilities which use Recharge
if ( recharge.value ) {
return game.i18n.format(recharge.charged ? "DND5E.AbilityUseChargedHint" : "DND5E.AbilityUseRechargeHint", {
type: game.i18n.localize(CONFIG.Item.typeLabels[item.type])
});
}
// Does not use any resource
if ( !uses.per || !uses.max ) return "";
// Consumables
if ( item.type === "consumable" ) {
let str = "DND5E.AbilityUseNormalHint";
if ( uses.value > 1 ) str = "DND5E.AbilityUseConsumableChargeHint";
else if ( item.system.quantity === 1 && uses.autoDestroy ) str = "DND5E.AbilityUseConsumableDestroyHint";
else if ( item.system.quantity > 1 ) str = "DND5E.AbilityUseConsumableQuantityHint";
return game.i18n.format(str, {
type: game.i18n.localize(`DND5E.Consumable${item.system.consumableType.capitalize()}`),
value: uses.value,
quantity: item.system.quantity,
max: uses.max,
per: CONFIG.DND5E.limitedUsePeriods[uses.per]
});
}
// Other Items
else {
return game.i18n.format("DND5E.AbilityUseNormalHint", {
type: game.i18n.localize(CONFIG.Item.typeLabels[item.type]),
value: uses.value,
max: uses.max,
per: CONFIG.DND5E.limitedUsePeriods[uses.per]
});
}
}
}
/**
* Override and extend the basic Item implementation.
*/
class Item5e extends Item {
/**
* Caches an item linked to this one, such as a subclass associated with a class.
* @type {Item5e}
* @private
*/
_classLink;
/* -------------------------------------------- */
/* Item Properties */
/* -------------------------------------------- */
/**
* Which ability score modifier is used by this item?
* @type {string|null}
* @see {@link ActionTemplate#abilityMod}
*/
get abilityMod() {
return this.system.abilityMod ?? null;
}
/* --------------------------------------------- */
/**
* What is the critical hit threshold for this item, if applicable?
* @type {number|null}
* @see {@link ActionTemplate#criticalThreshold}
*/
get criticalThreshold() {
return this.system.criticalThreshold ?? null;
}
/* --------------------------------------------- */
/**
* Does the Item implement an ability check as part of its usage?
* @type {boolean}
* @see {@link ActionTemplate#hasAbilityCheck}
*/
get hasAbilityCheck() {
return this.system.hasAbilityCheck ?? false;
}
/* -------------------------------------------- */
/**
* Does this item support advancement and have advancements defined?
* @type {boolean}
*/
get hasAdvancement() {
return !!this.system.advancement?.length;
}
/* -------------------------------------------- */
/**
* Does the Item have an area of effect target?
* @type {boolean}
* @see {@link ActivatedEffectTemplate#hasAreaTarget}
*/
get hasAreaTarget() {
return this.system.hasAreaTarget ?? false;
}
/* -------------------------------------------- */
/**
* Does the Item implement an attack roll as part of its usage?
* @type {boolean}
* @see {@link ActionTemplate#hasAttack}
*/
get hasAttack() {
return this.system.hasAttack ?? false;
}
/* -------------------------------------------- */
/**
* Does the Item implement a damage roll as part of its usage?
* @type {boolean}
* @see {@link ActionTemplate#hasDamage}
*/
get hasDamage() {
return this.system.hasDamage ?? false;
}
/* -------------------------------------------- */
/**
* Does the Item target one or more distinct targets?
* @type {boolean}
* @see {@link ActivatedEffectTemplate#hasIndividualTarget}
*/
get hasIndividualTarget() {
return this.system.hasIndividualTarget ?? false;
}
/* -------------------------------------------- */
/**
* Is this Item limited in its ability to be used by charges or by recharge?
* @type {boolean}
* @see {@link ActivatedEffectTemplate#hasLimitedUses}
* @see {@link FeatData#hasLimitedUses}
*/
get hasLimitedUses() {
return this.system.hasLimitedUses ?? false;
}
/* -------------------------------------------- */
/**
* Does the Item implement a saving throw as part of its usage?
* @type {boolean}
* @see {@link ActionTemplate#hasSave}
*/
get hasSave() {
return this.system.hasSave ?? false;
}
/* -------------------------------------------- */
/**
* Does the Item have a target?
* @type {boolean}
* @see {@link ActivatedEffectTemplate#hasTarget}
*/
get hasTarget() {
return this.system.hasTarget ?? false;
}
/* -------------------------------------------- */
/**
* Return an item's identifier.
* @type {string}
*/
get identifier() {
return this.system.identifier || this.name.slugify({strict: true});
}
/* -------------------------------------------- */
/**
* Is this item any of the armor subtypes?
* @type {boolean}
* @see {@link EquipmentTemplate#isArmor}
*/
get isArmor() {
return this.system.isArmor ?? false;
}
/* -------------------------------------------- */
/**
* Does the item provide an amount of healing instead of conventional damage?
* @type {boolean}
* @see {@link ActionTemplate#isHealing}
*/
get isHealing() {
return this.system.isHealing ?? false;
}
/* -------------------------------------------- */
/**
* Is this item a separate large object like a siege engine or vehicle component that is
* usually mounted on fixtures rather than equipped, and has its own AC and HP?
* @type {boolean}
* @see {@link EquipmentData#isMountable}
* @see {@link WeaponData#isMountable}
*/
get isMountable() {
return this.system.isMountable ?? false;
}
/* -------------------------------------------- */
/**
* Is this class item the original class for the containing actor? If the item is not a class or it is not
* embedded in an actor then this will return `null`.
* @type {boolean|null}
*/
get isOriginalClass() {
if ( this.type !== "class" || !this.isEmbedded ) return null;
return this.id === this.parent.system.details.originalClass;
}
/* -------------------------------------------- */
/**
* Does the Item implement a versatile damage roll as part of its usage?
* @type {boolean}
* @see {@link ActionTemplate#isVersatile}
*/
get isVersatile() {
return this.system.isVersatile ?? false;
}
/* -------------------------------------------- */
/**
* Class associated with this subclass. Always returns null on non-subclass or non-embedded items.
* @type {Item5e|null}
*/
get class() {
if ( !this.isEmbedded || (this.type !== "subclass") ) return null;
const cid = this.system.classIdentifier;
return this._classLink ??= this.parent.items.find(i => (i.type === "class") && (i.identifier === cid));
}
/* -------------------------------------------- */
/**
* Subclass associated with this class. Always returns null on non-class or non-embedded items.
* @type {Item5e|null}
*/
get subclass() {
if ( !this.isEmbedded || (this.type !== "class") ) return null;
const items = this.parent.items;
const cid = this.identifier;
return this._classLink ??= items.find(i => (i.type === "subclass") && (i.system.classIdentifier === cid));
}
/* -------------------------------------------- */
/**
* Retrieve scale values for current level from advancement data.
* @type {object}
*/
get scaleValues() {
if ( !["class", "subclass"].includes(this.type) || !this.advancement.byType.ScaleValue ) return {};
const level = this.type === "class" ? this.system.levels : this.class?.system.levels ?? 0;
return this.advancement.byType.ScaleValue.reduce((obj, advancement) => {
obj[advancement.identifier] = advancement.valueForLevel(level);
return obj;
}, {});
}
/* -------------------------------------------- */
/**
* Spellcasting details for a class or subclass.
*
* @typedef {object} SpellcastingDescription
* @property {string} type Spellcasting type as defined in ``CONFIG.DND5E.spellcastingTypes`.
* @property {string|null} progression Progression within the specified spellcasting type if supported.
* @property {string} ability Ability used when casting spells from this class or subclass.
* @property {number|null} levels Number of levels of this class or subclass's class if embedded.
*/
/**
* Retrieve the spellcasting for a class or subclass. For classes, this will return the spellcasting
* of the subclass if it overrides the class. For subclasses, this will return the class's spellcasting
* if no spellcasting is defined on the subclass.
* @type {SpellcastingDescription|null} Spellcasting object containing progression & ability.
*/
get spellcasting() {
const spellcasting = this.system.spellcasting;
if ( !spellcasting ) return null;
const isSubclass = this.type === "subclass";
const classSC = isSubclass ? this.class?.system.spellcasting : spellcasting;
const subclassSC = isSubclass ? spellcasting : this.subclass?.system.spellcasting;
const finalSC = foundry.utils.deepClone(
( subclassSC && (subclassSC.progression !== "none") ) ? subclassSC : classSC
);
if ( !finalSC ) return null;
finalSC.levels = this.isEmbedded ? (this.system.levels ?? this.class?.system.levels) : null;
// Temp method for determining spellcasting type until this data is available directly using advancement
if ( CONFIG.DND5E.spellcastingTypes[finalSC.progression] ) finalSC.type = finalSC.progression;
else finalSC.type = Object.entries(CONFIG.DND5E.spellcastingTypes).find(([type, data]) => {
return !!data.progression?.[finalSC.progression];
})?.[0];
return finalSC;
}
/* -------------------------------------------- */
/**
* Should this item's active effects be suppressed.
* @type {boolean}
*/
get areEffectsSuppressed() {
const requireEquipped = (this.type !== "consumable")
|| ["rod", "trinket", "wand"].includes(this.system.consumableType);
if ( requireEquipped && (this.system.equipped === false) ) return true;
return this.system.attunement === CONFIG.DND5E.attunementTypes.REQUIRED;
}
/* -------------------------------------------- */
/* Data Preparation */
/* -------------------------------------------- */
/** @inheritDoc */
prepareDerivedData() {
super.prepareDerivedData();
this.labels = {};
// Clear out linked item cache
this._classLink = undefined;
// Advancement
this._prepareAdvancement();
// Specialized preparation per Item type
switch ( this.type ) {
case "equipment":
this._prepareEquipment(); break;
case "feat":
this._prepareFeat(); break;
case "spell":
this._prepareSpell(); break;
}
// Activated Items
this._prepareActivation();
this._prepareAction();
// Un-owned items can have their final preparation done here, otherwise this needs to happen in the owning Actor
if ( !this.isOwned ) this.prepareFinalAttributes();
}
/* -------------------------------------------- */
/**
* Prepare derived data for an equipment-type item and define labels.
* @protected
*/
_prepareEquipment() {
this.labels.armor = this.system.armor.value ? `${this.system.armor.value} ${game.i18n.localize("DND5E.AC")}` : "";
}
/* -------------------------------------------- */
/**
* Prepare derived data for a feat-type item and define labels.
* @protected
*/
_prepareFeat() {
const act = this.system.activation;
const types = CONFIG.DND5E.abilityActivationTypes;
if ( act?.type === types.legendary ) this.labels.featType = game.i18n.localize("DND5E.LegendaryActionLabel");
else if ( act?.type === types.lair ) this.labels.featType = game.i18n.localize("DND5E.LairActionLabel");
else if ( act?.type ) {
this.labels.featType = game.i18n.localize(this.system.damage.length ? "DND5E.Attack" : "DND5E.Action");
}
else this.labels.featType = game.i18n.localize("DND5E.Passive");
}
/* -------------------------------------------- */
/**
* Prepare derived data for a spell-type item and define labels.
* @protected
*/
_prepareSpell() {
const tags = Object.fromEntries(Object.entries(CONFIG.DND5E.spellTags).map(([k, v]) => {
v.tag = true;
return [k, v];
}));
const attributes = {...CONFIG.DND5E.spellComponents, ...tags};
this.system.preparation.mode ||= "prepared";
this.labels.level = CONFIG.DND5E.spellLevels[this.system.level];
this.labels.school = CONFIG.DND5E.spellSchools[this.system.school];
this.labels.components = Object.entries(this.system.components).reduce((obj, [c, active]) => {
const config = attributes[c];
if ( !config || (active !== true) ) return obj;
obj.all.push({abbr: config.abbr, tag: config.tag});
if ( config.tag ) obj.tags.push(config.label);
else obj.vsm.push(config.abbr);
return obj;
}, {all: [], vsm: [], tags: []});
this.labels.components.vsm = new Intl.ListFormat(game.i18n.lang, { style: "narrow", type: "conjunction" })
.format(this.labels.components.vsm);
this.labels.materials = this.system?.materials?.value ?? null;
}
/* -------------------------------------------- */
/**
* Prepare derived data for activated items and define labels.
* @protected
*/
_prepareActivation() {
if ( !("activation" in this.system) ) return;
const C = CONFIG.DND5E;
// Ability Activation Label
const act = this.system.activation ?? {};
if ( ["none", ""].includes(act.type) ) act.type = null; // Backwards compatibility
this.labels.activation = act.type ? [act.cost, C.abilityActivationTypes[act.type]].filterJoin(" ") : "";
// Target Label
let tgt = this.system.target ?? {};
if ( ["none", ""].includes(tgt.type) ) tgt.type = null; // Backwards compatibility
if ( [null, "self"].includes(tgt.type) ) tgt.value = tgt.units = null;
else if ( tgt.units === "touch" ) tgt.value = null;
this.labels.target = tgt.type
? [tgt.value, C.distanceUnits[tgt.units], C.targetTypes[tgt.type]].filterJoin(" ") : "";
// Range Label
let rng = this.system.range ?? {};
if ( ["none", ""].includes(rng.units) ) rng.units = null; // Backwards compatibility
if ( [null, "touch", "self"].includes(rng.units) ) rng.value = rng.long = null;
this.labels.range = rng.units
? [rng.value, rng.long ? `/ ${rng.long}` : null, C.distanceUnits[rng.units]].filterJoin(" ") : "";
// Recharge Label
let chg = this.system.recharge ?? {};
const chgSuffix = `${chg.value}${parseInt(chg.value) < 6 ? "+" : ""}`;
this.labels.recharge = `${game.i18n.localize("DND5E.Recharge")} [${chgSuffix}]`;
}
/* -------------------------------------------- */
/**
* Prepare derived data and labels for items which have an action which deals damage.
* @protected
*/
_prepareAction() {
if ( !("actionType" in this.system) ) return;
let dmg = this.system.damage || {};
if ( dmg.parts ) {
const types = CONFIG.DND5E.damageTypes;
this.labels.damage = dmg.parts.map(d => d[0]).join(" + ").replace(/\+ -/g, "- ");
this.labels.damageTypes = dmg.parts.map(d => types[d[1]]).join(", ");
}
}
/* -------------------------------------------- */
/**
* Prepare advancement objects from stored advancement data.
* @protected
*/
_prepareAdvancement() {
const minAdvancementLevel = ["class", "subclass"].includes(this.type) ? 1 : 0;
this.advancement = {
byId: {},
byLevel: Object.fromEntries(
Array.fromRange(CONFIG.DND5E.maxLevel + 1).slice(minAdvancementLevel).map(l => [l, []])
),
byType: {},
needingConfiguration: []
};
for ( const advancement of this.system.advancement ?? [] ) {
if ( !(advancement instanceof Advancement) ) continue;
this.advancement.byId[advancement.id] = advancement;
this.advancement.byType[advancement.type] ??= [];
this.advancement.byType[advancement.type].push(advancement);
advancement.levels.forEach(l => this.advancement.byLevel[l].push(advancement));
if ( !advancement.levels.length ) this.advancement.needingConfiguration.push(advancement);
}
Object.entries(this.advancement.byLevel).forEach(([lvl, data]) => data.sort((a, b) => {
return a.sortingValueForLevel(lvl).localeCompare(b.sortingValueForLevel(lvl));
}));
}
/* -------------------------------------------- */
/**
* Compute item attributes which might depend on prepared actor data. If this item is embedded this method will
* be called after the actor's data is prepared.
* Otherwise, it will be called at the end of `Item5e#prepareDerivedData`.
*/
prepareFinalAttributes() {
// Proficiency
if ( this.actor?.system.attributes?.prof ) {
const isProficient = (this.type === "spell") || this.system.proficient; // Always proficient in spell attacks.
this.system.prof = new Proficiency(this.actor?.system.attributes.prof, isProficient);
}
// Class data
if ( this.type === "class" ) this.system.isOriginalClass = this.isOriginalClass;
// Action usage
if ( "actionType" in this.system ) {
this.labels.abilityCheck = game.i18n.format("DND5E.AbilityPromptTitle", {
ability: CONFIG.DND5E.abilities[this.system.ability]?.label ?? ""
});
// Saving throws
this.getSaveDC();
// To Hit
this.getAttackToHit();
// Limited Uses
this.prepareMaxUses();
// Duration
this.prepareDurationValue();
// Damage Label
this.getDerivedDamageLabel();
}
}
/* -------------------------------------------- */
/**
* Populate a label with the compiled and simplified damage formula based on owned item
* actor data. This is only used for display purposes and is not related to `Item5e#rollDamage`.
* @returns {{damageType: string, formula: string, label: string}[]}
*/
getDerivedDamageLabel() {
if ( !this.hasDamage || !this.isOwned ) return [];
const rollData = this.getRollData();
const damageLabels = { ...CONFIG.DND5E.damageTypes, ...CONFIG.DND5E.healingTypes };
const derivedDamage = this.system.damage?.parts?.map(damagePart => {
let formula;
try {
const roll = new Roll(damagePart[0], rollData);
formula = simplifyRollFormula(roll.formula, { preserveFlavor: true });
}
catch(err) {
console.warn(`Unable to simplify formula for ${this.name}: ${err}`);
}
const damageType = damagePart[1];
return { formula, damageType, label: `${formula} ${damageLabels[damageType] ?? ""}` };
});
return this.labels.derivedDamage = derivedDamage;
}
/* -------------------------------------------- */
/**
* Update the derived spell DC for an item that requires a saving throw.
* @returns {number|null}
*/
getSaveDC() {
if ( !this.hasSave ) return null;
const save = this.system.save;
// Actor spell-DC based scaling
if ( save.scaling === "spell" ) {
save.dc = this.isOwned ? this.actor.system.attributes.spelldc : null;
}
// Ability-score based scaling
else if ( save.scaling !== "flat" ) {
save.dc = this.isOwned ? this.actor.system.abilities[save.scaling].dc : null;
}
// Update labels
const abl = CONFIG.DND5E.abilities[save.ability]?.label ?? "";
this.labels.save = game.i18n.format("DND5E.SaveDC", {dc: save.dc || "", ability: abl});
return save.dc;
}
/* -------------------------------------------- */
/**
* Update a label to the Item detailing its total to hit bonus from the following sources:
* - item document's innate attack bonus
* - item's actor's proficiency bonus if applicable
* - item's actor's global bonuses to the given item type
* - item's ammunition if applicable
* @returns {{rollData: object, parts: string[]}|null} Data used in the item's Attack roll.
*/
getAttackToHit() {
if ( !this.hasAttack ) return null;
const rollData = this.getRollData();
const parts = [];
// Include the item's innate attack bonus as the initial value and label
const ab = this.system.attackBonus;
if ( ab ) {
parts.push(ab);
this.labels.toHit = !/^[+-]/.test(ab) ? `+ ${ab}` : ab;
}
// Take no further action for un-owned items
if ( !this.isOwned ) return {rollData, parts};
// Ability score modifier
parts.push("@mod");
// Add proficiency bonus if an explicit proficiency flag is present or for non-item features
if ( !["weapon", "consumable"].includes(this.type) || this.system.proficient ) {
parts.push("@prof");
if ( this.system.prof?.hasProficiency ) rollData.prof = this.system.prof.term;
}
// Actor-level global bonus to attack rolls
const actorBonus = this.actor.system.bonuses?.[this.system.actionType] || {};
if ( actorBonus.attack ) parts.push(actorBonus.attack);
// One-time bonus provided by consumed ammunition
if ( (this.system.consume?.type === "ammo") && this.actor.items ) {
const ammoItem = this.actor.items.get(this.system.consume.target);
if ( ammoItem ) {
const ammoItemQuantity = ammoItem.system.quantity;
const ammoCanBeConsumed = ammoItemQuantity && (ammoItemQuantity - (this.system.consume.amount ?? 0) >= 0);
const ammoItemAttackBonus = ammoItem.system.attackBonus;
const ammoIsTypeConsumable = (ammoItem.type === "consumable") && (ammoItem.system.consumableType === "ammo");
if ( ammoCanBeConsumed && ammoItemAttackBonus && ammoIsTypeConsumable ) {
parts.push("@ammo");
rollData.ammo = ammoItemAttackBonus;
}
}
}
// Condense the resulting attack bonus formula into a simplified label
const roll = new Roll(parts.join("+"), rollData);
const formula = simplifyRollFormula(roll.formula) || "0";
this.labels.toHit = !/^[+-]/.test(formula) ? `+ ${formula}` : formula;
return {rollData, parts};
}
/* -------------------------------------------- */
/**
* Populates the max uses of an item.
* If the item is an owned item and the `max` is not numeric, calculate based on actor data.
*/
prepareMaxUses() {
const uses = this.system.uses;
if ( !uses?.max ) return;
let max = uses.max;
if ( this.isOwned && !Number.isNumeric(max) ) {
const property = game.i18n.localize("DND5E.UsesMax");
try {
const rollData = this.getRollData({ deterministic: true });
max = Roll.safeEval(this.replaceFormulaData(max, rollData, { property }));
} catch(e) {
const message = game.i18n.format("DND5E.FormulaMalformedError", { property, name: this.name });
this.actor._preparationWarnings.push({ message, link: this.uuid, type: "error" });
console.error(message, e);
return;
}
}
uses.max = Number(max);
}
/* -------------------------------------------- */
/**
* Populate the duration value of an item. If the item is an owned item and the
* duration value is not numeric, calculate based on actor data.
*/
prepareDurationValue() {
const duration = this.system.duration;
if ( !duration?.value ) return;
let value = duration.value;
// If this is an owned item and the value is not numeric, we need to calculate it
if ( this.isOwned && !Number.isNumeric(value) ) {
const property = game.i18n.localize("DND5E.Duration");
try {
const rollData = this.getRollData({ deterministic: true });
value = Roll.safeEval(this.replaceFormulaData(value, rollData, { property }));
} catch(e) {
const message = game.i18n.format("DND5E.FormulaMalformedError", { property, name: this.name });
this.actor._preparationWarnings.push({ message, link: this.uuid, type: "error" });
console.error(message, e);
return;
}
}
duration.value = Number(value);
// Now that duration value is a number, set the label
if ( ["inst", "perm"].includes(duration.units) ) duration.value = null;
this.labels.duration = [duration.value, CONFIG.DND5E.timePeriods[duration.units]].filterJoin(" ");
}
/* -------------------------------------------- */
/**
* Replace referenced data attributes in the roll formula with values from the provided data.
* If the attribute is not found in the provided data, display a warning on the actor.
* @param {string} formula The original formula within which to replace.
* @param {object} data The data object which provides replacements.
* @param {object} options
* @param {string} options.property Name of the property to which this formula belongs.
* @returns {string} Formula with replaced data.
*/
replaceFormulaData(formula, data, { property }) {
const dataRgx = new RegExp(/@([a-z.0-9_-]+)/gi);
const missingReferences = new Set();
formula = formula.replace(dataRgx, (match, term) => {
let value = foundry.utils.getProperty(data, term);
if ( value == null ) {
missingReferences.add(match);
return "0";
}
return String(value).trim();
});
if ( (missingReferences.size > 0) && this.actor ) {
const listFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "conjunction" });
const message = game.i18n.format("DND5E.FormulaMissingReferenceWarn", {
property, name: this.name, references: listFormatter.format(missingReferences)
});
this.actor._preparationWarnings.push({ message, link: this.uuid, type: "warning" });
}
return formula;
}
/* -------------------------------------------- */
/**
* Configuration data for an item usage being prepared.
*
* @typedef {object} ItemUseConfiguration
* @property {boolean} createMeasuredTemplate Trigger a template creation
* @property {boolean} consumeQuantity Should the item's quantity be consumed?
* @property {boolean} consumeRecharge Should a recharge be consumed?
* @property {boolean} consumeResource Should a linked (non-ammo) resource be consumed?
* @property {number|string|null} consumeSpellLevel Specific spell level to consume, or "pact" for pact level.
* @property {boolean} consumeSpellSlot Should any spell slot be consumed?
* @property {boolean} consumeUsage Should limited uses be consumed?
* @property {boolean} needsConfiguration Is user-configuration needed?
*/
/**
* Additional options used for configuring item usage.
*
* @typedef {object} ItemUseOptions
* @property {boolean} configureDialog Display a configuration dialog for the item usage, if applicable?
* @property {string} rollMode The roll display mode with which to display (or not) the card.
* @property {boolean} createMessage Whether to automatically create a chat message (if true) or simply return
* the prepared chat message data (if false).
* @property {object} flags Additional flags added to the chat message.
* @property {Event} event The browser event which triggered the item usage, if any.
*/
/**
* Trigger an item usage, optionally creating a chat message with followup actions.
* @param {ItemUseOptions} [options] Options used for configuring item usage.
* @returns {Promise} Chat message if options.createMessage is true, message data if it is
* false, and nothing if the roll wasn't performed.
* @deprecated since 2.0 in favor of `Item5e#use`, targeted for removal in 2.4
*/
async roll(options={}) {
foundry.utils.logCompatibilityWarning(
"Item5e#roll has been renamed Item5e#use. Support for the old name will be removed in future versions.",
{ since: "DnD5e 2.0", until: "DnD5e 2.4" }
);
return this.use(undefined, options);
}
/**
* Trigger an item usage, optionally creating a chat message with followup actions.
* @param {ItemUseConfiguration} [config] Initial configuration data for the usage.
* @param {ItemUseOptions} [options] Options used for configuring item usage.
* @returns {Promise} Chat message if options.createMessage is true, message data if it is
* false, and nothing if the roll wasn't performed.
*/
async use(config={}, options={}) {
let item = this;
const is = item.system;
const as = item.actor.system;
// Ensure the options object is ready
options = foundry.utils.mergeObject({
configureDialog: true,
createMessage: true,
"flags.dnd5e.use": {type: this.type, itemId: this.id, itemUuid: this.uuid}
}, options);
// Reference aspects of the item data necessary for usage
const resource = is.consume || {}; // Resource consumption
const isSpell = item.type === "spell"; // Does the item require a spell slot?
const requireSpellSlot = isSpell && (is.level > 0) && CONFIG.DND5E.spellUpcastModes.includes(is.preparation.mode);
// Define follow-up actions resulting from the item usage
config = foundry.utils.mergeObject({
createMeasuredTemplate: item.hasAreaTarget,
consumeQuantity: is.uses?.autoDestroy ?? false,
consumeRecharge: !!is.recharge?.value,
consumeResource: !!resource.target && (!item.hasAttack || (resource.type !== "ammo")),
consumeSpellLevel: requireSpellSlot ? is.preparation.mode === "pact" ? "pact" : is.level : null,
consumeSpellSlot: requireSpellSlot,
consumeUsage: !!is.uses?.per && (is.uses?.max > 0)
}, config);
// Display a configuration dialog to customize the usage
if ( config.needsConfiguration === undefined ) config.needsConfiguration = config.createMeasuredTemplate
|| config.consumeRecharge || config.consumeResource || config.consumeSpellSlot || config.consumeUsage;
/**
* A hook event that fires before an item usage is configured.
* @function dnd5e.preUseItem
* @memberof hookEvents
* @param {Item5e} item Item being used.
* @param {ItemUseConfiguration} config Configuration data for the item usage being prepared.
* @param {ItemUseOptions} options Additional options used for configuring item usage.
* @returns {boolean} Explicitly return `false` to prevent item from being used.
*/
if ( Hooks.call("dnd5e.preUseItem", item, config, options) === false ) return;
// Display configuration dialog
if ( (options.configureDialog !== false) && config.needsConfiguration ) {
const configuration = await AbilityUseDialog.create(item);
if ( !configuration ) return;
foundry.utils.mergeObject(config, configuration);
}
// Handle spell upcasting
if ( isSpell && (config.consumeSpellSlot || config.consumeSpellLevel) ) {
const upcastLevel = config.consumeSpellLevel === "pact" ? as.spells.pact.level
: parseInt(config.consumeSpellLevel);
if ( upcastLevel && (upcastLevel !== is.level) ) {
item = item.clone({"system.level": upcastLevel}, {keepId: true});
item.prepareData();
item.prepareFinalAttributes();
}
}
if ( isSpell ) foundry.utils.mergeObject(options.flags, {"dnd5e.use.spellLevel": item.system.level});
/**
* A hook event that fires before an item's resource consumption has been calculated.
* @function dnd5e.preItemUsageConsumption
* @memberof hookEvents
* @param {Item5e} item Item being used.
* @param {ItemUseConfiguration} config Configuration data for the item usage being prepared.
* @param {ItemUseOptions} options Additional options used for configuring item usage.
* @returns {boolean} Explicitly return `false` to prevent item from being used.
*/
if ( Hooks.call("dnd5e.preItemUsageConsumption", item, config, options) === false ) return;
// Determine whether the item can be used by testing for resource consumption
const usage = item._getUsageUpdates(config);
if ( !usage ) return;
/**
* A hook event that fires after an item's resource consumption has been calculated but before any
* changes have been made.
* @function dnd5e.itemUsageConsumption
* @memberof hookEvents
* @param {Item5e} item Item being used.
* @param {ItemUseConfiguration} config Configuration data for the item usage being prepared.
* @param {ItemUseOptions} options Additional options used for configuring item usage.
* @param {object} usage
* @param {object} usage.actorUpdates Updates that will be applied to the actor.
* @param {object} usage.itemUpdates Updates that will be applied to the item being used.
* @param {object[]} usage.resourceUpdates Updates that will be applied to other items on the actor.
* @returns {boolean} Explicitly return `false` to prevent item from being used.
*/
if ( Hooks.call("dnd5e.itemUsageConsumption", item, config, options, usage) === false ) return;
// Commit pending data updates
const { actorUpdates, itemUpdates, resourceUpdates } = usage;
if ( !foundry.utils.isEmpty(itemUpdates) ) await item.update(itemUpdates);
if ( config.consumeQuantity && (item.system.quantity === 0) ) await item.delete();
if ( !foundry.utils.isEmpty(actorUpdates) ) await this.actor.update(actorUpdates);
if ( resourceUpdates.length ) await this.actor.updateEmbeddedDocuments("Item", resourceUpdates);
// Prepare card data & display it if options.createMessage is true
const cardData = await item.displayCard(options);
// Initiate measured template creation
let templates;
if ( config.createMeasuredTemplate ) {
try {
templates = await (dnd5e.canvas.AbilityTemplate.fromItem(item))?.drawPreview();
} catch(err) {}
}
/**
* A hook event that fires when an item is used, after the measured template has been created if one is needed.
* @function dnd5e.useItem
* @memberof hookEvents
* @param {Item5e} item Item being used.
* @param {ItemUseConfiguration} config Configuration data for the roll.
* @param {ItemUseOptions} options Additional options for configuring item usage.
* @param {MeasuredTemplateDocument[]|null} templates The measured templates if they were created.
*/
Hooks.callAll("dnd5e.useItem", item, config, options, templates ?? null);
return cardData;
}
/* -------------------------------------------- */
/**
* Verify that the consumed resources used by an Item are available and prepare the updates that should
* be performed. If required resources are not available, display an error and return false.
* @param {ItemUseConfiguration} config Configuration data for an item usage being prepared.
* @returns {object|boolean} A set of data changes to apply when the item is used, or false.
* @protected
*/
_getUsageUpdates({
consumeQuantity, consumeRecharge, consumeResource, consumeSpellSlot,
consumeSpellLevel, consumeUsage}) {
const actorUpdates = {};
const itemUpdates = {};
const resourceUpdates = [];
// Consume Recharge
if ( consumeRecharge ) {
const recharge = this.system.recharge || {};
if ( recharge.charged === false ) {
ui.notifications.warn(game.i18n.format("DND5E.ItemNoUses", {name: this.name}));
return false;
}
itemUpdates["system.recharge.charged"] = false;
}
// Consume Limited Resource
if ( consumeResource ) {
const canConsume = this._handleConsumeResource(itemUpdates, actorUpdates, resourceUpdates);
if ( canConsume === false ) return false;
}
// Consume Spell Slots
if ( consumeSpellSlot && consumeSpellLevel ) {
if ( Number.isNumeric(consumeSpellLevel) ) consumeSpellLevel = `spell${consumeSpellLevel}`;
const level = this.actor?.system.spells[consumeSpellLevel];
const spells = Number(level?.value ?? 0);
if ( spells === 0 ) {
const labelKey = consumeSpellLevel === "pact" ? "DND5E.SpellProgPact" : `DND5E.SpellLevel${this.system.level}`;
const label = game.i18n.localize(labelKey);
ui.notifications.warn(game.i18n.format("DND5E.SpellCastNoSlots", {name: this.name, level: label}));
return false;
}
actorUpdates[`system.spells.${consumeSpellLevel}.value`] = Math.max(spells - 1, 0);
}
// Consume Limited Usage
if ( consumeUsage ) {
const uses = this.system.uses || {};
const available = Number(uses.value ?? 0);
let used = false;
const remaining = Math.max(available - 1, 0);
if ( available >= 1 ) {
used = true;
itemUpdates["system.uses.value"] = remaining;
}
// Reduce quantity if not reducing usages or if usages hit zero, and we are set to consumeQuantity
if ( consumeQuantity && (!used || (remaining === 0)) ) {
const q = Number(this.system.quantity ?? 1);
if ( q >= 1 ) {
used = true;
itemUpdates["system.quantity"] = Math.max(q - 1, 0);
itemUpdates["system.uses.value"] = uses.max ?? 1;
}
}
// If the item was not used, return a warning
if ( !used ) {
ui.notifications.warn(game.i18n.format("DND5E.ItemNoUses", {name: this.name}));
return false;
}
}
// Return the configured usage
return {itemUpdates, actorUpdates, resourceUpdates};
}
/* -------------------------------------------- */
/**
* Handle update actions required when consuming an external resource
* @param {object} itemUpdates An object of data updates applied to this item
* @param {object} actorUpdates An object of data updates applied to the item owner (Actor)
* @param {object[]} resourceUpdates An array of updates to apply to other items owned by the actor
* @returns {boolean|void} Return false to block further progress, or return nothing to continue
* @protected
*/
_handleConsumeResource(itemUpdates, actorUpdates, resourceUpdates) {
const consume = this.system.consume || {};
if ( !consume.type ) return;
// No consumed target
const typeLabel = CONFIG.DND5E.abilityConsumptionTypes[consume.type];
if ( !consume.target ) {
ui.notifications.warn(game.i18n.format("DND5E.ConsumeWarningNoResource", {name: this.name, type: typeLabel}));
return false;
}
// Identify the consumed resource and its current quantity
let resource = null;
let amount = Number(consume.amount ?? 1);
let quantity = 0;
switch ( consume.type ) {
case "attribute":
resource = foundry.utils.getProperty(this.actor.system, consume.target);
quantity = resource || 0;
break;
case "ammo":
case "material":
resource = this.actor.items.get(consume.target);
quantity = resource ? resource.system.quantity : 0;
break;
case "hitDice":
const denom = !["smallest", "largest"].includes(consume.target) ? consume.target : false;
resource = Object.values(this.actor.classes).filter(cls => !denom || (cls.system.hitDice === denom));
quantity = resource.reduce((count, cls) => count + cls.system.levels - cls.system.hitDiceUsed, 0);
break;
case "charges":
resource = this.actor.items.get(consume.target);
if ( !resource ) break;
const uses = resource.system.uses;
if ( uses.per && uses.max ) quantity = uses.value;
else if ( resource.system.recharge?.value ) {
quantity = resource.system.recharge.charged ? 1 : 0;
amount = 1;
}
break;
}
// Verify that a consumed resource is available
if ( resource === undefined ) {
ui.notifications.warn(game.i18n.format("DND5E.ConsumeWarningNoSource", {name: this.name, type: typeLabel}));
return false;
}
// Verify that the required quantity is available
let remaining = quantity - amount;
if ( remaining < 0 ) {
ui.notifications.warn(game.i18n.format("DND5E.ConsumeWarningNoQuantity", {name: this.name, type: typeLabel}));
return false;
}
// Define updates to provided data objects
switch ( consume.type ) {
case "attribute":
actorUpdates[`system.${consume.target}`] = remaining;
break;
case "ammo":
case "material":
resourceUpdates.push({_id: consume.target, "system.quantity": remaining});
break;
case "hitDice":
if ( ["smallest", "largest"].includes(consume.target) ) resource = resource.sort((lhs, rhs) => {
let sort = lhs.system.hitDice.localeCompare(rhs.system.hitDice, "en", {numeric: true});
if ( consume.target === "largest" ) sort *= -1;
return sort;
});
let toConsume = consume.amount;
for ( const cls of resource ) {
const available = (toConsume > 0 ? cls.system.levels : 0) - cls.system.hitDiceUsed;
const delta = toConsume > 0 ? Math.min(toConsume, available) : Math.max(toConsume, available);
if ( delta !== 0 ) {
resourceUpdates.push({_id: cls.id, "system.hitDiceUsed": cls.system.hitDiceUsed + delta});
toConsume -= delta;
if ( toConsume === 0 ) break;
}
}
break;
case "charges":
const uses = resource.system.uses || {};
const recharge = resource.system.recharge || {};
const update = {_id: consume.target};
if ( uses.per && uses.max ) update["system.uses.value"] = remaining;
else if ( recharge.value ) update["system.recharge.charged"] = false;
resourceUpdates.push(update);
break;
}
}
/* -------------------------------------------- */
/**
* Display the chat card for an Item as a Chat Message
* @param {ItemUseOptions} [options] Options which configure the display of the item chat card.
* @returns {ChatMessage|object} Chat message if `createMessage` is true, otherwise an object containing
* message data.
*/
async displayCard(options={}) {
// Render the chat card template
const token = this.actor.token;
const templateData = {
actor: this.actor,
tokenId: token?.uuid || null,
item: this,
data: await this.getChatData(),
labels: this.labels,
hasAttack: this.hasAttack,
isHealing: this.isHealing,
hasDamage: this.hasDamage,
isVersatile: this.isVersatile,
isSpell: this.type === "spell",
hasSave: this.hasSave,
hasAreaTarget: this.hasAreaTarget,
isTool: this.type === "tool",
hasAbilityCheck: this.hasAbilityCheck
};
const html = await renderTemplate("systems/dnd5e/templates/chat/item-card.hbs", templateData);
// Create the ChatMessage data object
const chatData = {
user: game.user.id,
type: CONST.CHAT_MESSAGE_TYPES.OTHER,
content: html,
flavor: this.system.chatFlavor || this.name,
speaker: ChatMessage.getSpeaker({actor: this.actor, token}),
flags: {"core.canPopout": true}
};
// If the Item was destroyed in the process of displaying its card - embed the item data in the chat message
if ( (this.type === "consumable") && !this.actor.items.has(this.id) ) {
chatData.flags["dnd5e.itemData"] = templateData.item.toObject();
}
// Merge in the flags from options
chatData.flags = foundry.utils.mergeObject(chatData.flags, options.flags);
/**
* A hook event that fires before an item chat card is created.
* @function dnd5e.preDisplayCard
* @memberof hookEvents
* @param {Item5e} item Item for which the chat card is being displayed.
* @param {object} chatData Data used to create the chat message.
* @param {ItemUseOptions} options Options which configure the display of the item chat card.
*/
Hooks.callAll("dnd5e.preDisplayCard", this, chatData, options);
// Apply the roll mode to adjust message visibility
ChatMessage.applyRollMode(chatData, options.rollMode ?? game.settings.get("core", "rollMode"));
// Create the Chat Message or return its data
const card = (options.createMessage !== false) ? await ChatMessage.create(chatData) : chatData;
/**
* A hook event that fires after an item chat card is created.
* @function dnd5e.displayCard
* @memberof hookEvents
* @param {Item5e} item Item for which the chat card is being displayed.
* @param {ChatMessage|object} card The created ChatMessage instance or ChatMessageData depending on whether
* options.createMessage was set to `true`.
*/
Hooks.callAll("dnd5e.displayCard", this, card);
return card;
}
/* -------------------------------------------- */
/* Chat Cards */
/* -------------------------------------------- */
/**
* Prepare an object of chat data used to display a card for the Item in the chat log.
* @param {object} htmlOptions Options used by the TextEditor.enrichHTML function.
* @returns {object} An object of chat data to render.
*/
async getChatData(htmlOptions={}) {
const data = this.toObject().system;
// Rich text description
data.description.value = await TextEditor.enrichHTML(data.description.value, {
async: true,
relativeTo: this,
rollData: this.getRollData(),
...htmlOptions
});
// Type specific properties
data.properties = [
...this.system.chatProperties ?? [],
...this.system.equippableItemChatProperties ?? [],
...this.system.activatedEffectChatProperties ?? []
].filter(p => p);
return data;
}
/* -------------------------------------------- */
/* Item Rolls - Attack, Damage, Saves, Checks */
/* -------------------------------------------- */
/**
* Place an attack roll using an item (weapon, feat, spell, or equipment)
* Rely upon the d20Roll logic for the core implementation
*
* @param {D20RollConfiguration} options Roll options which are configured and provided to the d20Roll function
* @returns {Promise} A Promise which resolves to the created Roll instance
*/
async rollAttack(options={}) {
const flags = this.actor.flags.dnd5e ?? {};
if ( !this.hasAttack ) throw new Error("You may not place an Attack Roll with this Item.");
let title = `${this.name} - ${game.i18n.localize("DND5E.AttackRoll")}`;
// Get the parts and rollData for this item's attack
const {parts, rollData} = this.getAttackToHit();
if ( options.spellLevel ) rollData.item.level = options.spellLevel;
// Handle ammunition consumption
delete this._ammo;
let ammo = null;
let ammoUpdate = [];
const consume = this.system.consume;
if ( consume?.type === "ammo" ) {
ammo = this.actor.items.get(consume.target);
if ( ammo?.system ) {
const q = ammo.system.quantity;
const consumeAmount = consume.amount ?? 0;
if ( q && (q - consumeAmount >= 0) ) {
this._ammo = ammo;
title += ` [${ammo.name}]`;
}
}
// Get pending ammunition update
const usage = this._getUsageUpdates({consumeResource: true});
if ( usage === false ) return null;
ammoUpdate = usage.resourceUpdates ?? [];
}
// Flags
const elvenAccuracy = (flags.elvenAccuracy
&& CONFIG.DND5E.characterFlags.elvenAccuracy.abilities.includes(this.abilityMod)) || undefined;
// Compose roll options
const rollConfig = foundry.utils.mergeObject({
actor: this.actor,
data: rollData,
critical: this.criticalThreshold,
title,
flavor: title,
elvenAccuracy,
halflingLucky: flags.halflingLucky,
dialogOptions: {
width: 400,
top: options.event ? options.event.clientY - 80 : null,
left: window.innerWidth - 710
},
messageData: {
"flags.dnd5e.roll": {type: "attack", itemId: this.id, itemUuid: this.uuid},
speaker: ChatMessage.getSpeaker({actor: this.actor})
}
}, options);
rollConfig.parts = parts.concat(options.parts ?? []);
/**
* A hook event that fires before an attack is rolled for an Item.
* @function dnd5e.preRollAttack
* @memberof hookEvents
* @param {Item5e} item Item for which the roll is being performed.
* @param {D20RollConfiguration} config Configuration data for the pending roll.
* @returns {boolean} Explicitly return false to prevent the roll from being performed.
*/
if ( Hooks.call("dnd5e.preRollAttack", this, rollConfig) === false ) return;
const roll = await d20Roll(rollConfig);
if ( roll === null ) return null;
/**
* A hook event that fires after an attack has been rolled for an Item.
* @function dnd5e.rollAttack
* @memberof hookEvents
* @param {Item5e} item Item for which the roll was performed.
* @param {D20Roll} roll The resulting roll.
* @param {object[]} ammoUpdate Updates that will be applied to ammo Items as a result of this attack.
*/
Hooks.callAll("dnd5e.rollAttack", this, roll, ammoUpdate);
// Commit ammunition consumption on attack rolls resource consumption if the attack roll was made
if ( ammoUpdate.length ) await this.actor?.updateEmbeddedDocuments("Item", ammoUpdate);
return roll;
}
/* -------------------------------------------- */
/**
* Place a damage roll using an item (weapon, feat, spell, or equipment)
* Rely upon the damageRoll logic for the core implementation.
* @param {object} [config]
* @param {MouseEvent} [config.event] An event which triggered this roll, if any
* @param {boolean} [config.critical] Should damage be rolled as a critical hit?
* @param {number} [config.spellLevel] If the item is a spell, override the level for damage scaling
* @param {boolean} [config.versatile] If the item is a weapon, roll damage using the versatile formula
* @param {DamageRollConfiguration} [config.options] Additional options passed to the damageRoll function
* @returns {Promise} A Promise which resolves to the created Roll instance, or null if the action
* cannot be performed.
*/
async rollDamage({critical, event=null, spellLevel=null, versatile=false, options={}}={}) {
if ( !this.hasDamage ) throw new Error("You may not make a Damage Roll with this Item.");
const messageData = {
"flags.dnd5e.roll": {type: "damage", itemId: this.id, itemUuid: this.uuid},
speaker: ChatMessage.getSpeaker({actor: this.actor})
};
// Get roll data
const dmg = this.system.damage;
const parts = dmg.parts.map(d => d[0]);
const rollData = this.getRollData();
if ( spellLevel ) rollData.item.level = spellLevel;
// Configure the damage roll
const actionFlavor = game.i18n.localize(this.system.actionType === "heal" ? "DND5E.Healing" : "DND5E.DamageRoll");
const title = `${this.name} - ${actionFlavor}`;
const rollConfig = {
actor: this.actor,
critical,
data: rollData,
event,
title: title,
flavor: this.labels.damageTypes.length ? `${title} (${this.labels.damageTypes})` : title,
dialogOptions: {
width: 400,
top: event ? event.clientY - 80 : null,
left: window.innerWidth - 710
},
messageData
};
// Adjust damage from versatile usage
if ( versatile && dmg.versatile ) {
parts[0] = dmg.versatile;
messageData["flags.dnd5e.roll"].versatile = true;
}
// Scale damage from up-casting spells
const scaling = this.system.scaling;
if ( (this.type === "spell") ) {
if ( scaling.mode === "cantrip" ) {
let level;
if ( this.actor.type === "character" ) level = this.actor.system.details.level;
else if ( this.system.preparation.mode === "innate" ) level = Math.ceil(this.actor.system.details.cr);
else level = this.actor.system.details.spellLevel;
this._scaleCantripDamage(parts, scaling.formula, level, rollData);
}
else if ( spellLevel && (scaling.mode === "level") && scaling.formula ) {
this._scaleSpellDamage(parts, this.system.level, spellLevel, scaling.formula, rollData);
}
}
// Add damage bonus formula
const actorBonus = foundry.utils.getProperty(this.actor.system, `bonuses.${this.system.actionType}`) || {};
if ( actorBonus.damage && (parseInt(actorBonus.damage) !== 0) ) {
parts.push(actorBonus.damage);
}
// Only add the ammunition damage if the ammunition is a consumable with type 'ammo'
if ( this._ammo && (this._ammo.type === "consumable") && (this._ammo.system.consumableType === "ammo") ) {
parts.push("@ammo");
rollData.ammo = this._ammo.system.damage.parts.map(p => p[0]).join("+");
rollConfig.flavor += ` [${this._ammo.name}]`;
delete this._ammo;
}
// Factor in extra critical damage dice from the Barbarian's "Brutal Critical"
if ( this.system.actionType === "mwak" ) {
rollConfig.criticalBonusDice = this.actor.getFlag("dnd5e", "meleeCriticalDamageDice") ?? 0;
}
// Factor in extra weapon-specific critical damage
if ( this.system.critical?.damage ) rollConfig.criticalBonusDamage = this.system.critical.damage;
foundry.utils.mergeObject(rollConfig, options);
rollConfig.parts = parts.concat(options.parts ?? []);
/**
* A hook event that fires before a damage is rolled for an Item.
* @function dnd5e.preRollDamage
* @memberof hookEvents
* @param {Item5e} item Item for which the roll is being performed.
* @param {DamageRollConfiguration} config Configuration data for the pending roll.
* @returns {boolean} Explicitly return false to prevent the roll from being performed.
*/
if ( Hooks.call("dnd5e.preRollDamage", this, rollConfig) === false ) return;
const roll = await damageRoll(rollConfig);
/**
* A hook event that fires after a damage has been rolled for an Item.
* @function dnd5e.rollDamage
* @memberof hookEvents
* @param {Item5e} item Item for which the roll was performed.
* @param {DamageRoll} roll The resulting roll.
*/
if ( roll ) Hooks.callAll("dnd5e.rollDamage", this, roll);
// Call the roll helper utility
return roll;
}
/* -------------------------------------------- */
/**
* Adjust a cantrip damage formula to scale it for higher level characters and monsters.
* @param {string[]} parts The original parts of the damage formula.
* @param {string} scale The scaling formula.
* @param {number} level Level at which the spell is being cast.
* @param {object} rollData A data object that should be applied to the scaled damage roll.
* @returns {string[]} The parts of the damage formula with the scaling applied.
* @private
*/
_scaleCantripDamage(parts, scale, level, rollData) {
const add = Math.floor((level + 1) / 6);
if ( add === 0 ) return [];
return this._scaleDamage(parts, scale || parts.join(" + "), add, rollData);
}
/* -------------------------------------------- */
/**
* Adjust the spell damage formula to scale it for spell level up-casting.
* @param {string[]} parts The original parts of the damage formula.
* @param {number} baseLevel Default level for the spell.
* @param {number} spellLevel Level at which the spell is being cast.
* @param {string} formula The scaling formula.
* @param {object} rollData A data object that should be applied to the scaled damage roll.
* @returns {string[]} The parts of the damage formula with the scaling applied.
* @private
*/
_scaleSpellDamage(parts, baseLevel, spellLevel, formula, rollData) {
const upcastLevels = Math.max(spellLevel - baseLevel, 0);
if ( upcastLevels === 0 ) return parts;
return this._scaleDamage(parts, formula, upcastLevels, rollData);
}
/* -------------------------------------------- */
/**
* Scale an array of damage parts according to a provided scaling formula and scaling multiplier.
* @param {string[]} parts The original parts of the damage formula.
* @param {string} scaling The scaling formula.
* @param {number} times A number of times to apply the scaling formula.
* @param {object} rollData A data object that should be applied to the scaled damage roll
* @returns {string[]} The parts of the damage formula with the scaling applied.
* @private
*/
_scaleDamage(parts, scaling, times, rollData) {
if ( times <= 0 ) return parts;
const p0 = new Roll(parts[0], rollData);
const s = new Roll(scaling, rollData).alter(times);
// Attempt to simplify by combining like dice terms
let simplified = false;
if ( (s.terms[0] instanceof Die) && (s.terms.length === 1) ) {
const d0 = p0.terms[0];
const s0 = s.terms[0];
if ( (d0 instanceof Die) && (d0.faces === s0.faces) && d0.modifiers.equals(s0.modifiers) ) {
d0.number += s0.number;
parts[0] = p0.formula;
simplified = true;
}
}
// Otherwise, add to the first part
if ( !simplified ) parts[0] = `${parts[0]} + ${s.formula}`;
return parts;
}
/* -------------------------------------------- */
/**
* Prepare data needed to roll an attack using an item (weapon, feat, spell, or equipment)
* and then pass it off to `d20Roll`.
* @param {object} [options]
* @param {boolean} [options.spellLevel] Level at which a spell is cast.
* @returns {Promise} A Promise which resolves to the created Roll instance.
*/
async rollFormula({spellLevel}={}) {
if ( !this.system.formula ) throw new Error("This Item does not have a formula to roll!");
const rollConfig = {
formula: this.system.formula,
data: this.getRollData(),
chatMessage: true
};
if ( spellLevel ) rollConfig.data.item.level = spellLevel;
/**
* A hook event that fires before a formula is rolled for an Item.
* @function dnd5e.preRollFormula
* @memberof hookEvents
* @param {Item5e} item Item for which the roll is being performed.
* @param {object} config Configuration data for the pending roll.
* @param {string} config.formula Formula that will be rolled.
* @param {object} config.data Data used when evaluating the roll.
* @param {boolean} config.chatMessage Should a chat message be created for this roll?
* @returns {boolean} Explicitly return false to prevent the roll from being performed.
*/
if ( Hooks.call("dnd5e.preRollFormula", this, rollConfig) === false ) return;
const roll = await new Roll(rollConfig.formula, rollConfig.data).roll({async: true});
if ( rollConfig.chatMessage ) {
roll.toMessage({
speaker: ChatMessage.getSpeaker({actor: this.actor}),
flavor: `${this.name} - ${game.i18n.localize("DND5E.OtherFormula")}`,
rollMode: game.settings.get("core", "rollMode"),
messageData: {"flags.dnd5e.roll": {type: "other", itemId: this.id, itemUuid: this.uuid}}
});
}
/**
* A hook event that fires after a formula has been rolled for an Item.
* @function dnd5e.rollFormula
* @memberof hookEvents
* @param {Item5e} item Item for which the roll was performed.
* @param {Roll} roll The resulting roll.
*/
Hooks.callAll("dnd5e.rollFormula", this, roll);
return roll;
}
/* -------------------------------------------- */
/**
* Perform an ability recharge test for an item which uses the d6 recharge mechanic.
* @returns {Promise} A Promise which resolves to the created Roll instance
*/
async rollRecharge() {
const recharge = this.system.recharge ?? {};
if ( !recharge.value ) return;
const rollConfig = {
formula: "1d6",
data: this.getRollData(),
target: parseInt(recharge.value),
chatMessage: true
};
/**
* A hook event that fires before the Item is rolled to recharge.
* @function dnd5e.preRollRecharge
* @memberof hookEvents
* @param {Item5e} item Item for which the roll is being performed.
* @param {object} config Configuration data for the pending roll.
* @param {string} config.formula Formula that will be used to roll the recharge.
* @param {object} config.data Data used when evaluating the roll.
* @param {number} config.target Total required to be considered recharged.
* @param {boolean} config.chatMessage Should a chat message be created for this roll?
* @returns {boolean} Explicitly return false to prevent the roll from being performed.
*/
if ( Hooks.call("dnd5e.preRollRecharge", this, rollConfig) === false ) return;
const roll = await new Roll(rollConfig.formula, rollConfig.data).roll({async: true});
const success = roll.total >= rollConfig.target;
if ( rollConfig.chatMessage ) {
const resultMessage = game.i18n.localize(`DND5E.ItemRecharge${success ? "Success" : "Failure"}`);
roll.toMessage({
flavor: `${game.i18n.format("DND5E.ItemRechargeCheck", {name: this.name})} - ${resultMessage}`,
speaker: ChatMessage.getSpeaker({actor: this.actor, token: this.actor.token})
});
}
/**
* A hook event that fires after the Item has rolled to recharge, but before any changes have been performed.
* @function dnd5e.rollRecharge
* @memberof hookEvents
* @param {Item5e} item Item for which the roll was performed.
* @param {Roll} roll The resulting roll.
* @returns {boolean} Explicitly return false to prevent the item from being recharged.
*/
if ( Hooks.call("dnd5e.rollRecharge", this, roll) === false ) return roll;
// Update the Item data
if ( success ) this.update({"system.recharge.charged": true});
return roll;
}
/* -------------------------------------------- */
/**
* Prepare data needed to roll a tool check and then pass it off to `d20Roll`.
* @param {D20RollConfiguration} [options] Roll configuration options provided to the d20Roll function.
* @returns {Promise} A Promise which resolves to the created Roll instance.
*/
async rollToolCheck(options={}) {
if ( this.type !== "tool" ) throw new Error("Wrong item type!");
return this.actor?.rollToolCheck(this.system.baseItem, {
ability: this.system.ability,
bonus: this.system.bonus,
...options
});
}
/* -------------------------------------------- */
/**
* @inheritdoc
* @param {object} [options]
* @param {boolean} [options.deterministic] Whether to force deterministic values for data properties that could be
* either a die term or a flat term.
*/
getRollData({ deterministic=false }={}) {
if ( !this.actor ) return null;
const actorRollData = this.actor.getRollData({ deterministic });
const rollData = {
...actorRollData,
item: this.toObject().system
};
// Include an ability score modifier if one exists
const abl = this.abilityMod;
if ( abl && ("abilities" in rollData) ) {
const ability = rollData.abilities[abl];
if ( !ability ) {
console.warn(`Item ${this.name} in Actor ${this.actor.name} has an invalid item ability modifier of ${abl} defined`);
}
rollData.mod = ability?.mod ?? 0;
}
return rollData;
}
/* -------------------------------------------- */
/* Chat Message Helpers */
/* -------------------------------------------- */
/**
* Apply listeners to chat messages.
* @param {HTML} html Rendered chat message.
*/
static chatListeners(html) {
html.on("click", ".card-buttons button", this._onChatCardAction.bind(this));
html.on("click", ".item-name", this._onChatCardToggleContent.bind(this));
}
/* -------------------------------------------- */
/**
* Handle execution of a chat card action via a click event on one of the card buttons
* @param {Event} event The originating click event
* @returns {Promise} A promise which resolves once the handler workflow is complete
* @private
*/
static async _onChatCardAction(event) {
event.preventDefault();
// Extract card data
const button = event.currentTarget;
button.disabled = true;
const card = button.closest(".chat-card");
const messageId = card.closest(".message").dataset.messageId;
const message = game.messages.get(messageId);
const action = button.dataset.action;
// Recover the actor for the chat card
const actor = await this._getChatCardActor(card);
if ( !actor ) return;
// Validate permission to proceed with the roll
const isTargetted = action === "save";
if ( !( isTargetted || game.user.isGM || actor.isOwner ) ) return;
// Get the Item from stored flag data or by the item ID on the Actor
const storedData = message.getFlag("dnd5e", "itemData");
const item = storedData ? new this(storedData, {parent: actor}) : actor.items.get(card.dataset.itemId);
if ( !item ) {
const err = game.i18n.format("DND5E.ActionWarningNoItem", {item: card.dataset.itemId, name: actor.name});
return ui.notifications.error(err);
}
const spellLevel = parseInt(card.dataset.spellLevel) || null;
// Handle different actions
let targets;
switch ( action ) {
case "attack":
await item.rollAttack({
event: event,
spellLevel: spellLevel
});
break;
case "damage":
case "versatile":
await item.rollDamage({
event: event,
spellLevel: spellLevel,
versatile: action === "versatile"
});
break;
case "formula":
await item.rollFormula({event, spellLevel}); break;
case "save":
targets = this._getChatCardTargets(card);
for ( let token of targets ) {
const speaker = ChatMessage.getSpeaker({scene: canvas.scene, token: token.document});
await token.actor.rollAbilitySave(button.dataset.ability, { event, speaker });
}
break;
case "toolCheck":
await item.rollToolCheck({event}); break;
case "placeTemplate":
try {
await dnd5e.canvas.AbilityTemplate.fromItem(item)?.drawPreview();
} catch(err) {}
break;
case "abilityCheck":
targets = this._getChatCardTargets(card);
for ( let token of targets ) {
const speaker = ChatMessage.getSpeaker({scene: canvas.scene, token: token.document});
await token.actor.rollAbilityTest(button.dataset.ability, { event, speaker });
}
break;
}
// Re-enable the button
button.disabled = false;
}
/* -------------------------------------------- */
/**
* Handle toggling the visibility of chat card content when the name is clicked
* @param {Event} event The originating click event
* @private
*/
static _onChatCardToggleContent(event) {
event.preventDefault();
const header = event.currentTarget;
const card = header.closest(".chat-card");
const content = card.querySelector(".card-content");
content.style.display = content.style.display === "none" ? "block" : "none";
}
/* -------------------------------------------- */
/**
* Get the Actor which is the author of a chat card
* @param {HTMLElement} card The chat card being used
* @returns {Actor|null} The Actor document or null
* @private
*/
static async _getChatCardActor(card) {
// Case 1 - a synthetic actor from a Token
if ( card.dataset.tokenId ) {
const token = await fromUuid(card.dataset.tokenId);
if ( !token ) return null;
return token.actor;
}
// Case 2 - use Actor ID directory
const actorId = card.dataset.actorId;
return game.actors.get(actorId) || null;
}
/* -------------------------------------------- */
/**
* Get the Actor which is the author of a chat card
* @param {HTMLElement} card The chat card being used
* @returns {Actor[]} An Array of Actor documents, if any
* @private
*/
static _getChatCardTargets(card) {
let targets = canvas.tokens.controlled.filter(t => !!t.actor);
if ( !targets.length && game.user.character ) targets = targets.concat(game.user.character.getActiveTokens());
if ( !targets.length ) ui.notifications.warn(game.i18n.localize("DND5E.ActionWarningNoToken"));
return targets;
}
/* -------------------------------------------- */
/* Advancements */
/* -------------------------------------------- */
/**
* Create a new advancement of the specified type.
* @param {string} type Type of advancement to create.
* @param {object} [data] Data to use when creating the advancement.
* @param {object} [options]
* @param {boolean} [options.showConfig=true] Should the new advancement's configuration application be shown?
* @param {boolean} [options.source=false] Should a source-only update be performed?
* @returns {Promise|Item5e} Promise for advancement config for new advancement if local
* is `false`, or item with newly added advancement.
*/
createAdvancement(type, data={}, { showConfig=true, source=false }={}) {
if ( !this.system.advancement ) return this;
const Advancement = CONFIG.DND5E.advancementTypes[type];
if ( !Advancement ) throw new Error(`${type} not found in CONFIG.DND5E.advancementTypes`);
if ( !Advancement.metadata.validItemTypes.has(this.type) || !Advancement.availableForItem(this) ) {
throw new Error(`${type} advancement cannot be added to ${this.name}`);
}
const advancement = new Advancement(data, {parent: this});
const advancementCollection = this.toObject().system.advancement;
advancementCollection.push(advancement.toObject());
if ( source ) return this.updateSource({"system.advancement": advancementCollection});
return this.update({"system.advancement": advancementCollection}).then(() => {
if ( !showConfig ) return this;
const config = new Advancement.metadata.apps.config(this.advancement.byId[advancement.id]);
return config.render(true);
});
}
/* -------------------------------------------- */
/**
* Update an advancement belonging to this item.
* @param {string} id ID of the advancement to update.
* @param {object} updates Updates to apply to this advancement.
* @param {object} [options={}]
* @param {boolean} [options.source=false] Should a source-only update be performed?
* @returns {Promise|Item5e} This item with the changes applied, promised if source is `false`.
*/
updateAdvancement(id, updates, { source=false }={}) {
if ( !this.system.advancement ) return this;
const idx = this.system.advancement.findIndex(a => a._id === id);
if ( idx === -1 ) throw new Error(`Advancement of ID ${id} could not be found to update`);
const advancement = this.advancement.byId[id];
advancement.updateSource(updates);
if ( source ) {
advancement.render();
return this;
}
const advancementCollection = this.toObject().system.advancement;
advancementCollection[idx] = advancement.toObject();
return this.update({"system.advancement": advancementCollection}).then(r => {
advancement.render();
return r;
});
}
/* -------------------------------------------- */
/**
* Remove an advancement from this item.
* @param {string} id ID of the advancement to remove.
* @param {object} [options={}]
* @param {boolean} [options.source=false] Should a source-only update be performed?
* @returns {Promise|Item5e} This item with the changes applied.
*/
deleteAdvancement(id, { source=false }={}) {
if ( !this.system.advancement ) return this;
const advancementCollection = this.toObject().system.advancement.filter(a => a._id !== id);
if ( source ) return this.updateSource({"system.advancement": advancementCollection});
return this.update({"system.advancement": advancementCollection});
}
/* -------------------------------------------- */
/**
* Duplicate an advancement, resetting its value to default and giving it a new ID.
* @param {string} id ID of the advancement to duplicate.
* @param {object} [options]
* @param {boolean} [options.showConfig=true] Should the new advancement's configuration application be shown?
* @param {boolean} [options.source=false] Should a source-only update be performed?
* @returns {Promise|Item5e} Promise for advancement config for duplicate advancement if source
* is `false`, or item with newly duplicated advancement.
*/
duplicateAdvancement(id, options) {
const original = this.advancement.byId[id];
if ( !original ) return this;
const duplicate = original.toObject();
delete duplicate._id;
if ( original.constructor.metadata.dataModels?.value ) {
duplicate.value = (new original.constructor.metadata.dataModels.value()).toObject();
} else {
duplicate.value = original.constructor.metadata.defaults?.value ?? {};
}
return this.createAdvancement(original.constructor.typeName, duplicate, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getEmbeddedDocument(embeddedName, id, options) {
if ( embeddedName !== "Advancement" ) return super.getEmbeddedDocument(embeddedName, id, options);
const advancement = this.advancement.byId[id];
if ( options?.strict && (advancement === undefined) ) {
throw new Error(`The key ${id} does not exist in the ${embeddedName} Collection`);
}
return advancement;
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
async _preCreate(data, options, user) {
await super._preCreate(data, options, user);
// Create class identifier based on name
if ( ["class", "subclass"].includes(this.type) && !this.system.identifier ) {
await this.updateSource({ "system.identifier": data.name.slugify({strict: true}) });
}
if ( !this.isEmbedded || (this.parent.type === "vehicle") ) return;
const isNPC = this.parent.type === "npc";
let updates;
switch (data.type) {
case "equipment":
updates = this._onCreateOwnedEquipment(data, isNPC);
break;
case "spell":
updates = this._onCreateOwnedSpell(data, isNPC);
break;
case "tool":
updates = this._onCreateOwnedTool(data, isNPC);
break;
case "weapon":
updates = this._onCreateOwnedWeapon(data, isNPC);
break;
}
if ( updates ) return this.updateSource(updates);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onCreate(data, options, userId) {
super._onCreate(data, options, userId);
if ( (userId !== game.user.id) || !this.parent ) return;
// Assign a new original class
if ( (this.parent.type === "character") && (this.type === "class") ) {
const pc = this.parent.items.get(this.parent.system.details.originalClass);
if ( !pc ) await this.parent._assignPrimaryClass();
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _preUpdate(changed, options, user) {
await super._preUpdate(changed, options, user);
if ( (this.type !== "class") || !("levels" in (changed.system || {})) ) return;
// Check to make sure the updated class level isn't below zero
if ( changed.system.levels <= 0 ) {
ui.notifications.warn(game.i18n.localize("DND5E.MaxClassLevelMinimumWarn"));
changed.system.levels = 1;
}
// Check to make sure the updated class level doesn't exceed level cap
if ( changed.system.levels > CONFIG.DND5E.maxLevel ) {
ui.notifications.warn(game.i18n.format("DND5E.MaxClassLevelExceededWarn", {max: CONFIG.DND5E.maxLevel}));
changed.system.levels = CONFIG.DND5E.maxLevel;
}
if ( !this.isEmbedded || (this.parent.type !== "character") ) return;
// Check to ensure the updated character doesn't exceed level cap
const newCharacterLevel = this.actor.system.details.level + (changed.system.levels - this.system.levels);
if ( newCharacterLevel > CONFIG.DND5E.maxLevel ) {
ui.notifications.warn(game.i18n.format("DND5E.MaxCharacterLevelExceededWarn", {max: CONFIG.DND5E.maxLevel}));
changed.system.levels -= newCharacterLevel - CONFIG.DND5E.maxLevel;
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDelete(options, userId) {
super._onDelete(options, userId);
if ( (userId !== game.user.id) || !this.parent ) return;
// Assign a new original class
if ( (this.type === "class") && (this.id === this.parent.system.details.originalClass) ) {
this.parent._assignPrimaryClass();
}
}
/* -------------------------------------------- */
/**
* Pre-creation logic for the automatic configuration of owned equipment type Items.
*
* @param {object} data Data for the newly created item.
* @param {boolean} isNPC Is this actor an NPC?
* @returns {object} Updates to apply to the item data.
* @private
*/
_onCreateOwnedEquipment(data, isNPC) {
const updates = {};
if ( foundry.utils.getProperty(data, "system.equipped") === undefined ) {
updates["system.equipped"] = isNPC; // NPCs automatically equip equipment
}
if ( foundry.utils.getProperty(data, "system.proficient") === undefined ) {
if ( isNPC ) {
updates["system.proficient"] = true; // NPCs automatically have equipment proficiency
} else {
const armorProf = CONFIG.DND5E.armorProficienciesMap[this.system.armor?.type]; // Player characters check proficiency
const actorArmorProfs = this.parent.system.traits?.armorProf?.value || new Set();
updates["system.proficient"] = (armorProf === true) || actorArmorProfs.has(armorProf)
|| actorArmorProfs.has(this.system.baseItem);
}
}
return updates;
}
/* -------------------------------------------- */
/**
* Pre-creation logic for the automatic configuration of owned spell type Items.
*
* @param {object} data Data for the newly created item.
* @param {boolean} isNPC Is this actor an NPC?
* @returns {object} Updates to apply to the item data.
* @private
*/
_onCreateOwnedSpell(data, isNPC) {
const updates = {};
if ( foundry.utils.getProperty(data, "system.preparation.prepared") === undefined ) {
updates["system.preparation.prepared"] = isNPC; // NPCs automatically prepare spells
}
return updates;
}
/* -------------------------------------------- */
/**
* Pre-creation logic for the automatic configuration of owned tool type Items.
* @param {object} data Data for the newly created item.
* @param {boolean} isNPC Is this actor an NPC?
* @returns {object} Updates to apply to the item data.
* @private
*/
_onCreateOwnedTool(data, isNPC) {
const updates = {};
if ( data.system?.proficient === undefined ) {
if ( isNPC ) updates["system.proficient"] = 1;
else {
const actorToolProfs = this.parent.system.tools || {};
const toolProf = actorToolProfs[this.system.baseItem]?.value;
const generalProf = actorToolProfs[this.system.toolType]?.value;
updates["system.proficient"] = toolProf ?? generalProf ?? 0;
}
}
return updates;
}
/* -------------------------------------------- */
/**
* Pre-creation logic for the automatic configuration of owned weapon type Items.
* @param {object} data Data for the newly created item.
* @param {boolean} isNPC Is this actor an NPC?
* @returns {object} Updates to apply to the item data.
* @private
*/
_onCreateOwnedWeapon(data, isNPC) {
// NPCs automatically equip items and are proficient with them
if ( isNPC ) {
const updates = {};
if ( !foundry.utils.hasProperty(data, "system.equipped") ) updates["system.equipped"] = true;
if ( !foundry.utils.hasProperty(data, "system.proficient") ) updates["system.proficient"] = true;
return updates;
}
if ( data.system?.proficient !== undefined ) return {};
// Some weapon types are always proficient
const weaponProf = CONFIG.DND5E.weaponProficienciesMap[this.system.weaponType];
const updates = {};
if ( weaponProf === true ) updates["system.proficient"] = true;
// Characters may have proficiency in this weapon type (or specific base weapon)
else {
const actorProfs = this.parent.system.traits?.weaponProf?.value || new Set();
updates["system.proficient"] = actorProfs.has(weaponProf) || actorProfs.has(this.system.baseItem);
}
return updates;
}
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* Create a consumable spell scroll Item from a spell Item.
* @param {Item5e} spell The spell to be made into a scroll
* @returns {Item5e} The created scroll consumable item
*/
static async createScrollFromSpell(spell) {
// Get spell data
const itemData = (spell instanceof Item5e) ? spell.toObject() : spell;
let {
actionType, description, source, activation, duration, target, range, damage, formula, save, level, attackBonus
} = itemData.system;
// Get scroll data
const scrollUuid = `Compendium.${CONFIG.DND5E.sourcePacks.ITEMS}.${CONFIG.DND5E.spellScrollIds[level]}`;
const scrollItem = await fromUuid(scrollUuid);
const scrollData = scrollItem.toObject();
delete scrollData._id;
// Split the scroll description into an intro paragraph and the remaining details
const scrollDescription = scrollData.system.description.value;
const pdel = "
";
const scrollIntroEnd = scrollDescription.indexOf(pdel);
const scrollIntro = scrollDescription.slice(0, scrollIntroEnd + pdel.length);
const scrollDetails = scrollDescription.slice(scrollIntroEnd + pdel.length);
// Create a composite description from the scroll description and the spell details
const desc = `${scrollIntro}${itemData.name} (Level ${level}) ${description.value}Scroll Details ${scrollDetails}`;
// Used a fixed attack modifier and saving throw according to the level of spell scroll.
if ( ["mwak", "rwak", "msak", "rsak"].includes(actionType) ) {
attackBonus = `${scrollData.system.attackBonus} - @mod`;
}
if ( save.ability ) {
save.scaling = "flat";
save.dc = scrollData.system.save.dc;
}
// Create the spell scroll data
const spellScrollData = foundry.utils.mergeObject(scrollData, {
name: `${game.i18n.localize("DND5E.SpellScroll")}: ${itemData.name}`,
img: itemData.img,
system: {
description: {value: desc.trim()}, source, actionType, activation, duration, target, range, damage, formula,
save, level, attackBonus
}
});
return new this(spellScrollData);
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* Retrieve an item's critical hit threshold. Uses the smallest value from among the following sources:
* - item document
* - item document's actor (if it has one)
* - item document's ammunition (if it has any)
* - the constant '20'
* @returns {number|null} The minimum value that must be rolled to be considered a critical hit.
* @deprecated since dnd5e 2.2, targeted for removal in 2.4
*/
getCriticalThreshold() {
foundry.utils.logCompatibilityWarning(
"Item5e#getCriticalThreshold has been replaced with the Item5e#criticalThreshold getter.",
{ since: "DnD5e 2.2", until: "DnD5e 2.4" }
);
return this.criticalThreshold;
}
}
/**
* An abstract class containing common functionality between actor sheet configuration apps.
* @extends {DocumentSheet}
* @abstract
*/
class BaseConfigSheet extends DocumentSheet {
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
if ( this.isEditable ) {
for ( const override of this._getActorOverrides() ) {
html.find(`input[name="${override}"],select[name="${override}"]`).each((i, el) => {
el.disabled = true;
el.dataset.tooltip = "DND5E.ActiveEffectOverrideWarning";
});
}
}
}
/* -------------------------------------------- */
/**
* Retrieve the list of fields that are currently modified by Active Effects on the Actor.
* @returns {string[]}
* @protected
*/
_getActorOverrides() {
return Object.keys(foundry.utils.flattenObject(this.object.overrides || {}));
}
}
/**
* A simple form to set save throw configuration for a given ability score.
*
* @param {Actor5e} actor The Actor instance being displayed within the sheet.
* @param {ApplicationOptions} options Additional application configuration options.
* @param {string} abilityId The ability key as defined in CONFIG.DND5E.abilities.
*/
class ActorAbilityConfig extends BaseConfigSheet {
constructor(actor, options, abilityId) {
super(actor, options);
this._abilityId = abilityId;
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e"],
template: "systems/dnd5e/templates/apps/ability-config.hbs",
width: 500,
height: "auto"
});
}
/* -------------------------------------------- */
/** @override */
get title() {
return `${game.i18n.format("DND5E.AbilityConfigureTitle", {
ability: CONFIG.DND5E.abilities[this._abilityId].label})}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @override */
getData(options) {
const src = this.document.toObject();
const ability = CONFIG.DND5E.abilities[this._abilityId].label;
return {
ability: src.system.abilities[this._abilityId] ?? this.document.system.abilities[this._abilityId] ?? {},
labelSaves: game.i18n.format("DND5E.AbilitySaveConfigure", {ability}),
labelChecks: game.i18n.format("DND5E.AbilityCheckConfigure", {ability}),
abilityId: this._abilityId,
proficiencyLevels: {
0: CONFIG.DND5E.proficiencyLevels[0],
1: CONFIG.DND5E.proficiencyLevels[1]
},
bonusGlobalSave: src.system.bonuses?.abilities?.save,
bonusGlobalCheck: src.system.bonuses?.abilities?.check
};
}
}
/**
* Interface for managing a character's armor calculation.
*/
class ActorArmorConfig extends BaseConfigSheet {
constructor(...args) {
super(...args);
/**
* Cloned copy of the actor for previewing changes.
* @type {Actor5e}
*/
this.clone = this.document.clone();
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "actor-armor-config"],
template: "systems/dnd5e/templates/apps/actor-armor.hbs",
width: 320,
height: "auto",
sheetConfig: false
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return `${game.i18n.localize("DND5E.ArmorConfig")}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData() {
const ac = this.clone.system.attributes.ac;
const isFlat = ["flat", "natural"].includes(ac.calc);
// Get configuration data for the calculation mode, reset to flat if configuration is unavailable
let cfg = CONFIG.DND5E.armorClasses[ac.calc];
if ( !cfg ) {
ac.calc = "flat";
cfg = CONFIG.DND5E.armorClasses.flat;
this.clone.updateSource({ "system.attributes.ac.calc": "flat" });
}
return {
ac, isFlat,
calculations: CONFIG.DND5E.armorClasses,
valueDisabled: !isFlat,
formula: ac.calc === "custom" ? ac.formula : cfg.formula,
formulaDisabled: ac.calc !== "custom"
};
}
/* -------------------------------------------- */
/** @inheritdoc */
_getActorOverrides() {
return Object.keys(foundry.utils.flattenObject(this.object.overrides?.system?.attributes || {}));
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
const ac = foundry.utils.expandObject(formData).ac;
return this.document.update({"system.attributes.ac": ac});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
await super._onChangeInput(event);
// Update clone with new data & re-render
this.clone.updateSource({ [`system.attributes.${event.currentTarget.name}`]: event.currentTarget.value });
this.render();
}
}
/**
* A simple form to set actor hit dice amounts.
*/
class ActorHitDiceConfig extends BaseConfigSheet {
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "hd-config", "dialog"],
template: "systems/dnd5e/templates/apps/hit-dice-config.hbs",
width: 360,
height: "auto"
});
}
/* -------------------------------------------- */
/** @inheritDoc */
get title() {
return `${game.i18n.localize("DND5E.HitDiceConfig")}: ${this.object.name}`;
}
/* -------------------------------------------- */
/** @inheritDoc */
getData(options) {
return {
classes: this.object.items.reduce((classes, item) => {
if (item.type === "class") {
classes.push({
classItemId: item.id,
name: item.name,
diceDenom: item.system.hitDice,
currentHitDice: item.system.levels - item.system.hitDiceUsed,
maxHitDice: item.system.levels,
canRoll: (item.system.levels - item.system.hitDiceUsed) > 0
});
}
return classes;
}, []).sort((a, b) => parseInt(b.diceDenom.slice(1)) - parseInt(a.diceDenom.slice(1)))
};
}
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
// Hook up -/+ buttons to adjust the current value in the form
html.find("button.increment,button.decrement").click(event => {
const button = event.currentTarget;
const current = button.parentElement.querySelector(".current");
const max = button.parentElement.querySelector(".max");
const direction = button.classList.contains("increment") ? 1 : -1;
current.value = Math.clamped(parseInt(current.value) + direction, 0, parseInt(max.value));
});
html.find("button.roll-hd").click(this._onRollHitDie.bind(this));
}
/* -------------------------------------------- */
/** @inheritDoc */
async _updateObject(event, formData) {
const actorItems = this.object.items;
const classUpdates = Object.entries(formData).map(([id, hd]) => ({
_id: id,
"system.hitDiceUsed": actorItems.get(id).system.levels - hd
}));
return this.object.updateEmbeddedDocuments("Item", classUpdates);
}
/* -------------------------------------------- */
/**
* Rolls the hit die corresponding with the class row containing the event's target button.
* @param {MouseEvent} event Triggering click event.
* @protected
*/
async _onRollHitDie(event) {
event.preventDefault();
const button = event.currentTarget;
await this.object.rollHitDie(button.dataset.hdDenom);
// Re-render dialog to reflect changed hit dice quantities
this.render();
}
}
/**
* A form for configuring actor hit points and bonuses.
*/
class ActorHitPointsConfig extends BaseConfigSheet {
constructor(...args) {
super(...args);
/**
* Cloned copy of the actor for previewing changes.
* @type {Actor5e}
*/
this.clone = this.object.clone();
}
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "actor-hit-points-config"],
template: "systems/dnd5e/templates/apps/hit-points-config.hbs",
width: 320,
height: "auto",
sheetConfig: false
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return `${game.i18n.localize("DND5E.HitPointsConfig")}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options) {
return {
hp: this.clone.system.attributes.hp,
source: this.clone.toObject().system.attributes.hp,
isCharacter: this.document.type === "character"
};
}
/* -------------------------------------------- */
/** @inheritdoc */
_getActorOverrides() {
return Object.keys(foundry.utils.flattenObject(this.object.overrides?.system?.attributes || {}));
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
const hp = foundry.utils.expandObject(formData).hp;
this.clone.updateSource({"system.attributes.hp": hp});
const maxDelta = this.clone.system.attributes.hp.max - this.document.system.attributes.hp.max;
hp.value = Math.max(this.document.system.attributes.hp.value + maxDelta, 0);
return this.document.update({"system.attributes.hp": hp});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".roll-hit-points").click(this._onRollHPFormula.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
await super._onChangeInput(event);
const t = event.currentTarget;
// Update clone with new data & re-render
this.clone.updateSource({ [`system.attributes.${t.name}`]: t.value || null });
if ( t.name !== "hp.formula" ) this.render();
}
/* -------------------------------------------- */
/**
* Handle rolling NPC health values using the provided formula.
* @param {Event} event The original click event.
* @protected
*/
async _onRollHPFormula(event) {
event.preventDefault();
try {
const roll = await this.clone.rollNPCHitPoints();
this.clone.updateSource({"system.attributes.hp.max": roll.total});
this.render();
} catch(error) {
ui.notifications.error(game.i18n.localize("DND5E.HPFormulaError"));
throw error;
}
}
}
/**
* A simple sub-application of the ActorSheet which is used to configure properties related to initiative.
*/
class ActorInitiativeConfig extends BaseConfigSheet {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e"],
template: "systems/dnd5e/templates/apps/initiative-config.hbs",
width: 360,
height: "auto"
});
}
/* -------------------------------------------- */
/** @override */
get title() {
return `${game.i18n.localize("DND5E.InitiativeConfig")}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const source = this.document.toObject();
const init = source.system.attributes.init || {};
const flags = source.flags.dnd5e || {};
return {
ability: init.ability,
abilities: CONFIG.DND5E.abilities,
bonus: init.bonus,
initiativeAlert: flags.initiativeAlert,
initiativeAdv: flags.initiativeAdv
};
}
/* -------------------------------------------- */
/** @inheritDoc */
_getSubmitData(updateData={}) {
const formData = super._getSubmitData(updateData);
formData.flags = {dnd5e: {}};
for ( const flag of ["initiativeAlert", "initiativeAdv"] ) {
const k = `flags.dnd5e.${flag}`;
if ( formData[k] ) formData.flags.dnd5e[flag] = true;
else formData.flags.dnd5e[`-=${flag}`] = null;
delete formData[k];
}
return formData;
}
}
/**
* A simple form to set actor movement speeds.
*/
class ActorMovementConfig extends BaseConfigSheet {
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e"],
template: "systems/dnd5e/templates/apps/movement-config.hbs",
width: 300,
height: "auto"
});
}
/* -------------------------------------------- */
/** @override */
get title() {
return `${game.i18n.localize("DND5E.MovementConfig")}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
const source = this.document.toObject();
// Current movement values
const movement = source.system.attributes?.movement || {};
for ( let [k, v] of Object.entries(movement) ) {
if ( ["units", "hover"].includes(k) ) continue;
movement[k] = Number.isNumeric(v) ? v.toNearest(0.1) : 0;
}
// Allowed speeds
const speeds = source.type === "group" ? {
land: "DND5E.MovementLand",
water: "DND5E.MovementWater",
air: "DND5E.MovementAir"
} : {
walk: "DND5E.MovementWalk",
burrow: "DND5E.MovementBurrow",
climb: "DND5E.MovementClimb",
fly: "DND5E.MovementFly",
swim: "DND5E.MovementSwim"
};
// Return rendering context
return {
speeds,
movement,
selectUnits: source.type !== "group",
canHover: source.type !== "group",
units: CONFIG.DND5E.movementUnits
};
}
}
/**
* A simple form to configure Actor senses.
*/
class ActorSensesConfig extends BaseConfigSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e"],
template: "systems/dnd5e/templates/apps/senses-config.hbs",
width: 300,
height: "auto"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return `${game.i18n.localize("DND5E.SensesConfig")}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options) {
const source = this.document.toObject().system.attributes?.senses || {};
const data = {
senses: {},
special: source.special ?? "",
units: source.units, movementUnits: CONFIG.DND5E.movementUnits
};
for ( let [name, label] of Object.entries(CONFIG.DND5E.senses) ) {
const v = Number(source[name]);
data.senses[name] = {
label: game.i18n.localize(label),
value: Number.isNumeric(v) ? v.toNearest(0.1) : 0
};
}
return data;
}
}
/**
* An application class which provides advanced configuration for special character flags which modify an Actor.
*/
class ActorSheetFlags extends BaseConfigSheet {
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "actor-flags",
classes: ["dnd5e"],
template: "systems/dnd5e/templates/apps/actor-flags.hbs",
width: 500,
closeOnSubmit: true
});
}
/* -------------------------------------------- */
/** @inheritDoc */
get title() {
return `${game.i18n.localize("DND5E.FlagsTitle")}: ${this.object.name}`;
}
/* -------------------------------------------- */
/** @inheritDoc */
getData() {
const data = {};
data.actor = this.object;
data.classes = this._getClasses();
data.flags = this._getFlags();
data.bonuses = this._getBonuses();
return data;
}
/* -------------------------------------------- */
/**
* Prepare an object of sorted classes.
* @returns {object}
* @private
*/
_getClasses() {
const classes = this.object.items.filter(i => i.type === "class");
return classes.sort((a, b) => a.name.localeCompare(b.name)).reduce((obj, i) => {
obj[i.id] = i.name;
return obj;
}, {});
}
/* -------------------------------------------- */
/**
* Prepare an object of flags data which groups flags by section
* Add some additional data for rendering
* @returns {object}
* @private
*/
_getFlags() {
const flags = {};
const baseData = this.document.toJSON();
for ( let [k, v] of Object.entries(CONFIG.DND5E.characterFlags) ) {
if ( !flags.hasOwnProperty(v.section) ) flags[v.section] = {};
let flag = foundry.utils.deepClone(v);
flag.type = v.type.name;
flag.isCheckbox = v.type === Boolean;
flag.isSelect = v.hasOwnProperty("choices");
flag.value = foundry.utils.getProperty(baseData.flags, `dnd5e.${k}`);
flags[v.section][`flags.dnd5e.${k}`] = flag;
}
return flags;
}
/* -------------------------------------------- */
/**
* Get the bonuses fields and their localization strings
* @returns {Array}
* @private
*/
_getBonuses() {
const src = this.object.toObject();
const bonuses = [
{name: "system.bonuses.mwak.attack", label: "DND5E.BonusMWAttack"},
{name: "system.bonuses.mwak.damage", label: "DND5E.BonusMWDamage"},
{name: "system.bonuses.rwak.attack", label: "DND5E.BonusRWAttack"},
{name: "system.bonuses.rwak.damage", label: "DND5E.BonusRWDamage"},
{name: "system.bonuses.msak.attack", label: "DND5E.BonusMSAttack"},
{name: "system.bonuses.msak.damage", label: "DND5E.BonusMSDamage"},
{name: "system.bonuses.rsak.attack", label: "DND5E.BonusRSAttack"},
{name: "system.bonuses.rsak.damage", label: "DND5E.BonusRSDamage"},
{name: "system.bonuses.abilities.check", label: "DND5E.BonusAbilityCheck"},
{name: "system.bonuses.abilities.save", label: "DND5E.BonusAbilitySave"},
{name: "system.bonuses.abilities.skill", label: "DND5E.BonusAbilitySkill"},
{name: "system.bonuses.spell.dc", label: "DND5E.BonusSpellDC"}
];
for ( let b of bonuses ) {
b.value = foundry.utils.getProperty(src, b.name) || "";
}
return bonuses;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _updateObject(event, formData) {
const actor = this.object;
let updateData = foundry.utils.expandObject(formData);
const src = actor.toObject();
// Unset any flags which are "false"
const flags = updateData.flags.dnd5e;
for ( let [k, v] of Object.entries(flags) ) {
if ( [undefined, null, "", false, 0].includes(v) ) {
delete flags[k];
if ( foundry.utils.hasProperty(src.flags, `dnd5e.${k}`) ) flags[`-=${k}`] = null;
}
}
// Clear any bonuses which are whitespace only
for ( let b of Object.values(updateData.system.bonuses ) ) {
for ( let [k, v] of Object.entries(b) ) {
b[k] = v.trim();
}
}
// Diff the data against any applied overrides and apply
await actor.update(updateData, {diff: false});
}
}
/**
* A specialized form used to select from a checklist of attributes, traits, or properties
*/
class ActorTypeConfig extends FormApplication {
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "actor-type", "trait-selector"],
template: "systems/dnd5e/templates/apps/actor-type.hbs",
width: 280,
height: "auto",
choices: {},
allowCustom: true,
minimum: 0,
maximum: null
});
}
/* -------------------------------------------- */
/** @inheritDoc */
get title() {
return `${game.i18n.localize("DND5E.CreatureTypeTitle")}: ${this.object.name}`;
}
/* -------------------------------------------- */
/** @override */
get id() {
return `actor-type-${this.object.id}`;
}
/* -------------------------------------------- */
/** @override */
getData(options={}) {
// Get current value or new default
let attr = foundry.utils.getProperty(this.object.system, "details.type");
if ( foundry.utils.getType(attr) !== "Object" ) attr = {
value: (attr in CONFIG.DND5E.creatureTypes) ? attr : "humanoid",
subtype: "",
swarm: "",
custom: ""
};
// Populate choices
const types = {};
for ( let [k, v] of Object.entries(CONFIG.DND5E.creatureTypes) ) {
types[k] = {
label: game.i18n.localize(v),
chosen: attr.value === k
};
}
// Return data for rendering
return {
types: types,
custom: {
value: attr.custom,
label: game.i18n.localize("DND5E.CreatureTypeSelectorCustom"),
chosen: attr.value === "custom"
},
subtype: attr.subtype,
swarm: attr.swarm,
sizes: Array.from(Object.entries(CONFIG.DND5E.actorSizes)).reverse().reduce((obj, e) => {
obj[e[0]] = e[1];
return obj;
}, {}),
preview: Actor5e.formatCreatureType(attr) || "–"
};
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
const typeObject = foundry.utils.expandObject(formData);
return this.object.update({"system.details.type": typeObject});
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("input[name='custom']").focusin(this._onCustomFieldFocused.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
_onChangeInput(event) {
super._onChangeInput(event);
const typeObject = foundry.utils.expandObject(this._getSubmitData());
this.form.preview.value = Actor5e.formatCreatureType(typeObject) || "—";
}
/* -------------------------------------------- */
/**
* Select the custom radio button when the custom text field is focused.
* @param {FocusEvent} event The original focusin event
* @private
*/
_onCustomFieldFocused(event) {
this.form.querySelector("input[name='value'][value='custom']").checked = true;
this._onChangeInput(event);
}
}
/**
* Dialog to confirm the deletion of an embedded item with advancement or decreasing a class level.
*/
class AdvancementConfirmationDialog extends Dialog {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "systems/dnd5e/templates/advancement/advancement-confirmation-dialog.hbs",
jQuery: false
});
}
/* -------------------------------------------- */
/**
* A helper function that displays the dialog prompting for an item deletion.
* @param {Item5e} item Item to be deleted.
* @returns {Promise} Resolves with whether advancements should be unapplied. Rejects with null.
*/
static forDelete(item) {
return this.createDialog(
item,
game.i18n.localize("DND5E.AdvancementDeleteConfirmationTitle"),
game.i18n.localize("DND5E.AdvancementDeleteConfirmationMessage"),
{
icon: ' ',
label: game.i18n.localize("Delete")
}
);
}
/* -------------------------------------------- */
/**
* A helper function that displays the dialog prompting for leveling down.
* @param {Item5e} item The class whose level is being changed.
* @returns {Promise} Resolves with whether advancements should be unapplied. Rejects with null.
*/
static forLevelDown(item) {
return this.createDialog(
item,
game.i18n.localize("DND5E.AdvancementLevelDownConfirmationTitle"),
game.i18n.localize("DND5E.AdvancementLevelDownConfirmationMessage"),
{
icon: ' ',
label: game.i18n.localize("DND5E.LevelActionDecrease")
}
);
}
/* -------------------------------------------- */
/**
* A helper constructor function which displays the confirmation dialog.
* @param {Item5e} item Item to be changed.
* @param {string} title Localized dialog title.
* @param {string} message Localized dialog message.
* @param {object} continueButton Object containing label and icon for the action button.
* @returns {Promise} Resolves with whether advancements should be unapplied. Rejects with null.
*/
static createDialog(item, title, message, continueButton) {
return new Promise((resolve, reject) => {
const dialog = new this({
title: `${title}: ${item.name}`,
content: message,
buttons: {
continue: foundry.utils.mergeObject(continueButton, {
callback: html => {
const checkbox = html.querySelector('input[name="apply-advancement"]');
resolve(checkbox.checked);
}
}),
cancel: {
icon: ' ',
label: game.i18n.localize("Cancel"),
callback: html => reject(null)
}
},
default: "continue",
close: () => reject(null)
});
dialog.render(true);
});
}
}
/**
* Internal type used to manage each step within the advancement process.
*
* @typedef {object} AdvancementStep
* @property {string} type Step type from "forward", "reverse", "restore", or "delete".
* @property {AdvancementFlow} [flow] Flow object for the advancement being applied by this step.
* @property {Item5e} [item] For "delete" steps only, the item to be removed.
* @property {object} [class] Contains data on class if step was triggered by class level change.
* @property {Item5e} [class.item] Class item that caused this advancement step.
* @property {number} [class.level] Level the class should be during this step.
* @property {boolean} [automatic=false] Should the manager attempt to apply this step without user interaction?
*/
/**
* Application for controlling the advancement workflow and displaying the interface.
*
* @param {Actor5e} actor Actor on which this advancement is being performed.
* @param {object} [options={}] Additional application options.
*/
class AdvancementManager extends Application {
constructor(actor, options={}) {
super(options);
/**
* The original actor to which changes will be applied when the process is complete.
* @type {Actor5e}
*/
this.actor = actor;
/**
* A clone of the original actor to which the changes can be applied during the advancement process.
* @type {Actor5e}
*/
this.clone = actor.clone();
/**
* Individual steps that will be applied in order.
* @type {object}
*/
this.steps = [];
/**
* Step being currently displayed.
* @type {number|null}
* @private
*/
this._stepIndex = null;
/**
* Is the prompt currently advancing through un-rendered steps?
* @type {boolean}
* @private
*/
this._advancing = false;
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "advancement", "flow"],
template: "systems/dnd5e/templates/advancement/advancement-manager.hbs",
width: 460,
height: "auto"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
const visibleSteps = this.steps.filter(s => !s.automatic);
const visibleIndex = visibleSteps.indexOf(this.step);
const step = visibleIndex < 0 ? "" : game.i18n.format("DND5E.AdvancementManagerSteps", {
current: visibleIndex + 1,
total: visibleSteps.length
});
return `${game.i18n.localize("DND5E.AdvancementManagerTitle")} ${step}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get id() {
return `actor-${this.actor.id}-advancement`;
}
/* -------------------------------------------- */
/**
* Get the step that is currently in progress.
* @type {object|null}
*/
get step() {
return this.steps[this._stepIndex] ?? null;
}
/* -------------------------------------------- */
/**
* Get the step before the current one.
* @type {object|null}
*/
get previousStep() {
return this.steps[this._stepIndex - 1] ?? null;
}
/* -------------------------------------------- */
/**
* Get the step after the current one.
* @type {object|null}
*/
get nextStep() {
const nextIndex = this._stepIndex === null ? 0 : this._stepIndex + 1;
return this.steps[nextIndex] ?? null;
}
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* Construct a manager for a newly added advancement from drag-drop.
* @param {Actor5e} actor Actor from which the advancement should be updated.
* @param {string} itemId ID of the item to which the advancements are being dropped.
* @param {Advancement[]} advancements Dropped advancements to add.
* @param {object} options Rendering options passed to the application.
* @returns {AdvancementManager} Prepared manager. Steps count can be used to determine if advancements are needed.
*/
static forNewAdvancement(actor, itemId, advancements, options) {
const manager = new this(actor, options);
const clonedItem = manager.clone.items.get(itemId);
if ( !clonedItem || !advancements.length ) return manager;
const currentLevel = this.currentLevel(clonedItem, manager.clone);
const minimumLevel = advancements.reduce((min, a) => Math.min(a.levels[0] ?? Infinity, min), Infinity);
if ( minimumLevel > currentLevel ) return manager;
const oldFlows = Array.fromRange(currentLevel + 1).slice(minimumLevel)
.flatMap(l => this.flowsForLevel(clonedItem, l));
// Revert advancements through minimum level
oldFlows.reverse().forEach(flow => manager.steps.push({ type: "reverse", flow, automatic: true }));
// Add new advancements
const advancementArray = clonedItem.toObject().system.advancement;
advancementArray.push(...advancements.map(a => {
const obj = a.toObject();
if ( obj.constructor.dataModels?.value ) a.value = (new a.constructor.metadata.dataModels.value()).toObject();
else obj.value = foundry.utils.deepClone(a.constructor.metadata.defaults?.value ?? {});
return obj;
}));
clonedItem.updateSource({"system.advancement": advancementArray});
const newFlows = Array.fromRange(currentLevel + 1).slice(minimumLevel)
.flatMap(l => this.flowsForLevel(clonedItem, l));
// Restore existing advancements and apply new advancements
newFlows.forEach(flow => {
const matchingFlow = oldFlows.find(f => (f.advancement.id === flow.advancement.id) && (f.level === flow.level));
if ( matchingFlow ) manager.steps.push({ type: "restore", flow: matchingFlow, automatic: true });
else manager.steps.push({ type: "forward", flow });
});
return manager;
}
/* -------------------------------------------- */
/**
* Construct a manager for a newly added item.
* @param {Actor5e} actor Actor to which the item is being added.
* @param {object} itemData Data for the item being added.
* @param {object} options Rendering options passed to the application.
* @returns {AdvancementManager} Prepared manager. Steps count can be used to determine if advancements are needed.
*/
static forNewItem(actor, itemData, options={}) {
const manager = new this(actor, options);
// Prepare data for adding to clone
const dataClone = foundry.utils.deepClone(itemData);
dataClone._id = foundry.utils.randomID();
if ( itemData.type === "class" ) {
dataClone.system.levels = 0;
if ( !manager.clone.system.details.originalClass ) {
manager.clone.updateSource({"system.details.originalClass": dataClone._id});
}
}
// Add item to clone & get new instance from clone
manager.clone.updateSource({items: [dataClone]});
const clonedItem = manager.clone.items.get(dataClone._id);
// For class items, prepare level change data
if ( itemData.type === "class" ) {
return manager.createLevelChangeSteps(clonedItem, itemData.system?.levels ?? 1);
}
// All other items, just create some flows up to current character level (or class level for subclasses)
let targetLevel = manager.clone.system.details.level;
if ( clonedItem.type === "subclass" ) targetLevel = clonedItem.class?.system.levels ?? 0;
Array.fromRange(targetLevel + 1)
.flatMap(l => this.flowsForLevel(clonedItem, l))
.forEach(flow => manager.steps.push({ type: "forward", flow }));
return manager;
}
/* -------------------------------------------- */
/**
* Construct a manager for modifying choices on an item at a specific level.
* @param {Actor5e} actor Actor from which the choices should be modified.
* @param {object} itemId ID of the item whose choices are to be changed.
* @param {number} level Level at which the choices are being changed.
* @param {object} options Rendering options passed to the application.
* @returns {AdvancementManager} Prepared manager. Steps count can be used to determine if advancements are needed.
*/
static forModifyChoices(actor, itemId, level, options) {
const manager = new this(actor, options);
const clonedItem = manager.clone.items.get(itemId);
if ( !clonedItem ) return manager;
const flows = Array.fromRange(this.currentLevel(clonedItem, manager.clone) + 1).slice(level)
.flatMap(l => this.flowsForLevel(clonedItem, l));
// Revert advancements through changed level
flows.reverse().forEach(flow => manager.steps.push({ type: "reverse", flow, automatic: true }));
// Create forward advancements for level being changed
flows.reverse().filter(f => f.level === level).forEach(flow => manager.steps.push({ type: "forward", flow }));
// Create restore advancements for other levels
flows.filter(f => f.level > level).forEach(flow => manager.steps.push({ type: "restore", flow, automatic: true }));
return manager;
}
/* -------------------------------------------- */
/**
* Construct a manager for an advancement that needs to be deleted.
* @param {Actor5e} actor Actor from which the advancement should be unapplied.
* @param {string} itemId ID of the item from which the advancement should be deleted.
* @param {string} advancementId ID of the advancement to delete.
* @param {object} options Rendering options passed to the application.
* @returns {AdvancementManager} Prepared manager. Steps count can be used to determine if advancements are needed.
*/
static forDeletedAdvancement(actor, itemId, advancementId, options) {
const manager = new this(actor, options);
const clonedItem = manager.clone.items.get(itemId);
const advancement = clonedItem?.advancement.byId[advancementId];
if ( !advancement ) return manager;
const minimumLevel = advancement.levels[0];
const currentLevel = this.currentLevel(clonedItem, manager.clone);
// If minimum level is greater than current level, no changes to remove
if ( (minimumLevel > currentLevel) || !advancement.appliesToClass ) return manager;
advancement.levels
.reverse()
.filter(l => l <= currentLevel)
.map(l => new advancement.constructor.metadata.apps.flow(clonedItem, advancementId, l))
.forEach(flow => manager.steps.push({ type: "reverse", flow, automatic: true }));
if ( manager.steps.length ) manager.steps.push({ type: "delete", advancement, automatic: true });
return manager;
}
/* -------------------------------------------- */
/**
* Construct a manager for an item that needs to be deleted.
* @param {Actor5e} actor Actor from which the item should be deleted.
* @param {string} itemId ID of the item to be deleted.
* @param {object} options Rendering options passed to the application.
* @returns {AdvancementManager} Prepared manager. Steps count can be used to determine if advancements are needed.
*/
static forDeletedItem(actor, itemId, options) {
const manager = new this(actor, options);
const clonedItem = manager.clone.items.get(itemId);
if ( !clonedItem ) return manager;
// For class items, prepare level change data
if ( clonedItem.type === "class" ) {
return manager.createLevelChangeSteps(clonedItem, clonedItem.system.levels * -1);
}
// All other items, just create some flows down from current character level
Array.fromRange(manager.clone.system.details.level + 1)
.flatMap(l => this.flowsForLevel(clonedItem, l))
.reverse()
.forEach(flow => manager.steps.push({ type: "reverse", flow, automatic: true }));
// Add a final step to remove the item only if there are advancements to apply
if ( manager.steps.length ) manager.steps.push({ type: "delete", item: clonedItem, automatic: true });
return manager;
}
/* -------------------------------------------- */
/**
* Construct a manager for a change in a class's levels.
* @param {Actor5e} actor Actor whose level has changed.
* @param {string} classId ID of the class being changed.
* @param {number} levelDelta Levels by which to increase or decrease the class.
* @param {object} options Rendering options passed to the application.
* @returns {AdvancementManager} Prepared manager. Steps count can be used to determine if advancements are needed.
*/
static forLevelChange(actor, classId, levelDelta, options={}) {
const manager = new this(actor, options);
const clonedItem = manager.clone.items.get(classId);
if ( !clonedItem ) return manager;
return manager.createLevelChangeSteps(clonedItem, levelDelta);
}
/* -------------------------------------------- */
/**
* Create steps based on the provided level change data.
* @param {string} classItem Class being changed.
* @param {number} levelDelta Levels by which to increase or decrease the class.
* @returns {AdvancementManager} Manager with new steps.
* @private
*/
createLevelChangeSteps(classItem, levelDelta) {
const pushSteps = (flows, data) => this.steps.push(...flows.map(flow => ({ flow, ...data })));
const getItemFlows = characterLevel => this.clone.items.contents.flatMap(i => {
if ( ["class", "subclass"].includes(i.type) ) return [];
return this.constructor.flowsForLevel(i, characterLevel);
});
// Level increased
for ( let offset = 1; offset <= levelDelta; offset++ ) {
const classLevel = classItem.system.levels + offset;
const characterLevel = this.actor.system.details.level + offset;
const stepData = { type: "forward", class: {item: classItem, level: classLevel} };
pushSteps(this.constructor.flowsForLevel(classItem, classLevel), stepData);
pushSteps(this.constructor.flowsForLevel(classItem.subclass, classLevel), stepData);
pushSteps(getItemFlows(characterLevel), stepData);
}
// Level decreased
for ( let offset = 0; offset > levelDelta; offset-- ) {
const classLevel = classItem.system.levels + offset;
const characterLevel = this.actor.system.details.level + offset;
const stepData = { type: "reverse", class: {item: classItem, level: classLevel}, automatic: true };
pushSteps(getItemFlows(characterLevel).reverse(), stepData);
pushSteps(this.constructor.flowsForLevel(classItem.subclass, classLevel).reverse(), stepData);
pushSteps(this.constructor.flowsForLevel(classItem, classLevel).reverse(), stepData);
if ( classLevel === 1 ) this.steps.push({ type: "delete", item: classItem, automatic: true });
}
// Ensure the class level ends up at the appropriate point
this.steps.push({
type: "forward", automatic: true,
class: {item: classItem, level: classItem.system.levels += levelDelta}
});
return this;
}
/* -------------------------------------------- */
/**
* Creates advancement flows for all advancements at a specific level.
* @param {Item5e} item Item that has advancement.
* @param {number} level Level in question.
* @returns {AdvancementFlow[]} Created flow applications.
* @protected
*/
static flowsForLevel(item, level) {
return (item?.advancement.byLevel[level] ?? [])
.filter(a => a.appliesToClass)
.map(a => new a.constructor.metadata.apps.flow(item, a.id, level));
}
/* -------------------------------------------- */
/**
* Determine the proper working level either from the provided item or from the cloned actor.
* @param {Item5e} item Item being advanced. If class or subclass, its level will be used.
* @param {Actor5e} actor Actor being advanced.
* @returns {number} Working level.
*/
static currentLevel(item, actor) {
return item.system.levels ?? item.class?.system.levels ?? actor.system.details.level;
}
/* -------------------------------------------- */
/* Form Rendering */
/* -------------------------------------------- */
/** @inheritdoc */
getData() {
if ( !this.step ) return {};
// Prepare information for subheading
const item = this.step.flow.item;
let level = this.step.flow.level;
if ( (this.step.class) && ["class", "subclass"].includes(item.type) ) level = this.step.class.level;
const visibleSteps = this.steps.filter(s => !s.automatic);
const visibleIndex = visibleSteps.indexOf(this.step);
return {
actor: this.clone,
flowId: this.step.flow.id,
header: item.name,
subheader: level ? game.i18n.format("DND5E.AdvancementLevelHeader", { level }) : "",
steps: {
current: visibleIndex + 1,
total: visibleSteps.length,
hasPrevious: visibleIndex > 0,
hasNext: visibleIndex < visibleSteps.length - 1
}
};
}
/* -------------------------------------------- */
/** @inheritdoc */
render(...args) {
if ( this.steps.length && (this._stepIndex === null) ) this._stepIndex = 0;
// Ensure the level on the class item matches the specified level
if ( this.step?.class ) {
let level = this.step.class.level;
if ( this.step.type === "reverse" ) level -= 1;
this.step.class.item.updateSource({"system.levels": level});
this.clone.reset();
}
/**
* A hook event that fires when an AdvancementManager is about to be processed.
* @function dnd5e.preAdvancementManagerRender
* @memberof hookEvents
* @param {AdvancementManager} advancementManager The advancement manager about to be rendered
*/
const allowed = Hooks.call("dnd5e.preAdvancementManagerRender", this);
// Abort if not allowed
if ( allowed === false ) return this;
if ( this.step?.automatic ) {
if ( this._advancing ) return this;
this._forward();
return this;
}
return super.render(...args);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _render(force, options) {
await super._render(force, options);
if ( (this._state !== Application.RENDER_STATES.RENDERED) || !this.step ) return;
// Render the step
this.step.flow._element = null;
await this.step.flow._render(force, options);
this.setPosition();
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("button[data-action]").click(event => {
const buttons = html.find("button");
buttons.attr("disabled", true);
html.find(".error").removeClass("error");
try {
switch ( event.currentTarget.dataset.action ) {
case "restart":
if ( !this.previousStep ) return;
return this._restart(event);
case "previous":
if ( !this.previousStep ) return;
return this._backward(event);
case "next":
case "complete":
return this._forward(event);
}
} finally {
buttons.attr("disabled", false);
}
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
if ( !options.skipConfirmation ) {
return new Dialog({
title: `${game.i18n.localize("DND5E.AdvancementManagerCloseTitle")}: ${this.actor.name}`,
content: game.i18n.localize("DND5E.AdvancementManagerCloseMessage"),
buttons: {
close: {
icon: ' ',
label: game.i18n.localize("DND5E.AdvancementManagerCloseButtonStop"),
callback: () => super.close(options)
},
continue: {
icon: ' ',
label: game.i18n.localize("DND5E.AdvancementManagerCloseButtonContinue")
}
},
default: "close"
}).render(true);
}
await super.close(options);
}
/* -------------------------------------------- */
/* Process */
/* -------------------------------------------- */
/**
* Advance through the steps until one requiring user interaction is encountered.
* @param {Event} [event] Triggering click event if one occurred.
* @returns {Promise}
* @private
*/
async _forward(event) {
this._advancing = true;
try {
do {
const flow = this.step.flow;
// Apply changes based on step type
if ( (this.step.type === "delete") && this.step.item ) this.clone.items.delete(this.step.item.id);
else if ( (this.step.type === "delete") && this.step.advancement ) {
this.step.advancement.item.deleteAdvancement(this.step.advancement.id, { source: true });
}
else if ( this.step.type === "restore" ) await flow.advancement.restore(flow.level, flow.retainedData);
else if ( this.step.type === "reverse" ) flow.retainedData = await flow.advancement.reverse(flow.level);
else if ( flow ) await flow._updateObject(event, flow._getSubmitData());
this._stepIndex++;
// Ensure the level on the class item matches the specified level
if ( this.step?.class ) {
let level = this.step.class.level;
if ( this.step.type === "reverse" ) level -= 1;
this.step.class.item.updateSource({"system.levels": level});
}
this.clone.reset();
} while ( this.step?.automatic );
} catch(error) {
if ( !(error instanceof Advancement.ERROR) ) throw error;
ui.notifications.error(error.message);
this.step.automatic = false;
if ( this.step.type === "restore" ) this.step.type = "forward";
} finally {
this._advancing = false;
}
if ( this.step ) this.render(true);
else this._complete();
}
/* -------------------------------------------- */
/**
* Reverse through the steps until one requiring user interaction is encountered.
* @param {Event} [event] Triggering click event if one occurred.
* @param {object} [options] Additional options to configure behavior.
* @param {boolean} [options.render=true] Whether to render the Application after the step has been reversed. Used
* by the restart workflow.
* @returns {Promise}
* @private
*/
async _backward(event, { render=true }={}) {
this._advancing = true;
try {
do {
this._stepIndex--;
if ( !this.step ) break;
const flow = this.step.flow;
// Reverse step based on step type
if ( (this.step.type === "delete") && this.step.item ) this.clone.updateSource({items: [this.step.item]});
else if ( (this.step.type === "delete") && this.step.advancement ) this.advancement.item.createAdvancement(
this.advancement.typeName, this.advancement._source, { source: true }
);
else if ( this.step.type === "reverse" ) await flow.advancement.restore(flow.level, flow.retainedData);
else if ( flow ) flow.retainedData = await flow.advancement.reverse(flow.level);
this.clone.reset();
} while ( this.step?.automatic );
} catch(error) {
if ( !(error instanceof Advancement.ERROR) ) throw error;
ui.notifications.error(error.message);
this.step.automatic = false;
} finally {
this._advancing = false;
}
if ( !render ) return;
if ( this.step ) this.render(true);
else this.close({ skipConfirmation: true });
}
/* -------------------------------------------- */
/**
* Reset back to the manager's initial state.
* @param {MouseEvent} [event] The triggering click event if one occurred.
* @returns {Promise}
* @private
*/
async _restart(event) {
const restart = await Dialog.confirm({
title: game.i18n.localize("DND5E.AdvancementManagerRestartConfirmTitle"),
content: game.i18n.localize("DND5E.AdvancementManagerRestartConfirm")
});
if ( !restart ) return;
// While there is still a renderable step.
while ( this.steps.slice(0, this._stepIndex).some(s => !s.automatic) ) {
await this._backward(event, {render: false});
}
this.render(true);
}
/* -------------------------------------------- */
/**
* Apply changes to actual actor after all choices have been made.
* @param {Event} event Button click that triggered the change.
* @returns {Promise}
* @private
*/
async _complete(event) {
const updates = this.clone.toObject();
const items = updates.items;
delete updates.items;
// Gather changes to embedded items
const { toCreate, toUpdate, toDelete } = items.reduce((obj, item) => {
if ( !this.actor.items.get(item._id) ) {
obj.toCreate.push(item);
} else {
obj.toUpdate.push(item);
obj.toDelete.findSplice(id => id === item._id);
}
return obj;
}, { toCreate: [], toUpdate: [], toDelete: this.actor.items.map(i => i.id) });
/**
* A hook event that fires at the final stage of a character's advancement process, before actor and item updates
* are applied.
* @function dnd5e.preAdvancementManagerComplete
* @memberof hookEvents
* @param {AdvancementManager} advancementManager The advancement manager.
* @param {object} actorUpdates Updates to the actor.
* @param {object[]} toCreate Items that will be created on the actor.
* @param {object[]} toUpdate Items that will be updated on the actor.
* @param {string[]} toDelete IDs of items that will be deleted on the actor.
*/
if ( Hooks.call("dnd5e.preAdvancementManagerComplete", this, updates, toCreate, toUpdate, toDelete) === false ) {
console.log("AdvancementManager completion was prevented by the 'preAdvancementManagerComplete' hook.");
return this.close({ skipConfirmation: true });
}
// Apply changes from clone to original actor
await Promise.all([
this.actor.update(updates, { isAdvancement: true }),
this.actor.createEmbeddedDocuments("Item", toCreate, { keepId: true, isAdvancement: true }),
this.actor.updateEmbeddedDocuments("Item", toUpdate, { isAdvancement: true }),
this.actor.deleteEmbeddedDocuments("Item", toDelete, { isAdvancement: true })
]);
/**
* A hook event that fires when an AdvancementManager is done modifying an actor.
* @function dnd5e.advancementManagerComplete
* @memberof hookEvents
* @param {AdvancementManager} advancementManager The advancement manager that just completed
*/
Hooks.callAll("dnd5e.advancementManagerComplete", this);
// Close prompt
return this.close({ skipConfirmation: true });
}
}
/**
* Description for a single part of a property attribution.
* @typedef {object} AttributionDescription
* @property {string} label Descriptive label that will be displayed. If the label is in the form
* of an @ property, the system will try to turn it into a human-readable label.
* @property {number} mode Application mode for this step as defined in
* [CONST.ACTIVE_EFFECT_MODES](https://foundryvtt.com/api/module-constants.html#.ACTIVE_EFFECT_MODES).
* @property {number} value Value of this step.
*/
/**
* Interface for viewing what factors went into determining a specific property.
*
* @param {Document} object The Document that owns the property being attributed.
* @param {AttributionDescription[]} attributions An array of all the attribution data.
* @param {string} property Dot separated path to the property.
* @param {object} [options={}] Application rendering options.
*/
class PropertyAttribution extends Application {
constructor(object, attributions, property, options={}) {
super(options);
this.object = object;
this.attributions = attributions;
this.property = property;
}
/* -------------------------------------------- */
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "property-attribution",
classes: ["dnd5e", "property-attribution"],
template: "systems/dnd5e/templates/apps/property-attribution.hbs",
width: 320,
height: "auto"
});
}
/* -------------------------------------------- */
/**
* Render this view as a tooltip rather than a whole window.
* @param {HTMLElement} element The element to which the tooltip should be attached.
*/
async renderTooltip(element) {
const data = this.getData(this.options);
const text = (await this._renderInner(data))[0].outerHTML;
game.tooltip.activate(element, { text, cssClass: "property-attribution" });
}
/* -------------------------------------------- */
/** @inheritDoc */
getData() {
const property = foundry.utils.getProperty(this.object.system, this.property);
let total;
if ( Number.isNumeric(property)) total = property;
else if ( typeof property === "object" && Number.isNumeric(property.value) ) total = property.value;
const sources = foundry.utils.duplicate(this.attributions);
return {
caption: this.options.title,
sources: sources.map(entry => {
if ( entry.label.startsWith("@") ) entry.label = this.getPropertyLabel(entry.label.slice(1));
if ( (entry.mode === CONST.ACTIVE_EFFECT_MODES.ADD) && (entry.value < 0) ) {
entry.negative = true;
entry.value = entry.value * -1;
}
return entry;
}),
total: total
};
}
/* -------------------------------------------- */
/**
* Produce a human-readable and localized name for the provided property.
* @param {string} property Dot separated path to the property.
* @returns {string} Property name for display.
*/
getPropertyLabel(property) {
const parts = property.split(".");
if ( parts[0] === "abilities" && parts[1] ) {
return CONFIG.DND5E.abilities[parts[1]]?.label ?? property;
} else if ( (property === "attributes.ac.dex") && CONFIG.DND5E.abilities.dex ) {
return CONFIG.DND5E.abilities.dex.label;
} else if ( (parts[0] === "prof") || (property === "attributes.prof") ) {
return game.i18n.localize("DND5E.Proficiency");
}
return property;
}
}
/**
* A specialized application used to modify actor traits.
*
* @param {Actor5e} actor Actor for whose traits are being edited.
* @param {string} trait Trait key as defined in CONFIG.traits.
* @param {object} [options={}]
* @param {boolean} [options.allowCustom=true] Support user custom trait entries.
*/
let TraitSelector$1 = class TraitSelector extends BaseConfigSheet {
constructor(actor, trait, options={}) {
if ( !CONFIG.DND5E.traits[trait] ) throw new Error(
`Cannot instantiate TraitSelector with a trait not defined in CONFIG.DND5E.traits: ${trait}.`
);
if ( ["saves", "skills"].includes(trait) ) throw new Error(
`TraitSelector does not support selection of ${trait}. That should be handled through `
+ "that type's more specialized configuration application."
);
super(actor, options);
/**
* Trait key as defined in CONFIG.traits.
* @type {string}
*/
this.trait = trait;
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "trait-selector",
classes: ["dnd5e", "trait-selector", "subconfig"],
template: "systems/dnd5e/templates/apps/trait-selector.hbs",
width: 320,
height: "auto",
sheetConfig: false,
allowCustom: true
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get id() {
return `${this.constructor.name}-${this.trait}-Actor-${this.document.id}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return `${this.document.name}: ${traitLabel(this.trait)}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData() {
const path = `system.${actorKeyPath(this.trait)}`;
const data = foundry.utils.getProperty(this.document, path);
if ( !data ) return super.getData();
return {
...super.getData(),
choices: await choices(this.trait, data.value),
custom: data.custom,
customPath: "custom" in data ? `${path}.custom` : null,
bypasses: "bypasses" in data ? Object.entries(CONFIG.DND5E.physicalWeaponProperties).reduce((obj, [k, v]) => {
obj[k] = { label: v, chosen: data.bypasses.has(k) };
return obj;
}, {}) : null,
bypassesPath: "bypasses" in data ? `${path}.bypasses` : null
};
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
for ( const checkbox of html[0].querySelectorAll("input[type='checkbox']") ) {
if ( checkbox.checked ) this._onToggleCategory(checkbox);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_getActorOverrides() {
const overrides = super._getActorOverrides();
const path = `system.${actorKeyPath(this.trait)}.value`;
const src = new Set(foundry.utils.getProperty(this.document._source, path));
const current = foundry.utils.getProperty(this.document, path);
const delta = current.difference(src);
for ( const choice of delta ) {
overrides.push(`choices.${choice}`);
}
return overrides;
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
super._onChangeInput(event);
if ( event.target.name?.startsWith("choices") ) this._onToggleCategory(event.target);
}
/* -------------------------------------------- */
/**
* Enable/disable all children when a category is checked.
* @param {HTMLElement} checkbox Checkbox that was changed.
* @protected
*/
_onToggleCategory(checkbox) {
const children = checkbox.closest("li")?.querySelector("ol");
if ( !children ) return;
for ( const child of children.querySelectorAll("input[type='checkbox']") ) {
child.checked = child.disabled = checkbox.checked;
}
}
/* -------------------------------------------- */
/**
* Filter a list of choices that begin with the provided key for update.
* @param {string} prefix They initial form prefix under which the choices are grouped.
* @param {string} path Path in actor data where the final choices will be saved.
* @param {object} formData Form data being prepared. *Will be mutated.*
* @protected
*/
_prepareChoices(prefix, path, formData) {
const chosen = [];
for ( const key of Object.keys(formData).filter(k => k.startsWith(`${prefix}.`)) ) {
if ( formData[key] ) chosen.push(key.replace(`${prefix}.`, ""));
delete formData[key];
}
formData[path] = chosen;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
const path = `system.${actorKeyPath(this.trait)}`;
const data = foundry.utils.getProperty(this.document, path);
this._prepareChoices("choices", `${path}.value`, formData);
if ( "bypasses" in data ) this._prepareChoices("bypasses", `${path}.bypasses`, formData);
return this.object.update(formData);
}
};
/**
* @typedef {FormApplicationOptions} ProficiencyConfigOptions
* @property {string} key The ID of the skill or tool being configured.
* @property {string} property The property on the actor being configured, either 'skills', or 'tools'.
*/
/**
* An application responsible for configuring proficiencies and bonuses in tools and skills.
*
* @param {Actor5e} actor The Actor being configured.
* @param {ProficiencyConfigOptions} options Additional configuration options.
*/
class ProficiencyConfig extends BaseConfigSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e"],
template: "systems/dnd5e/templates/apps/proficiency-config.hbs",
width: 500,
height: "auto"
});
}
/* -------------------------------------------- */
/**
* Are we configuring a tool?
* @returns {boolean}
*/
get isTool() {
return this.options.property === "tools";
}
/* -------------------------------------------- */
/**
* Are we configuring a skill?
* @returns {boolean}
*/
get isSkill() {
return this.options.property === "skills";
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
const label = this.isSkill ? CONFIG.DND5E.skills[this.options.key].label : keyLabel("tool", this.options.key);
return `${game.i18n.format("DND5E.ProficiencyConfigureTitle", {label})}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get id() {
return `ProficiencyConfig-${this.document.documentName}-${this.document.id}-${this.options.key}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
return {
abilities: CONFIG.DND5E.abilities,
proficiencyLevels: CONFIG.DND5E.proficiencyLevels,
entry: this.document.system[this.options.property]?.[this.options.key],
isTool: this.isTool,
isSkill: this.isSkill,
key: this.options.key,
property: this.options.property
};
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
if ( this.isTool ) return super._updateObject(event, formData);
const passive = formData[`system.skills.${this.options.key}.bonuses.passive`];
const passiveRoll = new Roll(passive);
if ( !passiveRoll.isDeterministic ) {
const message = game.i18n.format("DND5E.FormulaCannotContainDiceError", {
name: game.i18n.localize("DND5E.SkillBonusPassive")
});
ui.notifications.error(message);
throw new Error(message);
}
return super._updateObject(event, formData);
}
}
/**
* A specialized version of the TraitSelector used for selecting tool and vehicle proficiencies.
* @extends {TraitSelector}
*/
class ToolSelector extends TraitSelector$1 {
/** @inheritdoc */
async getData() {
return {
...super.getData(),
choices: await choices(this.trait, Object.keys(this.document.system.tools))
};
}
/* -------------------------------------------- */
/** @inheritdoc */
_getActorOverrides() {
return Object.keys(foundry.utils.flattenObject(this.document.overrides));
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
return this.document.update(Object.entries(formData).reduce((obj, [k, v]) => {
const [, key] = k.split(".");
const tool = this.document.system.tools[key];
if ( tool && !v ) obj[`system.tools.-=${key}`] = null;
else if ( !tool && v ) obj[`system.tools.${key}`] = {value: 1};
return obj;
}, {}));
}
}
/**
* Extend the basic ActorSheet class to suppose system-specific logic and functionality.
* @abstract
*/
class ActorSheet5e extends ActorSheet {
/**
* Track the set of item filters which are applied
* @type {Object}
* @protected
*/
_filters = {
inventory: new Set(),
spellbook: new Set(),
features: new Set(),
effects: new Set()
};
/* -------------------------------------------- */
/**
* IDs for items on the sheet that have been expanded.
* @type {Set}
* @protected
*/
_expanded = new Set();
/* -------------------------------------------- */
/** @override */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
scrollY: [
".inventory .inventory-list",
".features .inventory-list",
".spellbook .inventory-list",
".effects .inventory-list",
".center-pane"
],
tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}],
width: 720,
height: Math.max(680, Math.max(
237 + (Object.keys(CONFIG.DND5E.abilities).length * 70),
240 + (Object.keys(CONFIG.DND5E.skills).length * 24)
))
});
}
/* -------------------------------------------- */
/**
* A set of item types that should be prevented from being dropped on this type of actor sheet.
* @type {Set}
*/
static unsupportedItemTypes = new Set();
/* -------------------------------------------- */
/** @override */
get template() {
if ( !game.user.isGM && this.actor.limited ) return "systems/dnd5e/templates/actors/limited-sheet.hbs";
return `systems/dnd5e/templates/actors/${this.actor.type}-sheet.hbs`;
}
/* -------------------------------------------- */
/* Context Preparation */
/* -------------------------------------------- */
/** @override */
async getData(options) {
// The Actor's data
const source = this.actor.toObject();
// Basic data
const context = {
actor: this.actor,
source: source.system,
system: this.actor.system,
items: Array.from(this.actor.items),
itemContext: {},
abilities: foundry.utils.deepClone(this.actor.system.abilities),
skills: foundry.utils.deepClone(this.actor.system.skills ?? {}),
tools: foundry.utils.deepClone(this.actor.system.tools ?? {}),
labels: this._getLabels(),
movement: this._getMovementSpeed(this.actor.system),
senses: this._getSenses(this.actor.system),
effects: ActiveEffect5e.prepareActiveEffectCategories(this.actor.effects),
warnings: foundry.utils.deepClone(this.actor._preparationWarnings),
filters: this._filters,
owner: this.actor.isOwner,
limited: this.actor.limited,
options: this.options,
editable: this.isEditable,
cssClass: this.actor.isOwner ? "editable" : "locked",
isCharacter: this.actor.type === "character",
isNPC: this.actor.type === "npc",
isVehicle: this.actor.type === "vehicle",
config: CONFIG.DND5E,
rollableClass: this.isEditable ? "rollable" : "",
rollData: this.actor.getRollData()
};
// Sort Owned Items
context.items.sort((a, b) => (a.sort || 0) - (b.sort || 0));
// Temporary HP
const hp = {...context.system.attributes.hp};
if ( hp.temp === 0 ) delete hp.temp;
if ( hp.tempmax === 0 ) delete hp.tempmax;
context.hp = hp;
// Ability Scores
for ( const [a, abl] of Object.entries(context.abilities) ) {
abl.icon = this._getProficiencyIcon(abl.proficient);
abl.hover = CONFIG.DND5E.proficiencyLevels[abl.proficient];
abl.label = CONFIG.DND5E.abilities[a]?.label;
abl.baseProf = source.system.abilities[a]?.proficient ?? 0;
}
// Skills & tools.
["skills", "tools"].forEach(prop => {
for ( const [key, entry] of Object.entries(context[prop]) ) {
entry.abbreviation = CONFIG.DND5E.abilities[entry.ability]?.abbreviation;
entry.icon = this._getProficiencyIcon(entry.value);
entry.hover = CONFIG.DND5E.proficiencyLevels[entry.value];
entry.label = prop === "skills" ? CONFIG.DND5E.skills[key]?.label : keyLabel("tool", key);
entry.baseValue = source.system[prop]?.[key]?.value ?? 0;
}
});
// Update traits
context.traits = this._prepareTraits(context.system);
// Prepare owned items
this._prepareItems(context);
context.expandedData = {};
for ( const id of this._expanded ) {
const item = this.actor.items.get(id);
if ( item ) context.expandedData[id] = await item.getChatData({secrets: this.actor.isOwner});
}
// Biography HTML enrichment
context.biographyHTML = await TextEditor.enrichHTML(context.system.details.biography.value, {
secrets: this.actor.isOwner,
rollData: context.rollData,
async: true,
relativeTo: this.actor
});
return context;
}
/* -------------------------------------------- */
/**
* Prepare labels object for the context.
* @returns {object} Object containing various labels.
* @protected
*/
_getLabels() {
const labels = {...this.actor.labels};
// Currency Labels
labels.currencies = Object.entries(CONFIG.DND5E.currencies).reduce((obj, [k, c]) => {
obj[k] = c.label;
return obj;
}, {});
// Proficiency
labels.proficiency = game.settings.get("dnd5e", "proficiencyModifier") === "dice"
? `d${this.actor.system.attributes.prof * 2}`
: `+${this.actor.system.attributes.prof}`;
return labels;
}
/* -------------------------------------------- */
/**
* Prepare the display of movement speed data for the Actor.
* @param {object} systemData System data for the Actor being prepared.
* @param {boolean} [largestPrimary=false] Show the largest movement speed as "primary", otherwise show "walk".
* @returns {{primary: string, special: string}}
* @protected
*/
_getMovementSpeed(systemData, largestPrimary=false) {
const movement = systemData.attributes.movement ?? {};
// Prepare an array of available movement speeds
let speeds = [
[movement.burrow, `${game.i18n.localize("DND5E.MovementBurrow")} ${movement.burrow}`],
[movement.climb, `${game.i18n.localize("DND5E.MovementClimb")} ${movement.climb}`],
[movement.fly, `${game.i18n.localize("DND5E.MovementFly")} ${movement.fly}${movement.hover ? ` (${game.i18n.localize("DND5E.MovementHover")})` : ""}`],
[movement.swim, `${game.i18n.localize("DND5E.MovementSwim")} ${movement.swim}`]
];
if ( largestPrimary ) {
speeds.push([movement.walk, `${game.i18n.localize("DND5E.MovementWalk")} ${movement.walk}`]);
}
// Filter and sort speeds on their values
speeds = speeds.filter(s => s[0]).sort((a, b) => b[0] - a[0]);
// Case 1: Largest as primary
if ( largestPrimary ) {
let primary = speeds.shift();
return {
primary: `${primary ? primary[1] : "0"} ${movement.units}`,
special: speeds.map(s => s[1]).join(", ")
};
}
// Case 2: Walk as primary
else {
return {
primary: `${movement.walk || 0} ${movement.units}`,
special: speeds.length ? speeds.map(s => s[1]).join(", ") : ""
};
}
}
/* -------------------------------------------- */
/**
* Prepare senses object for display.
* @param {object} systemData System data for the Actor being prepared.
* @returns {object} Senses grouped by key with localized and formatted string.
* @protected
*/
_getSenses(systemData) {
const senses = systemData.attributes.senses ?? {};
const tags = {};
for ( let [k, label] of Object.entries(CONFIG.DND5E.senses) ) {
const v = senses[k] ?? 0;
if ( v === 0 ) continue;
tags[k] = `${game.i18n.localize(label)} ${v} ${senses.units}`;
}
if ( senses.special ) tags.special = senses.special;
return tags;
}
/* -------------------------------------------- */
/** @inheritdoc */
async activateEditor(name, options={}, initialContent="") {
options.relativeLinks = true;
return super.activateEditor(name, options, initialContent);
}
/* --------------------------------------------- */
/* Property Attribution */
/* --------------------------------------------- */
/**
* Break down all of the Active Effects affecting a given target property.
* @param {string} target The data property being targeted.
* @returns {AttributionDescription[]} Any active effects that modify that property.
* @protected
*/
_prepareActiveEffectAttributions(target) {
return this.actor.effects.reduce((arr, e) => {
let source = e.sourceName;
if ( e.origin === this.actor.uuid ) source = e.label;
if ( !source || e.disabled || e.isSuppressed ) return arr;
const value = e.changes.reduce((n, change) => {
if ( (change.key !== target) || !Number.isNumeric(change.value) ) return n;
if ( change.mode !== CONST.ACTIVE_EFFECT_MODES.ADD ) return n;
return n + Number(change.value);
}, 0);
if ( !value ) return arr;
arr.push({value, label: source, mode: CONST.ACTIVE_EFFECT_MODES.ADD});
return arr;
}, []);
}
/* -------------------------------------------- */
/**
* Produce a list of armor class attribution objects.
* @param {object} rollData Data provided by Actor5e#getRollData
* @returns {AttributionDescription[]} List of attribution descriptions.
* @protected
*/
_prepareArmorClassAttribution(rollData) {
const ac = rollData.attributes.ac;
const cfg = CONFIG.DND5E.armorClasses[ac.calc];
const attribution = [];
// Base AC Attribution
switch ( ac.calc ) {
// Flat AC
case "flat":
return [{
label: game.i18n.localize("DND5E.ArmorClassFlat"),
mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
value: ac.flat
}];
// Natural armor
case "natural":
attribution.push({
label: game.i18n.localize("DND5E.ArmorClassNatural"),
mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
value: ac.flat
});
break;
default:
const formula = ac.calc === "custom" ? ac.formula : cfg.formula;
let base = ac.base;
const dataRgx = new RegExp(/@([a-z.0-9_-]+)/gi);
for ( const [match, term] of formula.matchAll(dataRgx) ) {
const value = String(foundry.utils.getProperty(rollData, term));
if ( (term === "attributes.ac.armor") || (value === "0") ) continue;
if ( Number.isNumeric(value) ) base -= Number(value);
attribution.push({
label: match,
mode: CONST.ACTIVE_EFFECT_MODES.ADD,
value
});
}
const armorInFormula = formula.includes("@attributes.ac.armor");
let label = game.i18n.localize("DND5E.PropertyBase");
if ( armorInFormula ) label = this.actor.armor?.name ?? game.i18n.localize("DND5E.ArmorClassUnarmored");
attribution.unshift({
label,
mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
value: base
});
break;
}
// Shield
if ( ac.shield !== 0 ) attribution.push({
label: this.actor.shield?.name ?? game.i18n.localize("DND5E.EquipmentShield"),
mode: CONST.ACTIVE_EFFECT_MODES.ADD,
value: ac.shield
});
// Bonus
if ( ac.bonus !== 0 ) attribution.push(...this._prepareActiveEffectAttributions("system.attributes.ac.bonus"));
// Cover
if ( ac.cover !== 0 ) attribution.push({
label: game.i18n.localize("DND5E.Cover"),
mode: CONST.ACTIVE_EFFECT_MODES.ADD,
value: ac.cover
});
return attribution;
}
/* -------------------------------------------- */
/**
* Prepare the data structure for traits data like languages, resistances & vulnerabilities, and proficiencies.
* @param {object} systemData System data for the Actor being prepared.
* @returns {object} Prepared trait data.
* @protected
*/
_prepareTraits(systemData) {
const traits = {};
for ( const [trait$1, traitConfig] of Object.entries(CONFIG.DND5E.traits) ) {
const key = traitConfig.actorKeyPath ?? `traits.${trait$1}`;
const data = foundry.utils.deepClone(foundry.utils.getProperty(systemData, key));
const choices = CONFIG.DND5E[traitConfig.configKey];
if ( !data ) continue;
foundry.utils.setProperty(traits, key, data);
let values = data.value;
if ( !values ) values = [];
else if ( values instanceof Set ) values = Array.from(values);
else if ( !Array.isArray(values) ) values = [values];
// Split physical damage types from others if bypasses is set
const physical = [];
if ( data.bypasses?.size ) {
values = values.filter(t => {
if ( !CONFIG.DND5E.physicalDamageTypes[t] ) return true;
physical.push(t);
return false;
});
}
data.selected = values.reduce((obj, key) => {
obj[key] = keyLabel(trait$1, key) ?? key;
return obj;
}, {});
// Display bypassed damage types
if ( physical.length ) {
const damageTypesFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "conjunction" });
const bypassFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "disjunction" });
data.selected.physical = game.i18n.format("DND5E.DamagePhysicalBypasses", {
damageTypes: damageTypesFormatter.format(physical.map(t => choices[t])),
bypassTypes: bypassFormatter.format(data.bypasses.map(t => CONFIG.DND5E.physicalWeaponProperties[t]))
});
}
// Add custom entries
if ( data.custom ) data.custom.split(";").forEach((c, i) => data.selected[`custom${i+1}`] = c.trim());
data.cssClass = !foundry.utils.isEmpty(data.selected) ? "" : "inactive";
}
return traits;
}
/* -------------------------------------------- */
/**
* Prepare the data structure for items which appear on the actor sheet.
* Each subclass overrides this method to implement type-specific logic.
* @protected
*/
_prepareItems() {}
/* -------------------------------------------- */
/**
* Insert a spell into the spellbook object when rendering the character sheet.
* @param {object} context Sheet rendering context data being prepared for render.
* @param {object[]} spells Spells to be included in the spellbook.
* @returns {object[]} Spellbook sections in the proper order.
* @protected
*/
_prepareSpellbook(context, spells) {
const owner = this.actor.isOwner;
const levels = context.actor.system.spells;
const spellbook = {};
// Define section and label mappings
const sections = {atwill: -20, innate: -10, pact: 0.5 };
const useLabels = {"-20": "-", "-10": "-", 0: "∞"};
// Format a spellbook entry for a certain indexed level
const registerSection = (sl, i, label, {prepMode="prepared", value, max, override}={}) => {
const aeOverride = foundry.utils.hasProperty(this.actor.overrides, `system.spells.spell${i}.override`);
spellbook[i] = {
order: i,
label: label,
usesSlots: i > 0,
canCreate: owner,
canPrepare: (context.actor.type === "character") && (i >= 1),
spells: [],
uses: useLabels[i] || value || 0,
slots: useLabels[i] || max || 0,
override: override || 0,
dataset: {type: "spell", level: prepMode in sections ? 1 : i, "preparation.mode": prepMode},
prop: sl,
editable: context.editable && !aeOverride
};
};
// Determine the maximum spell level which has a slot
const maxLevel = Array.fromRange(10).reduce((max, i) => {
if ( i === 0 ) return max;
const level = levels[`spell${i}`];
if ( (level.max || level.override ) && ( i > max ) ) max = i;
return max;
}, 0);
// Level-based spellcasters have cantrips and leveled slots
if ( maxLevel > 0 ) {
registerSection("spell0", 0, CONFIG.DND5E.spellLevels[0]);
for (let lvl = 1; lvl <= maxLevel; lvl++) {
const sl = `spell${lvl}`;
registerSection(sl, lvl, CONFIG.DND5E.spellLevels[lvl], levels[sl]);
}
}
// Pact magic users have cantrips and a pact magic section
if ( levels.pact && levels.pact.max ) {
if ( !spellbook["0"] ) registerSection("spell0", 0, CONFIG.DND5E.spellLevels[0]);
const l = levels.pact;
const config = CONFIG.DND5E.spellPreparationModes.pact;
const level = game.i18n.localize(`DND5E.SpellLevel${levels.pact.level}`);
const label = `${config} — ${level}`;
registerSection("pact", sections.pact, label, {
prepMode: "pact",
value: l.value,
max: l.max,
override: l.override
});
}
// Iterate over every spell item, adding spells to the spellbook by section
spells.forEach(spell => {
const mode = spell.system.preparation.mode || "prepared";
let s = spell.system.level || 0;
const sl = `spell${s}`;
// Specialized spellcasting modes (if they exist)
if ( mode in sections ) {
s = sections[mode];
if ( !spellbook[s] ) {
const l = levels[mode] || {};
const config = CONFIG.DND5E.spellPreparationModes[mode];
registerSection(mode, s, config, {
prepMode: mode,
value: l.value,
max: l.max,
override: l.override
});
}
}
// Sections for higher-level spells which the caster "should not" have, but spell items exist for
else if ( !spellbook[s] ) {
registerSection(sl, s, CONFIG.DND5E.spellLevels[s], {levels: levels[sl]});
}
// Add the spell to the relevant heading
spellbook[s].spells.push(spell);
});
// Sort the spellbook by section level
const sorted = Object.values(spellbook);
sorted.sort((a, b) => a.order - b.order);
return sorted;
}
/* -------------------------------------------- */
/**
* Determine whether an Owned Item will be shown based on the current set of filters.
* @param {object[]} items Copies of item data to be filtered.
* @param {Set} filters Filters applied to the item list.
* @returns {object[]} Subset of input items limited by the provided filters.
* @protected
*/
_filterItems(items, filters) {
return items.filter(item => {
// Action usage
for ( let f of ["action", "bonus", "reaction"] ) {
if ( filters.has(f) && (item.system.activation?.type !== f) ) return false;
}
// Spell-specific filters
if ( filters.has("ritual") && (item.system.components.ritual !== true) ) return false;
if ( filters.has("concentration") && (item.system.components.concentration !== true) ) return false;
if ( filters.has("prepared") ) {
if ( (item.system.level === 0) || ["innate", "always"].includes(item.system.preparation.mode) ) return true;
if ( this.actor.type === "npc" ) return true;
return item.system.preparation.prepared;
}
// Equipment-specific filters
if ( filters.has("equipped") && (item.system.equipped !== true) ) return false;
return true;
});
}
/* -------------------------------------------- */
/**
* Get the font-awesome icon used to display a certain level of skill proficiency.
* @param {number} level A proficiency mode defined in `CONFIG.DND5E.proficiencyLevels`.
* @returns {string} HTML string for the chosen icon.
* @private
*/
_getProficiencyIcon(level) {
const icons = {
0: ' ',
0.5: ' ',
1: ' ',
2: ' '
};
return icons[level] || icons[0];
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
// Activate Item Filters
const filterLists = html.find(".filter-list");
filterLists.each(this._initializeFilterItemList.bind(this));
filterLists.on("click", ".filter-item", this._onToggleFilter.bind(this));
// Item summaries
html.find(".item .item-name.rollable h4").click(event => this._onItemSummary(event));
// View Item Sheets
html.find(".item-edit").click(this._onItemEdit.bind(this));
// Property attributions
html.find("[data-attribution]").mouseover(this._onPropertyAttribution.bind(this));
html.find(".attributable").mouseover(this._onPropertyAttribution.bind(this));
// Preparation Warnings
html.find(".warnings").click(this._onWarningLink.bind(this));
// Editable Only Listeners
if ( this.isEditable ) {
// Input focus and update
const inputs = html.find("input");
inputs.focus(ev => ev.currentTarget.select());
inputs.addBack().find('[type="text"][data-dtype="Number"]').change(this._onChangeInputDelta.bind(this));
// Ability Proficiency
html.find(".ability-proficiency").click(this._onToggleAbilityProficiency.bind(this));
// Toggle Skill Proficiency
html.find(".skill-proficiency").on("click contextmenu", event => this._onCycleProficiency(event, "skill"));
// Toggle Tool Proficiency
html.find(".tool-proficiency").on("click contextmenu", event => this._onCycleProficiency(event, "tool"));
// Trait Selector
html.find(".trait-selector").click(this._onTraitSelector.bind(this));
// Configure Special Flags
html.find(".config-button").click(this._onConfigMenu.bind(this));
// Owned Item management
html.find(".item-create").click(this._onItemCreate.bind(this));
html.find(".item-delete").click(this._onItemDelete.bind(this));
html.find(".item-uses input").click(ev => ev.target.select()).change(this._onUsesChange.bind(this));
html.find(".slot-max-override").click(this._onSpellSlotOverride.bind(this));
// Active Effect management
html.find(".effect-control").click(ev => ActiveEffect5e.onManageActiveEffect(ev, this.actor));
this._disableOverriddenFields(html);
}
// Owner Only Listeners
if ( this.actor.isOwner ) {
// Ability Checks
html.find(".ability-name").click(this._onRollAbilityTest.bind(this));
// Roll Skill Checks
html.find(".skill-name").click(this._onRollSkillCheck.bind(this));
// Roll Tool Checks.
html.find(".tool-name").on("click", this._onRollToolCheck.bind(this));
// Item Rolling
html.find(".rollable .item-image").click(event => this._onItemUse(event));
html.find(".item .item-recharge").click(event => this._onItemRecharge(event));
// Item Context Menu
new ContextMenu(html, ".item-list .item", [], {onOpen: this._onItemContext.bind(this)});
}
// Otherwise, remove rollable classes
else {
html.find(".rollable").each((i, el) => el.classList.remove("rollable"));
}
// Handle default listeners last so system listeners are triggered first
super.activateListeners(html);
}
/* -------------------------------------------- */
/**
* Disable any fields that are overridden by active effects and display an informative tooltip.
* @param {jQuery} html The sheet's rendered HTML.
* @protected
*/
_disableOverriddenFields(html) {
const proficiencyToggles = {
ability: /system\.abilities\.([^.]+)\.proficient/,
skill: /system\.skills\.([^.]+)\.value/,
tool: /system\.tools\.([^.]+)\.value/
};
for ( const override of Object.keys(foundry.utils.flattenObject(this.actor.overrides)) ) {
html.find(`input[name="${override}"],select[name="${override}"]`).each((i, el) => {
el.disabled = true;
el.dataset.tooltip = "DND5E.ActiveEffectOverrideWarning";
});
for ( const [key, regex] of Object.entries(proficiencyToggles) ) {
const [, match] = override.match(regex) || [];
if ( match ) {
const toggle = html.find(`li[data-${key}="${match}"] .proficiency-toggle`);
toggle.addClass("disabled");
toggle.attr("data-tooltip", "DND5E.ActiveEffectOverrideWarning");
}
}
const [, spell] = override.match(/system\.spells\.(spell\d)\.override/) || [];
if ( spell ) {
html.find(`.spell-max[data-level="${spell}"]`).attr("data-tooltip", "DND5E.ActiveEffectOverrideWarning");
}
}
}
/* -------------------------------------------- */
/**
* Handle activation of a context menu for an embedded Item or ActiveEffect document.
* Dynamically populate the array of context menu options.
* @param {HTMLElement} element The HTML element for which the context menu is activated
* @protected
*/
_onItemContext(element) {
// Active Effects
if ( element.classList.contains("effect") ) {
const effect = this.actor.effects.get(element.dataset.effectId);
if ( !effect ) return;
ui.context.menuItems = this._getActiveEffectContextOptions(effect);
Hooks.call("dnd5e.getActiveEffectContextOptions", effect, ui.context.menuItems);
}
// Items
else {
const item = this.actor.items.get(element.dataset.itemId);
if ( !item ) return;
ui.context.menuItems = this._getItemContextOptions(item);
Hooks.call("dnd5e.getItemContextOptions", item, ui.context.menuItems);
}
}
/* -------------------------------------------- */
/**
* Prepare an array of context menu options which are available for owned ActiveEffect documents.
* @param {ActiveEffect5e} effect The ActiveEffect for which the context menu is activated
* @returns {ContextMenuEntry[]} An array of context menu options offered for the ActiveEffect
* @protected
*/
_getActiveEffectContextOptions(effect) {
return [
{
name: "DND5E.ContextMenuActionEdit",
icon: " ",
callback: () => effect.sheet.render(true)
},
{
name: "DND5E.ContextMenuActionDuplicate",
icon: " ",
callback: () => effect.clone({label: game.i18n.format("DOCUMENT.CopyOf", {name: effect.label})}, {save: true})
},
{
name: "DND5E.ContextMenuActionDelete",
icon: " ",
callback: () => effect.deleteDialog()
},
{
name: effect.disabled ? "DND5E.ContextMenuActionEnable" : "DND5E.ContextMenuActionDisable",
icon: effect.disabled ? " " : " ",
callback: () => effect.update({disabled: !effect.disabled})
}
];
}
/* -------------------------------------------- */
/**
* Prepare an array of context menu options which are available for owned Item documents.
* @param {Item5e} item The Item for which the context menu is activated
* @returns {ContextMenuEntry[]} An array of context menu options offered for the Item
* @protected
*/
_getItemContextOptions(item) {
// Standard Options
const options = [
{
name: "DND5E.ContextMenuActionEdit",
icon: " ",
callback: () => item.sheet.render(true)
},
{
name: "DND5E.ContextMenuActionDuplicate",
icon: " ",
condition: () => !["race", "background", "class", "subclass"].includes(item.type),
callback: () => item.clone({name: game.i18n.format("DOCUMENT.CopyOf", {name: item.name})}, {save: true})
},
{
name: "DND5E.ContextMenuActionDelete",
icon: " ",
callback: () => item.deleteDialog()
}
];
// Toggle Attunement State
if ( ("attunement" in item.system) && (item.system.attunement !== CONFIG.DND5E.attunementTypes.NONE) ) {
const isAttuned = item.system.attunement === CONFIG.DND5E.attunementTypes.ATTUNED;
options.push({
name: isAttuned ? "DND5E.ContextMenuActionUnattune" : "DND5E.ContextMenuActionAttune",
icon: " ",
callback: () => item.update({
"system.attunement": CONFIG.DND5E.attunementTypes[isAttuned ? "REQUIRED" : "ATTUNED"]
})
});
}
// Toggle Equipped State
if ( "equipped" in item.system ) options.push({
name: item.system.equipped ? "DND5E.ContextMenuActionUnequip" : "DND5E.ContextMenuActionEquip",
icon: " ",
callback: () => item.update({"system.equipped": !item.system.equipped})
});
// Toggle Prepared State
if ( ("preparation" in item.system) && (item.system.preparation?.mode === "prepared") ) options.push({
name: item.system?.preparation?.prepared ? "DND5E.ContextMenuActionUnprepare" : "DND5E.ContextMenuActionPrepare",
icon: " ",
callback: () => item.update({"system.preparation.prepared": !item.system.preparation?.prepared})
});
return options;
}
/* -------------------------------------------- */
/**
* Initialize Item list filters by activating the set of filters which are currently applied
* @param {number} i Index of the filter in the list.
* @param {HTML} ul HTML object for the list item surrounding the filter.
* @private
*/
_initializeFilterItemList(i, ul) {
const set = this._filters[ul.dataset.filter];
const filters = ul.querySelectorAll(".filter-item");
for ( let li of filters ) {
if ( set.has(li.dataset.filter) ) li.classList.add("active");
}
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/**
* Handle input changes to numeric form fields, allowing them to accept delta-typed inputs.
* @param {Event} event Triggering event.
* @protected
*/
_onChangeInputDelta(event) {
const input = event.target;
const value = input.value;
if ( ["+", "-"].includes(value[0]) ) {
const delta = parseFloat(value);
input.value = Number(foundry.utils.getProperty(this.actor, input.name)) + delta;
} else if ( value[0] === "=" ) input.value = value.slice(1);
}
/* -------------------------------------------- */
/**
* Handle spawning the TraitSelector application which allows a checkbox of multiple trait options.
* @param {Event} event The click event which originated the selection.
* @private
*/
_onConfigMenu(event) {
event.preventDefault();
event.stopPropagation();
const button = event.currentTarget;
let app;
switch ( button.dataset.action ) {
case "armor":
app = new ActorArmorConfig(this.actor);
break;
case "hit-dice":
app = new ActorHitDiceConfig(this.actor);
break;
case "hit-points":
app = new ActorHitPointsConfig(this.actor);
break;
case "initiative":
app = new ActorInitiativeConfig(this.actor);
break;
case "movement":
app = new ActorMovementConfig(this.actor);
break;
case "flags":
app = new ActorSheetFlags(this.actor);
break;
case "senses":
app = new ActorSensesConfig(this.actor);
break;
case "type":
app = new ActorTypeConfig(this.actor);
break;
case "ability": {
const ability = event.currentTarget.closest("[data-ability]").dataset.ability;
app = new ActorAbilityConfig(this.actor, null, ability);
break;
}
case "skill": {
const skill = event.currentTarget.closest("[data-key]").dataset.key;
app = new ProficiencyConfig(this.actor, {property: "skills", key: skill});
break;
}
case "tool": {
const tool = event.currentTarget.closest("[data-key]").dataset.key;
app = new ProficiencyConfig(this.actor, {property: "tools", key: tool});
break;
}
}
app?.render(true);
}
/* -------------------------------------------- */
/**
* Handle cycling proficiency in a skill or tool.
* @param {Event} event A click or contextmenu event which triggered this action.
* @returns {Promise|void} Updated data for this actor after changes are applied.
* @protected
*/
_onCycleProficiency(event) {
if ( event.currentTarget.classList.contains("disabled") ) return;
event.preventDefault();
const parent = event.currentTarget.closest(".proficiency-row");
const field = parent.querySelector('[name$=".value"]');
const {property, key} = parent.dataset;
const value = this.actor._source.system[property]?.[key]?.value ?? 0;
// Cycle to the next or previous skill level.
const levels = [0, 1, .5, 2];
const idx = levels.indexOf(value);
const next = idx + (event.type === "contextmenu" ? 3 : 1);
field.value = levels[next % levels.length];
// Update the field value and save the form.
return this._onSubmit(event);
}
/* -------------------------------------------- */
/** @override */
async _onDropActor(event, data) {
const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get("dnd5e", "allowPolymorphing"));
if ( !canPolymorph ) return false;
// Get the target actor
const cls = getDocumentClass("Actor");
const sourceActor = await cls.fromDropData(data);
if ( !sourceActor ) return;
// Define a function to record polymorph settings for future use
const rememberOptions = html => {
const options = {};
html.find("input").each((i, el) => {
options[el.name] = el.checked;
});
const settings = foundry.utils.mergeObject(game.settings.get("dnd5e", "polymorphSettings") ?? {}, options);
game.settings.set("dnd5e", "polymorphSettings", settings);
return settings;
};
// Create and render the Dialog
return new Dialog({
title: game.i18n.localize("DND5E.PolymorphPromptTitle"),
content: {
options: game.settings.get("dnd5e", "polymorphSettings"),
settings: CONFIG.DND5E.polymorphSettings,
effectSettings: CONFIG.DND5E.polymorphEffectSettings,
isToken: this.actor.isToken
},
default: "accept",
buttons: {
accept: {
icon: ' ',
label: game.i18n.localize("DND5E.PolymorphAcceptSettings"),
callback: html => this.actor.transformInto(sourceActor, rememberOptions(html))
},
wildshape: {
icon: CONFIG.DND5E.transformationPresets.wildshape.icon,
label: CONFIG.DND5E.transformationPresets.wildshape.label,
callback: html => this.actor.transformInto(sourceActor, foundry.utils.mergeObject(
CONFIG.DND5E.transformationPresets.wildshape.options,
{ transformTokens: rememberOptions(html).transformTokens }
))
},
polymorph: {
icon: CONFIG.DND5E.transformationPresets.polymorph.icon,
label: CONFIG.DND5E.transformationPresets.polymorph.label,
callback: html => this.actor.transformInto(sourceActor, foundry.utils.mergeObject(
CONFIG.DND5E.transformationPresets.polymorph.options,
{ transformTokens: rememberOptions(html).transformTokens }
))
},
self: {
icon: CONFIG.DND5E.transformationPresets.polymorphSelf.icon,
label: CONFIG.DND5E.transformationPresets.polymorphSelf.label,
callback: html => this.actor.transformInto(sourceActor, foundry.utils.mergeObject(
CONFIG.DND5E.transformationPresets.polymorphSelf.options,
{ transformTokens: rememberOptions(html).transformTokens }
))
},
cancel: {
icon: ' ',
label: game.i18n.localize("Cancel")
}
}
}, {
classes: ["dialog", "dnd5e", "polymorph"],
width: 900,
template: "systems/dnd5e/templates/apps/polymorph-prompt.hbs"
}).render(true);
}
/* -------------------------------------------- */
/** @override */
async _onDropItemCreate(itemData) {
let items = itemData instanceof Array ? itemData : [itemData];
const itemsWithoutAdvancement = items.filter(i => !i.system.advancement?.length);
const multipleAdvancements = (items.length - itemsWithoutAdvancement.length) > 1;
if ( multipleAdvancements && !game.settings.get("dnd5e", "disableAdvancements") ) {
ui.notifications.warn(game.i18n.format("DND5E.WarnCantAddMultipleAdvancements"));
items = itemsWithoutAdvancement;
}
const toCreate = [];
for ( const item of items ) {
const result = await this._onDropSingleItem(item);
if ( result ) toCreate.push(result);
}
// Create the owned items as normal
return this.actor.createEmbeddedDocuments("Item", toCreate);
}
/* -------------------------------------------- */
/**
* Handles dropping of a single item onto this character sheet.
* @param {object} itemData The item data to create.
* @returns {Promise} The item data to create after processing, or false if the item should not be
* created or creation has been otherwise handled.
* @protected
*/
async _onDropSingleItem(itemData) {
// Check to make sure items of this type are allowed on this actor
if ( this.constructor.unsupportedItemTypes.has(itemData.type) ) {
ui.notifications.warn(game.i18n.format("DND5E.ActorWarningInvalidItem", {
itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]),
actorType: game.i18n.localize(CONFIG.Actor.typeLabels[this.actor.type])
}));
return false;
}
// Create a Consumable spell scroll on the Inventory tab
if ( (itemData.type === "spell")
&& (this._tabs[0].active === "inventory" || this.actor.type === "vehicle") ) {
const scroll = await Item5e.createScrollFromSpell(itemData);
return scroll.toObject();
}
// Clean up data
this._onDropResetData(itemData);
// Stack identical consumables
const stacked = this._onDropStackConsumables(itemData);
if ( stacked ) return false;
// Bypass normal creation flow for any items with advancement
if ( itemData.system.advancement?.length && !game.settings.get("dnd5e", "disableAdvancements") ) {
const manager = AdvancementManager.forNewItem(this.actor, itemData);
if ( manager.steps.length ) {
manager.render(true);
return false;
}
}
return itemData;
}
/* -------------------------------------------- */
/**
* Reset certain pieces of data stored on items when they are dropped onto the actor.
* @param {object} itemData The item data requested for creation. **Will be mutated.**
*/
_onDropResetData(itemData) {
if ( !itemData.system ) return;
["equipped", "proficient", "prepared"].forEach(k => delete itemData.system[k]);
if ( "attunement" in itemData.system ) {
itemData.system.attunement = Math.min(itemData.system.attunement, CONFIG.DND5E.attunementTypes.REQUIRED);
}
}
/* -------------------------------------------- */
/**
* Stack identical consumables when a new one is dropped rather than creating a duplicate item.
* @param {object} itemData The item data requested for creation.
* @returns {Promise|null} If a duplicate was found, returns the adjusted item stack.
*/
_onDropStackConsumables(itemData) {
const droppedSourceId = itemData.flags.core?.sourceId;
if ( itemData.type !== "consumable" || !droppedSourceId ) return null;
const similarItem = this.actor.items.find(i => {
const sourceId = i.getFlag("core", "sourceId");
return sourceId && (sourceId === droppedSourceId) && (i.type === "consumable") && (i.name === itemData.name);
});
if ( !similarItem ) return null;
return similarItem.update({
"system.quantity": similarItem.system.quantity + Math.max(itemData.system.quantity, 1)
});
}
/* -------------------------------------------- */
/**
* Handle enabling editing for a spell slot override value.
* @param {MouseEvent} event The originating click event.
* @private
*/
async _onSpellSlotOverride(event) {
const span = event.currentTarget.parentElement;
const level = span.dataset.level;
const override = this.actor.system.spells[level].override || span.dataset.slots;
const input = document.createElement("INPUT");
input.type = "text";
input.name = `system.spells.${level}.override`;
input.value = override;
input.placeholder = span.dataset.slots;
input.dataset.dtype = "Number";
// Replace the HTML
const parent = span.parentElement;
parent.removeChild(span);
parent.appendChild(input);
}
/* -------------------------------------------- */
/**
* Change the uses amount of an Owned Item within the Actor.
* @param {Event} event The triggering click event.
* @returns {Promise} Updated item.
* @private
*/
async _onUsesChange(event) {
event.preventDefault();
const itemId = event.currentTarget.closest(".item").dataset.itemId;
const item = this.actor.items.get(itemId);
const uses = Math.clamped(0, parseInt(event.target.value), item.system.uses.max);
event.target.value = uses;
return item.update({"system.uses.value": uses});
}
/* -------------------------------------------- */
/**
* Handle using an item from the Actor sheet, obtaining the Item instance, and dispatching to its use method.
* @param {Event} event The triggering click event.
* @returns {Promise} Results of the usage.
* @protected
*/
_onItemUse(event) {
event.preventDefault();
const itemId = event.currentTarget.closest(".item").dataset.itemId;
const item = this.actor.items.get(itemId);
return item.use({}, {event});
}
/* -------------------------------------------- */
/**
* Handle attempting to recharge an item usage by rolling a recharge check.
* @param {Event} event The originating click event.
* @returns {Promise} The resulting recharge roll.
* @private
*/
_onItemRecharge(event) {
event.preventDefault();
const itemId = event.currentTarget.closest(".item").dataset.itemId;
const item = this.actor.items.get(itemId);
return item.rollRecharge();
}
/* -------------------------------------------- */
/**
* Handle toggling and items expanded description.
* @param {Event} event Triggering event.
* @private
*/
async _onItemSummary(event) {
event.preventDefault();
const li = $(event.currentTarget).parents(".item");
const item = this.actor.items.get(li.data("item-id"));
const chatData = await item.getChatData({secrets: this.actor.isOwner});
// Toggle summary
if ( li.hasClass("expanded") ) {
const summary = li.children(".item-summary");
summary.slideUp(200, () => summary.remove());
this._expanded.delete(item.id);
} else {
const summary = $(await renderTemplate("systems/dnd5e/templates/items/parts/item-summary.hbs", chatData));
li.append(summary.hide());
summary.slideDown(200);
this._expanded.add(item.id);
}
li.toggleClass("expanded");
}
/* -------------------------------------------- */
/**
* Handle creating a new Owned Item for the actor using initial data defined in the HTML dataset.
* @param {Event} event The originating click event.
* @returns {Promise} The newly created item.
* @private
*/
_onItemCreate(event) {
event.preventDefault();
const header = event.currentTarget;
const type = header.dataset.type;
// Check to make sure the newly created class doesn't take player over level cap
if ( type === "class" && (this.actor.system.details.level + 1 > CONFIG.DND5E.maxLevel) ) {
const err = game.i18n.format("DND5E.MaxCharacterLevelExceededWarn", {max: CONFIG.DND5E.maxLevel});
return ui.notifications.error(err);
}
const itemData = {
name: game.i18n.format("DND5E.ItemNew", {type: game.i18n.localize(CONFIG.Item.typeLabels[type])}),
type: type,
system: foundry.utils.expandObject({ ...header.dataset })
};
delete itemData.system.type;
return this.actor.createEmbeddedDocuments("Item", [itemData]);
}
/* -------------------------------------------- */
/**
* Handle editing an existing Owned Item for the Actor.
* @param {Event} event The originating click event.
* @returns {ItemSheet5e} The rendered item sheet.
* @private
*/
_onItemEdit(event) {
event.preventDefault();
const li = event.currentTarget.closest(".item");
const item = this.actor.items.get(li.dataset.itemId);
return item.sheet.render(true);
}
/* -------------------------------------------- */
/**
* Handle deleting an existing Owned Item for the Actor.
* @param {Event} event The originating click event.
* @returns {Promise|undefined} The deleted item if something was deleted or the
* advancement manager if advancements need removing.
* @private
*/
async _onItemDelete(event) {
event.preventDefault();
const li = event.currentTarget.closest(".item");
const item = this.actor.items.get(li.dataset.itemId);
if ( !item ) return;
// If item has advancement, handle it separately
if ( !game.settings.get("dnd5e", "disableAdvancements") ) {
const manager = AdvancementManager.forDeletedItem(this.actor, item.id);
if ( manager.steps.length ) {
if ( ["class", "subclass"].includes(item.type) ) {
try {
const shouldRemoveAdvancements = await AdvancementConfirmationDialog.forDelete(item);
if ( shouldRemoveAdvancements ) return manager.render(true);
} catch(err) {
return;
}
} else {
return manager.render(true);
}
}
}
return item.deleteDialog();
}
/* -------------------------------------------- */
/**
* Handle displaying the property attribution tooltip when a property is hovered over.
* @param {Event} event The originating mouse event.
* @private
*/
async _onPropertyAttribution(event) {
const element = event.target;
let property = element.dataset.attribution;
if ( !property ) {
property = element.dataset.property;
if ( !property ) return;
foundry.utils.logCompatibilityWarning(
"Defining attributable properties on sheets with the `.attributable` class and `data-property` value"
+ " has been deprecated in favor of a single `data-attribution` value.",
{ since: "DnD5e 2.1.3", until: "DnD5e 2.4" }
);
}
const rollData = this.actor.getRollData({ deterministic: true });
const title = game.i18n.localize(element.dataset.attributionCaption);
let attributions;
switch ( property ) {
case "attributes.ac":
attributions = this._prepareArmorClassAttribution(rollData); break;
}
if ( !attributions ) return;
new PropertyAttribution(this.actor, attributions, property, {title}).renderTooltip(element);
}
/* -------------------------------------------- */
/**
* Handle rolling an Ability test or saving throw.
* @param {Event} event The originating click event.
* @private
*/
_onRollAbilityTest(event) {
event.preventDefault();
let ability = event.currentTarget.parentElement.dataset.ability;
this.actor.rollAbility(ability, {event: event});
}
/* -------------------------------------------- */
/**
* Handle rolling a Skill check.
* @param {Event} event The originating click event.
* @returns {Promise} The resulting roll.
* @private
*/
_onRollSkillCheck(event) {
event.preventDefault();
const skill = event.currentTarget.closest("[data-key]").dataset.key;
return this.actor.rollSkill(skill, {event: event});
}
/* -------------------------------------------- */
_onRollToolCheck(event) {
event.preventDefault();
const tool = event.currentTarget.closest("[data-key]").dataset.key;
return this.actor.rollToolCheck(tool, {event});
}
/* -------------------------------------------- */
/**
* Handle toggling Ability score proficiency level.
* @param {Event} event The originating click event.
* @returns {Promise|void} Updated actor instance.
* @private
*/
_onToggleAbilityProficiency(event) {
if ( event.currentTarget.classList.contains("disabled") ) return;
event.preventDefault();
const field = event.currentTarget.previousElementSibling;
return this.actor.update({[field.name]: 1 - parseInt(field.value)});
}
/* -------------------------------------------- */
/**
* Handle toggling of filters to display a different set of owned items.
* @param {Event} event The click event which triggered the toggle.
* @returns {ActorSheet5e} This actor sheet with toggled filters.
* @private
*/
_onToggleFilter(event) {
event.preventDefault();
const li = event.currentTarget;
const set = this._filters[li.parentElement.dataset.filter];
const filter = li.dataset.filter;
if ( set.has(filter) ) set.delete(filter);
else set.add(filter);
return this.render();
}
/* -------------------------------------------- */
/**
* Handle spawning the TraitSelector application which allows a checkbox of multiple trait options.
* @param {Event} event The click event which originated the selection.
* @returns {TraitSelector} Newly displayed application.
* @private
*/
_onTraitSelector(event) {
event.preventDefault();
const trait = event.currentTarget.dataset.trait;
if ( trait === "tool" ) return new ToolSelector(this.actor, trait).render(true);
return new TraitSelector$1(this.actor, trait).render(true);
}
/* -------------------------------------------- */
/**
* Handle links within preparation warnings.
* @param {Event} event The click event on the warning.
* @protected
*/
async _onWarningLink(event) {
event.preventDefault();
const a = event.target;
if ( !a || !a.dataset.target ) return;
switch ( a.dataset.target ) {
case "armor":
(new ActorArmorConfig(this.actor)).render(true);
return;
default:
const item = await fromUuid(a.dataset.target);
item?.sheet.render(true);
}
}
/* -------------------------------------------- */
/** @override */
_getHeaderButtons() {
let buttons = super._getHeaderButtons();
if ( this.actor.isPolymorphed ) {
buttons.unshift({
label: "DND5E.PolymorphRestoreTransformation",
class: "restore-transformation",
icon: "fas fa-backward",
onclick: () => this.actor.revertOriginalForm()
});
}
return buttons;
}
}
/**
* An Actor sheet for player character type actors.
*/
class ActorSheet5eCharacter extends ActorSheet5e {
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "sheet", "actor", "character"]
});
}
/* -------------------------------------------- */
/* Context Preparation */
/* -------------------------------------------- */
/** @inheritDoc */
async getData(options={}) {
const context = await super.getData(options);
// Resources
context.resources = ["primary", "secondary", "tertiary"].reduce((arr, r) => {
const res = context.actor.system.resources[r] || {};
res.name = r;
res.placeholder = game.i18n.localize(`DND5E.Resource${r.titleCase()}`);
if (res && res.value === 0) delete res.value;
if (res && res.max === 0) delete res.max;
return arr.concat([res]);
}, []);
const classes = this.actor.itemTypes.class;
return foundry.utils.mergeObject(context, {
disableExperience: game.settings.get("dnd5e", "disableExperienceTracking"),
classLabels: classes.map(c => c.name).join(", "),
multiclassLabels: classes.map(c => [c.subclass?.name ?? "", c.name, c.system.levels].filterJoin(" ")).join(", "),
weightUnit: game.i18n.localize(`DND5E.Abbreviation${
game.settings.get("dnd5e", "metricWeightUnits") ? "Kg" : "Lbs"}`),
encumbrance: context.system.attributes.encumbrance
});
}
/* -------------------------------------------- */
/** @override */
_prepareItems(context) {
// Categorize items as inventory, spellbook, features, and classes
const inventory = {};
for ( const type of ["weapon", "equipment", "consumable", "tool", "backpack", "loot"] ) {
inventory[type] = {label: `${CONFIG.Item.typeLabels[type]}Pl`, items: [], dataset: {type}};
}
// Partition items by category
let {items, spells, feats, backgrounds, classes, subclasses} = context.items.reduce((obj, item) => {
const {quantity, uses, recharge, target} = item.system;
// Item details
const ctx = context.itemContext[item.id] ??= {};
ctx.isStack = Number.isNumeric(quantity) && (quantity !== 1);
ctx.attunement = {
[CONFIG.DND5E.attunementTypes.REQUIRED]: {
icon: "fa-sun",
cls: "not-attuned",
title: "DND5E.AttunementRequired"
},
[CONFIG.DND5E.attunementTypes.ATTUNED]: {
icon: "fa-sun",
cls: "attuned",
title: "DND5E.AttunementAttuned"
}
}[item.system.attunement];
// Prepare data needed to display expanded sections
ctx.isExpanded = this._expanded.has(item.id);
// Item usage
ctx.hasUses = uses && (uses.max > 0);
ctx.isOnCooldown = recharge && !!recharge.value && (recharge.charged === false);
ctx.isDepleted = ctx.isOnCooldown && (uses.per && (uses.value > 0));
ctx.hasTarget = !!target && !(["none", ""].includes(target.type));
// Item toggle state
this._prepareItemToggleState(item, ctx);
// Classify items into types
if ( item.type === "spell" ) obj.spells.push(item);
else if ( item.type === "feat" ) obj.feats.push(item);
else if ( item.type === "background" ) obj.backgrounds.push(item);
else if ( item.type === "class" ) obj.classes.push(item);
else if ( item.type === "subclass" ) obj.subclasses.push(item);
else if ( Object.keys(inventory).includes(item.type) ) obj.items.push(item);
return obj;
}, { items: [], spells: [], feats: [], backgrounds: [], classes: [], subclasses: [] });
// Apply active item filters
items = this._filterItems(items, this._filters.inventory);
spells = this._filterItems(spells, this._filters.spellbook);
feats = this._filterItems(feats, this._filters.features);
// Organize items
for ( let i of items ) {
const ctx = context.itemContext[i.id] ??= {};
ctx.totalWeight = (i.system.quantity * i.system.weight).toNearest(0.1);
inventory[i.type].items.push(i);
}
// Organize Spellbook and count the number of prepared spells (excluding always, at will, etc...)
const spellbook = this._prepareSpellbook(context, spells);
const nPrepared = spells.filter(spell => {
const prep = spell.system.preparation;
return (spell.system.level > 0) && (prep.mode === "prepared") && prep.prepared;
}).length;
// Sort classes and interleave matching subclasses, put unmatched subclasses into features so they don't disappear
classes.sort((a, b) => b.system.levels - a.system.levels);
const maxLevelDelta = CONFIG.DND5E.maxLevel - this.actor.system.details.level;
classes = classes.reduce((arr, cls) => {
const ctx = context.itemContext[cls.id] ??= {};
ctx.availableLevels = Array.fromRange(CONFIG.DND5E.maxLevel + 1).slice(1).map(level => {
const delta = level - cls.system.levels;
return { level, delta, disabled: delta > maxLevelDelta };
});
arr.push(cls);
const identifier = cls.system.identifier || cls.name.slugify({strict: true});
const subclass = subclasses.findSplice(s => s.system.classIdentifier === identifier);
if ( subclass ) arr.push(subclass);
return arr;
}, []);
for ( const subclass of subclasses ) {
feats.push(subclass);
const message = game.i18n.format("DND5E.SubclassMismatchWarn", {
name: subclass.name, class: subclass.system.classIdentifier
});
context.warnings.push({ message, type: "warning" });
}
// Organize Features
const features = {
background: {
label: CONFIG.Item.typeLabels.background, items: backgrounds,
hasActions: false, dataset: {type: "background"} },
classes: {
label: `${CONFIG.Item.typeLabels.class}Pl`, items: classes,
hasActions: false, dataset: {type: "class"}, isClass: true },
active: {
label: "DND5E.FeatureActive", items: [],
hasActions: true, dataset: {type: "feat", "activation.type": "action"} },
passive: {
label: "DND5E.FeaturePassive", items: [],
hasActions: false, dataset: {type: "feat"} }
};
for ( const feat of feats ) {
if ( feat.system.activation?.type ) features.active.items.push(feat);
else features.passive.items.push(feat);
}
// Assign and return
context.inventoryFilters = true;
context.inventory = Object.values(inventory);
context.spellbook = spellbook;
context.preparedSpells = nPrepared;
context.features = Object.values(features);
context.labels.background = backgrounds[0]?.name;
}
/* -------------------------------------------- */
/**
* A helper method to establish the displayed preparation state for an item.
* @param {Item5e} item Item being prepared for display.
* @param {object} context Context data for display.
* @protected
*/
_prepareItemToggleState(item, context) {
if ( item.type === "spell" ) {
const prep = item.system.preparation || {};
const isAlways = prep.mode === "always";
const isPrepared = !!prep.prepared;
context.toggleClass = isPrepared ? "active" : "";
if ( isAlways ) context.toggleClass = "fixed";
if ( isAlways ) context.toggleTitle = CONFIG.DND5E.spellPreparationModes.always;
else if ( isPrepared ) context.toggleTitle = CONFIG.DND5E.spellPreparationModes.prepared;
else context.toggleTitle = game.i18n.localize("DND5E.SpellUnprepared");
}
else {
const isActive = !!item.system.equipped;
context.toggleClass = isActive ? "active" : "";
context.toggleTitle = game.i18n.localize(isActive ? "DND5E.Equipped" : "DND5E.Unequipped");
context.canToggle = "equipped" in item.system;
}
}
/* -------------------------------------------- */
/* Event Listeners and Handlers
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
if ( !this.isEditable ) return;
html.find(".level-selector").change(this._onLevelChange.bind(this));
html.find(".item-toggle").click(this._onToggleItem.bind(this));
html.find(".short-rest").click(this._onShortRest.bind(this));
html.find(".long-rest").click(this._onLongRest.bind(this));
html.find(".rollable[data-action]").click(this._onSheetAction.bind(this));
}
/* -------------------------------------------- */
/**
* Handle mouse click events for character sheet actions.
* @param {MouseEvent} event The originating click event.
* @returns {Promise} Dialog or roll result.
* @private
*/
_onSheetAction(event) {
event.preventDefault();
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "convertCurrency":
return Dialog.confirm({
title: `${game.i18n.localize("DND5E.CurrencyConvert")}`,
content: `${game.i18n.localize("DND5E.CurrencyConvertHint")}
`,
yes: () => this.actor.convertCurrency()
});
case "rollDeathSave":
return this.actor.rollDeathSave({event: event});
case "rollInitiative":
return this.actor.rollInitiativeDialog({event});
}
}
/* -------------------------------------------- */
/**
* Respond to a new level being selected from the level selector.
* @param {Event} event The originating change.
* @returns {Promise} Manager if advancements needed, otherwise updated class item.
* @private
*/
async _onLevelChange(event) {
event.preventDefault();
const delta = Number(event.target.value);
const classId = event.target.closest(".item")?.dataset.itemId;
if ( !delta || !classId ) return;
const classItem = this.actor.items.get(classId);
if ( !game.settings.get("dnd5e", "disableAdvancements") ) {
const manager = AdvancementManager.forLevelChange(this.actor, classId, delta);
if ( manager.steps.length ) {
if ( delta > 0 ) return manager.render(true);
try {
const shouldRemoveAdvancements = await AdvancementConfirmationDialog.forLevelDown(classItem);
if ( shouldRemoveAdvancements ) return manager.render(true);
}
catch(err) {
return;
}
}
}
return classItem.update({"system.levels": classItem.system.levels + delta});
}
/* -------------------------------------------- */
/**
* Handle toggling the state of an Owned Item within the Actor.
* @param {Event} event The triggering click event.
* @returns {Promise} Item with the updates applied.
* @private
*/
_onToggleItem(event) {
event.preventDefault();
const itemId = event.currentTarget.closest(".item").dataset.itemId;
const item = this.actor.items.get(itemId);
const attr = item.type === "spell" ? "system.preparation.prepared" : "system.equipped";
return item.update({[attr]: !foundry.utils.getProperty(item, attr)});
}
/* -------------------------------------------- */
/**
* Take a short rest, calling the relevant function on the Actor instance.
* @param {Event} event The triggering click event.
* @returns {Promise} Result of the rest action.
* @private
*/
async _onShortRest(event) {
event.preventDefault();
await this._onSubmit(event);
return this.actor.shortRest();
}
/* -------------------------------------------- */
/**
* Take a long rest, calling the relevant function on the Actor instance.
* @param {Event} event The triggering click event.
* @returns {Promise} Result of the rest action.
* @private
*/
async _onLongRest(event) {
event.preventDefault();
await this._onSubmit(event);
return this.actor.longRest();
}
/* -------------------------------------------- */
/** @override */
async _onDropSingleItem(itemData) {
// Increment the number of class levels a character instead of creating a new item
if ( itemData.type === "class" ) {
const charLevel = this.actor.system.details.level;
itemData.system.levels = Math.min(itemData.system.levels, CONFIG.DND5E.maxLevel - charLevel);
if ( itemData.system.levels <= 0 ) {
const err = game.i18n.format("DND5E.MaxCharacterLevelExceededWarn", { max: CONFIG.DND5E.maxLevel });
ui.notifications.error(err);
return false;
}
const cls = this.actor.itemTypes.class.find(c => c.identifier === itemData.system.identifier);
if ( cls ) {
const priorLevel = cls.system.levels;
if ( !game.settings.get("dnd5e", "disableAdvancements") ) {
const manager = AdvancementManager.forLevelChange(this.actor, cls.id, itemData.system.levels);
if ( manager.steps.length ) {
manager.render(true);
return false;
}
}
cls.update({"system.levels": priorLevel + itemData.system.levels});
return false;
}
}
// If a subclass is dropped, ensure it doesn't match another subclass with the same identifier
else if ( itemData.type === "subclass" ) {
const other = this.actor.itemTypes.subclass.find(i => i.identifier === itemData.system.identifier);
if ( other ) {
const err = game.i18n.format("DND5E.SubclassDuplicateError", {identifier: other.identifier});
ui.notifications.error(err);
return false;
}
const cls = this.actor.itemTypes.class.find(i => i.identifier === itemData.system.classIdentifier);
if ( cls && cls.subclass ) {
const err = game.i18n.format("DND5E.SubclassAssignmentError", {class: cls.name, subclass: cls.subclass.name});
ui.notifications.error(err);
return false;
}
}
return super._onDropSingleItem(itemData);
}
}
/**
* An Actor sheet for NPC type characters.
*/
class ActorSheet5eNPC extends ActorSheet5e {
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "sheet", "actor", "npc"],
width: 600
});
}
/* -------------------------------------------- */
/** @override */
static unsupportedItemTypes = new Set(["background", "class", "subclass"]);
/* -------------------------------------------- */
/* Context Preparation */
/* -------------------------------------------- */
/** @inheritDoc */
async getData(options) {
const context = await super.getData(options);
// Challenge Rating
const cr = parseFloat(context.system.details.cr ?? 0);
const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"};
return foundry.utils.mergeObject(context, {
labels: {
cr: cr >= 1 ? String(cr) : crLabels[cr] ?? 1,
type: this.actor.constructor.formatCreatureType(context.system.details.type),
armorType: this.getArmorLabel()
}
});
}
/* -------------------------------------------- */
/** @override */
_prepareItems(context) {
// Categorize Items as Features and Spells
const features = {
weapons: { label: game.i18n.localize("DND5E.AttackPl"), items: [], hasActions: true,
dataset: {type: "weapon", "weapon-type": "natural"} },
actions: { label: game.i18n.localize("DND5E.ActionPl"), items: [], hasActions: true,
dataset: {type: "feat", "activation.type": "action"} },
passive: { label: game.i18n.localize("DND5E.Features"), items: [], dataset: {type: "feat"} },
equipment: { label: game.i18n.localize("DND5E.Inventory"), items: [], dataset: {type: "loot"}}
};
// Start by classifying items into groups for rendering
let [spells, other] = context.items.reduce((arr, item) => {
const {quantity, uses, recharge, target} = item.system;
const ctx = context.itemContext[item.id] ??= {};
ctx.isStack = Number.isNumeric(quantity) && (quantity !== 1);
ctx.isExpanded = this._expanded.has(item.id);
ctx.hasUses = uses && (uses.max > 0);
ctx.isOnCooldown = recharge && !!recharge.value && (recharge.charged === false);
ctx.isDepleted = item.isOnCooldown && (uses.per && (uses.value > 0));
ctx.hasTarget = !!target && !(["none", ""].includes(target.type));
ctx.canToggle = false;
if ( item.type === "spell" ) arr[0].push(item);
else arr[1].push(item);
return arr;
}, [[], []]);
// Apply item filters
spells = this._filterItems(spells, this._filters.spellbook);
other = this._filterItems(other, this._filters.features);
// Organize Spellbook
const spellbook = this._prepareSpellbook(context, spells);
// Organize Features
for ( let item of other ) {
if ( item.type === "weapon" ) features.weapons.items.push(item);
else if ( item.type === "feat" ) {
if ( item.system.activation.type ) features.actions.items.push(item);
else features.passive.items.push(item);
}
else features.equipment.items.push(item);
}
// Assign and return
context.inventoryFilters = true;
context.features = Object.values(features);
context.spellbook = spellbook;
}
/* -------------------------------------------- */
/**
* Format NPC armor information into a localized string.
* @returns {string} Formatted armor label.
*/
getArmorLabel() {
const ac = this.actor.system.attributes.ac;
const label = [];
if ( ac.calc === "default" ) label.push(this.actor.armor?.name || game.i18n.localize("DND5E.ArmorClassUnarmored"));
else label.push(game.i18n.localize(CONFIG.DND5E.armorClasses[ac.calc].label));
if ( this.actor.shield ) label.push(this.actor.shield.name);
return label.filterJoin(", ");
}
/* -------------------------------------------- */
/* Object Updates */
/* -------------------------------------------- */
/** @inheritDoc */
async _updateObject(event, formData) {
// Format NPC Challenge Rating
const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5};
let crv = "system.details.cr";
let cr = formData[crv];
cr = crs[cr] || parseFloat(cr);
if ( cr ) formData[crv] = cr < 1 ? cr : parseInt(cr);
// Parent ActorSheet update steps
return super._updateObject(event, formData);
}
}
/**
* An Actor sheet for Vehicle type actors.
*/
class ActorSheet5eVehicle extends ActorSheet5e {
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "sheet", "actor", "vehicle"]
});
}
/* -------------------------------------------- */
/** @override */
static unsupportedItemTypes = new Set(["background", "class", "subclass"]);
/* -------------------------------------------- */
/**
* Creates a new cargo entry for a vehicle Actor.
* @type {object}
*/
static get newCargo() {
return {name: "", quantity: 1};
}
/* -------------------------------------------- */
/* Context Preparation */
/* -------------------------------------------- */
/**
* Compute the total weight of the vehicle's cargo.
* @param {number} totalWeight The cumulative item weight from inventory items
* @param {object} actorData The data object for the Actor being rendered
* @returns {{max: number, value: number, pct: number}}
* @private
*/
_computeEncumbrance(totalWeight, actorData) {
// Compute currency weight
const totalCoins = Object.values(actorData.system.currency).reduce((acc, denom) => acc + denom, 0);
const currencyPerWeight = game.settings.get("dnd5e", "metricWeightUnits")
? CONFIG.DND5E.encumbrance.currencyPerWeight.metric
: CONFIG.DND5E.encumbrance.currencyPerWeight.imperial;
totalWeight += totalCoins / currencyPerWeight;
// Vehicle weights are an order of magnitude greater.
totalWeight /= game.settings.get("dnd5e", "metricWeightUnits")
? CONFIG.DND5E.encumbrance.vehicleWeightMultiplier.metric
: CONFIG.DND5E.encumbrance.vehicleWeightMultiplier.imperial;
// Compute overall encumbrance
const max = actorData.system.attributes.capacity.cargo;
const pct = Math.clamped((totalWeight * 100) / max, 0, 100);
return {value: totalWeight.toNearest(0.1), max, pct};
}
/* -------------------------------------------- */
/** @override */
_getMovementSpeed(actorData, largestPrimary=true) {
return super._getMovementSpeed(actorData, largestPrimary);
}
/* -------------------------------------------- */
/**
* Prepare items that are mounted to a vehicle and require one or more crew to operate.
* @param {object} item Copy of the item data being prepared for display.
* @param {object} context Display context for the item.
* @protected
*/
_prepareCrewedItem(item, context) {
// Determine crewed status
const isCrewed = item.system.crewed;
context.toggleClass = isCrewed ? "active" : "";
context.toggleTitle = game.i18n.localize(`DND5E.${isCrewed ? "Crewed" : "Uncrewed"}`);
// Handle crew actions
if ( item.type === "feat" && item.system.activation.type === "crew" ) {
context.cover = game.i18n.localize(`DND5E.${item.system.cover ? "CoverTotal" : "None"}`);
if ( item.system.cover === .5 ) context.cover = "½";
else if ( item.system.cover === .75 ) context.cover = "¾";
else if ( item.system.cover === null ) context.cover = "—";
}
// Prepare vehicle weapons
if ( (item.type === "equipment") || (item.type === "weapon") ) {
context.threshold = item.system.hp.dt ? item.system.hp.dt : "—";
}
}
/* -------------------------------------------- */
/** @override */
_prepareItems(context) {
const cargoColumns = [{
label: game.i18n.localize("DND5E.Quantity"),
css: "item-qty",
property: "quantity",
editable: "Number"
}];
const equipmentColumns = [{
label: game.i18n.localize("DND5E.Quantity"),
css: "item-qty",
property: "system.quantity",
editable: "Number"
}, {
label: game.i18n.localize("DND5E.AC"),
css: "item-ac",
property: "system.armor.value"
}, {
label: game.i18n.localize("DND5E.HP"),
css: "item-hp",
property: "system.hp.value",
editable: "Number"
}, {
label: game.i18n.localize("DND5E.Threshold"),
css: "item-threshold",
property: "threshold"
}];
const features = {
actions: {
label: game.i18n.localize("DND5E.ActionPl"),
items: [],
hasActions: true,
crewable: true,
dataset: {type: "feat", "activation.type": "crew"},
columns: [{
label: game.i18n.localize("DND5E.Cover"),
css: "item-cover",
property: "cover"
}]
},
equipment: {
label: game.i18n.localize(CONFIG.Item.typeLabels.equipment),
items: [],
crewable: true,
dataset: {type: "equipment", "armor.type": "vehicle"},
columns: equipmentColumns
},
passive: {
label: game.i18n.localize("DND5E.Features"),
items: [],
dataset: {type: "feat"}
},
reactions: {
label: game.i18n.localize("DND5E.ReactionPl"),
items: [],
dataset: {type: "feat", "activation.type": "reaction"}
},
weapons: {
label: game.i18n.localize(`${CONFIG.Item.typeLabels.weapon}Pl`),
items: [],
crewable: true,
dataset: {type: "weapon", "weapon-type": "siege"},
columns: equipmentColumns
}
};
context.items.forEach(item => {
const {uses, recharge} = item.system;
const ctx = context.itemContext[item.id] ??= {};
ctx.canToggle = false;
ctx.isExpanded = this._expanded.has(item.id);
ctx.hasUses = uses && (uses.max > 0);
ctx.isOnCooldown = recharge && !!recharge.value && (recharge.charged === false);
ctx.isDepleted = item.isOnCooldown && (uses.per && (uses.value > 0));
});
const cargo = {
crew: {
label: game.i18n.localize("DND5E.VehicleCrew"),
items: context.actor.system.cargo.crew,
css: "cargo-row crew",
editableName: true,
dataset: {type: "crew"},
columns: cargoColumns
},
passengers: {
label: game.i18n.localize("DND5E.VehiclePassengers"),
items: context.actor.system.cargo.passengers,
css: "cargo-row passengers",
editableName: true,
dataset: {type: "passengers"},
columns: cargoColumns
},
cargo: {
label: game.i18n.localize("DND5E.VehicleCargo"),
items: [],
dataset: {type: "loot"},
columns: [{
label: game.i18n.localize("DND5E.Quantity"),
css: "item-qty",
property: "system.quantity",
editable: "Number"
}, {
label: game.i18n.localize("DND5E.Price"),
css: "item-price",
property: "system.price.value",
editable: "Number"
}, {
label: game.i18n.localize("DND5E.Weight"),
css: "item-weight",
property: "system.weight",
editable: "Number"
}]
}
};
// Classify items owned by the vehicle and compute total cargo weight
let totalWeight = 0;
for ( const item of context.items ) {
const ctx = context.itemContext[item.id] ??= {};
this._prepareCrewedItem(item, ctx);
// Handle cargo explicitly
const isCargo = item.flags.dnd5e?.vehicleCargo === true;
if ( isCargo ) {
totalWeight += (item.system.weight || 0) * item.system.quantity;
cargo.cargo.items.push(item);
continue;
}
// Handle non-cargo item types
switch ( item.type ) {
case "weapon":
features.weapons.items.push(item);
break;
case "equipment":
features.equipment.items.push(item);
break;
case "feat":
const act = item.system.activation;
if ( !act.type || (act.type === "none") ) features.passive.items.push(item);
else if (act.type === "reaction") features.reactions.items.push(item);
else features.actions.items.push(item);
break;
default:
totalWeight += (item.system.weight || 0) * item.system.quantity;
cargo.cargo.items.push(item);
}
}
// Update the rendering context data
context.inventoryFilters = false;
context.features = Object.values(features);
context.cargo = Object.values(cargo);
context.encumbrance = this._computeEncumbrance(totalWeight, context);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @override */
activateListeners(html) {
super.activateListeners(html);
if ( !this.isEditable ) return;
html.find(".item-toggle").click(this._onToggleItem.bind(this));
html.find(".item-hp input")
.click(evt => evt.target.select())
.change(this._onHPChange.bind(this));
html.find(".item:not(.cargo-row) input[data-property]")
.click(evt => evt.target.select())
.change(this._onEditInSheet.bind(this));
html.find(".cargo-row input")
.click(evt => evt.target.select())
.change(this._onCargoRowChange.bind(this));
html.find(".item:not(.cargo-row) .item-qty input")
.click(evt => evt.target.select())
.change(this._onQtyChange.bind(this));
if (this.actor.system.attributes.actions.stations) {
html.find(".counter.actions, .counter.action-thresholds").hide();
}
}
/* -------------------------------------------- */
/**
* Handle saving a cargo row (i.e. crew or passenger) in-sheet.
* @param {Event} event Triggering event.
* @returns {Promise|null} Actor after update if any changes were made.
* @private
*/
_onCargoRowChange(event) {
event.preventDefault();
const target = event.currentTarget;
const row = target.closest(".item");
const idx = Number(row.dataset.itemIndex);
const property = row.classList.contains("crew") ? "crew" : "passengers";
// Get the cargo entry
const cargo = foundry.utils.deepClone(this.actor.system.cargo[property]);
const entry = cargo[idx];
if ( !entry ) return null;
// Update the cargo value
const key = target.dataset.property ?? "name";
const type = target.dataset.dtype;
let value = target.value;
if (type === "Number") value = Number(value);
entry[key] = value;
// Perform the Actor update
return this.actor.update({[`system.cargo.${property}`]: cargo});
}
/* -------------------------------------------- */
/**
* Handle editing certain values like quantity, price, and weight in-sheet.
* @param {Event} event Triggering event.
* @returns {Promise} Item with updates applied.
* @private
*/
_onEditInSheet(event) {
event.preventDefault();
const itemID = event.currentTarget.closest(".item").dataset.itemId;
const item = this.actor.items.get(itemID);
const property = event.currentTarget.dataset.property;
const type = event.currentTarget.dataset.dtype;
let value = event.currentTarget.value;
switch (type) {
case "Number": value = parseInt(value); break;
case "Boolean": value = value === "true"; break;
}
return item.update({[`${property}`]: value});
}
/* -------------------------------------------- */
/** @inheritDoc */
_onItemCreate(event) {
event.preventDefault();
// Handle creating a new crew or passenger row.
const target = event.currentTarget;
const type = target.dataset.type;
if (type === "crew" || type === "passengers") {
const cargo = foundry.utils.deepClone(this.actor.system.cargo[type]);
cargo.push(this.constructor.newCargo);
return this.actor.update({[`system.cargo.${type}`]: cargo});
}
return super._onItemCreate(event);
}
/* -------------------------------------------- */
/** @inheritDoc */
_onItemDelete(event) {
event.preventDefault();
// Handle deleting a crew or passenger row.
const row = event.currentTarget.closest(".item");
if (row.classList.contains("cargo-row")) {
const idx = Number(row.dataset.itemIndex);
const type = row.classList.contains("crew") ? "crew" : "passengers";
const cargo = foundry.utils.deepClone(this.actor.system.cargo[type]).filter((_, i) => i !== idx);
return this.actor.update({[`system.cargo.${type}`]: cargo});
}
return super._onItemDelete(event);
}
/* -------------------------------------------- */
/** @override */
async _onDropSingleItem(itemData) {
const cargoTypes = ["weapon", "equipment", "consumable", "tool", "loot", "backpack"];
const isCargo = cargoTypes.includes(itemData.type) && (this._tabs[0].active === "cargo");
foundry.utils.setProperty(itemData, "flags.dnd5e.vehicleCargo", isCargo);
return super._onDropSingleItem(itemData);
}
/* -------------------------------------------- */
/**
* Special handling for editing HP to clamp it within appropriate range.
* @param {Event} event Triggering event.
* @returns {Promise} Item after the update is applied.
* @private
*/
_onHPChange(event) {
event.preventDefault();
const itemID = event.currentTarget.closest(".item").dataset.itemId;
const item = this.actor.items.get(itemID);
let hp = Math.clamped(0, parseInt(event.currentTarget.value), item.system.hp.max);
if ( Number.isNaN(hp) ) hp = 0;
return item.update({"system.hp.value": hp});
}
/* -------------------------------------------- */
/**
* Special handling for editing quantity value of equipment and weapons inside the features tab.
* @param {Event} event Triggering event.
* @returns {Promise} Item after the update is applied.
* @private
*/
_onQtyChange(event) {
event.preventDefault();
const itemID = event.currentTarget.closest(".item").dataset.itemId;
const item = this.actor.items.get(itemID);
let qty = parseInt(event.currentTarget.value);
if ( Number.isNaN(qty) ) qty = 0;
return item.update({"system.quantity": qty});
}
/* -------------------------------------------- */
/**
* Handle toggling an item's crewed status.
* @param {Event} event Triggering event.
* @returns {Promise} Item after the toggling is applied.
* @private
*/
_onToggleItem(event) {
event.preventDefault();
const itemID = event.currentTarget.closest(".item").dataset.itemId;
const item = this.actor.items.get(itemID);
return item.update({"system.crewed": !item.system.crewed});
}
}
/**
* A character sheet for group-type Actors.
* The functionality of this sheet is sufficiently different from other Actor types that we extend the base
* Foundry VTT ActorSheet instead of the ActorSheet5e abstraction used for character, npc, and vehicle types.
*/
class GroupActorSheet extends ActorSheet {
/**
* IDs for items on the sheet that have been expanded.
* @type {Set}
* @protected
*/
_expanded = new Set();
/* -------------------------------------------- */
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "sheet", "actor", "group"],
template: "systems/dnd5e/templates/actors/group-sheet.hbs",
tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "members"}],
scrollY: [".inventory .inventory-list"],
width: 620,
height: 620
});
}
/* -------------------------------------------- */
/**
* A set of item types that should be prevented from being dropped on this type of actor sheet.
* @type {Set}
*/
static unsupportedItemTypes = new Set(["background", "class", "subclass", "feat"]);
/* -------------------------------------------- */
/* Context Preparation */
/* -------------------------------------------- */
/** @inheritDoc */
async getData(options={}) {
const context = super.getData(options);
context.system = context.data.system;
context.items = Array.from(this.actor.items);
// Membership
const {sections, stats} = this.#prepareMembers();
Object.assign(context, stats);
context.sections = sections;
// Movement
context.movement = this.#prepareMovementSpeed();
// Inventory
context.itemContext = {};
context.inventory = this.#prepareInventory(context);
context.expandedData = {};
for ( const id of this._expanded ) {
const item = this.actor.items.get(id);
if ( item ) context.expandedData[id] = await item.getChatData({secrets: this.actor.isOwner});
}
context.inventoryFilters = false;
context.rollableClass = this.isEditable ? "rollable" : "";
// Biography HTML
context.descriptionFull = await TextEditor.enrichHTML(this.actor.system.description.full, {
secrets: this.actor.isOwner,
rollData: context.rollData,
async: true,
relativeTo: this.actor
});
// Summary tag
context.summary = this.#getSummary(stats);
// Text labels
context.labels = {
currencies: Object.entries(CONFIG.DND5E.currencies).reduce((obj, [k, c]) => {
obj[k] = c.label;
return obj;
}, {})
};
return context;
}
/* -------------------------------------------- */
/**
* Prepare a localized summary of group membership.
* @param {{nMembers: number, nVehicles: number}} stats The number of members in the group
* @returns {string} The formatted summary string
*/
#getSummary(stats) {
const formatter = new Intl.ListFormat(game.i18n.lang, {style: "long", type: "conjunction"});
const members = [];
if ( stats.nMembers ) members.push(`${stats.nMembers} ${game.i18n.localize("DND5E.GroupMembers")}`);
if ( stats.nVehicles ) members.push(`${stats.nVehicles} ${game.i18n.localize("DND5E.GroupVehicles")}`);
return game.i18n.format("DND5E.GroupSummary", {members: formatter.format(members)});
}
/* -------------------------------------------- */
/**
* Prepare membership data for the sheet.
* @returns {{sections: object, stats: object}}
*/
#prepareMembers() {
const stats = {
currentHP: 0,
maxHP: 0,
nMembers: 0,
nVehicles: 0
};
const sections = {
character: {label: `${CONFIG.Actor.typeLabels.character}Pl`, members: []},
npc: {label: `${CONFIG.Actor.typeLabels.npc}Pl`, members: []},
vehicle: {label: `${CONFIG.Actor.typeLabels.vehicle}Pl`, members: []}
};
for ( const member of this.object.system.members ) {
const m = {
actor: member,
id: member.id,
name: member.name,
img: member.img,
hp: {},
displayHPValues: member.testUserPermission(game.user, "OBSERVER")
};
// HP bar
const hp = member.system.attributes.hp;
m.hp.current = hp.value + (hp.temp || 0);
m.hp.max = Math.max(0, hp.max + (hp.tempmax || 0));
m.hp.pct = Math.clamped((m.hp.current / m.hp.max) * 100, 0, 100).toFixed(2);
m.hp.color = dnd5e.documents.Actor5e.getHPColor(m.hp.current, m.hp.max).css;
stats.currentHP += m.hp.current;
stats.maxHP += m.hp.max;
if ( member.type === "vehicle" ) stats.nVehicles++;
else stats.nMembers++;
sections[member.type].members.push(m);
}
for ( const [k, section] of Object.entries(sections) ) {
if ( !section.members.length ) delete sections[k];
}
return {sections, stats};
}
/* -------------------------------------------- */
/**
* Prepare movement speed data for rendering on the sheet.
* @returns {{secondary: string, primary: string}}
*/
#prepareMovementSpeed() {
const movement = this.object.system.attributes.movement;
let speeds = [
[movement.land, `${game.i18n.localize("DND5E.MovementLand")} ${movement.land}`],
[movement.water, `${game.i18n.localize("DND5E.MovementWater")} ${movement.water}`],
[movement.air, `${game.i18n.localize("DND5E.MovementAir")} ${movement.air}`]
];
speeds = speeds.filter(s => s[0]).sort((a, b) => b[0] - a[0]);
const primary = speeds.shift();
return {
primary: `${primary ? primary[1] : "0"}`,
secondary: speeds.map(s => s[1]).join(", ")
};
}
/* -------------------------------------------- */
/**
* Prepare inventory items for rendering on the sheet.
* @param {object} context Prepared rendering context.
* @returns {Object}
*/
#prepareInventory(context) {
// Categorize as weapons, equipment, containers, and loot
const sections = {};
for ( const type of ["weapon", "equipment", "consumable", "backpack", "loot"] ) {
sections[type] = {label: `${CONFIG.Item.typeLabels[type]}Pl`, items: [], hasActions: false, dataset: {type}};
}
// Classify items
for ( const item of context.items ) {
const ctx = context.itemContext[item.id] ??= {};
const {quantity} = item.system;
ctx.isStack = Number.isNumeric(quantity) && (quantity > 1);
ctx.canToggle = false;
ctx.isExpanded = this._expanded.has(item.id);
if ( (item.type in sections) && (item.type !== "loot") ) sections[item.type].items.push(item);
else sections.loot.items.push(item);
}
return sections;
}
/* -------------------------------------------- */
/* Rendering Workflow */
/* -------------------------------------------- */
/** @inheritDoc */
async _render(force, options={}) {
for ( const member of this.object.system.members) {
member.apps[this.id] = this;
}
return super._render(force, options);
}
/* -------------------------------------------- */
/** @inheritDoc */
async close(options={}) {
for ( const member of this.object.system.members ) {
delete member.apps[this.id];
}
return super.close(options);
}
/* -------------------------------------------- */
/* Event Listeners and Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".group-member .name").click(this._onClickMemberName.bind(this));
if ( this.isEditable ) {
html.find(".action-button").click(this._onClickActionButton.bind(this));
html.find(".item-control").click(this._onClickItemControl.bind(this));
html.find(".item .rollable h4").click(event => this._onClickItemName(event));
new ContextMenu(html, ".item-list .item", [], {onOpen: this._onItemContext.bind(this)});
}
}
/* -------------------------------------------- */
/**
* Handle clicks to action buttons on the group sheet.
* @param {PointerEvent} event The initiating click event
* @protected
*/
_onClickActionButton(event) {
event.preventDefault();
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "convertCurrency":
Dialog.confirm({
title: `${game.i18n.localize("DND5E.CurrencyConvert")}`,
content: `${game.i18n.localize("DND5E.CurrencyConvertHint")}
`,
yes: () => this.actor.convertCurrency()
});
break;
case "removeMember":
const removeMemberId = button.closest("li.group-member").dataset.actorId;
this.object.system.removeMember(removeMemberId);
break;
case "movementConfig":
const movementConfig = new ActorMovementConfig(this.object);
movementConfig.render(true);
break;
}
}
/* -------------------------------------------- */
/**
* Handle clicks to item control buttons on the group sheet.
* @param {PointerEvent} event The initiating click event
* @protected
*/
_onClickItemControl(event) {
event.preventDefault();
const button = event.currentTarget;
switch ( button.dataset.action ) {
case "itemCreate":
this._createItem(button);
break;
case "itemDelete":
const deleteLi = event.currentTarget.closest(".item");
const deleteItem = this.actor.items.get(deleteLi.dataset.itemId);
deleteItem.deleteDialog();
break;
case "itemEdit":
const editLi = event.currentTarget.closest(".item");
const editItem = this.actor.items.get(editLi.dataset.itemId);
editItem.sheet.render(true);
break;
}
}
/* -------------------------------------------- */
/**
* Handle workflows to create a new Item directly within the Group Actor sheet.
* @param {HTMLElement} button The clicked create button
* @returns {Item5e} The created embedded Item
* @protected
*/
_createItem(button) {
const type = button.dataset.type;
const system = {...button.dataset};
delete system.type;
const name = game.i18n.format("DND5E.ItemNew", {type: game.i18n.localize(CONFIG.Item.typeLabels[type])});
const itemData = {name, type, system};
return this.actor.createEmbeddedDocuments("Item", [itemData]);
}
/* -------------------------------------------- */
/**
* Handle activation of a context menu for an embedded Item document.
* Dynamically populate the array of context menu options.
* Reuse the item context options provided by the base ActorSheet5e class.
* @param {HTMLElement} element The HTML element for which the context menu is activated
* @protected
*/
_onItemContext(element) {
const item = this.actor.items.get(element.dataset.itemId);
if ( !item ) return;
ui.context.menuItems = ActorSheet5e.prototype._getItemContextOptions.call(this, item);
Hooks.call("dnd5e.getItemContextOptions", item, ui.context.menuItems);
}
/* -------------------------------------------- */
/**
* Handle clicks on member names in the members list.
* @param {PointerEvent} event The initiating click event
* @protected
*/
_onClickMemberName(event) {
event.preventDefault();
const member = event.currentTarget.closest("li.group-member");
const actor = game.actors.get(member.dataset.actorId);
if ( actor ) actor.sheet.render(true, {focus: true});
}
/* -------------------------------------------- */
/**
* Handle clicks on an item name to expand its description
* @param {PointerEvent} event The initiating click event
* @protected
*/
_onClickItemName(event) {
game.system.applications.actor.ActorSheet5e.prototype._onItemSummary.call(this, event);
}
/* -------------------------------------------- */
/** @override */
async _onDropActor(event, data) {
if ( !this.isEditable ) return;
const cls = getDocumentClass("Actor");
const sourceActor = await cls.fromDropData(data);
if ( !sourceActor ) return;
return this.object.system.addMember(sourceActor);
}
/* -------------------------------------------- */
/** @override */
async _onDropItemCreate(itemData) {
const items = itemData instanceof Array ? itemData : [itemData];
const toCreate = [];
for ( const item of items ) {
const result = await this._onDropSingleItem(item);
if ( result ) toCreate.push(result);
}
// Create the owned items as normal
return this.actor.createEmbeddedDocuments("Item", toCreate);
}
/* -------------------------------------------- */
/**
* Handles dropping of a single item onto this group sheet.
* @param {object} itemData The item data to create.
* @returns {Promise} The item data to create after processing, or false if the item should not be
* created or creation has been otherwise handled.
* @protected
*/
async _onDropSingleItem(itemData) {
// Check to make sure items of this type are allowed on this actor
if ( this.constructor.unsupportedItemTypes.has(itemData.type) ) {
ui.notifications.warn(game.i18n.format("DND5E.ActorWarningInvalidItem", {
itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]),
actorType: game.i18n.localize(CONFIG.Actor.typeLabels[this.actor.type])
}));
return false;
}
// Create a Consumable spell scroll on the Inventory tab
if ( itemData.type === "spell" ) {
const scroll = await Item5e.createScrollFromSpell(itemData);
return scroll.toObject();
}
// TODO: Stack identical consumables
return itemData;
}
}
/**
* A simple form to set skill configuration for a given skill.
*
* @param {Actor} actor The Actor instance being displayed within the sheet.
* @param {ApplicationOptions} options Additional application configuration options.
* @param {string} skillId The skill key as defined in CONFIG.DND5E.skills.
* @deprecated since dnd5e 2.2, targeted for removal in 2.4
*/
class ActorSkillConfig extends BaseConfigSheet {
constructor(actor, options, skillId) {
super(actor, options);
this._skillId = skillId;
foundry.utils.logCompatibilityWarning("ActorSkillConfig has been deprecated in favor of the more general "
+ "ProficiencyConfig available at 'dnd5e.applications.actor.ProficiencyConfig'. Support for the old application "
+ "will be removed in a future version.", {since: "DnD5e 2.2", until: "DnD5e 2.4"});
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e"],
template: "systems/dnd5e/templates/apps/skill-config.hbs",
width: 500,
height: "auto"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
const label = CONFIG.DND5E.skills[this._skillId].label;
return `${game.i18n.format("DND5E.SkillConfigureTitle", {skill: label})}: ${this.document.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options) {
const src = this.document.toObject();
return {
abilities: CONFIG.DND5E.abilities,
skill: src.system.skills?.[this._skillId] ?? this.document.system.skills[this._skillId] ?? {},
skillId: this._skillId,
proficiencyLevels: CONFIG.DND5E.proficiencyLevels,
bonusGlobal: src.system.bonuses?.abilities.skill
};
}
/* -------------------------------------------- */
/** @inheritdoc */
_updateObject(event, formData) {
const passive = formData[`system.skills.${this._skillId}.bonuses.passive`];
const passiveRoll = new Roll(passive);
if ( !passiveRoll.isDeterministic ) {
const message = game.i18n.format("DND5E.FormulaCannotContainDiceError", {
name: game.i18n.localize("DND5E.SkillBonusPassive")
});
ui.notifications.error(message);
throw new Error(message);
}
super._updateObject(event, formData);
}
}
var _module$9 = /*#__PURE__*/Object.freeze({
__proto__: null,
ActorAbilityConfig: ActorAbilityConfig,
ActorArmorConfig: ActorArmorConfig,
ActorHitDiceConfig: ActorHitDiceConfig,
ActorHitPointsConfig: ActorHitPointsConfig,
ActorInitiativeConfig: ActorInitiativeConfig,
ActorMovementConfig: ActorMovementConfig,
ActorSensesConfig: ActorSensesConfig,
ActorSheet5e: ActorSheet5e,
ActorSheet5eCharacter: ActorSheet5eCharacter,
ActorSheet5eNPC: ActorSheet5eNPC,
ActorSheet5eVehicle: ActorSheet5eVehicle,
ActorSheetFlags: ActorSheetFlags,
ActorSkillConfig: ActorSkillConfig,
ActorTypeConfig: ActorTypeConfig,
BaseConfigSheet: BaseConfigSheet,
GroupActorSheet: GroupActorSheet,
LongRestDialog: LongRestDialog,
ProficiencyConfig: ProficiencyConfig,
ShortRestDialog: ShortRestDialog,
ToolSelector: ToolSelector,
TraitSelector: TraitSelector$1
});
/**
* Dialog to select which new advancements should be added to an item.
*/
class AdvancementMigrationDialog extends Dialog {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "advancement-migration", "dialog"],
jQuery: false,
width: 500
});
}
/* -------------------------------------------- */
/**
* A helper constructor function which displays the migration dialog.
* @param {Item5e} item Item to which the advancements are being added.
* @param {Advancement[]} advancements New advancements that should be displayed in the prompt.
* @returns {Promise} Resolves with the advancements that should be added, if any.
*/
static createDialog(item, advancements) {
const advancementContext = advancements.map(a => ({
id: a.id, icon: a.icon, title: a.title,
summary: a.levels.length === 1 ? a.summaryForLevel(a.levels[0]) : ""
}));
return new Promise(async (resolve, reject) => {
const dialog = new this({
title: `${game.i18n.localize("DND5E.AdvancementMigrationTitle")}: ${item.name}`,
content: await renderTemplate(
"systems/dnd5e/templates/advancement/advancement-migration-dialog.hbs",
{ item, advancements: advancementContext }
),
buttons: {
continue: {
icon: ' ',
label: game.i18n.localize("DND5E.AdvancementMigrationConfirm"),
callback: html => resolve(advancements.filter(a => html.querySelector(`[name="${a.id}"]`)?.checked))
},
cancel: {
icon: ' ',
label: game.i18n.localize("Cancel"),
callback: html => reject(null)
}
},
default: "continue",
close: () => reject(null)
});
dialog.render(true);
});
}
}
/**
* Presents a list of advancement types to create when clicking the new advancement button.
* Once a type is selected, this hands the process over to the advancement's individual editing interface.
*
* @param {Item5e} item Item to which this advancement will be added.
* @param {object} [dialogData={}] An object of dialog data which configures how the modal window is rendered.
* @param {object} [options={}] Dialog rendering options.
*/
class AdvancementSelection extends Dialog {
constructor(item, dialogData={}, options={}) {
super(dialogData, options);
/**
* Store a reference to the Item to which this Advancement is being added.
* @type {Item5e}
*/
this.item = item;
}
/* -------------------------------------------- */
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["dnd5e", "sheet", "advancement"],
template: "systems/dnd5e/templates/advancement/advancement-selection.hbs",
title: "DND5E.AdvancementSelectionTitle",
width: 500,
height: "auto"
});
}
/* -------------------------------------------- */
/** @inheritDoc */
get id() {
return `item-${this.item.id}-advancement-selection`;
}
/* -------------------------------------------- */
/** @inheritDoc */
getData() {
const context = { types: {} };
for ( const [name, advancement] of Object.entries(CONFIG.DND5E.advancementTypes) ) {
if ( !(advancement.prototype instanceof Advancement)
|| !advancement.metadata.validItemTypes.has(this.item.type) ) continue;
context.types[name] = {
label: advancement.metadata.title,
icon: advancement.metadata.icon,
hint: advancement.metadata.hint,
disabled: !advancement.availableForItem(this.item)
};
}
context.types = dnd5e.utils.sortObjectEntries(context.types, "label");
return context;
}
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
html.on("change", "input", this._onChangeInput.bind(this));
}
/* -------------------------------------------- */
/** @inheritDoc */
_onChangeInput(event) {
const submit = this.element[0].querySelector("button[data-button='submit']");
submit.disabled = !this.element[0].querySelector("input[name='type']:checked");
}
/* -------------------------------------------- */
/**
* A helper constructor function which displays the selection dialog and returns a Promise once its workflow has
* been resolved.
* @param {Item5e} item Item to which the advancement should be added.
* @param {object} [config={}]
* @param {boolean} [config.rejectClose=false] Trigger a rejection if the window was closed without a choice.
* @param {object} [config.options={}] Additional rendering options passed to the Dialog.
* @returns {Promise} Result of `Item5e#createAdvancement`.
*/
static async createDialog(item, { rejectClose=false, options={} }={}) {
return new Promise((resolve, reject) => {
const dialog = new this(item, {
title: `${game.i18n.localize("DND5E.AdvancementSelectionTitle")}: ${item.name}`,
buttons: {
submit: {
callback: html => {
const formData = new FormDataExtended(html.querySelector("form"));
const type = formData.get("type");
resolve(item.createAdvancement(type));
}
}
},
close: () => {
if ( rejectClose ) reject("No advancement type was selected");
else resolve(null);
}
}, foundry.utils.mergeObject(options, { jQuery: false }));
dialog.render(true);
});
}
}
var _module$8 = /*#__PURE__*/Object.freeze({
__proto__: null,
AdvancementConfig: AdvancementConfig,
AdvancementConfirmationDialog: AdvancementConfirmationDialog,
AdvancementFlow: AdvancementFlow,
AdvancementManager: AdvancementManager,
AdvancementMigrationDialog: AdvancementMigrationDialog,
AdvancementSelection: AdvancementSelection,
HitPointsConfig: HitPointsConfig,
HitPointsFlow: HitPointsFlow,
ItemChoiceConfig: ItemChoiceConfig,
ItemChoiceFlow: ItemChoiceFlow,
ItemGrantConfig: ItemGrantConfig,
ItemGrantFlow: ItemGrantFlow,
ScaleValueConfig: ScaleValueConfig,
ScaleValueFlow: ScaleValueFlow
});
/**
* An extension of the base CombatTracker class to provide some 5e-specific functionality.
* @extends {CombatTracker}
*/
class CombatTracker5e extends CombatTracker {
/** @inheritdoc */
async _onCombatantControl(event) {
const btn = event.currentTarget;
const combatantId = btn.closest(".combatant").dataset.combatantId;
const combatant = this.viewed.combatants.get(combatantId);
if ( (btn.dataset.control === "rollInitiative") && combatant?.actor ) return combatant.actor.rollInitiativeDialog();
return super._onCombatantControl(event);
}
}
var _module$7 = /*#__PURE__*/Object.freeze({
__proto__: null,
CombatTracker5e: CombatTracker5e
});
/**
* A specialized form used to select from a checklist of attributes, traits, or properties.
* @deprecated since dnd5e 2.1, targeted for removal in 2.3
*/
class TraitSelector extends DocumentSheet {
constructor(...args) {
super(...args);
if ( !this.options.suppressWarning ) foundry.utils.logCompatibilityWarning(
`${this.constructor.name} has been deprecated in favor of a more specialized TraitSelector `
+ "available at 'dnd5e.applications.actor.TraitSelector'. Support for the old application will "
+ "be removed in a future version.",
{ since: "DnD5e 2.1", until: "DnD5e 2.3" }
);
}
/* -------------------------------------------- */
/** @inheritDoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "trait-selector",
classes: ["dnd5e", "trait-selector", "subconfig"],
title: "Actor Trait Selection",
template: "systems/dnd5e/templates/apps/trait-selector.hbs",
width: 320,
height: "auto",
choices: {},
allowCustom: true,
minimum: 0,
maximum: null,
labelKey: null,
valueKey: "value",
customKey: "custom"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return this.options.title || super.title;
}
/* -------------------------------------------- */
/**
* Return a reference to the target attribute
* @type {string}
*/
get attribute() {
return this.options.name;
}
/* -------------------------------------------- */
/** @override */
getData() {
const attr = foundry.utils.getProperty(this.object, this.attribute);
const o = this.options;
const value = (o.valueKey) ? foundry.utils.getProperty(attr, o.valueKey) ?? [] : attr;
const custom = (o.customKey) ? foundry.utils.getProperty(attr, o.customKey) ?? "" : "";
// Populate choices
const choices = Object.entries(o.choices).reduce((obj, e) => {
let [k, v] = e;
const label = o.labelKey ? foundry.utils.getProperty(v, o.labelKey) ?? v : v;
obj[k] = { label, chosen: attr ? value.includes(k) : false };
return obj;
}, {});
// Return data
return {
choices: choices,
custom: custom,
customPath: o.allowCustom ? "custom" : null
};
}
/* -------------------------------------------- */
/**
* Prepare the update data to include choices in the provided object.
* @param {object} formData Form data to search for choices.
* @returns {object} Updates to apply to target.
*/
_prepareUpdateData(formData) {
const o = this.options;
formData = foundry.utils.expandObject(formData);
// Obtain choices
const chosen = Object.entries(formData.choices).filter(([, v]) => v).map(([k]) => k);
// Object including custom data
const updateData = {};
if ( o.valueKey ) updateData[`${this.attribute}.${o.valueKey}`] = chosen;
else updateData[this.attribute] = chosen;
if ( o.allowCustom ) updateData[`${this.attribute}.${o.customKey}`] = formData.custom;
// Validate the number chosen
if ( o.minimum && (chosen.length < o.minimum) ) {
return ui.notifications.error(`You must choose at least ${o.minimum} options`);
}
if ( o.maximum && (chosen.length > o.maximum) ) {
return ui.notifications.error(`You may choose no more than ${o.maximum} options`);
}
return updateData;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
const updateData = this._prepareUpdateData(formData);
if ( updateData ) this.object.update(updateData);
}
}
/**
* Override and extend the core ItemSheet implementation to handle specific item types.
*/
class ItemSheet5e extends ItemSheet {
constructor(...args) {
super(...args);
// Expand the default size of the class sheet
if ( this.object.type === "class" ) {
this.options.width = this.position.width = 600;
this.options.height = this.position.height = 680;
}
else if ( this.object.type === "subclass" ) {
this.options.height = this.position.height = 540;
}
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
width: 560,
height: 400,
classes: ["dnd5e", "sheet", "item"],
resizable: true,
scrollY: [".tab.details"],
tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}],
dragDrop: [
{dragSelector: "[data-effect-id]", dropSelector: ".effects-list"},
{dragSelector: ".advancement-item", dropSelector: ".advancement"}
]
});
}
/* -------------------------------------------- */
/**
* Whether advancements on embedded items should be configurable.
* @type {boolean}
*/
advancementConfigurationMode = false;
/* -------------------------------------------- */
/** @inheritdoc */
get template() {
return `systems/dnd5e/templates/items/${this.item.type}.hbs`;
}
/* -------------------------------------------- */
/* Context Preparation */
/* -------------------------------------------- */
/** @override */
async getData(options) {
const context = await super.getData(options);
const item = context.item;
const source = item.toObject();
// Game system configuration
context.config = CONFIG.DND5E;
// Item rendering data
foundry.utils.mergeObject(context, {
source: source.system,
system: item.system,
labels: item.labels,
isEmbedded: item.isEmbedded,
advancementEditable: (this.advancementConfigurationMode || !item.isEmbedded) && context.editable,
rollData: this.item.getRollData(),
// Item Type, Status, and Details
itemType: game.i18n.localize(CONFIG.Item.typeLabels[this.item.type]),
itemStatus: this._getItemStatus(),
itemProperties: this._getItemProperties(),
baseItems: await this._getItemBaseTypes(),
isPhysical: item.system.hasOwnProperty("quantity"),
// Action Details
isHealing: item.system.actionType === "heal",
isFlatDC: item.system.save?.scaling === "flat",
isLine: ["line", "wall"].includes(item.system.target?.type),
// Vehicles
isCrewed: item.system.activation?.type === "crew",
// Armor Class
hasDexModifier: item.isArmor && (item.system.armor?.type !== "shield"),
// Advancement
advancement: this._getItemAdvancement(item),
// Prepare Active Effects
effects: ActiveEffect5e.prepareActiveEffectCategories(item.effects)
});
context.abilityConsumptionTargets = this._getItemConsumptionTargets();
// Special handling for specific item types
switch ( item.type ) {
case "feat":
const featureType = CONFIG.DND5E.featureTypes[item.system.type?.value];
if ( featureType ) {
context.itemType = featureType.label;
context.featureSubtypes = featureType.subtypes;
}
break;
case "spell":
context.spellComponents = {...CONFIG.DND5E.spellComponents, ...CONFIG.DND5E.spellTags};
break;
}
// Enrich HTML description
context.descriptionHTML = await TextEditor.enrichHTML(item.system.description.value, {
secrets: item.isOwner,
async: true,
relativeTo: this.item,
rollData: context.rollData
});
return context;
}
/* -------------------------------------------- */
/**
* Get the display object used to show the advancement tab.
* @param {Item5e} item The item for which the advancement is being prepared.
* @returns {object} Object with advancement data grouped by levels.
*/
_getItemAdvancement(item) {
if ( !item.system.advancement ) return {};
const advancement = {};
const configMode = !item.parent || this.advancementConfigurationMode;
const maxLevel = !configMode
? (item.system.levels ?? item.class?.system.levels ?? item.parent.system.details?.level ?? -1) : -1;
// Improperly configured advancements
if ( item.advancement.needingConfiguration.length ) {
advancement.unconfigured = {
items: item.advancement.needingConfiguration.map(a => ({
id: a.id,
order: a.constructor.order,
title: a.title,
icon: a.icon,
classRestriction: a.classRestriction,
configured: false
})),
configured: "partial"
};
}
// All other advancements by level
for ( let [level, advancements] of Object.entries(item.advancement.byLevel) ) {
if ( !configMode ) advancements = advancements.filter(a => a.appliesToClass);
const items = advancements.map(advancement => ({
id: advancement.id,
order: advancement.sortingValueForLevel(level),
title: advancement.titleForLevel(level, { configMode }),
icon: advancement.icon,
classRestriction: advancement.classRestriction,
summary: advancement.summaryForLevel(level, { configMode }),
configured: advancement.configuredForLevel(level)
}));
if ( !items.length ) continue;
advancement[level] = {
items: items.sort((a, b) => a.order.localeCompare(b.order)),
configured: (level > maxLevel) ? false : items.some(a => !a.configured) ? "partial" : "full"
};
}
return advancement;
}
/* -------------------------------------------- */
/**
* Get the base weapons and tools based on the selected type.
* @returns {Promise} Object with base items for this type formatted for selectOptions.
* @protected
*/
async _getItemBaseTypes() {
const type = this.item.type === "equipment" ? "armor" : this.item.type;
const baseIds = CONFIG.DND5E[`${type}Ids`];
if ( baseIds === undefined ) return {};
const typeProperty = type === "armor" ? "armor.type" : `${type}Type`;
const baseType = foundry.utils.getProperty(this.item.system, typeProperty);
const items = {};
for ( const [name, id] of Object.entries(baseIds) ) {
const baseItem = await getBaseItem(id);
if ( baseType !== foundry.utils.getProperty(baseItem?.system, typeProperty) ) continue;
items[name] = baseItem.name;
}
return Object.fromEntries(Object.entries(items).sort((lhs, rhs) => lhs[1].localeCompare(rhs[1])));
}
/* -------------------------------------------- */
/**
* Get the valid item consumption targets which exist on the actor
* @returns {Object} An object of potential consumption targets
* @private
*/
_getItemConsumptionTargets() {
const consume = this.item.system.consume || {};
if ( !consume.type ) return [];
const actor = this.item.actor;
if ( !actor ) return {};
// Ammunition
if ( consume.type === "ammo" ) {
return actor.itemTypes.consumable.reduce((ammo, i) => {
if ( i.system.consumableType === "ammo" ) ammo[i.id] = `${i.name} (${i.system.quantity})`;
return ammo;
}, {});
}
// Attributes
else if ( consume.type === "attribute" ) {
const attrData = game.dnd5e.isV10 ? actor.system : actor.type;
const attributes = TokenDocument.implementation.getConsumedAttributes(attrData);
attributes.bar.forEach(a => a.push("value"));
return attributes.bar.concat(attributes.value).reduce((obj, a) => {
let k = a.join(".");
obj[k] = k;
return obj;
}, {});
}
// Hit Dice
else if ( consume.type === "hitDice" ) {
return {
smallest: game.i18n.localize("DND5E.ConsumeHitDiceSmallest"),
...CONFIG.DND5E.hitDieTypes.reduce((obj, hd) => { obj[hd] = hd; return obj; }, {}),
largest: game.i18n.localize("DND5E.ConsumeHitDiceLargest")
};
}
// Materials
else if ( consume.type === "material" ) {
return actor.items.reduce((obj, i) => {
if ( ["consumable", "loot"].includes(i.type) && !i.system.activation ) {
obj[i.id] = `${i.name} (${i.system.quantity})`;
}
return obj;
}, {});
}
// Charges
else if ( consume.type === "charges" ) {
return actor.items.reduce((obj, i) => {
// Limited-use items
const uses = i.system.uses || {};
if ( uses.per && uses.max ) {
const label = uses.per === "charges"
? ` (${game.i18n.format("DND5E.AbilityUseChargesLabel", {value: uses.value})})`
: ` (${game.i18n.format("DND5E.AbilityUseConsumableLabel", {max: uses.max, per: uses.per})})`;
obj[i.id] = i.name + label;
}
// Recharging items
const recharge = i.system.recharge || {};
if ( recharge.value ) obj[i.id] = `${i.name} (${game.i18n.format("DND5E.Recharge")})`;
return obj;
}, {});
}
else return {};
}
/* -------------------------------------------- */
/**
* Get the text item status which is shown beneath the Item type in the top-right corner of the sheet.
* @returns {string|null} Item status string if applicable to item's type.
* @protected
*/
_getItemStatus() {
switch ( this.item.type ) {
case "class":
return game.i18n.format("DND5E.LevelCount", {ordinal: this.item.system.levels.ordinalString()});
case "equipment":
case "weapon":
return game.i18n.localize(this.item.system.equipped ? "DND5E.Equipped" : "DND5E.Unequipped");
case "feat":
const typeConfig = CONFIG.DND5E.featureTypes[this.item.system.type.value];
if ( typeConfig?.subtypes ) return typeConfig.subtypes[this.item.system.type.subtype] ?? null;
break;
case "spell":
return CONFIG.DND5E.spellPreparationModes[this.item.system.preparation];
case "tool":
return game.i18n.localize(this.item.system.proficient ? "DND5E.Proficient" : "DND5E.NotProficient");
}
return null;
}
/* -------------------------------------------- */
/**
* Get the Array of item properties which are used in the small sidebar of the description tab.
* @returns {string[]} List of property labels to be shown.
* @private
*/
_getItemProperties() {
const props = [];
const labels = this.item.labels;
switch ( this.item.type ) {
case "equipment":
props.push(CONFIG.DND5E.equipmentTypes[this.item.system.armor.type]);
if ( this.item.isArmor || this.item.isMountable ) props.push(labels.armor);
break;
case "feat":
props.push(labels.featType);
break;
case "spell":
props.push(labels.components.vsm, labels.materials, ...labels.components.tags);
break;
case "weapon":
for ( const [k, v] of Object.entries(this.item.system.properties) ) {
if ( v === true ) props.push(CONFIG.DND5E.weaponProperties[k]);
}
break;
}
// Action type
if ( this.item.system.actionType ) {
props.push(CONFIG.DND5E.itemActionTypes[this.item.system.actionType]);
}
// Action usage
if ( (this.item.type !== "weapon") && !foundry.utils.isEmpty(this.item.system.activation) ) {
props.push(labels.activation, labels.range, labels.target, labels.duration);
}
return props.filter(p => !!p);
}
/* -------------------------------------------- */
/** @inheritDoc */
setPosition(position={}) {
if ( !(this._minimized || position.height) ) {
position.height = (this._tabs[0].active === "details") ? "auto" : Math.max(this.height, this.options.height);
}
return super.setPosition(position);
}
/* -------------------------------------------- */
/** @inheritdoc */
async activateEditor(name, options={}, initialContent="") {
options.relativeLinks = true;
options.plugins = {
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
compact: true,
destroyOnSave: true,
onSave: () => this.saveEditor(name, {remove: true})
})
};
return super.activateEditor(name, options, initialContent);
}
/* -------------------------------------------- */
/* Form Submission */
/* -------------------------------------------- */
/** @inheritDoc */
_getSubmitData(updateData={}) {
const formData = foundry.utils.expandObject(super._getSubmitData(updateData));
// Handle Damage array
const damage = formData.system?.damage;
if ( damage ) damage.parts = Object.values(damage?.parts || {}).map(d => [d[0] || "", d[1] || ""]);
// Check max uses formula
const uses = formData.system?.uses;
if ( uses?.max ) {
const maxRoll = new Roll(uses.max);
if ( !maxRoll.isDeterministic ) {
uses.max = this.item._source.system.uses.max;
this.form.querySelector("input[name='system.uses.max']").value = uses.max;
return ui.notifications.error(game.i18n.format("DND5E.FormulaCannotContainDiceError", {
name: game.i18n.localize("DND5E.LimitedUses")
}));
}
}
// Check duration value formula
const duration = formData.system?.duration;
if ( duration?.value ) {
const durationRoll = new Roll(duration.value);
if ( !durationRoll.isDeterministic ) {
duration.value = this.item._source.system.duration.value;
this.form.querySelector("input[name='system.duration.value']").value = duration.value;
return ui.notifications.error(game.i18n.format("DND5E.FormulaCannotContainDiceError", {
name: game.i18n.localize("DND5E.Duration")
}));
}
}
// Check class identifier
if ( formData.system?.identifier && !dnd5e.utils.validators.isValidIdentifier(formData.system.identifier) ) {
formData.system.identifier = this.item._source.system.identifier;
this.form.querySelector("input[name='system.identifier']").value = formData.system.identifier;
return ui.notifications.error(game.i18n.localize("DND5E.IdentifierError"));
}
// Return the flattened submission data
return foundry.utils.flattenObject(formData);
}
/* -------------------------------------------- */
/** @inheritDoc */
activateListeners(html) {
super.activateListeners(html);
if ( this.isEditable ) {
html.find(".damage-control").click(this._onDamageControl.bind(this));
html.find(".trait-selector").click(this._onConfigureTraits.bind(this));
html.find(".effect-control").click(ev => {
const unsupported = game.dnd5e.isV10 && this.item.isOwned;
if ( unsupported ) return ui.notifications.warn("Managing Active Effects within an Owned Item is not currently supported and will be added in a subsequent update.");
ActiveEffect5e.onManageActiveEffect(ev, this.item);
});
html.find(".advancement .item-control").click(event => {
const t = event.currentTarget;
if ( t.dataset.action ) this._onAdvancementAction(t, t.dataset.action);
});
}
// Advancement context menu
const contextOptions = this._getAdvancementContextMenuOptions();
/**
* A hook event that fires when the context menu for the advancements list is constructed.
* @function dnd5e.getItemAdvancementContext
* @memberof hookEvents
* @param {jQuery} html The HTML element to which the context options are attached.
* @param {ContextMenuEntry[]} entryOptions The context menu entries.
*/
Hooks.call("dnd5e.getItemAdvancementContext", html, contextOptions);
if ( contextOptions ) new ContextMenu(html, ".advancement-item", contextOptions);
}
/* -------------------------------------------- */
/**
* Get the set of ContextMenu options which should be applied for advancement entries.
* @returns {ContextMenuEntry[]} Context menu entries.
* @protected
*/
_getAdvancementContextMenuOptions() {
const condition = li => (this.advancementConfigurationMode || !this.isEmbedded) && this.isEditable;
return [
{
name: "DND5E.AdvancementControlEdit",
icon: " ",
condition,
callback: li => this._onAdvancementAction(li[0], "edit")
},
{
name: "DND5E.AdvancementControlDuplicate",
icon: " ",
condition: li => {
const id = li[0].closest(".advancement-item")?.dataset.id;
const advancement = this.item.advancement.byId[id];
return condition() && advancement?.constructor.availableForItem(this.item);
},
callback: li => this._onAdvancementAction(li[0], "duplicate")
},
{
name: "DND5E.AdvancementControlDelete",
icon: " ",
condition,
callback: li => this._onAdvancementAction(li[0], "delete")
}
];
}
/* -------------------------------------------- */
/**
* Add or remove a damage part from the damage formula.
* @param {Event} event The original click event.
* @returns {Promise|null} Item with updates applied.
* @private
*/
async _onDamageControl(event) {
event.preventDefault();
const a = event.currentTarget;
// Add new damage component
if ( a.classList.contains("add-damage") ) {
await this._onSubmit(event); // Submit any unsaved changes
const damage = this.item.system.damage;
return this.item.update({"system.damage.parts": damage.parts.concat([["", ""]])});
}
// Remove a damage component
if ( a.classList.contains("delete-damage") ) {
await this._onSubmit(event); // Submit any unsaved changes
const li = a.closest(".damage-part");
const damage = foundry.utils.deepClone(this.item.system.damage);
damage.parts.splice(Number(li.dataset.damagePart), 1);
return this.item.update({"system.damage.parts": damage.parts});
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDragStart(event) {
const li = event.currentTarget;
if ( event.target.classList.contains("content-link") ) return;
// Create drag data
let dragData;
// Active Effect
if ( li.dataset.effectId ) {
const effect = this.item.effects.get(li.dataset.effectId);
dragData = effect.toDragData();
} else if ( li.classList.contains("advancement-item") ) {
dragData = this.item.advancement.byId[li.dataset.id]?.toDragData();
}
if ( !dragData ) return;
// Set data transfer
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
}
/* -------------------------------------------- */
/** @inheritdoc */
_onDrop(event) {
const data = TextEditor.getDragEventData(event);
const item = this.item;
/**
* A hook event that fires when some useful data is dropped onto an ItemSheet5e.
* @function dnd5e.dropItemSheetData
* @memberof hookEvents
* @param {Item5e} item The Item5e
* @param {ItemSheet5e} sheet The ItemSheet5e application
* @param {object} data The data that has been dropped onto the sheet
* @returns {boolean} Explicitly return `false` to prevent normal drop handling.
*/
const allowed = Hooks.call("dnd5e.dropItemSheetData", item, this, data);
if ( allowed === false ) return;
switch ( data.type ) {
case "ActiveEffect":
return this._onDropActiveEffect(event, data);
case "Advancement":
case "Item":
return this._onDropAdvancement(event, data);
}
}
/* -------------------------------------------- */
/**
* Handle the dropping of ActiveEffect data onto an Item Sheet
* @param {DragEvent} event The concluding DragEvent which contains drop data
* @param {object} data The data transfer extracted from the event
* @returns {Promise} The created ActiveEffect object or false if it couldn't be created.
* @protected
*/
async _onDropActiveEffect(event, data) {
const effect = await ActiveEffect.implementation.fromDropData(data);
if ( !this.item.isOwner || !effect ) return false;
if ( (this.item.uuid === effect.parent.uuid) || (this.item.uuid === effect.origin) ) return false;
return ActiveEffect.create({
...effect.toObject(),
origin: this.item.uuid
}, {parent: this.item});
}
/* -------------------------------------------- */
/**
* Handle the dropping of an advancement or item with advancements onto the advancements tab.
* @param {DragEvent} event The concluding DragEvent which contains drop data.
* @param {object} data The data transfer extracted from the event.
*/
async _onDropAdvancement(event, data) {
let advancements;
let showDialog = false;
if ( data.type === "Advancement" ) {
advancements = [await fromUuid(data.uuid)];
} else if ( data.type === "Item" ) {
const item = await Item.implementation.fromDropData(data);
if ( !item ) return false;
advancements = Object.values(item.advancement.byId);
showDialog = true;
} else {
return false;
}
advancements = advancements.filter(a => {
return !this.item.advancement.byId[a.id]
&& a.constructor.metadata.validItemTypes.has(this.item.type)
&& a.constructor.availableForItem(this.item);
});
// Display dialog prompting for which advancements to add
if ( showDialog ) {
try {
advancements = await AdvancementMigrationDialog.createDialog(this.item, advancements);
} catch(err) {
return false;
}
}
if ( !advancements.length ) return false;
if ( this.item.isEmbedded && !game.settings.get("dnd5e", "disableAdvancements") ) {
const manager = AdvancementManager.forNewAdvancement(this.item.actor, this.item.id, advancements);
if ( manager.steps.length ) return manager.render(true);
}
// If no advancements need to be applied, just add them to the item
const advancementArray = foundry.utils.deepClone(this.item.system.advancement);
advancementArray.push(...advancements.map(a => a.toObject()));
this.item.update({"system.advancement": advancementArray});
}
/* -------------------------------------------- */
/**
* Handle spawning the TraitSelector application for selection various options.
* @param {Event} event The click event which originated the selection.
* @private
*/
_onConfigureTraits(event) {
event.preventDefault();
const a = event.currentTarget;
const options = {
name: a.dataset.target,
title: a.parentElement.innerText,
choices: [],
allowCustom: false,
suppressWarning: true
};
switch (a.dataset.options) {
case "saves":
options.choices = CONFIG.DND5E.abilities;
options.valueKey = null;
options.labelKey = "label";
break;
case "skills.choices":
options.choices = CONFIG.DND5E.skills;
options.valueKey = null;
options.labelKey = "label";
break;
case "skills":
const skills = this.item.system.skills;
const choices = skills.choices?.length ? skills.choices : Object.keys(CONFIG.DND5E.skills);
options.choices = Object.fromEntries(Object.entries(CONFIG.DND5E.skills).filter(([s]) => choices.includes(s)));
options.maximum = skills.number;
options.labelKey = "label";
break;
}
new TraitSelector(this.item, options).render(true);
}
/* -------------------------------------------- */
/**
* Handle one of the advancement actions from the buttons or context menu.
* @param {Element} target Button or context menu entry that triggered this action.
* @param {string} action Action being triggered.
* @returns {Promise|void}
*/
_onAdvancementAction(target, action) {
const id = target.closest(".advancement-item")?.dataset.id;
const advancement = this.item.advancement.byId[id];
let manager;
if ( ["edit", "delete", "duplicate"].includes(action) && !advancement ) return;
switch (action) {
case "add": return game.dnd5e.applications.advancement.AdvancementSelection.createDialog(this.item);
case "edit": return new advancement.constructor.metadata.apps.config(advancement).render(true);
case "delete":
if ( this.item.isEmbedded && !game.settings.get("dnd5e", "disableAdvancements") ) {
manager = AdvancementManager.forDeletedAdvancement(this.item.actor, this.item.id, id);
if ( manager.steps.length ) return manager.render(true);
}
return this.item.deleteAdvancement(id);
case "duplicate": return this.item.duplicateAdvancement(id);
case "modify-choices":
const level = target.closest("li")?.dataset.level;
manager = AdvancementManager.forModifyChoices(this.item.actor, this.item.id, Number(level));
if ( manager.steps.length ) manager.render(true);
return;
case "toggle-configuration":
this.advancementConfigurationMode = !this.advancementConfigurationMode;
return this.render();
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onSubmit(...args) {
if ( this._tabs[0].active === "details" ) this.position.height = "auto";
await super._onSubmit(...args);
}
}
var _module$6 = /*#__PURE__*/Object.freeze({
__proto__: null,
AbilityUseDialog: AbilityUseDialog,
ItemSheet5e: ItemSheet5e
});
/**
* Pop out ProseMirror editor window for journal entries with multiple text areas that need editing.
*
* @param {JournalEntryPage} document Journal entry page to be edited.
* @param {object} options
* @param {string} options.textKeyPath The path to the specific HTML field being edited.
*/
class JournalEditor extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["journal-editor"],
template: "systems/dnd5e/templates/journal/journal-editor.hbs",
width: 550,
height: 640,
textKeyPath: null,
resizable: true
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
if ( this.options.title ) return `${this.document.name}: ${this.options.title}`;
else return this.document.name;
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData() {
const data = super.getData();
const rawText = foundry.utils.getProperty(this.document, this.options.textKeyPath) ?? "";
return foundry.utils.mergeObject(data, {
enriched: await TextEditor.enrichHTML(rawText, {
relativeTo: this.document, secrets: this.document.isOwner, async: true
})
});
}
/* -------------------------------------------- */
/** @inheritdoc */
_updateObject(event, formData) {
this.document.update(formData);
}
}
/**
* Journal entry page that displays an automatically generated summary of a class along with additional description.
*/
class JournalClassPageSheet extends JournalPageSheet {
/** @inheritdoc */
static get defaultOptions() {
const options = foundry.utils.mergeObject(super.defaultOptions, {
dragDrop: [{dropSelector: ".drop-target"}],
submitOnChange: true
});
options.classes.push("class-journal");
return options;
}
/* -------------------------------------------- */
/** @inheritdoc */
get template() {
return `systems/dnd5e/templates/journal/page-class-${this.isEditable ? "edit" : "view"}.hbs`;
}
/* -------------------------------------------- */
/** @inheritdoc */
toc = {};
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options) {
const context = super.getData(options);
context.system = context.document.system;
context.title = Object.fromEntries(
Array.fromRange(4, 1).map(n => [`level${n}`, context.data.title.level + n - 1])
);
const linked = await fromUuid(this.document.system.item);
context.subclasses = await this._getSubclasses(this.document.system.subclassItems);
if ( !linked ) return context;
context.linked = {
document: linked,
name: linked.name,
lowercaseName: linked.name.toLowerCase()
};
context.advancement = this._getAdvancement(linked);
context.enriched = await this._getDescriptions(context.document);
context.table = await this._getTable(linked);
context.optionalTable = await this._getOptionalTable(linked);
context.features = await this._getFeatures(linked);
context.optionalFeatures = await this._getFeatures(linked, true);
context.subclasses?.sort((lhs, rhs) => lhs.name.localeCompare(rhs.name));
return context;
}
/* -------------------------------------------- */
/**
* Prepare features granted by various advancement types.
* @param {Item5e} item Class item belonging to this journal.
* @returns {object} Prepared advancement section.
*/
_getAdvancement(item) {
const advancement = {};
const hp = item.advancement.byType.HitPoints?.[0];
if ( hp ) {
advancement.hp = {
hitDice: `1${hp.hitDie}`,
max: hp.hitDieValue,
average: Math.floor(hp.hitDieValue / 2) + 1
};
}
return advancement;
}
/* -------------------------------------------- */
/**
* Enrich all of the entries within the descriptions object on the sheet's system data.
* @param {JournalEntryPage} page Journal page being enriched.
* @returns {Promise} Object with enriched descriptions.
*/
async _getDescriptions(page) {
const descriptions = await Promise.all(Object.entries(page.system.description ?? {})
.map(async ([id, text]) => {
const enriched = await TextEditor.enrichHTML(text, {
relativeTo: this.object,
secrets: this.object.isOwner,
async: true
});
return [id, enriched];
})
);
return Object.fromEntries(descriptions);
}
/* -------------------------------------------- */
/**
* Prepare table based on non-optional GrantItem advancement & ScaleValue advancement.
* @param {Item5e} item Class item belonging to this journal.
* @param {number} [initialLevel=1] Level at which the table begins.
* @returns {object} Prepared table.
*/
async _getTable(item, initialLevel=1) {
const hasFeatures = !!item.advancement.byType.ItemGrant;
const scaleValues = (item.advancement.byType.ScaleValue ?? []);
const spellProgression = await this._getSpellProgression(item);
const headers = [[{content: game.i18n.localize("DND5E.Level")}]];
if ( item.type === "class" ) headers[0].push({content: game.i18n.localize("DND5E.ProficiencyBonus")});
if ( hasFeatures ) headers[0].push({content: game.i18n.localize("DND5E.Features")});
headers[0].push(...scaleValues.map(a => ({content: a.title})));
if ( spellProgression ) {
if ( spellProgression.headers.length > 1 ) {
headers[0].forEach(h => h.rowSpan = 2);
headers[0].push(...spellProgression.headers[0]);
headers[1] = spellProgression.headers[1];
} else {
headers[0].push(...spellProgression.headers[0]);
}
}
const cols = [{ class: "level", span: 1 }];
if ( item.type === "class" ) cols.push({class: "prof", span: 1});
if ( hasFeatures ) cols.push({class: "features", span: 1});
if ( scaleValues.length ) cols.push({class: "scale", span: scaleValues.length});
if ( spellProgression ) cols.push(...spellProgression.cols);
const makeLink = async uuid => (await fromUuid(uuid))?.toAnchor({classes: ["content-link"]}).outerHTML;
const rows = [];
for ( const level of Array.fromRange((CONFIG.DND5E.maxLevel - (initialLevel - 1)), initialLevel) ) {
const features = [];
for ( const advancement of item.advancement.byLevel[level] ) {
switch ( advancement.constructor.typeName ) {
case "ItemGrant":
if ( advancement.configuration.optional ) continue;
features.push(...await Promise.all(advancement.configuration.items.map(makeLink)));
break;
}
}
// Level & proficiency bonus
const cells = [{class: "level", content: level.ordinalString()}];
if ( item.type === "class" ) cells.push({class: "prof", content: `+${Proficiency.calculateMod(level)}`});
if ( hasFeatures ) cells.push({class: "features", content: features.join(", ")});
scaleValues.forEach(s => cells.push({class: "scale", content: s.valueForLevel(level)?.display}));
const spellCells = spellProgression?.rows[rows.length];
if ( spellCells ) cells.push(...spellCells);
// Skip empty rows on subclasses
if ( (item.type === "subclass") && !features.length && !scaleValues.length && !spellCells ) continue;
rows.push(cells);
}
return { headers, cols, rows };
}
/* -------------------------------------------- */
/**
* Build out the spell progression data.
* @param {Item5e} item Class item belonging to this journal.
* @returns {object} Prepared spell progression table.
*/
async _getSpellProgression(item) {
const spellcasting = foundry.utils.deepClone(item.spellcasting);
if ( !spellcasting || (spellcasting.progression === "none") ) return null;
const table = { rows: [] };
if ( spellcasting.type === "leveled" ) {
const spells = {};
const maxSpellLevel = CONFIG.DND5E.SPELL_SLOT_TABLE[CONFIG.DND5E.SPELL_SLOT_TABLE.length - 1].length;
Array.fromRange(maxSpellLevel, 1).forEach(l => spells[`spell${l}`] = {});
let largestSlot;
for ( const level of Array.fromRange(CONFIG.DND5E.maxLevel, 1).reverse() ) {
const progression = { slot: 0 };
spellcasting.levels = level;
Actor5e.computeClassProgression(progression, item, { spellcasting });
Actor5e.prepareSpellcastingSlots(spells, "leveled", progression);
if ( !largestSlot ) largestSlot = Object.entries(spells).reduce((slot, [key, data]) => {
if ( !data.max ) return slot;
const level = parseInt(key.slice(5));
if ( !Number.isNaN(level) && (level > slot) ) return level;
return slot;
}, -1);
table.rows.push(Array.fromRange(largestSlot, 1).map(spellLevel => {
return {class: "spell-slots", content: spells[`spell${spellLevel}`]?.max || "—"};
}));
}
// Prepare headers & columns
table.headers = [
[{content: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.SpellSlotsPerSpellLevel"), colSpan: largestSlot}],
Array.fromRange(largestSlot, 1).map(spellLevel => ({content: spellLevel.ordinalString()}))
];
table.cols = [{class: "spellcasting", span: largestSlot}];
table.rows.reverse();
}
else if ( spellcasting.type === "pact" ) {
const spells = { pact: {} };
table.headers = [[
{ content: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.SpellSlots") },
{ content: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.SpellSlotLevel") }
]];
table.cols = [{class: "spellcasting", span: 2}];
// Loop through each level, gathering "Spell Slots" & "Slot Level" for each one
for ( const level of Array.fromRange(CONFIG.DND5E.maxLevel, 1) ) {
const progression = { pact: 0 };
spellcasting.levels = level;
Actor5e.computeClassProgression(progression, item, { spellcasting });
Actor5e.prepareSpellcastingSlots(spells, "pact", progression);
table.rows.push([
{ class: "spell-slots", content: `${spells.pact.max}` },
{ class: "slot-level", content: spells.pact.level.ordinalString() }
]);
}
}
else {
/**
* A hook event that fires to generate the table for custom spellcasting types.
* The actual hook names include the spellcasting type (e.g. `dnd5e.buildPsionicSpellcastingTable`).
* @param {object} table Table definition being built. *Will be mutated.*
* @param {Item5e} item Class for which the spellcasting table is being built.
* @param {SpellcastingDescription} spellcasting Spellcasting descriptive object.
* @function dnd5e.buildSpellcastingTable
* @memberof hookEvents
*/
Hooks.callAll(
`dnd5e.build${spellcasting.type.capitalize()}SpellcastingTable`, table, item, spellcasting
);
}
return table;
}
/* -------------------------------------------- */
/**
* Prepare options table based on optional GrantItem advancement.
* @param {Item5e} item Class item belonging to this journal.
* @returns {object|null} Prepared optional features table.
*/
async _getOptionalTable(item) {
const headers = [[
{ content: game.i18n.localize("DND5E.Level") },
{ content: game.i18n.localize("DND5E.Features") }
]];
const cols = [
{ class: "level", span: 1 },
{ class: "features", span: 1 }
];
const makeLink = async uuid => (await fromUuid(uuid))?.toAnchor({classes: ["content-link"]}).outerHTML;
const rows = [];
for ( const level of Array.fromRange(CONFIG.DND5E.maxLevel, 1) ) {
const features = [];
for ( const advancement of item.advancement.byLevel[level] ) {
switch ( advancement.constructor.typeName ) {
case "ItemGrant":
if ( !advancement.configuration.optional ) continue;
features.push(...await Promise.all(advancement.configuration.items.map(makeLink)));
break;
}
}
if ( !features.length ) continue;
// Level & proficiency bonus
const cells = [
{ class: "level", content: level.ordinalString() },
{ class: "features", content: features.join(", ") }
];
rows.push(cells);
}
if ( !rows.length ) return null;
return { headers, cols, rows };
}
/* -------------------------------------------- */
/**
* Fetch data for each class feature listed.
* @param {Item5e} item Class or subclass item belonging to this journal.
* @param {boolean} [optional=false] Should optional features be fetched rather than required features?
* @returns {object[]} Prepared features.
*/
async _getFeatures(item, optional=false) {
const prepareFeature = async uuid => {
const document = await fromUuid(uuid);
return {
document,
name: document.name,
description: await TextEditor.enrichHTML(document.system.description.value, {
relativeTo: item, secrets: false, async: true
})
};
};
let features = [];
for ( const advancement of item.advancement.byType.ItemGrant ?? [] ) {
if ( !!advancement.configuration.optional !== optional ) continue;
features.push(...advancement.configuration.items.map(prepareFeature));
}
features = await Promise.all(features);
return features;
}
/* -------------------------------------------- */
/**
* Fetch each subclass and their features.
* @param {string[]} uuids UUIDs for the subclasses to fetch.
* @returns {object[]|null} Prepared subclasses.
*/
async _getSubclasses(uuids) {
const prepareSubclass = async uuid => {
const document = await fromUuid(uuid);
return this._getSubclass(document);
};
const subclasses = await Promise.all(uuids.map(prepareSubclass));
return subclasses.length ? subclasses : null;
}
/* -------------------------------------------- */
/**
* Prepare data for the provided subclass.
* @param {Item5e} item Subclass item being prepared.
* @returns {object} Presentation data for this subclass.
*/
async _getSubclass(item) {
const initialLevel = Object.entries(item.advancement.byLevel).find(([lvl, d]) => d.length)?.[0] ?? 1;
return {
document: item,
name: item.name,
description: await TextEditor.enrichHTML(item.system.description.value, {
relativeTo: item, secrets: false, async: true
}),
features: await this._getFeatures(item),
table: await this._getTable(item, parseInt(initialLevel))
};
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
const html = await super._renderInner(...args);
this.toc = JournalEntryPage.buildTOC(html.get());
return html;
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html[0].querySelectorAll(".item-delete").forEach(e => {
e.addEventListener("click", this._onDeleteItem.bind(this));
});
html[0].querySelectorAll(".launch-text-editor").forEach(e => {
e.addEventListener("click", this._onLaunchTextEditor.bind(this));
});
}
/* -------------------------------------------- */
/**
* Handle deleting a dropped item.
* @param {Event} event The triggering click event.
* @returns {JournalClassSummary5ePageSheet}
*/
async _onDeleteItem(event) {
event.preventDefault();
const container = event.currentTarget.closest("[data-item-uuid]");
const uuidToDelete = container?.dataset.itemUuid;
if ( !uuidToDelete ) return;
switch (container.dataset.itemType) {
case "class":
await this.document.update({"system.item": ""});
return this.render();
case "subclass":
const itemSet = this.document.system.subclassItems;
itemSet.delete(uuidToDelete);
await this.document.update({"system.subclassItems": Array.from(itemSet)});
return this.render();
}
}
/* -------------------------------------------- */
/**
* Handle launching the individual text editing window.
* @param {Event} event The triggering click event.
*/
_onLaunchTextEditor(event) {
event.preventDefault();
const textKeyPath = event.currentTarget.dataset.target;
const label = event.target.closest(".form-group").querySelector("label");
const editor = new JournalEditor(this.document, { textKeyPath, title: label?.innerText });
editor.render(true);
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
const data = TextEditor.getDragEventData(event);
if ( data?.type !== "Item" ) return false;
const item = await Item.implementation.fromDropData(data);
switch ( item.type ) {
case "class":
await this.document.update({"system.item": item.uuid});
return this.render();
case "subclass":
const itemSet = this.document.system.subclassItems;
itemSet.add(item.uuid);
await this.document.update({"system.subclassItems": Array.from(itemSet)});
return this.render();
default:
return false;
}
}
}
class SRDCompendium extends Compendium {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["srd-compendium"],
template: "systems/dnd5e/templates/journal/srd-compendium.hbs",
width: 800,
height: 950,
resizable: true
});
}
/* -------------------------------------------- */
/**
* The IDs of some special pages that we use when configuring the display of the compendium.
* @type {Object}
* @protected
*/
static _SPECIAL_PAGES = {
disclaimer: "xxt7YT2t76JxNTel",
magicItemList: "sfJtvPjEs50Ruzi4",
spellList: "plCB5ei1JbVtBseb"
};
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options) {
const data = await super.getData(options);
const documents = await this.collection.getDocuments();
const getOrder = o => ({chapter: 0, appendix: 100}[o.flags?.dnd5e?.type] ?? 200) + (o.flags?.dnd5e?.position ?? 0);
data.disclaimer = this.collection.get(this.constructor._SPECIAL_PAGES.disclaimer).pages.contents[0].text.content;
data.chapters = documents.reduce((arr, entry) => {
const type = entry.getFlag("dnd5e", "type");
if ( !type ) return arr;
const e = entry.toObject();
e.showPages = (e.pages.length > 1) && (type === "chapter");
arr.push(e);
return arr;
}, []).sort((a, b) => getOrder(a) - getOrder(b));
// Add spells A-Z to the end of Chapter 10.
const spellList = this.collection.get(this.constructor._SPECIAL_PAGES.spellList);
data.chapters[9].pages.push({_id: spellList.id, name: spellList.name, entry: true});
// Add magic items A-Z to the end of Chapter 11.
const magicItemList = this.collection.get(this.constructor._SPECIAL_PAGES.magicItemList);
data.chapters[10].pages.push({_id: magicItemList.id, name: magicItemList.name, entry: true});
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("a").on("click", this._onClickLink.bind(this));
}
/* -------------------------------------------- */
/**
* Handle clicking a link to a journal entry or page.
* @param {MouseEvent} event The triggering click event.
* @protected
*/
async _onClickLink(event) {
const target = event.currentTarget;
const entryId = target.closest("[data-entry-id]")?.dataset.entryId;
const pageId = target.closest("[data-page-id]")?.dataset.pageId;
if ( !entryId ) return;
const options = {};
if ( pageId ) options.pageId = pageId;
const entry = await this.collection.getDocument(entryId);
entry?.sheet.render(true, options);
}
}
var _module$5 = /*#__PURE__*/Object.freeze({
__proto__: null,
JournalClassPageSheet: JournalClassPageSheet,
JournalEditor: JournalEditor,
SRDCompendium: SRDCompendium
});
/**
* @deprecated since dnd5e 2.1, targeted for removal in 2.3
*/
class DamageTraitSelector extends TraitSelector {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "systems/dnd5e/templates/apps/damage-trait-selector.hbs"
});
}
/* -------------------------------------------- */
/** @override */
getData() {
const data = super.getData();
const attr = foundry.utils.getProperty(this.object, this.attribute);
data.bypasses = Object.entries(this.options.bypasses).reduce((obj, [k, v]) => {
obj[k] = { label: v, chosen: attr ? attr.bypasses.includes(k) : false };
return obj;
}, {});
return data;
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
const data = foundry.utils.expandObject(formData);
const updateData = this._prepareUpdateData(data.choices);
if ( !updateData ) return;
updateData[`${this.attribute}.bypasses`] = Object.entries(data.bypasses).filter(([, v]) => v).map(([k]) => k);
this.object.update(updateData);
}
}
/**
* An application for selecting proficiencies with categories that can contain children.
* @deprecated since dnd5e 2.1, targeted for removal in 2.3
*/
class ProficiencySelector extends TraitSelector {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: "Actor Proficiency Selection",
type: ""
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData() {
const attr = foundry.utils.getProperty(this.object, this.attribute);
const chosen = (this.options.valueKey) ? foundry.utils.getProperty(attr, this.options.valueKey) ?? [] : attr;
const data = super.getData();
data.choices = await choices(this.options.type, chosen);
return data;
}
/* -------------------------------------------- */
/**
* A static helper method to get a list of choices for a proficiency type.
*
* @param {string} type Proficiency type to select, either `armor`, `tool`, or `weapon`.
* @param {string[]} [chosen] Optional list of items to be marked as chosen.
* @returns {Object} Object mapping proficiency ids to choice objects.
* @deprecated since dnd5e 2.1, targeted for removal in 2.3
*/
static async getChoices(type, chosen=[]) {
foundry.utils.logCompatibilityWarning(
"ProficiencySelector#getChoices has been deprecated in favor of Trait#choices.",
{ since: "DnD5e 2.1", until: "DnD5e 2.3" }
);
return choices(type, chosen);
}
/* -------------------------------------------- */
/**
* Fetch an item for the provided ID. If the provided ID contains a compendium pack name
* it will be fetched from that pack, otherwise it will be fetched from the compendium defined
* in `DND5E.sourcePacks.ITEMS`.
*
* @param {string} identifier Simple ID or compendium name and ID separated by a dot.
* @param {object} [options]
* @param {boolean} [options.indexOnly] If set to true, only the index data will be fetched (will never return
* Promise).
* @param {boolean} [options.fullItem] If set to true, the full item will be returned as long as `indexOnly` is
* false.
* @returns {Promise|object} Promise for a `Document` if `indexOnly` is false & `fullItem` is true,
* otherwise else a simple object containing the minimal index data.
* @deprecated since dnd5e 2.1, targeted for removal in 2.3
*/
static getBaseItem(identifier, options) {
foundry.utils.logCompatibilityWarning(
"ProficiencySelector#getBaseItem has been deprecated in favor of Trait#getBaseItem.",
{ since: "DnD5e 2.1", until: "DnD5e 2.3" }
);
return getBaseItem(identifier, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
for ( const checkbox of html[0].querySelectorAll("input[type='checkbox']") ) {
if ( checkbox.checked ) this._onToggleCategory(checkbox);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
super._onChangeInput(event);
if ( event.target.tagName === "INPUT" ) this._onToggleCategory(event.target);
}
/* -------------------------------------------- */
/**
* Enable/disable all children when a category is checked.
*
* @param {HTMLElement} checkbox Checkbox that was changed.
* @private
*/
_onToggleCategory(checkbox) {
const children = checkbox.closest("li")?.querySelector("ol");
if ( !children ) return;
for ( const child of children.querySelectorAll("input[type='checkbox']") ) {
child.checked = child.disabled = checkbox.checked;
}
}
}
var applications = /*#__PURE__*/Object.freeze({
__proto__: null,
DamageTraitSelector: DamageTraitSelector,
ProficiencySelector: ProficiencySelector,
PropertyAttribution: PropertyAttribution,
TraitSelector: TraitSelector,
actor: _module$9,
advancement: _module$8,
combat: _module$7,
item: _module$6,
journal: _module$5
});
/**
* A helper class for building MeasuredTemplates for 5e spells and abilities
*/
class AbilityTemplate extends MeasuredTemplate {
/**
* Track the timestamp when the last mouse move event was captured.
* @type {number}
*/
#moveTime = 0;
/* -------------------------------------------- */
/**
* The initially active CanvasLayer to re-activate after the workflow is complete.
* @type {CanvasLayer}
*/
#initialLayer;
/* -------------------------------------------- */
/**
* Track the bound event handlers so they can be properly canceled later.
* @type {object}
*/
#events;
/* -------------------------------------------- */
/**
* A factory method to create an AbilityTemplate instance using provided data from an Item5e instance
* @param {Item5e} item The Item object for which to construct the template
* @returns {AbilityTemplate|null} The template object, or null if the item does not produce a template
*/
static fromItem(item) {
const target = item.system.target ?? {};
const templateShape = dnd5e.config.areaTargetTypes[target.type]?.template;
if ( !templateShape ) return null;
// Prepare template data
const templateData = {
t: templateShape,
user: game.user.id,
distance: target.value,
direction: 0,
x: 0,
y: 0,
fillColor: game.user.color,
flags: { dnd5e: { origin: item.uuid } }
};
// Additional type-specific data
switch ( templateShape ) {
case "cone":
templateData.angle = CONFIG.MeasuredTemplate.defaults.angle;
break;
case "rect": // 5e rectangular AoEs are always cubes
templateData.distance = Math.hypot(target.value, target.value);
templateData.width = target.value;
templateData.direction = 45;
break;
case "ray": // 5e rays are most commonly 1 square (5 ft) in width
templateData.width = target.width ?? canvas.dimensions.distance;
break;
}
// Return the template constructed from the item data
const cls = CONFIG.MeasuredTemplate.documentClass;
const template = new cls(templateData, {parent: canvas.scene});
const object = new this(template);
object.item = item;
object.actorSheet = item.actor?.sheet || null;
return object;
}
/* -------------------------------------------- */
/**
* Creates a preview of the spell template.
* @returns {Promise} A promise that resolves with the final measured template if created.
*/
drawPreview() {
const initialLayer = canvas.activeLayer;
// Draw the template and switch to the template layer
this.draw();
this.layer.activate();
this.layer.preview.addChild(this);
// Hide the sheet that originated the preview
this.actorSheet?.minimize();
// Activate interactivity
return this.activatePreviewListeners(initialLayer);
}
/* -------------------------------------------- */
/**
* Activate listeners for the template preview
* @param {CanvasLayer} initialLayer The initially active CanvasLayer to re-activate after the workflow is complete
* @returns {Promise} A promise that resolves with the final measured template if created.
*/
activatePreviewListeners(initialLayer) {
return new Promise((resolve, reject) => {
this.#initialLayer = initialLayer;
this.#events = {
cancel: this._onCancelPlacement.bind(this),
confirm: this._onConfirmPlacement.bind(this),
move: this._onMovePlacement.bind(this),
resolve,
reject,
rotate: this._onRotatePlacement.bind(this)
};
// Activate listeners
canvas.stage.on("mousemove", this.#events.move);
canvas.stage.on("mousedown", this.#events.confirm);
canvas.app.view.oncontextmenu = this.#events.cancel;
canvas.app.view.onwheel = this.#events.rotate;
});
}
/* -------------------------------------------- */
/**
* Shared code for when template placement ends by being confirmed or canceled.
* @param {Event} event Triggering event that ended the placement.
*/
async _finishPlacement(event) {
this.layer._onDragLeftCancel(event);
canvas.stage.off("mousemove", this.#events.move);
canvas.stage.off("mousedown", this.#events.confirm);
canvas.app.view.oncontextmenu = null;
canvas.app.view.onwheel = null;
this.#initialLayer.activate();
await this.actorSheet?.maximize();
}
/* -------------------------------------------- */
/**
* Move the template preview when the mouse moves.
* @param {Event} event Triggering mouse event.
*/
_onMovePlacement(event) {
event.stopPropagation();
const now = Date.now(); // Apply a 20ms throttle
if ( now - this.#moveTime <= 20 ) return;
const center = event.data.getLocalPosition(this.layer);
const interval = canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ? 0 : 2;
const snapped = canvas.grid.getSnappedPosition(center.x, center.y, interval);
this.document.updateSource({x: snapped.x, y: snapped.y});
this.refresh();
this.#moveTime = now;
}
/* -------------------------------------------- */
/**
* Rotate the template preview by 3Ëš increments when the mouse wheel is rotated.
* @param {Event} event Triggering mouse event.
*/
_onRotatePlacement(event) {
if ( event.ctrlKey ) event.preventDefault(); // Avoid zooming the browser window
event.stopPropagation();
const delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15;
const snap = event.shiftKey ? delta : 5;
const update = {direction: this.document.direction + (snap * Math.sign(event.deltaY))};
this.document.updateSource(update);
this.refresh();
}
/* -------------------------------------------- */
/**
* Confirm placement when the left mouse button is clicked.
* @param {Event} event Triggering mouse event.
*/
async _onConfirmPlacement(event) {
await this._finishPlacement(event);
const interval = canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ? 0 : 2;
const destination = canvas.grid.getSnappedPosition(this.document.x, this.document.y, interval);
this.document.updateSource(destination);
this.#events.resolve(canvas.scene.createEmbeddedDocuments("MeasuredTemplate", [this.document.toObject()]));
}
/* -------------------------------------------- */
/**
* Cancel placement when the right mouse button is clicked.
* @param {Event} event Triggering mouse event.
*/
async _onCancelPlacement(event) {
await this._finishPlacement(event);
this.#events.reject();
}
}
/**
* Extend the base Token class to implement additional system-specific logic.
*/
class Token5e extends Token {
/** @inheritdoc */
_drawBar(number, bar, data) {
if ( data.attribute === "attributes.hp" ) return this._drawHPBar(number, bar, data);
return super._drawBar(number, bar, data);
}
/* -------------------------------------------- */
/**
* Specialized drawing function for HP bars.
* @param {number} number The Bar number
* @param {PIXI.Graphics} bar The Bar container
* @param {object} data Resource data for this bar
* @private
*/
_drawHPBar(number, bar, data) {
// Extract health data
let {value, max, temp, tempmax} = this.document.actor.system.attributes.hp;
temp = Number(temp || 0);
tempmax = Number(tempmax || 0);
// Differentiate between effective maximum and displayed maximum
const effectiveMax = Math.max(0, max + tempmax);
let displayMax = max + (tempmax > 0 ? tempmax : 0);
// Allocate percentages of the total
const tempPct = Math.clamped(temp, 0, displayMax) / displayMax;
const colorPct = Math.clamped(value, 0, effectiveMax) / displayMax;
const hpColor = dnd5e.documents.Actor5e.getHPColor(value, effectiveMax);
// Determine colors to use
const blk = 0x000000;
const c = CONFIG.DND5E.tokenHPColors;
// Determine the container size (logic borrowed from core)
const w = this.w;
let h = Math.max((canvas.dimensions.size / 12), 8);
if ( this.document.height >= 2 ) h *= 1.6;
const bs = Math.clamped(h / 8, 1, 2);
const bs1 = bs+1;
// Overall bar container
bar.clear();
bar.beginFill(blk, 0.5).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, w, h, 3);
// Temporary maximum HP
if (tempmax > 0) {
const pct = max / effectiveMax;
bar.beginFill(c.tempmax, 1.0).lineStyle(1, blk, 1.0).drawRoundedRect(pct*w, 0, (1-pct)*w, h, 2);
}
// Maximum HP penalty
else if (tempmax < 0) {
const pct = (max + tempmax) / max;
bar.beginFill(c.negmax, 1.0).lineStyle(1, blk, 1.0).drawRoundedRect(pct*w, 0, (1-pct)*w, h, 2);
}
// Health bar
bar.beginFill(hpColor, 1.0).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, colorPct*w, h, 2);
// Temporary hit points
if ( temp > 0 ) {
bar.beginFill(c.temp, 1.0).lineStyle(0).drawRoundedRect(bs1, bs1, (tempPct*w)-(2*bs1), h-(2*bs1), 1);
}
// Set position
let posY = (number === 0) ? (this.h - h) : 0;
bar.position.set(0, posY);
}
}
/** @inheritDoc */
function measureDistances(segments, options={}) {
if ( !options.gridSpaces ) return BaseGrid.prototype.measureDistances.call(this, segments, options);
// Track the total number of diagonals
let nDiagonal = 0;
const rule = this.parent.diagonalRule;
const d = canvas.dimensions;
// Iterate over measured segments
return segments.map(s => {
let r = s.ray;
// Determine the total distance traveled
let nx = Math.abs(Math.ceil(r.dx / d.size));
let ny = Math.abs(Math.ceil(r.dy / d.size));
// Determine the number of straight and diagonal moves
let nd = Math.min(nx, ny);
let ns = Math.abs(ny - nx);
nDiagonal += nd;
// Alternative DMG Movement
if (rule === "5105") {
let nd10 = Math.floor(nDiagonal / 2) - Math.floor((nDiagonal - nd) / 2);
let spaces = (nd10 * 2) + (nd - nd10) + ns;
return spaces * canvas.dimensions.distance;
}
// Euclidean Measurement
else if (rule === "EUCL") {
return Math.round(Math.hypot(nx, ny) * canvas.scene.grid.distance);
}
// Standard PHB Movement
else return (ns + nd) * canvas.scene.grid.distance;
});
}
var canvas$1 = /*#__PURE__*/Object.freeze({
__proto__: null,
AbilityTemplate: AbilityTemplate,
Token5e: Token5e,
measureDistances: measureDistances
});
/**
* Data Model variant with some extra methods to support template mix-ins.
*
* **Note**: This uses some advanced Javascript techniques that are not necessary for most data models.
* Please refer to the [advancement data models]{@link BaseAdvancement} for an example of a more typical usage.
*
* In template.json, each Actor or Item type can incorporate several templates which are chunks of data that are
* common across all the types that use them. One way to represent them in the schema for a given Document type is to
* duplicate schema definitions for the templates and write them directly into the Data Model for the Document type.
* This works fine for small templates or systems that do not need many Document types but for more complex systems
* this boilerplate can become prohibitive.
*
* Here we have opted to instead create a separate Data Model for each template available. These define their own
* schemas which are then mixed-in to the final schema for the Document type's Data Model. A Document type Data Model
* can define its own schema unique to it, and then add templates in direct correspondence to those in template.json
* via SystemDataModel.mixin.
*/
class SystemDataModel extends foundry.abstract.DataModel {
/** @inheritdoc */
static _enableV10Validation = true;
/**
* System type that this system data model represents (e.g. "character", "npc", "vehicle").
* @type {string}
*/
static _systemType;
/* -------------------------------------------- */
/**
* Base templates used for construction.
* @type {*[]}
* @private
*/
static _schemaTemplates = [];
/* -------------------------------------------- */
/**
* A list of properties that should not be mixed-in to the final type.
* @type {Set}
* @private
*/
static _immiscible = new Set(["length", "mixed", "name", "prototype", "migrateData", "defineSchema"]);
/* -------------------------------------------- */
/** @inheritdoc */
static defineSchema() {
const schema = {};
for ( const template of this._schemaTemplates ) {
if ( !template.defineSchema ) {
throw new Error(`Invalid dnd5e template mixin ${template} defined on class ${this.constructor}`);
}
this.mergeSchema(schema, template.defineSchema());
}
return schema;
}
/* -------------------------------------------- */
/**
* Merge two schema definitions together as well as possible.
* @param {DataSchema} a First schema that forms the basis for the merge. *Will be mutated.*
* @param {DataSchema} b Second schema that will be merged in, overwriting any non-mergeable properties.
* @returns {DataSchema} Fully merged schema.
*/
static mergeSchema(a, b) {
Object.assign(a, b);
return a;
}
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
for ( const template of this._schemaTemplates ) {
template.migrateData?.(source);
}
return super.migrateData(source);
}
/* -------------------------------------------- */
/** @inheritdoc */
validate(options={}) {
if ( this.constructor._enableV10Validation === false ) return true;
return super.validate(options);
}
/* -------------------------------------------- */
/**
* Mix multiple templates with the base type.
* @param {...*} templates Template classes to mix.
* @returns {typeof SystemDataModel} Final prepared type.
*/
static mixin(...templates) {
const Base = class extends this {};
Object.defineProperty(Base, "_schemaTemplates", {
value: Object.seal([...this._schemaTemplates, ...templates]),
writable: false,
configurable: false
});
for ( const template of templates ) {
// Take all static methods and fields from template and mix in to base class
for ( const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(template)) ) {
if ( this._immiscible.has(key) ) continue;
Object.defineProperty(Base, key, descriptor);
}
// Take all instance methods and fields from template and mix in to base class
for ( const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(template.prototype)) ) {
if ( ["constructor"].includes(key) ) continue;
Object.defineProperty(Base.prototype, key, descriptor);
}
}
return Base;
}
}
/**
* Shared contents of the attributes schema between various actor types.
*/
class AttributesFields {
/**
* Fields shared between characters, NPCs, and vehicles.
*
* @type {object}
* @property {object} init
* @property {number} init.value Calculated initiative modifier.
* @property {number} init.bonus Fixed bonus provided to initiative rolls.
* @property {object} movement
* @property {number} movement.burrow Actor burrowing speed.
* @property {number} movement.climb Actor climbing speed.
* @property {number} movement.fly Actor flying speed.
* @property {number} movement.swim Actor swimming speed.
* @property {number} movement.walk Actor walking speed.
* @property {string} movement.units Movement used to measure the various speeds.
* @property {boolean} movement.hover Is this flying creature able to hover in place.
*/
static get common() {
return {
init: new foundry.data.fields.SchemaField({
ability: new foundry.data.fields.StringField({label: "DND5E.AbilityModifier"}),
bonus: new FormulaField({label: "DND5E.InitiativeBonus"})
}, { label: "DND5E.Initiative" }),
movement: new foundry.data.fields.SchemaField({
burrow: new foundry.data.fields.NumberField({
nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementBurrow"
}),
climb: new foundry.data.fields.NumberField({
nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementClimb"
}),
fly: new foundry.data.fields.NumberField({
nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementFly"
}),
swim: new foundry.data.fields.NumberField({
nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementSwim"
}),
walk: new foundry.data.fields.NumberField({
nullable: false, min: 0, step: 0.1, initial: 30, label: "DND5E.MovementWalk"
}),
units: new foundry.data.fields.StringField({initial: "ft", label: "DND5E.MovementUnits"}),
hover: new foundry.data.fields.BooleanField({label: "DND5E.MovementHover"})
}, {label: "DND5E.Movement"})
};
}
/* -------------------------------------------- */
/**
* Fields shared between characters and NPCs.
*
* @type {object}
* @property {object} attunement
* @property {number} attunement.max Maximum number of attuned items.
* @property {object} senses
* @property {number} senses.darkvision Creature's darkvision range.
* @property {number} senses.blindsight Creature's blindsight range.
* @property {number} senses.tremorsense Creature's tremorsense range.
* @property {number} senses.truesight Creature's truesight range.
* @property {string} senses.units Distance units used to measure senses.
* @property {string} senses.special Description of any special senses or restrictions.
* @property {string} spellcasting Primary spellcasting ability.
*/
static get creature() {
return {
attunement: new foundry.data.fields.SchemaField({
max: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 3, label: "DND5E.AttunementMax"
})
}, {label: "DND5E.Attunement"}),
senses: new foundry.data.fields.SchemaField({
darkvision: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SenseDarkvision"
}),
blindsight: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SenseBlindsight"
}),
tremorsense: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SenseTremorsense"
}),
truesight: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SenseTruesight"
}),
units: new foundry.data.fields.StringField({required: true, initial: "ft", label: "DND5E.SenseUnits"}),
special: new foundry.data.fields.StringField({required: true, label: "DND5E.SenseSpecial"})
}, {label: "DND5E.Senses"}),
spellcasting: new foundry.data.fields.StringField({
required: true, blank: true, initial: "int", label: "DND5E.SpellAbility"
})
};
}
/* -------------------------------------------- */
/**
* Migrate the old init.value and incorporate it into init.bonus.
* @param {object} source The source attributes object.
* @internal
*/
static _migrateInitiative(source) {
const init = source?.init;
if ( !init?.value || (typeof init?.bonus === "string") ) return;
if ( init.bonus ) init.bonus += init.value < 0 ? ` - ${init.value * -1}` : ` + ${init.value}`;
else init.bonus = `${init.value}`;
}
}
/**
* A template for currently held currencies.
*
* @property {object} currency Object containing currencies as numbers.
* @mixin
*/
class CurrencyTemplate extends foundry.abstract.DataModel {
/** @inheritdoc */
static defineSchema() {
return {
currency: new MappingField(new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0
}), {initialKeys: CONFIG.DND5E.currencies, initialKeysOnly: true, label: "DND5E.Currency"})
};
}
}
/**
* @typedef {object} AbilityData
* @property {number} value Ability score.
* @property {number} proficient Proficiency value for saves.
* @property {object} bonuses Bonuses that modify ability checks and saves.
* @property {string} bonuses.check Numeric or dice bonus to ability checks.
* @property {string} bonuses.save Numeric or dice bonus to ability saving throws.
*/
/**
* A template for all actors that share the common template.
*
* @property {Object} abilities Actor's abilities.
* @mixin
*/
class CommonTemplate extends SystemDataModel.mixin(CurrencyTemplate) {
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
abilities: new MappingField(new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 10, label: "DND5E.AbilityScore"
}),
proficient: new foundry.data.fields.NumberField({required: true, initial: 0, label: "DND5E.ProficiencyLevel"}),
bonuses: new foundry.data.fields.SchemaField({
check: new FormulaField({required: true, label: "DND5E.AbilityCheckBonus"}),
save: new FormulaField({required: true, label: "DND5E.SaveBonus"})
}, {label: "DND5E.AbilityBonuses"})
}), {
initialKeys: CONFIG.DND5E.abilities, initialValue: this._initialAbilityValue.bind(this),
initialKeysOnly: true, label: "DND5E.Abilities"
})
});
}
/* -------------------------------------------- */
/**
* Populate the proper initial value for abilities.
* @param {string} key Key for which the initial data will be created.
* @param {object} initial The initial skill object created by SkillData.
* @param {object} existing Any existing mapping data.
* @returns {object} Initial ability object.
* @private
*/
static _initialAbilityValue(key, initial, existing) {
const config = CONFIG.DND5E.abilities[key];
if ( config ) {
let defaultValue = config.defaults?.[this._systemType] ?? initial.value;
if ( typeof defaultValue === "string" ) defaultValue = existing?.[defaultValue]?.value ?? initial.value;
initial.value = defaultValue;
}
return initial;
}
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
super.migrateData(source);
CommonTemplate.#migrateACData(source);
CommonTemplate.#migrateMovementData(source);
}
/* -------------------------------------------- */
/**
* Migrate the actor ac.value to new ac.flat override field.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateACData(source) {
if ( !source.attributes?.ac ) return;
const ac = source.attributes.ac;
// If the actor has a numeric ac.value, then their AC has not been migrated to the auto-calculation schema yet.
if ( Number.isNumeric(ac.value) ) {
ac.flat = parseInt(ac.value);
ac.calc = this._systemType === "npc" ? "natural" : "flat";
return;
}
// Migrate ac.base in custom formulas to ac.armor
if ( (typeof ac.formula === "string") && ac.formula.includes("@attributes.ac.base") ) {
ac.formula = ac.formula.replaceAll("@attributes.ac.base", "@attributes.ac.armor");
}
}
/* -------------------------------------------- */
/**
* Migrate the actor speed string to movement object.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateMovementData(source) {
const original = source.attributes?.speed?.value ?? source.attributes?.speed;
if ( (typeof original !== "string") || (source.attributes.movement?.walk !== undefined) ) return;
source.attributes.movement ??= {};
const s = original.split(" ");
if ( s.length > 0 ) source.attributes.movement.walk = Number.isNumeric(s[0]) ? parseInt(s[0]) : 0;
}
}
/**
* @typedef {object} SkillData
* @property {number} value Proficiency level creature has in this skill.
* @property {string} ability Default ability used for this skill.
* @property {object} bonuses Bonuses for this skill.
* @property {string} bonuses.check Numeric or dice bonus to skill's check.
* @property {string} bonuses.passive Numeric bonus to skill's passive check.
*/
/**
* A template for all actors that are creatures
*
* @property {object} bonuses
* @property {AttackBonusesData} bonuses.mwak Bonuses to melee weapon attacks.
* @property {AttackBonusesData} bonuses.rwak Bonuses to ranged weapon attacks.
* @property {AttackBonusesData} bonuses.msak Bonuses to melee spell attacks.
* @property {AttackBonusesData} bonuses.rsak Bonuses to ranged spell attacks.
* @property {object} bonuses.abilities Bonuses to ability scores.
* @property {string} bonuses.abilities.check Numeric or dice bonus to ability checks.
* @property {string} bonuses.abilities.save Numeric or dice bonus to ability saves.
* @property {string} bonuses.abilities.skill Numeric or dice bonus to skill checks.
* @property {object} bonuses.spell Bonuses to spells.
* @property {string} bonuses.spell.dc Numeric bonus to spellcasting DC.
* @property {Object} skills Actor's skills.
* @property {Object} spells Actor's spell slots.
*/
class CreatureTemplate extends CommonTemplate {
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
bonuses: new foundry.data.fields.SchemaField({
mwak: makeAttackBonuses({label: "DND5E.BonusMWAttack"}),
rwak: makeAttackBonuses({label: "DND5E.BonusRWAttack"}),
msak: makeAttackBonuses({label: "DND5E.BonusMSAttack"}),
rsak: makeAttackBonuses({label: "DND5E.BonusRSAttack"}),
abilities: new foundry.data.fields.SchemaField({
check: new FormulaField({required: true, label: "DND5E.BonusAbilityCheck"}),
save: new FormulaField({required: true, label: "DND5E.BonusAbilitySave"}),
skill: new FormulaField({required: true, label: "DND5E.BonusAbilitySkill"})
}, {label: "DND5E.BonusAbility"}),
spell: new foundry.data.fields.SchemaField({
dc: new FormulaField({required: true, deterministic: true, label: "DND5E.BonusSpellDC"})
}, {label: "DND5E.BonusSpell"})
}, {label: "DND5E.Bonuses"}),
skills: new MappingField(new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
required: true, min: 0, max: 2, step: 0.5, initial: 0, label: "DND5E.ProficiencyLevel"
}),
ability: new foundry.data.fields.StringField({required: true, initial: "dex", label: "DND5E.Ability"}),
bonuses: new foundry.data.fields.SchemaField({
check: new FormulaField({required: true, label: "DND5E.SkillBonusCheck"}),
passive: new FormulaField({required: true, label: "DND5E.SkillBonusPassive"})
}, {label: "DND5E.SkillBonuses"})
}), {
initialKeys: CONFIG.DND5E.skills, initialValue: this._initialSkillValue,
initialKeysOnly: true, label: "DND5E.Skills"
}),
tools: new MappingField(new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
required: true, min: 0, max: 2, step: 0.5, initial: 1, label: "DND5E.ProficiencyLevel"
}),
ability: new foundry.data.fields.StringField({required: true, initial: "int", label: "DND5E.Ability"}),
bonuses: new foundry.data.fields.SchemaField({
check: new FormulaField({required: true, label: "DND5E.CheckBonus"})
}, {label: "DND5E.ToolBonuses"})
})),
spells: new MappingField(new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SpellProgAvailable"
}),
override: new foundry.data.fields.NumberField({
integer: true, min: 0, label: "DND5E.SpellProgOverride"
})
}), {initialKeys: this._spellLevels, label: "DND5E.SpellLevels"})
});
}
/* -------------------------------------------- */
/**
* Populate the proper initial abilities for the skills.
* @param {string} key Key for which the initial data will be created.
* @param {object} initial The initial skill object created by SkillData.
* @returns {object} Initial skills object with the ability defined.
* @private
*/
static _initialSkillValue(key, initial) {
if ( CONFIG.DND5E.skills[key]?.ability ) initial.ability = CONFIG.DND5E.skills[key].ability;
return initial;
}
/* -------------------------------------------- */
/**
* Helper for building the default list of spell levels.
* @type {string[]}
* @private
*/
static get _spellLevels() {
const levels = Object.keys(CONFIG.DND5E.spellLevels).filter(a => a !== "0").map(l => `spell${l}`);
return [...levels, "pact"];
}
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
super.migrateData(source);
CreatureTemplate.#migrateSensesData(source);
CreatureTemplate.#migrateToolData(source);
}
/* -------------------------------------------- */
/**
* Migrate the actor traits.senses string to attributes.senses object.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateSensesData(source) {
const original = source.traits?.senses;
if ( (original === undefined) || (typeof original !== "string") ) return;
source.attributes ??= {};
source.attributes.senses ??= {};
// Try to match old senses with the format like "Darkvision 60 ft, Blindsight 30 ft"
const pattern = /([A-z]+)\s?([0-9]+)\s?([A-z]+)?/;
let wasMatched = false;
// Match each comma-separated term
for ( let s of original.split(",") ) {
s = s.trim();
const match = s.match(pattern);
if ( !match ) continue;
const type = match[1].toLowerCase();
if ( (type in CONFIG.DND5E.senses) && !(type in source.attributes.senses) ) {
source.attributes.senses[type] = Number(match[2]).toNearest(0.5);
wasMatched = true;
}
}
// If nothing was matched, but there was an old string - put the whole thing in "special"
if ( !wasMatched && original ) source.attributes.senses.special = original;
}
/* -------------------------------------------- */
/**
* Migrate traits.toolProf to the tools field.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateToolData(source) {
const original = source.traits?.toolProf;
if ( !original || foundry.utils.isEmpty(original.value) ) return;
source.tools ??= {};
for ( const prof of original.value ) {
const validProf = (prof in CONFIG.DND5E.toolProficiencies) || (prof in CONST.DND5E.toolIds);
if ( !validProf || (prof in source.tools) ) continue;
source.tools[prof] = {
value: 1,
ability: "int",
bonuses: {check: ""}
};
}
}
}
/* -------------------------------------------- */
/**
* Data on configuration of a specific spell slot.
*
* @typedef {object} SpellSlotData
* @property {number} value Currently available spell slots.
* @property {number} override Number to replace auto-calculated max slots.
*/
/* -------------------------------------------- */
/**
* Data structure for actor's attack bonuses.
*
* @typedef {object} AttackBonusesData
* @property {string} attack Numeric or dice bonus to attack rolls.
* @property {string} damage Numeric or dice bonus to damage rolls.
*/
/**
* Produce the schema field for a simple trait.
* @param {object} schemaOptions Options passed to the outer schema.
* @returns {AttackBonusesData}
*/
function makeAttackBonuses(schemaOptions={}) {
return new foundry.data.fields.SchemaField({
attack: new FormulaField({required: true, label: "DND5E.BonusAttack"}),
damage: new FormulaField({required: true, label: "DND5E.BonusDamage"})
}, schemaOptions);
}
/**
* Shared contents of the details schema between various actor types.
*/
class DetailsField {
/**
* Fields shared between characters, NPCs, and vehicles.
*
* @type {object}
* @property {object} biography Actor's biography data.
* @property {string} biography.value Full HTML biography information.
* @property {string} biography.public Biography that will be displayed to players with observer privileges.
*/
static get common() {
return {
biography: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.HTMLField({label: "DND5E.Biography"}),
public: new foundry.data.fields.HTMLField({label: "DND5E.BiographyPublic"})
}, {label: "DND5E.Biography"})
};
}
/* -------------------------------------------- */
/**
* Fields shared between characters and NPCs.
*
* @type {object}
* @property {string} alignment Creature's alignment.
* @property {string} race Creature's race.
*/
static get creature() {
return {
alignment: new foundry.data.fields.StringField({required: true, label: "DND5E.Alignment"}),
race: new foundry.data.fields.StringField({required: true, label: "DND5E.Race"})
};
}
}
/**
* Shared contents of the traits schema between various actor types.
*/
class TraitsField {
/**
* Data structure for a standard actor trait.
*
* @typedef {object} SimpleTraitData
* @property {Set} value Keys for currently selected traits.
* @property {string} custom Semicolon-separated list of custom traits.
*/
/**
* Data structure for a damage actor trait.
*
* @typedef {object} DamageTraitData
* @property {Set} value Keys for currently selected traits.
* @property {Set} bypasses Keys for physical weapon properties that cause resistances to be bypassed.
* @property {string} custom Semicolon-separated list of custom traits.
*/
/* -------------------------------------------- */
/**
* Fields shared between characters, NPCs, and vehicles.
*
* @type {object}
* @property {string} size Actor's size.
* @property {DamageTraitData} di Damage immunities.
* @property {DamageTraitData} dr Damage resistances.
* @property {DamageTraitData} dv Damage vulnerabilities.
* @property {SimpleTraitData} ci Condition immunities.
*/
static get common() {
return {
size: new foundry.data.fields.StringField({required: true, initial: "med", label: "DND5E.Size"}),
di: this.makeDamageTrait({label: "DND5E.DamImm"}),
dr: this.makeDamageTrait({label: "DND5E.DamRes"}),
dv: this.makeDamageTrait({label: "DND5E.DamVuln"}),
ci: this.makeSimpleTrait({label: "DND5E.ConImm"})
};
}
/* -------------------------------------------- */
/**
* Fields shared between characters and NPCs.
*
* @type {object}
* @property {SimpleTraitData} languages Languages known by this creature.
*/
static get creature() {
return {
languages: this.makeSimpleTrait({label: "DND5E.Languages"})
};
}
/* -------------------------------------------- */
/**
* Produce the schema field for a simple trait.
* @param {object} [schemaOptions={}] Options passed to the outer schema.
* @param {object} [options={}]
* @param {string[]} [options.initial={}] The initial value for the value set.
* @param {object} [options.extraFields={}] Additional fields added to schema.
* @returns {SchemaField}
*/
static makeSimpleTrait(schemaOptions={}, {initial=[], extraFields={}}={}) {
return new foundry.data.fields.SchemaField({
...extraFields,
value: new foundry.data.fields.SetField(
new foundry.data.fields.StringField(), {label: "DND5E.TraitsChosen", initial}
),
custom: new foundry.data.fields.StringField({required: true, label: "DND5E.Special"})
}, schemaOptions);
}
/* -------------------------------------------- */
/**
* Produce the schema field for a damage trait.
* @param {object} [schemaOptions={}] Options passed to the outer schema.
* @param {object} [options={}]
* @param {string[]} [options.initial={}] The initial value for the value set.
* @param {object} [options.extraFields={}] Additional fields added to schema.
* @returns {SchemaField}
*/
static makeDamageTrait(schemaOptions={}, {initial=[], initialBypasses=[], extraFields={}}={}) {
return this.makeSimpleTrait(schemaOptions, {initial, extraFields: {
...extraFields,
bypasses: new foundry.data.fields.SetField(new foundry.data.fields.StringField(), {
label: "DND5E.DamagePhysicalBypass", hint: "DND5E.DamagePhysicalBypassHint", initial: initialBypasses
})
}});
}
}
/**
* System data definition for Characters.
*
* @property {object} attributes
* @property {object} attributes.ac
* @property {number} attributes.ac.flat Flat value used for flat or natural armor calculation.
* @property {string} attributes.ac.calc Name of one of the built-in formulas to use.
* @property {string} attributes.ac.formula Custom formula to use.
* @property {object} attributes.hp
* @property {number} attributes.hp.value Current hit points.
* @property {number} attributes.hp.max Override for maximum HP.
* @property {number} attributes.hp.temp Temporary HP applied on top of value.
* @property {number} attributes.hp.tempmax Temporary change to the maximum HP.
* @property {object} attributes.hp.bonuses
* @property {string} attributes.hp.bonuses.level Bonus formula applied for each class level.
* @property {string} attributes.hp.bonuses.overall Bonus formula applied to total HP.
* @property {object} attributes.death
* @property {number} attributes.death.success Number of successful death saves.
* @property {number} attributes.death.failure Number of failed death saves.
* @property {number} attributes.exhaustion Number of levels of exhaustion.
* @property {number} attributes.inspiration Does this character have inspiration?
* @property {object} details
* @property {string} details.background Name of character's background.
* @property {string} details.originalClass ID of first class taken by character.
* @property {XPData} details.xp Experience points gained.
* @property {number} details.xp.value Total experience points earned.
* @property {string} details.appearance Description of character's appearance.
* @property {string} details.trait Character's personality traits.
* @property {string} details.ideal Character's ideals.
* @property {string} details.bond Character's bonds.
* @property {string} details.flaw Character's flaws.
* @property {object} traits
* @property {SimpleTraitData} traits.weaponProf Character's weapon proficiencies.
* @property {SimpleTraitData} traits.armorProf Character's armor proficiencies.
* @property {object} resources
* @property {CharacterResourceData} resources.primary Resource number one.
* @property {CharacterResourceData} resources.secondary Resource number two.
* @property {CharacterResourceData} resources.tertiary Resource number three.
*/
class CharacterData extends CreatureTemplate {
/** @inheritdoc */
static _systemType = "character";
/* -------------------------------------------- */
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
attributes: new foundry.data.fields.SchemaField({
...AttributesFields.common,
...AttributesFields.creature,
ac: new foundry.data.fields.SchemaField({
flat: new foundry.data.fields.NumberField({integer: true, min: 0, label: "DND5E.ArmorClassFlat"}),
calc: new foundry.data.fields.StringField({initial: "default", label: "DND5E.ArmorClassCalculation"}),
formula: new FormulaField({deterministic: true, label: "DND5E.ArmorClassFormula"})
}, {label: "DND5E.ArmorClass"}),
hp: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.HitPointsCurrent"
}),
max: new foundry.data.fields.NumberField({
nullable: true, integer: true, min: 0, initial: null, label: "DND5E.HitPointsOverride"
}),
temp: new foundry.data.fields.NumberField({integer: true, initial: 0, min: 0, label: "DND5E.HitPointsTemp"}),
tempmax: new foundry.data.fields.NumberField({integer: true, initial: 0, label: "DND5E.HitPointsTempMax"}),
bonuses: new foundry.data.fields.SchemaField({
level: new FormulaField({deterministic: true, label: "DND5E.HitPointsBonusLevel"}),
overall: new FormulaField({deterministic: true, label: "DND5E.HitPointsBonusOverall"})
})
}, {label: "DND5E.HitPoints"}),
death: new foundry.data.fields.SchemaField({
success: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.DeathSaveSuccesses"
}),
failure: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.DeathSaveFailures"
})
}, {label: "DND5E.DeathSave"}),
exhaustion: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.Exhaustion"
}),
inspiration: new foundry.data.fields.BooleanField({required: true, label: "DND5E.Inspiration"})
}, {label: "DND5E.Attributes"}),
details: new foundry.data.fields.SchemaField({
...DetailsField.common,
...DetailsField.creature,
background: new foundry.data.fields.StringField({required: true, label: "DND5E.Background"}),
originalClass: new foundry.data.fields.StringField({required: true, label: "DND5E.ClassOriginal"}),
xp: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.ExperiencePointsCurrent"
})
}, {label: "DND5E.ExperiencePoints"}),
appearance: new foundry.data.fields.StringField({required: true, label: "DND5E.Appearance"}),
trait: new foundry.data.fields.StringField({required: true, label: "DND5E.PersonalityTraits"}),
ideal: new foundry.data.fields.StringField({required: true, label: "DND5E.Ideals"}),
bond: new foundry.data.fields.StringField({required: true, label: "DND5E.Bonds"}),
flaw: new foundry.data.fields.StringField({required: true, label: "DND5E.Flaws"})
}, {label: "DND5E.Details"}),
traits: new foundry.data.fields.SchemaField({
...TraitsField.common,
...TraitsField.creature,
weaponProf: TraitsField.makeSimpleTrait({label: "DND5E.TraitWeaponProf"}),
armorProf: TraitsField.makeSimpleTrait({label: "DND5E.TraitArmorProf"})
}, {label: "DND5E.Traits"}),
resources: new foundry.data.fields.SchemaField({
primary: makeResourceField({label: "DND5E.ResourcePrimary"}),
secondary: makeResourceField({label: "DND5E.ResourceSecondary"}),
tertiary: makeResourceField({label: "DND5E.ResourceTertiary"})
}, {label: "DND5E.Resources"})
});
}
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
super.migrateData(source);
AttributesFields._migrateInitiative(source.attributes);
}
}
/* -------------------------------------------- */
/**
* Data structure for character's resources.
*
* @typedef {object} ResourceData
* @property {number} value Available uses of this resource.
* @property {number} max Maximum allowed uses of this resource.
* @property {boolean} sr Does this resource recover on a short rest?
* @property {boolean} lr Does this resource recover on a long rest?
* @property {string} label Displayed name.
*/
/**
* Produce the schema field for a simple trait.
* @param {object} schemaOptions Options passed to the outer schema.
* @returns {ResourceData}
*/
function makeResourceField(schemaOptions={}) {
return new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
required: true, integer: true, initial: 0, labels: "DND5E.ResourceValue"
}),
max: new foundry.data.fields.NumberField({
required: true, integer: true, initial: 0, labels: "DND5E.ResourceMax"
}),
sr: new foundry.data.fields.BooleanField({required: true, labels: "DND5E.ShortRestRecovery"}),
lr: new foundry.data.fields.BooleanField({required: true, labels: "DND5E.LongRestRecovery"}),
label: new foundry.data.fields.StringField({required: true, labels: "DND5E.ResourceLabel"})
}, schemaOptions);
}
/**
* A data model and API layer which handles the schema and functionality of "group" type Actors in the dnd5e system.
* @mixes CurrencyTemplate
*
* @property {object} description
* @property {string} description.full Description of this group.
* @property {string} description.summary Summary description (currently unused).
* @property {Set} members IDs of actors belonging to this group in the world collection.
* @property {object} attributes
* @property {object} attributes.movement
* @property {number} attributes.movement.land Base movement speed over land.
* @property {number} attributes.movement.water Base movement speed over water.
* @property {number} attributes.movement.air Base movement speed through the air.
*
* @example Create a new Group
* const g = new dnd5e.documents.Actor5e({
* type: "group",
* name: "Test Group",
* system: {
* members: ["3f3hoYFWUgDqBP4U"]
* }
* });
*/
class GroupActor extends SystemDataModel.mixin(CurrencyTemplate) {
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
description: new foundry.data.fields.SchemaField({
full: new foundry.data.fields.HTMLField({label: "DND5E.Description"}),
summary: new foundry.data.fields.HTMLField({label: "DND5E.DescriptionSummary"})
}),
members: new foundry.data.fields.SetField(
new foundry.data.fields.ForeignDocumentField(foundry.documents.BaseActor, {idOnly: true}),
{label: "DND5E.GroupMembers"}
),
attributes: new foundry.data.fields.SchemaField({
movement: new foundry.data.fields.SchemaField({
land: new foundry.data.fields.NumberField({
nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementLand"
}),
water: new foundry.data.fields.NumberField({
nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementWater"
}),
air: new foundry.data.fields.NumberField({
nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementAir"
})
})
}, {label: "DND5E.Attributes"})
});
}
/* -------------------------------------------- */
/* Data Preparation */
/* -------------------------------------------- */
/**
* Prepare base data for group actors.
* @internal
*/
_prepareBaseData() {
this.members.clear();
for ( const id of this._source.members ) {
const a = game.actors.get(id);
if ( a ) {
if ( a.type === "group" ) {
console.warn(`Group "${this._id}" may not contain another Group "${a.id}" as a member.`);
}
else this.members.add(a);
}
else console.warn(`Actor "${id}" in group "${this._id}" does not exist within the World.`);
}
}
/**
* Prepare derived data for group actors.
* @internal
*/
_prepareDerivedData() {
// No preparation needed at this time
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/**
* Add a new member to the group.
* @param {Actor5e} actor A non-group Actor to add to the group
* @returns {Promise} The updated group Actor
*/
async addMember(actor) {
if ( actor.type === "group" ) throw new Error("You may not add a group within a group.");
if ( actor.pack ) throw new Error("You may only add Actors to the group which exist within the World.");
const memberIds = this._source.members;
if ( memberIds.includes(actor.id) ) return;
return this.parent.update({
system: {
members: memberIds.concat([actor.id])
}
});
}
/* -------------------------------------------- */
/**
* Remove a member from the group.
* @param {Actor5e|string} actor An Actor or ID to remove from this group
* @returns {Promise} The updated group Actor
*/
async removeMember(actor) {
const memberIds = foundry.utils.deepClone(this._source.members);
// Handle user input
let actorId;
if ( typeof actor === "string" ) actorId = actor;
else if ( actor instanceof Actor ) actorId = actor.id;
else throw new Error("You must provide an Actor document or an actor ID to remove a group member");
if ( !memberIds.includes(actorId) ) throw new Error(`Actor id "${actorId}" is not a group member`);
// Remove the actor and update the parent document
memberIds.findSplice(id => id === actorId);
return this.parent.update({
system: {
members: memberIds
}
});
}
}
/**
* System data definition for NPCs.
*
* @property {object} attributes
* @property {object} attributes.ac
* @property {number} attributes.ac.flat Flat value used for flat or natural armor calculation.
* @property {string} attributes.ac.calc Name of one of the built-in formulas to use.
* @property {string} attributes.ac.formula Custom formula to use.
* @property {object} attributes.hp
* @property {number} attributes.hp.value Current hit points.
* @property {number} attributes.hp.max Maximum allowed HP value.
* @property {number} attributes.hp.temp Temporary HP applied on top of value.
* @property {number} attributes.hp.tempmax Temporary change to the maximum HP.
* @property {string} attributes.hp.formula Formula used to determine hit points.
* @property {object} details
* @property {TypeData} details.type Creature type of this NPC.
* @property {string} details.type.value NPC's type as defined in the system configuration.
* @property {string} details.type.subtype NPC's subtype usually displayed in parenthesis after main type.
* @property {string} details.type.swarm Size of the individual creatures in a swarm, if a swarm.
* @property {string} details.type.custom Custom type beyond what is available in the configuration.
* @property {string} details.environment Common environments in which this NPC is found.
* @property {number} details.cr NPC's challenge rating.
* @property {number} details.spellLevel Spellcasting level of this NPC.
* @property {string} details.source What book or adventure is this NPC from?
* @property {object} resources
* @property {object} resources.legact NPC's legendary actions.
* @property {number} resources.legact.value Currently available legendary actions.
* @property {number} resources.legact.max Maximum number of legendary actions.
* @property {object} resources.legres NPC's legendary resistances.
* @property {number} resources.legres.value Currently available legendary resistances.
* @property {number} resources.legres.max Maximum number of legendary resistances.
* @property {object} resources.lair NPC's lair actions.
* @property {boolean} resources.lair.value Does this NPC use lair actions.
* @property {number} resources.lair.initiative Initiative count when lair actions are triggered.
*/
class NPCData extends CreatureTemplate {
/** @inheritdoc */
static _systemType = "npc";
/* -------------------------------------------- */
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
attributes: new foundry.data.fields.SchemaField({
...AttributesFields.common,
...AttributesFields.creature,
ac: new foundry.data.fields.SchemaField({
flat: new foundry.data.fields.NumberField({integer: true, min: 0, label: "DND5E.ArmorClassFlat"}),
calc: new foundry.data.fields.StringField({initial: "default", label: "DND5E.ArmorClassCalculation"}),
formula: new FormulaField({deterministic: true, label: "DND5E.ArmorClassFormula"})
}, {label: "DND5E.ArmorClass"}),
hp: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
nullable: false, integer: true, min: 0, initial: 10, label: "DND5E.HitPointsCurrent"
}),
max: new foundry.data.fields.NumberField({
nullable: false, integer: true, min: 0, initial: 10, label: "DND5E.HitPointsMax"
}),
temp: new foundry.data.fields.NumberField({integer: true, initial: 0, min: 0, label: "DND5E.HitPointsTemp"}),
tempmax: new foundry.data.fields.NumberField({integer: true, initial: 0, label: "DND5E.HitPointsTempMax"}),
formula: new FormulaField({required: true, label: "DND5E.HPFormula"})
}, {label: "DND5E.HitPoints"})
}, {label: "DND5E.Attributes"}),
details: new foundry.data.fields.SchemaField({
...DetailsField.common,
...DetailsField.creature,
type: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.CreatureType"}),
subtype: new foundry.data.fields.StringField({required: true, label: "DND5E.CreatureTypeSelectorSubtype"}),
swarm: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.CreatureSwarmSize"}),
custom: new foundry.data.fields.StringField({required: true, label: "DND5E.CreatureTypeSelectorCustom"})
}, {label: "DND5E.CreatureType"}),
environment: new foundry.data.fields.StringField({required: true, label: "DND5E.Environment"}),
cr: new foundry.data.fields.NumberField({
required: true, nullable: false, min: 0, initial: 1, label: "DND5E.ChallengeRating"
}),
spellLevel: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SpellcasterLevel"
}),
source: new foundry.data.fields.StringField({required: true, label: "DND5E.Source"})
}, {label: "DND5E.Details"}),
resources: new foundry.data.fields.SchemaField({
legact: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegActRemaining"
}),
max: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegActMax"
})
}, {label: "DND5E.LegAct"}),
legres: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegResRemaining"
}),
max: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegResMax"
})
}, {label: "DND5E.LegRes"}),
lair: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.BooleanField({required: true, label: "DND5E.LairAct"}),
initiative: new foundry.data.fields.NumberField({
required: true, integer: true, label: "DND5E.LairActionInitiative"
})
}, {label: "DND5E.LairActionLabel"})
}, {label: "DND5E.Resources"}),
traits: new foundry.data.fields.SchemaField({
...TraitsField.common,
...TraitsField.creature
}, {label: "DND5E.Traits"})
});
}
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
super.migrateData(source);
NPCData.#migrateTypeData(source);
AttributesFields._migrateInitiative(source.attributes);
}
/* -------------------------------------------- */
/**
* Migrate the actor type string to type object.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateTypeData(source) {
const original = source.type;
if ( typeof original !== "string" ) return;
source.type = {
value: "",
subtype: "",
swarm: "",
custom: ""
};
// Match the existing string
const pattern = /^(?:swarm of (?[\w-]+) )?(?[^(]+?)(?:\((?[^)]+)\))?$/i;
const match = original.trim().match(pattern);
if ( match ) {
// Match a known creature type
const typeLc = match.groups.type.trim().toLowerCase();
const typeMatch = Object.entries(CONFIG.DND5E.creatureTypes).find(([k, v]) => {
return (typeLc === k)
|| (typeLc === game.i18n.localize(v).toLowerCase())
|| (typeLc === game.i18n.localize(`${v}Pl`).toLowerCase());
});
if ( typeMatch ) source.type.value = typeMatch[0];
else {
source.type.value = "custom";
source.type.custom = match.groups.type.trim().titleCase();
}
source.type.subtype = match.groups.subtype?.trim().titleCase() ?? "";
// Match a swarm
if ( match.groups.size ) {
const sizeLc = match.groups.size ? match.groups.size.trim().toLowerCase() : "tiny";
const sizeMatch = Object.entries(CONFIG.DND5E.actorSizes).find(([k, v]) => {
return (sizeLc === k) || (sizeLc === game.i18n.localize(v).toLowerCase());
});
source.type.swarm = sizeMatch ? sizeMatch[0] : "tiny";
}
else source.type.swarm = "";
}
// No match found
else {
source.type.value = "custom";
source.type.custom = original;
}
}
}
/**
* System data definition for Vehicles.
*
* @property {string} vehicleType Type of vehicle as defined in `DND5E.vehicleTypes`.
* @property {object} attributes
* @property {object} attributes.ac
* @property {number} attributes.ac.flat Flat value used for flat or natural armor calculation.
* @property {string} attributes.ac.calc Name of one of the built-in formulas to use.
* @property {string} attributes.ac.formula Custom formula to use.
* @property {string} attributes.ac.motionless Changes to vehicle AC when not moving.
* @property {object} attributes.hp
* @property {number} attributes.hp.value Current hit points.
* @property {number} attributes.hp.max Maximum allowed HP value.
* @property {number} attributes.hp.temp Temporary HP applied on top of value.
* @property {number} attributes.hp.tempmax Temporary change to the maximum HP.
* @property {number} attributes.hp.dt Damage threshold.
* @property {number} attributes.hp.mt Mishap threshold.
* @property {object} attributes.actions Information on how the vehicle performs actions.
* @property {boolean} attributes.actions.stations Does this vehicle rely on action stations that required
* individual crewing rather than general crew thresholds?
* @property {number} attributes.actions.value Maximum number of actions available with full crewing.
* @property {object} attributes.actions.thresholds Crew thresholds needed to perform various actions.
* @property {number} attributes.actions.thresholds.2 Minimum crew needed to take full action complement.
* @property {number} attributes.actions.thresholds.1 Minimum crew needed to take reduced action complement.
* @property {number} attributes.actions.thresholds.0 Minimum crew needed to perform any actions.
* @property {object} attributes.capacity Information on the vehicle's carrying capacity.
* @property {string} attributes.capacity.creature Description of the number of creatures the vehicle can carry.
* @property {number} attributes.capacity.cargo Cargo carrying capacity measured in tons.
* @property {object} traits
* @property {string} traits.dimensions Width and length of the vehicle.
* @property {object} cargo Details on this vehicle's crew and cargo capacities.
* @property {PassengerData[]} cargo.crew Creatures responsible for operating the vehicle.
* @property {PassengerData[]} cargo.passengers Creatures just takin' a ride.
*/
class VehicleData extends CommonTemplate {
/** @inheritdoc */
static _systemType = "vehicle";
/* -------------------------------------------- */
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
vehicleType: new foundry.data.fields.StringField({required: true, initial: "water", label: "DND5E.VehicleType"}),
attributes: new foundry.data.fields.SchemaField({
...AttributesFields.common,
ac: new foundry.data.fields.SchemaField({
flat: new foundry.data.fields.NumberField({integer: true, min: 0, label: "DND5E.ArmorClassFlat"}),
calc: new foundry.data.fields.StringField({initial: "default", label: "DND5E.ArmorClassCalculation"}),
formula: new FormulaField({deterministic: true, label: "DND5E.ArmorClassFormula"}),
motionless: new foundry.data.fields.StringField({required: true, label: "DND5E.ArmorClassMotionless"})
}, {label: "DND5E.ArmorClass"}),
hp: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
nullable: true, integer: true, min: 0, initial: null, label: "DND5E.HitPointsCurrent"
}),
max: new foundry.data.fields.NumberField({
nullable: true, integer: true, min: 0, initial: null, label: "DND5E.HitPointsMax"
}),
temp: new foundry.data.fields.NumberField({integer: true, initial: 0, min: 0, label: "DND5E.HitPointsTemp"}),
tempmax: new foundry.data.fields.NumberField({integer: true, initial: 0, label: "DND5E.HitPointsTempMax"}),
dt: new foundry.data.fields.NumberField({
required: true, integer: true, min: 0, label: "DND5E.DamageThreshold"
}),
mt: new foundry.data.fields.NumberField({
required: true, integer: true, min: 0, label: "DND5E.VehicleMishapThreshold"
})
}, {label: "DND5E.HitPoints"}),
actions: new foundry.data.fields.SchemaField({
stations: new foundry.data.fields.BooleanField({required: true, label: "DND5E.VehicleActionStations"}),
value: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, initial: 0, min: 0, label: "DND5E.VehicleActionMax"
}),
thresholds: new foundry.data.fields.SchemaField({
2: new foundry.data.fields.NumberField({
required: true, integer: true, min: 0, label: "DND5E.VehicleActionThresholdsFull"
}),
1: new foundry.data.fields.NumberField({
required: true, integer: true, min: 0, label: "DND5E.VehicleActionThresholdsMid"
}),
0: new foundry.data.fields.NumberField({
required: true, integer: true, min: 0, label: "DND5E.VehicleActionThresholdsMin"
})
}, {label: "DND5E.VehicleActionThresholds"})
}, {label: "DND5E.VehicleActions"}),
capacity: new foundry.data.fields.SchemaField({
creature: new foundry.data.fields.StringField({required: true, label: "DND5E.VehicleCreatureCapacity"}),
cargo: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, initial: 0, min: 0, label: "DND5E.VehicleCargoCapacity"
})
}, {label: "DND5E.VehicleCargoCrew"})
}, {label: "DND5E.Attributes"}),
details: new foundry.data.fields.SchemaField({
...DetailsField.common,
source: new foundry.data.fields.StringField({required: true, label: "DND5E.Source"})
}, {label: "DND5E.Details"}),
traits: new foundry.data.fields.SchemaField({
...TraitsField.common,
size: new foundry.data.fields.StringField({required: true, initial: "lg", label: "DND5E.Size"}),
di: TraitsField.makeDamageTrait({label: "DND5E.DamImm"}, {initial: ["poison", "psychic"]}),
ci: TraitsField.makeSimpleTrait({label: "DND5E.ConImm"}, {initial: [
"blinded", "charmed", "deafened", "frightened", "paralyzed",
"petrified", "poisoned", "stunned", "unconscious"
]}),
dimensions: new foundry.data.fields.StringField({required: true, label: "DND5E.Dimensions"})
}, {label: "DND5E.Traits"}),
cargo: new foundry.data.fields.SchemaField({
crew: new foundry.data.fields.ArrayField(makePassengerData(), {label: "DND5E.VehicleCrew"}),
passengers: new foundry.data.fields.ArrayField(makePassengerData(), {label: "DND5E.VehiclePassengers"})
}, {label: "DND5E.VehicleCrewPassengers"})
});
}
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
super.migrateData(source);
AttributesFields._migrateInitiative(source.attributes);
}
}
/* -------------------------------------------- */
/**
* Data structure for an entry in a vehicle's crew or passenger lists.
*
* @typedef {object} PassengerData
* @property {string} name Name of individual or type of creature.
* @property {number} quantity How many of this creature are onboard?
*/
/**
* Produce the schema field for a simple trait.
* @param {object} schemaOptions Options passed to the outer schema.
* @returns {PassengerData}
*/
function makePassengerData(schemaOptions={}) {
return new foundry.data.fields.SchemaField({
name: new foundry.data.fields.StringField({required: true, label: "DND5E.VehiclePassengerName"}),
quantity: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, initial: 0, min: 0, label: "DND5E.VehiclePassengerQuantity"
})
}, schemaOptions);
}
const config$2 = {
character: CharacterData,
group: GroupActor,
npc: NPCData,
vehicle: VehicleData
};
var _module$4 = /*#__PURE__*/Object.freeze({
__proto__: null,
AttributesFields: AttributesFields,
CharacterData: CharacterData,
CommonTemplate: CommonTemplate,
CreatureTemplate: CreatureTemplate,
DetailsFields: DetailsField,
GroupData: GroupActor,
NPCData: NPCData,
TraitsFields: TraitsField,
VehicleData: VehicleData,
config: config$2
});
var _module$3 = /*#__PURE__*/Object.freeze({
__proto__: null,
BaseAdvancement: BaseAdvancement,
ItemChoiceConfigurationData: ItemChoiceConfigurationData,
ItemGrantConfigurationData: ItemGrantConfigurationData,
SpellConfigurationData: SpellConfigurationData,
scaleValue: scaleValue
});
/**
* Data model template with item description & source.
*
* @property {object} description Various item descriptions.
* @property {string} description.value Full item description.
* @property {string} description.chat Description displayed in chat card.
* @property {string} description.unidentified Description displayed if item is unidentified.
* @property {string} source Adventure or sourcebook where this item originated.
* @mixin
*/
class ItemDescriptionTemplate extends foundry.abstract.DataModel {
/** @inheritdoc */
static defineSchema() {
return {
description: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.HTMLField({required: true, nullable: true, label: "DND5E.Description"}),
chat: new foundry.data.fields.HTMLField({required: true, nullable: true, label: "DND5E.DescriptionChat"}),
unidentified: new foundry.data.fields.HTMLField({
required: true, nullable: true, label: "DND5E.DescriptionUnidentified"
})
}),
source: new foundry.data.fields.StringField({required: true, label: "DND5E.Source"})
};
}
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
ItemDescriptionTemplate.#migrateSource(source);
}
/* -------------------------------------------- */
/**
* Convert null source to the blank string.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateSource(source) {
if ( source.source === null ) source.source = "";
}
}
/**
* Data definition for Background items.
* @mixes ItemDescriptionTemplate
*
* @property {object[]} advancement Advancement objects for this background.
*/
class BackgroundData extends SystemDataModel.mixin(ItemDescriptionTemplate) {
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
advancement: new foundry.data.fields.ArrayField(new AdvancementField(), {label: "DND5E.AdvancementTitle"})
});
}
}
/**
* Data definition for Class items.
* @mixes ItemDescriptionTemplate
*
* @property {string} identifier Identifier slug for this class.
* @property {number} levels Current number of levels in this class.
* @property {string} hitDice Denomination of hit dice available as defined in `DND5E.hitDieTypes`.
* @property {number} hitDiceUsed Number of hit dice consumed.
* @property {object[]} advancement Advancement objects for this class.
* @property {string[]} saves Savings throws in which this class grants proficiency.
* @property {object} skills Available class skills and selected skills.
* @property {number} skills.number Number of skills selectable by the player.
* @property {string[]} skills.choices List of skill keys that are valid to be chosen.
* @property {string[]} skills.value List of skill keys the player has chosen.
* @property {object} spellcasting Details on class's spellcasting ability.
* @property {string} spellcasting.progression Spell progression granted by class as from `DND5E.spellProgression`.
* @property {string} spellcasting.ability Ability score to use for spellcasting.
*/
class ClassData extends SystemDataModel.mixin(ItemDescriptionTemplate) {
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
identifier: new IdentifierField({required: true, label: "DND5E.Identifier"}),
levels: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 1, label: "DND5E.ClassLevels"
}),
hitDice: new foundry.data.fields.StringField({
required: true, initial: "d6", blank: false, label: "DND5E.HitDice",
validate: v => /d\d+/.test(v), validationError: "must be a dice value in the format d#"
}),
hitDiceUsed: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, initial: 0, min: 0, label: "DND5E.HitDiceUsed"
}),
advancement: new foundry.data.fields.ArrayField(new AdvancementField(), {label: "DND5E.AdvancementTitle"}),
saves: new foundry.data.fields.ArrayField(new foundry.data.fields.StringField(), {label: "DND5E.ClassSaves"}),
skills: new foundry.data.fields.SchemaField({
number: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, min: 0, initial: 2, label: "DND5E.ClassSkillsNumber"
}),
choices: new foundry.data.fields.ArrayField(
new foundry.data.fields.StringField(), {label: "DND5E.ClassSkillsEligible"}
),
value: new foundry.data.fields.ArrayField(
new foundry.data.fields.StringField(), {label: "DND5E.ClassSkillsChosen"}
)
}),
spellcasting: new foundry.data.fields.SchemaField({
progression: new foundry.data.fields.StringField({
required: true, initial: "none", blank: false, label: "DND5E.SpellProgression"
}),
ability: new foundry.data.fields.StringField({required: true, label: "DND5E.SpellAbility"})
}, {label: "DND5E.Spellcasting"})
});
}
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
super.migrateData(source);
ClassData.#migrateLevels(source);
ClassData.#migrateSpellcastingData(source);
}
/* -------------------------------------------- */
/**
* Migrate the class levels.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateLevels(source) {
if ( typeof source.levels !== "string" ) return;
if ( source.levels === "" ) source.levels = 1;
else if ( Number.isNumeric(source.levels) ) source.levels = Number(source.levels);
}
/* -------------------------------------------- */
/**
* Migrate the class's spellcasting string to object.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateSpellcastingData(source) {
if ( source.spellcasting?.progression === "" ) source.spellcasting.progression = "none";
if ( typeof source.spellcasting !== "string" ) return;
source.spellcasting = {
progression: source.spellcasting,
ability: ""
};
}
}
/**
* Data model template for item actions.
*
* @property {string} ability Ability score to use when determining modifier.
* @property {string} actionType Action type as defined in `DND5E.itemActionTypes`.
* @property {string} attackBonus Numeric or dice bonus to attack rolls.
* @property {string} chatFlavor Extra text displayed in chat.
* @property {object} critical Information on how critical hits are handled.
* @property {number} critical.threshold Minimum number on the dice to roll a critical hit.
* @property {string} critical.damage Extra damage on critical hit.
* @property {object} damage Item damage formulas.
* @property {string[][]} damage.parts Array of damage formula and types.
* @property {string} damage.versatile Special versatile damage formula.
* @property {string} formula Other roll formula.
* @property {object} save Item saving throw data.
* @property {string} save.ability Ability required for the save.
* @property {number} save.dc Custom saving throw value.
* @property {string} save.scaling Method for automatically determining saving throw DC.
* @mixin
*/
class ActionTemplate extends foundry.abstract.DataModel {
/** @inheritdoc */
static defineSchema() {
return {
ability: new foundry.data.fields.StringField({
required: true, nullable: true, initial: null, label: "DND5E.AbilityModifier"
}),
actionType: new foundry.data.fields.StringField({
required: true, nullable: true, initial: null, label: "DND5E.ItemActionType"
}),
attackBonus: new FormulaField({required: true, label: "DND5E.ItemAttackBonus"}),
chatFlavor: new foundry.data.fields.StringField({required: true, label: "DND5E.ChatFlavor"}),
critical: new foundry.data.fields.SchemaField({
threshold: new foundry.data.fields.NumberField({
required: true, integer: true, initial: null, positive: true, label: "DND5E.ItemCritThreshold"
}),
damage: new FormulaField({required: true, label: "DND5E.ItemCritExtraDamage"})
}),
damage: new foundry.data.fields.SchemaField({
parts: new foundry.data.fields.ArrayField(new foundry.data.fields.ArrayField(
new foundry.data.fields.StringField({nullable: true})
), {required: true}),
versatile: new FormulaField({required: true, label: "DND5E.VersatileDamage"})
}, {label: "DND5E.Damage"}),
formula: new FormulaField({required: true, label: "DND5E.OtherFormula"}),
save: new foundry.data.fields.SchemaField({
ability: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.Ability"}),
dc: new foundry.data.fields.NumberField({
required: true, min: 0, integer: true, label: "DND5E.AbbreviationDC"
}),
scaling: new foundry.data.fields.StringField({
required: true, blank: false, initial: "spell", label: "DND5E.ScalingFormula"
})
}, {label: "DND5E.SavingThrow"})
};
}
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
ActionTemplate.#migrateAbility(source);
ActionTemplate.#migrateAttackBonus(source);
ActionTemplate.#migrateCritical(source);
ActionTemplate.#migrateSave(source);
ActionTemplate.#migrateDamage(source);
}
/* -------------------------------------------- */
/**
* Migrate the ability field.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateAbility(source) {
if ( Array.isArray(source.ability) ) source.ability = source.ability[0];
}
/* -------------------------------------------- */
/**
* Ensure a 0 or null in attack bonus is converted to an empty string rather than "0".
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateAttackBonus(source) {
if ( [0, "0", null].includes(source.attackBonus) ) source.attackBonus = "";
else if ( typeof source.attackBonus === "number" ) source.attackBonus = source.attackBonus.toString();
}
/* -------------------------------------------- */
/**
* Ensure the critical field is an object.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateCritical(source) {
if ( !("critical" in source) ) return;
if ( (typeof source.critical !== "object") || (source.critical === null) ) source.critical = {
threshold: null,
damage: ""
};
if ( source.critical.damage === null ) source.critical.damage = "";
}
/* -------------------------------------------- */
/**
* Migrate the save field.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateSave(source) {
if ( !("save" in source) ) return;
source.save ??= {};
if ( source.save.scaling === "" ) source.save.scaling = "spell";
if ( source.save.ability === null ) source.save.ability = "";
if ( typeof source.save.dc === "string" ) {
if ( source.save.dc === "" ) source.save.dc = null;
else if ( Number.isNumeric(source.save.dc) ) source.save.dc = Number(source.save.dc);
}
}
/* -------------------------------------------- */
/**
* Migrate damage parts.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateDamage(source) {
if ( !("damage" in source) ) return;
source.damage ??= {};
source.damage.parts ??= [];
}
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/**
* Which ability score modifier is used by this item?
* @type {string|null}
*/
get abilityMod() {
return this.ability || this._typeAbilityMod || {
mwak: "str",
rwak: "dex",
msak: this.parent?.actor?.system.attributes.spellcasting || "int",
rsak: this.parent?.actor?.system.attributes.spellcasting || "int"
}[this.actionType] || null;
}
/* -------------------------------------------- */
/**
* Default ability key defined for this type.
* @type {string|null}
* @internal
*/
get _typeAbilityMod() {
return null;
}
/* -------------------------------------------- */
/**
* What is the critical hit threshold for this item? Uses the smallest value from among the following sources:
* - `critical.threshold` defined on the item
* - `critical.threshold` defined on ammunition, if consumption mode is set to ammo
* - Type-specific critical threshold
* @type {number|null}
*/
get criticalThreshold() {
if ( !this.hasAttack ) return null;
let ammoThreshold = Infinity;
if ( this.consume?.type === "ammo" ) {
ammoThreshold = this.parent?.actor?.items.get(this.consume.target).system.critical.threshold ?? Infinity;
}
const threshold = Math.min(this.critical.threshold ?? Infinity, this._typeCriticalThreshold, ammoThreshold);
return threshold < Infinity ? threshold : 20;
}
/* -------------------------------------------- */
/**
* Default critical threshold for this type.
* @type {number}
* @internal
*/
get _typeCriticalThreshold() {
return Infinity;
}
/* -------------------------------------------- */
/**
* Does the Item implement an ability check as part of its usage?
* @type {boolean}
*/
get hasAbilityCheck() {
return (this.actionType === "abil") && !!this.ability;
}
/* -------------------------------------------- */
/**
* Does the Item implement an attack roll as part of its usage?
* @type {boolean}
*/
get hasAttack() {
return ["mwak", "rwak", "msak", "rsak"].includes(this.actionType);
}
/* -------------------------------------------- */
/**
* Does the Item implement a damage roll as part of its usage?
* @type {boolean}
*/
get hasDamage() {
return this.actionType && (this.damage.parts.length > 0);
}
/* -------------------------------------------- */
/**
* Does the Item implement a saving throw as part of its usage?
* @type {boolean}
*/
get hasSave() {
return this.actionType && !!(this.save.ability && this.save.scaling);
}
/* -------------------------------------------- */
/**
* Does the Item provide an amount of healing instead of conventional damage?
* @type {boolean}
*/
get isHealing() {
return (this.actionType === "heal") && this.hasDamage;
}
/* -------------------------------------------- */
/**
* Does the Item implement a versatile damage roll as part of its usage?
* @type {boolean}
*/
get isVersatile() {
return this.actionType && !!(this.hasDamage && this.damage.versatile);
}
}
/**
* Data model template for items that can be used as some sort of action.
*
* @property {object} activation Effect's activation conditions.
* @property {string} activation.type Activation type as defined in `DND5E.abilityActivationTypes`.
* @property {number} activation.cost How much of the activation type is needed to use this item's effect.
* @property {string} activation.condition Special conditions required to activate the item.
* @property {object} duration Effect's duration.
* @property {number} duration.value How long the effect lasts.
* @property {string} duration.units Time duration period as defined in `DND5E.timePeriods`.
* @property {number} cover Amount of cover does this item affords to its crew on a vehicle.
* @property {object} target Effect's valid targets.
* @property {number} target.value Length or radius of target depending on targeting mode selected.
* @property {number} target.width Width of line when line type is selected.
* @property {string} target.units Units used for value and width as defined in `DND5E.distanceUnits`.
* @property {string} target.type Targeting mode as defined in `DND5E.targetTypes`.
* @property {object} range Effect's range.
* @property {number} range.value Regular targeting distance for item's effect.
* @property {number} range.long Maximum targeting distance for features that have a separate long range.
* @property {string} range.units Units used for value and long as defined in `DND5E.distanceUnits`.
* @property {object} uses Effect's limited uses.
* @property {number} uses.value Current available uses.
* @property {string} uses.max Maximum possible uses or a formula to derive that number.
* @property {string} uses.per Recharge time for limited uses as defined in `DND5E.limitedUsePeriods`.
* @property {object} consume Effect's resource consumption.
* @property {string} consume.type Type of resource to consume as defined in `DND5E.abilityConsumptionTypes`.
* @property {string} consume.target Item ID or resource key path of resource to consume.
* @property {number} consume.amount Quantity of the resource to consume per use.
* @mixin
*/
class ActivatedEffectTemplate extends foundry.abstract.DataModel {
/** @inheritdoc */
static defineSchema() {
return {
activation: new foundry.data.fields.SchemaField({
type: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.ItemActivationType"}),
cost: new foundry.data.fields.NumberField({required: true, label: "DND5E.ItemActivationCost"}),
condition: new foundry.data.fields.StringField({required: true, label: "DND5E.ItemActivationCondition"})
}, {label: "DND5E.ItemActivation"}),
duration: new foundry.data.fields.SchemaField({
value: new FormulaField({required: true, deterministic: true, label: "DND5E.Duration"}),
units: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.DurationType"})
}, {label: "DND5E.Duration"}),
cover: new foundry.data.fields.NumberField({
required: true, nullable: true, min: 0, max: 1, label: "DND5E.Cover"
}),
crewed: new foundry.data.fields.BooleanField({label: "DND5E.Crewed"}),
target: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({required: true, min: 0, label: "DND5E.TargetValue"}),
width: new foundry.data.fields.NumberField({required: true, min: 0, label: "DND5E.TargetWidth"}),
units: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.TargetUnits"}),
type: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.TargetType"})
}, {label: "DND5E.Target"}),
range: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({required: true, min: 0, label: "DND5E.RangeNormal"}),
long: new foundry.data.fields.NumberField({required: true, min: 0, label: "DND5E.RangeLong"}),
units: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.RangeUnits"})
}, {label: "DND5E.Range"}),
uses: new this.ItemUsesField({}, {label: "DND5E.LimitedUses"}),
consume: new foundry.data.fields.SchemaField({
type: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.ConsumeType"}),
target: new foundry.data.fields.StringField({
required: true, nullable: true, initial: null, label: "DND5E.ConsumeTarget"
}),
amount: new foundry.data.fields.NumberField({required: true, integer: true, label: "DND5E.ConsumeAmount"})
}, {label: "DND5E.ConsumeTitle"})
};
}
/* -------------------------------------------- */
/**
* Extension of SchemaField used to track item uses.
* @internal
*/
static ItemUsesField = class ItemUsesField extends foundry.data.fields.SchemaField {
constructor(extraSchema, options) {
super(SystemDataModel.mergeSchema({
value: new foundry.data.fields.NumberField({
required: true, min: 0, integer: true, label: "DND5E.LimitedUsesAvailable"
}),
max: new FormulaField({required: true, deterministic: true, label: "DND5E.LimitedUsesMax"}),
per: new foundry.data.fields.StringField({
required: true, nullable: true, blank: false, initial: null, label: "DND5E.LimitedUsesPer"
}),
recovery: new FormulaField({required: true, label: "DND5E.RecoveryFormula"})
}, extraSchema), options);
}
};
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
ActivatedEffectTemplate.#migrateFormulaFields(source);
ActivatedEffectTemplate.#migrateRanges(source);
ActivatedEffectTemplate.#migrateTargets(source);
ActivatedEffectTemplate.#migrateUses(source);
ActivatedEffectTemplate.#migrateConsume(source);
}
/* -------------------------------------------- */
/**
* Ensure a 0 or null in max uses & durations are converted to an empty string rather than "0". Convert numbers into
* strings.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateFormulaFields(source) {
if ( [0, "0", null].includes(source.uses?.max) ) source.uses.max = "";
else if ( typeof source.uses?.max === "number" ) source.uses.max = source.uses.max.toString();
if ( [0, "0", null].includes(source.duration?.value) ) source.duration.value = "";
else if ( typeof source.duration?.value === "number" ) source.duration.value = source.duration.value.toString();
}
/* -------------------------------------------- */
/**
* Fix issue with some imported range data that uses the format "100/400" in the range field,
* rather than splitting it between "range.value" & "range.long".
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateRanges(source) {
if ( !("range" in source) ) return;
source.range ??= {};
if ( source.range.units === null ) source.range.units = "";
if ( typeof source.range.long === "string" ) {
if ( source.range.long === "" ) source.range.long = null;
else if ( Number.isNumeric(source.range.long) ) source.range.long = Number(source.range.long);
}
if ( typeof source.range.value !== "string" ) return;
if ( source.range.value === "" ) {
source.range.value = null;
return;
}
const [value, long] = source.range.value.split("/");
if ( Number.isNumeric(value) ) source.range.value = Number(value);
if ( Number.isNumeric(long) ) source.range.long = Number(long);
}
/* -------------------------------------------- */
/**
* Ensure blank strings in targets are converted to null.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateTargets(source) {
if ( !("target" in source) ) return;
source.target ??= {};
if ( source.target.value === "" ) source.target.value = null;
if ( source.target.units === null ) source.target.units = "";
if ( source.target.type === null ) source.target.type = "";
}
/* -------------------------------------------- */
/**
* Ensure a blank string in uses.value is converted to null.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateUses(source) {
if ( !("uses" in source) ) return;
source.uses ??= {};
const value = source.uses.value;
if ( typeof value === "string" ) {
if ( value === "" ) source.uses.value = null;
else if ( Number.isNumeric(value) ) source.uses.value = Number(source.uses.value);
}
if ( source.uses.recovery === undefined ) source.uses.recovery = "";
}
/* -------------------------------------------- */
/**
* Migrate the consume field.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateConsume(source) {
if ( !("consume" in source) ) return;
source.consume ??= {};
if ( source.consume.type === null ) source.consume.type = "";
const amount = source.consume.amount;
if ( typeof amount === "string" ) {
if ( amount === "" ) source.consume.amount = null;
else if ( Number.isNumeric(amount) ) source.consume.amount = Number(amount);
}
}
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/**
* Chat properties for activated effects.
* @type {string[]}
*/
get activatedEffectChatProperties() {
return [
this.parent.labels.activation + (this.activation.condition ? ` (${this.activation.condition})` : ""),
this.parent.labels.target,
this.parent.labels.range,
this.parent.labels.duration
];
}
/* -------------------------------------------- */
/**
* Does the Item have an area of effect target?
* @type {boolean}
*/
get hasAreaTarget() {
return this.target.type in CONFIG.DND5E.areaTargetTypes;
}
/* -------------------------------------------- */
/**
* Does the Item target one or more distinct targets?
* @type {boolean}
*/
get hasIndividualTarget() {
return this.target.type in CONFIG.DND5E.individualTargetTypes;
}
/* -------------------------------------------- */
/**
* Is this Item limited in its ability to be used by charges or by recharge?
* @type {boolean}
*/
get hasLimitedUses() {
return !!this.uses.per && (this.uses.max > 0);
}
/* -------------------------------------------- */
/**
* Does the Item duration accept an associated numeric value or formula?
* @type {boolean}
*/
get hasScalarDuration() {
return this.duration.units in CONFIG.DND5E.scalarTimePeriods;
}
/* -------------------------------------------- */
/**
* Does the Item range accept an associated numeric value?
* @type {boolean}
*/
get hasScalarRange() {
return this.range.units in CONFIG.DND5E.movementUnits;
}
/* -------------------------------------------- */
/**
* Does the Item target accept an associated numeric value?
* @type {boolean}
*/
get hasScalarTarget() {
return ![null, "", "self"].includes(this.target.type);
}
/* -------------------------------------------- */
/**
* Does the Item have a target?
* @type {boolean}
*/
get hasTarget() {
return !["", null].includes(this.target.type);
}
}
/**
* Data model template with information on items that can be attuned and equipped.
*
* @property {number} attunement Attunement information as defined in `DND5E.attunementTypes`.
* @property {boolean} equipped Is this item equipped on its owning actor.
* @mixin
*/
class EquippableItemTemplate extends foundry.abstract.DataModel {
/** @inheritdoc */
static defineSchema() {
return {
attunement: new foundry.data.fields.NumberField({
required: true, integer: true, initial: CONFIG.DND5E.attunementTypes.NONE, label: "DND5E.Attunement"
}),
equipped: new foundry.data.fields.BooleanField({required: true, label: "DND5E.Equipped"})
};
}
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
EquippableItemTemplate.#migrateAttunement(source);
EquippableItemTemplate.#migrateEquipped(source);
}
/* -------------------------------------------- */
/**
* Migrate the item's attuned boolean to attunement string.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateAttunement(source) {
if ( (source.attuned === undefined) || (source.attunement !== undefined) ) return;
source.attunement = source.attuned ? CONFIG.DND5E.attunementTypes.ATTUNED : CONFIG.DND5E.attunementTypes.NONE;
}
/* -------------------------------------------- */
/**
* Migrate the equipped field.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateEquipped(source) {
if ( !("equipped" in source) ) return;
if ( (source.equipped === null) || (source.equipped === undefined) ) source.equipped = false;
}
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/**
* Chat properties for equippable items.
* @type {string[]}
*/
get equippableItemChatProperties() {
const req = CONFIG.DND5E.attunementTypes.REQUIRED;
return [
this.attunement === req ? CONFIG.DND5E.attunements[req] : null,
game.i18n.localize(this.equipped ? "DND5E.Equipped" : "DND5E.Unequipped"),
("proficient" in this) ? CONFIG.DND5E.proficiencyLevels[Number(this.proficient)] : null
];
}
}
/**
* Data model template with information on physical items.
*
* @property {number} quantity Number of items in a stack.
* @property {number} weight Item's weight in pounds or kilograms (depending on system setting).
* @property {object} price
* @property {number} price.value Item's cost in the specified denomination.
* @property {string} price.denomination Currency denomination used to determine price.
* @property {string} rarity Item rarity as defined in `DND5E.itemRarity`.
* @property {boolean} identified Has this item been identified?
* @mixin
*/
class PhysicalItemTemplate extends foundry.abstract.DataModel {
/** @inheritdoc */
static defineSchema() {
return {
quantity: new foundry.data.fields.NumberField({
required: true, nullable: false, integer: true, initial: 1, min: 0, label: "DND5E.Quantity"
}),
weight: new foundry.data.fields.NumberField({
required: true, nullable: false, initial: 0, min: 0, label: "DND5E.Weight"
}),
price: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
required: true, nullable: false, initial: 0, min: 0, label: "DND5E.Price"
}),
denomination: new foundry.data.fields.StringField({
required: true, blank: false, initial: "gp", label: "DND5E.Currency"
})
}, {label: "DND5E.Price"}),
rarity: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.Rarity"}),
identified: new foundry.data.fields.BooleanField({required: true, initial: true, label: "DND5E.Identified"})
};
}
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
PhysicalItemTemplate.#migratePrice(source);
PhysicalItemTemplate.#migrateRarity(source);
PhysicalItemTemplate.#migrateWeight(source);
}
/* -------------------------------------------- */
/**
* Migrate the item's price from a single field to an object with currency.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migratePrice(source) {
if ( !("price" in source) || foundry.utils.getType(source.price) === "Object" ) return;
source.price = {
value: Number.isNumeric(source.price) ? Number(source.price) : 0,
denomination: "gp"
};
}
/* -------------------------------------------- */
/**
* Migrate the item's rarity from freeform string to enum value.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateRarity(source) {
if ( !("rarity" in source) || CONFIG.DND5E.itemRarity[source.rarity] ) return;
source.rarity = Object.keys(CONFIG.DND5E.itemRarity).find(key =>
CONFIG.DND5E.itemRarity[key].toLowerCase() === source.rarity.toLowerCase()
) ?? "";
}
/* -------------------------------------------- */
/**
* Convert null weights to 0.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateWeight(source) {
if ( !("weight" in source) ) return;
if ( (source.weight === null) || (source.weight === undefined) ) source.weight = 0;
}
}
/**
* Data definition for Consumable items.
* @mixes ItemDescriptionTemplate
* @mixes PhysicalItemTemplate
* @mixes EquippableItemTemplate
* @mixes ActivatedEffectTemplate
* @mixes ActionTemplate
*
* @property {string} consumableType Type of consumable as defined in `DND5E.consumableTypes`.
* @property {object} uses
* @property {boolean} uses.autoDestroy Should this item be destroyed when it runs out of uses.
*/
class ConsumableData extends SystemDataModel.mixin(
ItemDescriptionTemplate, PhysicalItemTemplate, EquippableItemTemplate, ActivatedEffectTemplate, ActionTemplate
) {
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
consumableType: new foundry.data.fields.StringField({
required: true, initial: "potion", label: "DND5E.ItemConsumableType"
}),
uses: new ActivatedEffectTemplate.ItemUsesField({
autoDestroy: new foundry.data.fields.BooleanField({required: true, label: "DND5E.ItemDestroyEmpty"})
}, {label: "DND5E.LimitedUses"})
});
}
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/**
* Properties displayed in chat.
* @type {string[]}
*/
get chatProperties() {
return [
CONFIG.DND5E.consumableTypes[this.consumableType],
this.hasLimitedUses ? `${this.uses.value}/${this.uses.max} ${game.i18n.localize("DND5E.Charges")}` : null
];
}
/* -------------------------------------------- */
/** @inheritdoc */
get _typeAbilityMod() {
if ( this.consumableType !== "scroll" ) return null;
return this.parent?.actor?.system.attributes.spellcasting || "int";
}
}
/**
* Data definition for Backpack items.
* @mixes ItemDescriptionTemplate
* @mixes PhysicalItemTemplate
* @mixes EquippableItemTemplate
* @mixes CurrencyTemplate
*
* @property {object} capacity Information on container's carrying capacity.
* @property {string} capacity.type Method for tracking max capacity as defined in `DND5E.itemCapacityTypes`.
* @property {number} capacity.value Total amount of the type this container can carry.
* @property {boolean} capacity.weightless Does the weight of the items in the container carry over to the actor?
*/
class ContainerData extends SystemDataModel.mixin(
ItemDescriptionTemplate, PhysicalItemTemplate, EquippableItemTemplate, CurrencyTemplate
) {
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
capacity: new foundry.data.fields.SchemaField({
type: new foundry.data.fields.StringField({
required: true, initial: "weight", blank: false, label: "DND5E.ItemContainerCapacityType"
}),
value: new foundry.data.fields.NumberField({
required: true, min: 0, label: "DND5E.ItemContainerCapacityMax"
}),
weightless: new foundry.data.fields.BooleanField({required: true, label: "DND5E.ItemContainerWeightless"})
}, {label: "DND5E.ItemContainerCapacity"})
});
}
}
/**
* Data model template for equipment that can be mounted on a vehicle.
*
* @property {object} armor Equipment's armor class.
* @property {number} armor.value Armor class value for equipment.
* @property {object} hp Equipment's hit points.
* @property {number} hp.value Current hit point value.
* @property {number} hp.max Max hit points.
* @property {number} hp.dt Damage threshold.
* @property {string} hp.conditions Conditions that are triggered when this equipment takes damage.
* @mixin
*/
class MountableTemplate extends foundry.abstract.DataModel {
/** @inheritdoc */
static defineSchema() {
return {
armor: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
required: true, integer: true, min: 0, label: "DND5E.ArmorClass"
})
}, {label: "DND5E.ArmorClass"}),
hp: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
required: true, integer: true, min: 0, label: "DND5E.HitPointsCurrent"
}),
max: new foundry.data.fields.NumberField({
required: true, integer: true, min: 0, label: "DND5E.HitPointsMax"
}),
dt: new foundry.data.fields.NumberField({
required: true, integer: true, min: 0, label: "DND5E.DamageThreshold"
}),
conditions: new foundry.data.fields.StringField({required: true, label: "DND5E.HealthConditions"})
}, {label: "DND5E.HitPoints"})
};
}
}
/**
* Data definition for Equipment items.
* @mixes ItemDescriptionTemplate
* @mixes PhysicalItemTemplate
* @mixes EquippableItemTemplate
* @mixes ActivatedEffectTemplate
* @mixes ActionTemplate
* @mixes MountableTemplate
*
* @property {object} armor Armor details and equipment type information.
* @property {string} armor.type Equipment type as defined in `DND5E.equipmentTypes`.
* @property {number} armor.value Base armor class or shield bonus.
* @property {number} armor.dex Maximum dex bonus added to armor class.
* @property {string} baseItem Base armor as defined in `DND5E.armorIds` for determining proficiency.
* @property {object} speed Speed granted by a piece of vehicle equipment.
* @property {number} speed.value Speed granted by this piece of equipment measured in feet or meters
* depending on system setting.
* @property {string} speed.conditions Conditions that may affect item's speed.
* @property {number} strength Minimum strength required to use a piece of armor.
* @property {boolean} stealth Does this equipment grant disadvantage on stealth checks when used?
* @property {boolean} proficient Does the owner have proficiency in this piece of equipment?
*/
class EquipmentData extends SystemDataModel.mixin(
ItemDescriptionTemplate, PhysicalItemTemplate, EquippableItemTemplate,
ActivatedEffectTemplate, ActionTemplate, MountableTemplate
) {
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
armor: new foundry.data.fields.SchemaField({
type: new foundry.data.fields.StringField({
required: true, initial: "light", label: "DND5E.ItemEquipmentType"
}),
value: new foundry.data.fields.NumberField({required: true, integer: true, min: 0, label: "DND5E.ArmorClass"}),
dex: new foundry.data.fields.NumberField({required: true, integer: true, label: "DND5E.ItemEquipmentDexMod"})
}, {label: ""}),
baseItem: new foundry.data.fields.StringField({required: true, label: "DND5E.ItemEquipmentBase"}),
speed: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({required: true, min: 0, label: "DND5E.Speed"}),
conditions: new foundry.data.fields.StringField({required: true, label: "DND5E.SpeedConditions"})
}, {label: "DND5E.Speed"}),
strength: new foundry.data.fields.NumberField({
required: true, integer: true, min: 0, label: "DND5E.ItemRequiredStr"
}),
stealth: new foundry.data.fields.BooleanField({required: true, label: "DND5E.ItemEquipmentStealthDisav"}),
proficient: new foundry.data.fields.BooleanField({required: true, initial: true, label: "DND5E.Proficient"})
});
}
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
super.migrateData(source);
EquipmentData.#migrateArmor(source);
EquipmentData.#migrateStrength(source);
}
/* -------------------------------------------- */
/**
* Apply migrations to the armor field.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateArmor(source) {
if ( !("armor" in source) ) return;
source.armor ??= {};
if ( source.armor.type === "bonus" ) source.armor.type = "trinket";
if ( (typeof source.armor.dex === "string") ) {
const dex = source.armor.dex;
if ( dex === "" ) source.armor.dex = null;
else if ( Number.isNumeric(dex) ) source.armor.dex = Number(dex);
}
}
/* -------------------------------------------- */
/**
* Ensure blank strength values are migrated to null, and string values are converted to numbers.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateStrength(source) {
if ( typeof source.strength !== "string" ) return;
if ( source.strength === "" ) source.strength = null;
if ( Number.isNumeric(source.strength) ) source.strength = Number(source.strength);
}
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/**
* Properties displayed in chat.
* @type {string[]}
*/
get chatProperties() {
return [
CONFIG.DND5E.equipmentTypes[this.armor.type],
this.parent.labels?.armor ?? null,
this.stealth ? game.i18n.localize("DND5E.StealthDisadvantage") : null
];
}
/* -------------------------------------------- */
/**
* Is this Item any of the armor subtypes?
* @type {boolean}
*/
get isArmor() {
return this.armor.type in CONFIG.DND5E.armorTypes;
}
/* -------------------------------------------- */
/**
* Is this item a separate large object like a siege engine or vehicle component that is
* usually mounted on fixtures rather than equipped, and has its own AC and HP?
* @type {boolean}
*/
get isMountable() {
return this.armor.type === "vehicle";
}
}
/**
* Data definition for Feature items.
* @mixes ItemDescriptionTemplate
* @mixes ActivatedEffectTemplate
* @mixes ActionTemplate
*
* @property {object} type
* @property {string} type.value Category to which this feature belongs.
* @property {string} type.subtype Feature subtype according to its category.
* @property {string} requirements Actor details required to use this feature.
* @property {object} recharge Details on how a feature can roll for recharges.
* @property {number} recharge.value Minimum number needed to roll on a d6 to recharge this feature.
* @property {boolean} recharge.charged Does this feature have a charge remaining?
*/
class FeatData extends SystemDataModel.mixin(
ItemDescriptionTemplate, ActivatedEffectTemplate, ActionTemplate
) {
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
type: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.StringField({required: true, label: "DND5E.Type"}),
subtype: new foundry.data.fields.StringField({required: true, label: "DND5E.Subtype"})
}, {label: "DND5E.ItemFeatureType"}),
requirements: new foundry.data.fields.StringField({required: true, nullable: true, label: "DND5E.Requirements"}),
recharge: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.NumberField({
required: true, integer: true, min: 1, label: "DND5E.FeatureRechargeOn"
}),
charged: new foundry.data.fields.BooleanField({required: true, label: "DND5E.Charged"})
}, {label: "DND5E.FeatureActionRecharge"})
});
}
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
super.migrateData(source);
FeatData.#migrateType(source);
FeatData.#migrateRecharge(source);
}
/* -------------------------------------------- */
/**
* Ensure feats have a type object.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateType(source) {
if ( !("type" in source) ) return;
if ( !source.type ) source.type = {value: "", subtype: ""};
}
/* -------------------------------------------- */
/**
* Migrate 0 values to null.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateRecharge(source) {
if ( !("recharge" in source) ) return;
const value = source.recharge.value;
if ( (value === 0) || (value === "") ) source.recharge.value = null;
else if ( (typeof value === "string") && Number.isNumeric(value) ) source.recharge.value = Number(value);
if ( source.recharge.charged === null ) source.recharge.charged = false;
}
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/**
* Properties displayed in chat.
* @type {string[]}
*/
get chatProperties() {
return [this.requirements];
}
/* -------------------------------------------- */
/** @inheritdoc */
get hasLimitedUses() {
return !!this.recharge.value || super.hasLimitedUses;
}
}
/**
* Data definition for Loot items.
* @mixes ItemDescriptionTemplate
* @mixes PhysicalItemTemplate
*/
class LootData extends SystemDataModel.mixin(ItemDescriptionTemplate, PhysicalItemTemplate) {
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/**
* Properties displayed in chat.
* @type {string[]}
*/
get chatProperties() {
return [
game.i18n.localize(CONFIG.Item.typeLabels.loot),
this.weight ? `${this.weight} ${game.i18n.localize("DND5E.AbbreviationLbs")}` : null
];
}
}
/**
* Data definition for Spell items.
* @mixes ItemDescriptionTemplate
* @mixes ActivatedEffectTemplate
* @mixes ActionTemplate
*
* @property {number} level Base level of the spell.
* @property {string} school Magical school to which this spell belongs.
* @property {object} components General components and tags for this spell.
* @property {boolean} components.vocal Does this spell require vocal components?
* @property {boolean} components.somatic Does this spell require somatic components?
* @property {boolean} components.material Does this spell require material components?
* @property {boolean} components.ritual Can this spell be cast as a ritual?
* @property {boolean} components.concentration Does this spell require concentration?
* @property {object} materials Details on material components required for this spell.
* @property {string} materials.value Description of the material components required for casting.
* @property {boolean} materials.consumed Are these material components consumed during casting?
* @property {number} materials.cost GP cost for the required components.
* @property {number} materials.supply Quantity of this component available.
* @property {object} preparation Details on how this spell is prepared.
* @property {string} preparation.mode Spell preparation mode as defined in `DND5E.spellPreparationModes`.
* @property {boolean} preparation.prepared Is the spell currently prepared?
* @property {object} scaling Details on how casting at higher levels affects this spell.
* @property {string} scaling.mode Spell scaling mode as defined in `DND5E.spellScalingModes`.
* @property {string} scaling.formula Dice formula used for scaling.
*/
class SpellData extends SystemDataModel.mixin(
ItemDescriptionTemplate, ActivatedEffectTemplate, ActionTemplate
) {
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
level: new foundry.data.fields.NumberField({
required: true, integer: true, initial: 1, min: 0, label: "DND5E.SpellLevel"
}),
school: new foundry.data.fields.StringField({required: true, label: "DND5E.SpellSchool"}),
components: new MappingField(new foundry.data.fields.BooleanField(), {
required: true, label: "DND5E.SpellComponents",
initialKeys: [...Object.keys(CONFIG.DND5E.spellComponents), ...Object.keys(CONFIG.DND5E.spellTags)]
}),
materials: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.StringField({required: true, label: "DND5E.SpellMaterialsDescription"}),
consumed: new foundry.data.fields.BooleanField({required: true, label: "DND5E.SpellMaterialsConsumed"}),
cost: new foundry.data.fields.NumberField({
required: true, initial: 0, min: 0, label: "DND5E.SpellMaterialsCost"
}),
supply: new foundry.data.fields.NumberField({
required: true, initial: 0, min: 0, label: "DND5E.SpellMaterialsSupply"
})
}, {label: "DND5E.SpellMaterials"}),
preparation: new foundry.data.fields.SchemaField({
mode: new foundry.data.fields.StringField({
required: true, initial: "prepared", label: "DND5E.SpellPreparationMode"
}),
prepared: new foundry.data.fields.BooleanField({required: true, label: "DND5E.SpellPrepared"})
}, {label: "DND5E.SpellPreparation"}),
scaling: new foundry.data.fields.SchemaField({
mode: new foundry.data.fields.StringField({required: true, initial: "none", label: "DND5E.ScalingMode"}),
formula: new FormulaField({required: true, nullable: true, initial: null, label: "DND5E.ScalingFormula"})
}, {label: "DND5E.LevelScaling"})
});
}
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
super.migrateData(source);
SpellData.#migrateComponentData(source);
SpellData.#migrateScaling(source);
}
/* -------------------------------------------- */
/**
* Migrate the spell's component object to remove any old, non-boolean values.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateComponentData(source) {
if ( !source.components ) return;
for ( const [key, value] of Object.entries(source.components) ) {
if ( typeof value !== "boolean" ) delete source.components[key];
}
}
/* -------------------------------------------- */
/**
* Migrate spell scaling.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateScaling(source) {
if ( !("scaling" in source) ) return;
if ( (source.scaling.mode === "") || (source.scaling.mode === null) ) source.scaling.mode = "none";
}
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/**
* Properties displayed in chat.
* @type {string[]}
*/
get chatProperties() {
return [
this.parent.labels.level,
this.parent.labels.components.vsm + (this.parent.labels.materials ? ` (${this.parent.labels.materials})` : ""),
...this.parent.labels.components.tags
];
}
/* -------------------------------------------- */
/** @inheritdoc */
get _typeAbilityMod() {
return this.parent?.actor?.system.attributes.spellcasting || "int";
}
/* -------------------------------------------- */
/** @inheritdoc */
get _typeCriticalThreshold() {
return this.parent?.actor?.flags.dnd5e?.spellCriticalThreshold ?? Infinity;
}
}
/**
* Data definition for Subclass items.
* @mixes ItemDescriptionTemplate
*
* @property {string} identifier Identifier slug for this subclass.
* @property {string} classIdentifier Identifier slug for the class with which this subclass should be associated.
* @property {object[]} advancement Advancement objects for this subclass.
* @property {object} spellcasting Details on subclass's spellcasting ability.
* @property {string} spellcasting.progression Spell progression granted by class as from `DND5E.spellProgression`.
* @property {string} spellcasting.ability Ability score to use for spellcasting.
*/
class SubclassData extends SystemDataModel.mixin(ItemDescriptionTemplate) {
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
identifier: new IdentifierField({required: true, label: "DND5E.Identifier"}),
classIdentifier: new IdentifierField({
required: true, label: "DND5E.ClassIdentifier", hint: "DND5E.ClassIdentifierHint"
}),
advancement: new foundry.data.fields.ArrayField(new AdvancementField(), {label: "DND5E.AdvancementTitle"}),
spellcasting: new foundry.data.fields.SchemaField({
progression: new foundry.data.fields.StringField({
required: true, initial: "none", blank: false, label: "DND5E.SpellProgression"
}),
ability: new foundry.data.fields.StringField({required: true, label: "DND5E.SpellAbility"})
}, {label: "DND5E.Spellcasting"})
});
}
}
/**
* Data definition for Tool items.
* @mixes ItemDescriptionTemplate
* @mixes PhysicalItemTemplate
* @mixes EquippableItemTemplate
*
* @property {string} toolType Tool category as defined in `DND5E.toolTypes`.
* @property {string} baseItem Base tool as defined in `DND5E.toolIds` for determining proficiency.
* @property {string} ability Default ability when this tool is being used.
* @property {string} chatFlavor Additional text added to chat when this tool is used.
* @property {number} proficient Level of proficiency in this tool as defined in `DND5E.proficiencyLevels`.
* @property {string} bonus Bonus formula added to tool rolls.
*/
class ToolData extends SystemDataModel.mixin(
ItemDescriptionTemplate, PhysicalItemTemplate, EquippableItemTemplate
) {
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
toolType: new foundry.data.fields.StringField({required: true, label: "DND5E.ItemToolType"}),
baseItem: new foundry.data.fields.StringField({required: true, label: "DND5E.ItemToolBase"}),
ability: new foundry.data.fields.StringField({
required: true, blank: true, label: "DND5E.DefaultAbilityCheck"
}),
chatFlavor: new foundry.data.fields.StringField({required: true, label: "DND5E.ChatFlavor"}),
proficient: new foundry.data.fields.NumberField({
required: true, nullable: false, initial: 0, min: 0, label: "DND5E.ItemToolProficiency"
}),
bonus: new FormulaField({required: true, label: "DND5E.ItemToolBonus"})
});
}
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
super.migrateData(source);
ToolData.#migrateAbility(source);
}
/* -------------------------------------------- */
/**
* Migrate the ability field.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateAbility(source) {
if ( Array.isArray(source.ability) ) source.ability = source.ability[0];
}
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/**
* Properties displayed in chat.
* @type {string[]}
*/
get chatProperties() {
return [CONFIG.DND5E.abilities[this.ability]?.label];
}
/* -------------------------------------------- */
/**
* Which ability score modifier is used by this item?
* @type {string|null}
*/
get abilityMod() {
return this.ability || "int";
}
}
/**
* Data definition for Weapon items.
* @mixes ItemDescriptionTemplate
* @mixes PhysicalItemTemplate
* @mixes EquippableItemTemplate
* @mixes ActivatedEffectTemplate
* @mixes ActionTemplate
* @mixes MountableTemplate
*
* @property {string} weaponType Weapon category as defined in `DND5E.weaponTypes`.
* @property {string} baseItem Base weapon as defined in `DND5E.weaponIds` for determining proficiency.
* @property {object} properties Mapping of various weapon property booleans.
* @property {boolean} proficient Does the weapon's owner have proficiency?
*/
class WeaponData extends SystemDataModel.mixin(
ItemDescriptionTemplate, PhysicalItemTemplate, EquippableItemTemplate,
ActivatedEffectTemplate, ActionTemplate, MountableTemplate
) {
/** @inheritdoc */
static defineSchema() {
return this.mergeSchema(super.defineSchema(), {
weaponType: new foundry.data.fields.StringField({
required: true, initial: "simpleM", label: "DND5E.ItemWeaponType"
}),
baseItem: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.ItemWeaponBase"}),
properties: new MappingField(new foundry.data.fields.BooleanField(), {
required: true, initialKeys: CONFIG.DND5E.weaponProperties, label: "DND5E.ItemWeaponProperties"
}),
proficient: new foundry.data.fields.BooleanField({required: true, initial: true, label: "DND5E.Proficient"})
});
}
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
super.migrateData(source);
WeaponData.#migratePropertiesData(source);
WeaponData.#migrateProficient(source);
WeaponData.#migrateWeaponType(source);
}
/* -------------------------------------------- */
/**
* Migrate the weapons's properties object to remove any old, non-boolean values.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migratePropertiesData(source) {
if ( !source.properties ) return;
for ( const [key, value] of Object.entries(source.properties) ) {
if ( typeof value !== "boolean" ) delete source.properties[key];
}
}
/* -------------------------------------------- */
/**
* Migrate the proficient field to remove non-boolean values.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateProficient(source) {
if ( typeof source.proficient === "number" ) source.proficient = Boolean(source.proficient);
}
/* -------------------------------------------- */
/**
* Migrate the weapon type.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateWeaponType(source) {
if ( source.weaponType === null ) source.weaponType = "simpleM";
}
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/**
* Properties displayed in chat.
* @type {string[]}
*/
get chatProperties() {
return [CONFIG.DND5E.weaponTypes[this.weaponType]];
}
/* -------------------------------------------- */
/** @inheritdoc */
get _typeAbilityMod() {
if ( ["simpleR", "martialR"].includes(this.weaponType) ) return "dex";
const abilities = this.parent?.actor?.system.abilities;
if ( this.properties.fin && abilities ) {
return (abilities.dex?.mod ?? 0) >= (abilities.str?.mod ?? 0) ? "dex" : "str";
}
return null;
}
/* -------------------------------------------- */
/** @inheritdoc */
get _typeCriticalThreshold() {
return this.parent?.actor?.flags.dnd5e?.weaponCriticalThreshold ?? Infinity;
}
/* -------------------------------------------- */
/**
* Is this item a separate large object like a siege engine or vehicle component that is
* usually mounted on fixtures rather than equipped, and has its own AC and HP?
* @type {boolean}
*/
get isMountable() {
return this.weaponType === "siege";
}
}
const config$1 = {
background: BackgroundData,
backpack: ContainerData,
class: ClassData,
consumable: ConsumableData,
equipment: EquipmentData,
feat: FeatData,
loot: LootData,
spell: SpellData,
subclass: SubclassData,
tool: ToolData,
weapon: WeaponData
};
var _module$2 = /*#__PURE__*/Object.freeze({
__proto__: null,
ActionTemplate: ActionTemplate,
ActivatedEffectTemplate: ActivatedEffectTemplate,
BackgroundData: BackgroundData,
ClassData: ClassData,
ConsumableData: ConsumableData,
ContainerData: ContainerData,
EquipmentData: EquipmentData,
EquippableItemTemplate: EquippableItemTemplate,
FeatData: FeatData,
ItemDescriptionTemplate: ItemDescriptionTemplate,
LootData: LootData,
MountableTemplate: MountableTemplate,
PhysicalItemTemplate: PhysicalItemTemplate,
SpellData: SpellData,
SubclassData: SubclassData,
ToolData: ToolData,
WeaponData: WeaponData,
config: config$1
});
/**
* Data definition for Class Summary journal entry pages.
*
* @property {string} item UUID of the class item included.
* @property {object} description
* @property {string} description.value Introductory description for the class.
* @property {string} description.additionalHitPoints Additional text displayed beneath the hit points section.
* @property {string} description.additionalTraits Additional text displayed beneath the traits section.
* @property {string} description.additionalEquipment Additional text displayed beneath the equipment section.
* @property {string} description.subclass Introduction to the subclass section.
* @property {string} subclassHeader Subclass header to replace the default.
* @property {Set} subclassItems UUIDs of all subclasses to display.
*/
class ClassJournalPageData extends foundry.abstract.DataModel {
static defineSchema() {
return {
item: new foundry.data.fields.StringField({required: true, label: "JOURNALENTRYPAGE.DND5E.Class.Item"}),
description: new foundry.data.fields.SchemaField({
value: new foundry.data.fields.HTMLField({
label: "JOURNALENTRYPAGE.DND5E.Class.Description",
hint: "JOURNALENTRYPAGE.DND5E.Class.DescriptionHint"
}),
additionalHitPoints: new foundry.data.fields.HTMLField({
label: "JOURNALENTRYPAGE.DND5E.Class.AdditionalHitPoints",
hint: "JOURNALENTRYPAGE.DND5E.Class.AdditionalHitPointsHint"
}),
additionalTraits: new foundry.data.fields.HTMLField({
label: "JOURNALENTRYPAGE.DND5E.Class.AdditionalTraits",
hint: "JOURNALENTRYPAGE.DND5E.Class.AdditionalTraitsHint"
}),
additionalEquipment: new foundry.data.fields.HTMLField({
label: "JOURNALENTRYPAGE.DND5E.Class.AdditionalEquipment",
hint: "JOURNALENTRYPAGE.DND5E.Class.AdditionalEquipmentHint"
}),
subclass: new foundry.data.fields.HTMLField({
label: "JOURNALENTRYPAGE.DND5E.Class.SubclassDescription",
hint: "JOURNALENTRYPAGE.DND5E.Class.SubclassDescriptionHint"
})
}),
subclassHeader: new foundry.data.fields.StringField({
label: "JOURNALENTRYPAGE.DND5E.Class.SubclassHeader"
}),
subclassItems: new foundry.data.fields.SetField(new foundry.data.fields.StringField(), {
label: "JOURNALENTRYPAGE.DND5E.Class.SubclassItems"
})
};
}
}
const config = {
class: ClassJournalPageData
};
var _module$1 = /*#__PURE__*/Object.freeze({
__proto__: null,
ClassJournalPageData: ClassJournalPageData,
config: config
});
var _module = /*#__PURE__*/Object.freeze({
__proto__: null,
CurrencyTemplate: CurrencyTemplate
});
var dataModels = /*#__PURE__*/Object.freeze({
__proto__: null,
SystemDataModel: SystemDataModel,
actor: _module$4,
advancement: _module$3,
fields: fields,
item: _module$2,
journal: _module$1,
shared: _module
});
/**
* A type of Roll specific to a d20-based check, save, or attack roll in the 5e system.
* @param {string} formula The string formula to parse
* @param {object} data The data object against which to parse attributes within the formula
* @param {object} [options={}] Extra optional arguments which describe or modify the D20Roll
* @param {number} [options.advantageMode] What advantage modifier to apply to the roll (none, advantage,
* disadvantage)
* @param {number} [options.critical] The value of d20 result which represents a critical success
* @param {number} [options.fumble] The value of d20 result which represents a critical failure
* @param {(number)} [options.targetValue] Assign a target value against which the result of this roll should be
* compared
* @param {boolean} [options.elvenAccuracy=false] Allow Elven Accuracy to modify this roll?
* @param {boolean} [options.halflingLucky=false] Allow Halfling Luck to modify this roll?
* @param {boolean} [options.reliableTalent=false] Allow Reliable Talent to modify this roll?
*/
class D20Roll extends Roll {
constructor(formula, data, options) {
super(formula, data, options);
if ( !this.options.configured ) this.configureModifiers();
}
/* -------------------------------------------- */
/**
* Create a D20Roll from a standard Roll instance.
* @param {Roll} roll
* @returns {D20Roll}
*/
static fromRoll(roll) {
const newRoll = new this(roll.formula, roll.data, roll.options);
Object.assign(newRoll, roll);
return newRoll;
}
/* -------------------------------------------- */
/**
* Determine whether a d20 roll should be fast-forwarded, and whether advantage or disadvantage should be applied.
* @param {object} [options]
* @param {Event} [options.event] The Event that triggered the roll.
* @param {boolean} [options.advantage] Is something granting this roll advantage?
* @param {boolean} [options.disadvantage] Is something granting this roll disadvantage?
* @param {boolean} [options.fastForward] Should the roll dialog be skipped?
* @returns {{advantageMode: D20Roll.ADV_MODE, isFF: boolean}} Whether the roll is fast-forwarded, and its advantage
* mode.
*/
static determineAdvantageMode({event, advantage=false, disadvantage=false, fastForward}={}) {
const isFF = fastForward ?? (event?.shiftKey || event?.altKey || event?.ctrlKey || event?.metaKey);
let advantageMode = this.ADV_MODE.NORMAL;
if ( advantage || event?.altKey ) advantageMode = this.ADV_MODE.ADVANTAGE;
else if ( disadvantage || event?.ctrlKey || event?.metaKey ) advantageMode = this.ADV_MODE.DISADVANTAGE;
return {isFF: !!isFF, advantageMode};
}
/* -------------------------------------------- */
/**
* Advantage mode of a 5e d20 roll
* @enum {number}
*/
static ADV_MODE = {
NORMAL: 0,
ADVANTAGE: 1,
DISADVANTAGE: -1
}
/* -------------------------------------------- */
/**
* The HTML template path used to configure evaluation of this Roll
* @type {string}
*/
static EVALUATION_TEMPLATE = "systems/dnd5e/templates/chat/roll-dialog.hbs";
/* -------------------------------------------- */
/**
* Does this roll start with a d20?
* @type {boolean}
*/
get validD20Roll() {
return (this.terms[0] instanceof Die) && (this.terms[0].faces === 20);
}
/* -------------------------------------------- */
/**
* A convenience reference for whether this D20Roll has advantage
* @type {boolean}
*/
get hasAdvantage() {
return this.options.advantageMode === D20Roll.ADV_MODE.ADVANTAGE;
}
/* -------------------------------------------- */
/**
* A convenience reference for whether this D20Roll has disadvantage
* @type {boolean}
*/
get hasDisadvantage() {
return this.options.advantageMode === D20Roll.ADV_MODE.DISADVANTAGE;
}
/* -------------------------------------------- */
/**
* Is this roll a critical success? Returns undefined if roll isn't evaluated.
* @type {boolean|void}
*/
get isCritical() {
if ( !this.validD20Roll || !this._evaluated ) return undefined;
if ( !Number.isNumeric(this.options.critical) ) return false;
return this.dice[0].total >= this.options.critical;
}
/* -------------------------------------------- */
/**
* Is this roll a critical failure? Returns undefined if roll isn't evaluated.
* @type {boolean|void}
*/
get isFumble() {
if ( !this.validD20Roll || !this._evaluated ) return undefined;
if ( !Number.isNumeric(this.options.fumble) ) return false;
return this.dice[0].total <= this.options.fumble;
}
/* -------------------------------------------- */
/* D20 Roll Methods */
/* -------------------------------------------- */
/**
* Apply optional modifiers which customize the behavior of the d20term
* @private
*/
configureModifiers() {
if ( !this.validD20Roll ) return;
const d20 = this.terms[0];
d20.modifiers = [];
// Halfling Lucky
if ( this.options.halflingLucky ) d20.modifiers.push("r1=1");
// Reliable Talent
if ( this.options.reliableTalent ) d20.modifiers.push("min10");
// Handle Advantage or Disadvantage
if ( this.hasAdvantage ) {
d20.number = this.options.elvenAccuracy ? 3 : 2;
d20.modifiers.push("kh");
d20.options.advantage = true;
}
else if ( this.hasDisadvantage ) {
d20.number = 2;
d20.modifiers.push("kl");
d20.options.disadvantage = true;
}
else d20.number = 1;
// Assign critical and fumble thresholds
if ( this.options.critical ) d20.options.critical = this.options.critical;
if ( this.options.fumble ) d20.options.fumble = this.options.fumble;
if ( this.options.targetValue ) d20.options.target = this.options.targetValue;
// Re-compile the underlying formula
this._formula = this.constructor.getFormula(this.terms);
// Mark configuration as complete
this.options.configured = true;
}
/* -------------------------------------------- */
/** @inheritdoc */
async toMessage(messageData={}, options={}) {
// Evaluate the roll now so we have the results available to determine whether reliable talent came into play
if ( !this._evaluated ) await this.evaluate({async: true});
// Add appropriate advantage mode message flavor and dnd5e roll flags
messageData.flavor = messageData.flavor || this.options.flavor;
if ( this.hasAdvantage ) messageData.flavor += ` (${game.i18n.localize("DND5E.Advantage")})`;
else if ( this.hasDisadvantage ) messageData.flavor += ` (${game.i18n.localize("DND5E.Disadvantage")})`;
// Add reliable talent to the d20-term flavor text if it applied
if ( this.validD20Roll && this.options.reliableTalent ) {
const d20 = this.dice[0];
const isRT = d20.results.every(r => !r.active || (r.result < 10));
const label = `(${game.i18n.localize("DND5E.FlagsReliableTalent")})`;
if ( isRT ) d20.options.flavor = d20.options.flavor ? `${d20.options.flavor} (${label})` : label;
}
// Record the preferred rollMode
options.rollMode = options.rollMode ?? this.options.rollMode;
return super.toMessage(messageData, options);
}
/* -------------------------------------------- */
/* Configuration Dialog */
/* -------------------------------------------- */
/**
* Create a Dialog prompt used to configure evaluation of an existing D20Roll instance.
* @param {object} data Dialog configuration data
* @param {string} [data.title] The title of the shown dialog window
* @param {number} [data.defaultRollMode] The roll mode that the roll mode select element should default to
* @param {number} [data.defaultAction] The button marked as default
* @param {boolean} [data.chooseModifier] Choose which ability modifier should be applied to the roll?
* @param {string} [data.defaultAbility] For tool rolls, the default ability modifier applied to the roll
* @param {string} [data.template] A custom path to an HTML template to use instead of the default
* @param {object} options Additional Dialog customization options
* @returns {Promise} A resulting D20Roll object constructed with the dialog, or null if the
* dialog was closed
*/
async configureDialog({title, defaultRollMode, defaultAction=D20Roll.ADV_MODE.NORMAL, chooseModifier=false,
defaultAbility, template}={}, options={}) {
// Render the Dialog inner HTML
const content = await renderTemplate(template ?? this.constructor.EVALUATION_TEMPLATE, {
formula: `${this.formula} + @bonus`,
defaultRollMode,
rollModes: CONFIG.Dice.rollModes,
chooseModifier,
defaultAbility,
abilities: CONFIG.DND5E.abilities
});
let defaultButton = "normal";
switch ( defaultAction ) {
case D20Roll.ADV_MODE.ADVANTAGE: defaultButton = "advantage"; break;
case D20Roll.ADV_MODE.DISADVANTAGE: defaultButton = "disadvantage"; break;
}
// Create the Dialog window and await submission of the form
return new Promise(resolve => {
new Dialog({
title,
content,
buttons: {
advantage: {
label: game.i18n.localize("DND5E.Advantage"),
callback: html => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.ADVANTAGE))
},
normal: {
label: game.i18n.localize("DND5E.Normal"),
callback: html => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.NORMAL))
},
disadvantage: {
label: game.i18n.localize("DND5E.Disadvantage"),
callback: html => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.DISADVANTAGE))
}
},
default: defaultButton,
close: () => resolve(null)
}, options).render(true);
});
}
/* -------------------------------------------- */
/**
* Handle submission of the Roll evaluation configuration Dialog
* @param {jQuery} html The submitted dialog content
* @param {number} advantageMode The chosen advantage mode
* @returns {D20Roll} This damage roll.
* @private
*/
_onDialogSubmit(html, advantageMode) {
const form = html[0].querySelector("form");
// Append a situational bonus term
if ( form.bonus.value ) {
const bonus = new Roll(form.bonus.value, this.data);
if ( !(bonus.terms[0] instanceof OperatorTerm) ) this.terms.push(new OperatorTerm({operator: "+"}));
this.terms = this.terms.concat(bonus.terms);
}
// Customize the modifier
if ( form.ability?.value ) {
const abl = this.data.abilities[form.ability.value];
this.terms = this.terms.flatMap(t => {
if ( t.term === "@mod" ) return new NumericTerm({number: abl.mod});
if ( t.term === "@abilityCheckBonus" ) {
const bonus = abl.bonuses?.check;
if ( bonus ) return new Roll(bonus, this.data).terms;
return new NumericTerm({number: 0});
}
return t;
});
this.options.flavor += ` (${CONFIG.DND5E.abilities[form.ability.value]?.label ?? ""})`;
}
// Apply advantage or disadvantage
this.options.advantageMode = advantageMode;
this.options.rollMode = form.rollMode.value;
this.configureModifiers();
return this;
}
}
/**
* A type of Roll specific to a damage (or healing) roll in the 5e system.
* @param {string} formula The string formula to parse
* @param {object} data The data object against which to parse attributes within the formula
* @param {object} [options={}] Extra optional arguments which describe or modify the DamageRoll
* @param {number} [options.criticalBonusDice=0] A number of bonus damage dice that are added for critical hits
* @param {number} [options.criticalMultiplier=2] A critical hit multiplier which is applied to critical hits
* @param {boolean} [options.multiplyNumeric=false] Multiply numeric terms by the critical multiplier
* @param {boolean} [options.powerfulCritical=false] Apply the "powerful criticals" house rule to critical hits
* @param {string} [options.criticalBonusDamage] An extra damage term that is applied only on a critical hit
*/
class DamageRoll extends Roll {
constructor(formula, data, options) {
super(formula, data, options);
if ( !this.options.preprocessed ) this.preprocessFormula();
// For backwards compatibility, skip rolls which do not have the "critical" option defined
if ( (this.options.critical !== undefined) && !this.options.configured ) this.configureDamage();
}
/* -------------------------------------------- */
/**
* Create a DamageRoll from a standard Roll instance.
* @param {Roll} roll
* @returns {DamageRoll}
*/
static fromRoll(roll) {
const newRoll = new this(roll.formula, roll.data, roll.options);
Object.assign(newRoll, roll);
return newRoll;
}
/* -------------------------------------------- */
/**
* The HTML template path used to configure evaluation of this Roll
* @type {string}
*/
static EVALUATION_TEMPLATE = "systems/dnd5e/templates/chat/roll-dialog.hbs";
/* -------------------------------------------- */
/**
* A convenience reference for whether this DamageRoll is a critical hit
* @type {boolean}
*/
get isCritical() {
return this.options.critical;
}
/* -------------------------------------------- */
/* Damage Roll Methods */
/* -------------------------------------------- */
/**
* Perform any term-merging required to ensure that criticals can be calculated successfully.
* @protected
*/
preprocessFormula() {
for ( let [i, term] of this.terms.entries() ) {
const nextTerm = this.terms[i + 1];
const prevTerm = this.terms[i - 1];
// Convert shorthand dX terms to 1dX preemptively to allow them to be appropriately doubled for criticals
if ( (term instanceof StringTerm) && /^d\d+/.test(term.term) && !(prevTerm instanceof ParentheticalTerm) ) {
const formula = `1${term.term}`;
const newTerm = new Roll(formula).terms[0];
this.terms.splice(i, 1, newTerm);
term = newTerm;
}
// Merge parenthetical terms that follow string terms to build a dice term (to allow criticals)
else if ( (term instanceof ParentheticalTerm) && (prevTerm instanceof StringTerm)
&& prevTerm.term.match(/^[0-9]*d$/)) {
if ( term.isDeterministic ) {
let newFormula = `${prevTerm.term}${term.evaluate().total}`;
let deleteCount = 2;
// Merge in any roll modifiers
if ( nextTerm instanceof StringTerm ) {
newFormula += nextTerm.term;
deleteCount += 1;
}
const newTerm = (new Roll(newFormula)).terms[0];
this.terms.splice(i - 1, deleteCount, newTerm);
term = newTerm;
}
}
// Merge any parenthetical terms followed by string terms
else if ( (term instanceof ParentheticalTerm || term instanceof MathTerm) && (nextTerm instanceof StringTerm)
&& nextTerm.term.match(/^d[0-9]*$/)) {
if ( term.isDeterministic ) {
const newFormula = `${term.evaluate().total}${nextTerm.term}`;
const newTerm = (new Roll(newFormula)).terms[0];
this.terms.splice(i, 2, newTerm);
term = newTerm;
}
}
}
// Re-compile the underlying formula
this._formula = this.constructor.getFormula(this.terms);
// Mark configuration as complete
this.options.preprocessed = true;
}
/* -------------------------------------------- */
/**
* Apply optional modifiers which customize the behavior of the d20term.
* @protected
*/
configureDamage() {
let flatBonus = 0;
for ( let [i, term] of this.terms.entries() ) {
// Multiply dice terms
if ( term instanceof DiceTerm ) {
term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back
term.number = term.options.baseNumber;
if ( this.isCritical ) {
let cm = this.options.criticalMultiplier ?? 2;
// Powerful critical - maximize damage and reduce the multiplier by 1
if ( this.options.powerfulCritical ) {
flatBonus += (term.number * term.faces);
cm = Math.max(1, cm-1);
}
// Alter the damage term
let cb = (this.options.criticalBonusDice && (i === 0)) ? this.options.criticalBonusDice : 0;
term.alter(cm, cb);
term.options.critical = true;
}
}
// Multiply numeric terms
else if ( this.options.multiplyNumeric && (term instanceof NumericTerm) ) {
term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back
term.number = term.options.baseNumber;
if ( this.isCritical ) {
term.number *= (this.options.criticalMultiplier ?? 2);
term.options.critical = true;
}
}
}
// Add powerful critical bonus
if ( this.options.powerfulCritical && (flatBonus > 0) ) {
this.terms.push(new OperatorTerm({operator: "+"}));
this.terms.push(new NumericTerm({number: flatBonus}, {flavor: game.i18n.localize("DND5E.PowerfulCritical")}));
}
// Add extra critical damage term
if ( this.isCritical && this.options.criticalBonusDamage ) {
const extra = new Roll(this.options.criticalBonusDamage, this.data);
if ( !(extra.terms[0] instanceof OperatorTerm) ) this.terms.push(new OperatorTerm({operator: "+"}));
this.terms.push(...extra.terms);
}
// Re-compile the underlying formula
this._formula = this.constructor.getFormula(this.terms);
// Mark configuration as complete
this.options.configured = true;
}
/* -------------------------------------------- */
/** @inheritdoc */
toMessage(messageData={}, options={}) {
messageData.flavor = messageData.flavor || this.options.flavor;
if ( this.isCritical ) {
const label = game.i18n.localize("DND5E.CriticalHit");
messageData.flavor = messageData.flavor ? `${messageData.flavor} (${label})` : label;
}
options.rollMode = options.rollMode ?? this.options.rollMode;
return super.toMessage(messageData, options);
}
/* -------------------------------------------- */
/* Configuration Dialog */
/* -------------------------------------------- */
/**
* Create a Dialog prompt used to configure evaluation of an existing D20Roll instance.
* @param {object} data Dialog configuration data
* @param {string} [data.title] The title of the shown dialog window
* @param {number} [data.defaultRollMode] The roll mode that the roll mode select element should default to
* @param {string} [data.defaultCritical] Should critical be selected as default
* @param {string} [data.template] A custom path to an HTML template to use instead of the default
* @param {boolean} [data.allowCritical=true] Allow critical hit to be chosen as a possible damage mode
* @param {object} options Additional Dialog customization options
* @returns {Promise} A resulting D20Roll object constructed with the dialog, or null if the
* dialog was closed
*/
async configureDialog({title, defaultRollMode, defaultCritical=false, template, allowCritical=true}={}, options={}) {
// Render the Dialog inner HTML
const content = await renderTemplate(template ?? this.constructor.EVALUATION_TEMPLATE, {
formula: `${this.formula} + @bonus`,
defaultRollMode,
rollModes: CONFIG.Dice.rollModes
});
// Create the Dialog window and await submission of the form
return new Promise(resolve => {
new Dialog({
title,
content,
buttons: {
critical: {
condition: allowCritical,
label: game.i18n.localize("DND5E.CriticalHit"),
callback: html => resolve(this._onDialogSubmit(html, true))
},
normal: {
label: game.i18n.localize(allowCritical ? "DND5E.Normal" : "DND5E.Roll"),
callback: html => resolve(this._onDialogSubmit(html, false))
}
},
default: defaultCritical ? "critical" : "normal",
close: () => resolve(null)
}, options).render(true);
});
}
/* -------------------------------------------- */
/**
* Handle submission of the Roll evaluation configuration Dialog
* @param {jQuery} html The submitted dialog content
* @param {boolean} isCritical Is the damage a critical hit?
* @returns {DamageRoll} This damage roll.
* @private
*/
_onDialogSubmit(html, isCritical) {
const form = html[0].querySelector("form");
// Append a situational bonus term
if ( form.bonus.value ) {
const bonus = new DamageRoll(form.bonus.value, this.data);
if ( !(bonus.terms[0] instanceof OperatorTerm) ) this.terms.push(new OperatorTerm({operator: "+"}));
this.terms = this.terms.concat(bonus.terms);
}
// Apply advantage or disadvantage
this.options.critical = isCritical;
this.options.rollMode = form.rollMode.value;
this.configureDamage();
return this;
}
/* -------------------------------------------- */
/** @inheritdoc */
static fromData(data) {
const roll = super.fromData(data);
roll._formula = this.getFormula(roll.terms);
return roll;
}
}
var dice = /*#__PURE__*/Object.freeze({
__proto__: null,
D20Roll: D20Roll,
DamageRoll: DamageRoll,
d20Roll: d20Roll,
damageRoll: damageRoll,
simplifyRollFormula: simplifyRollFormula
});
/**
* Extend the base TokenDocument class to implement system-specific HP bar logic.
*/
class TokenDocument5e extends TokenDocument {
/** @inheritdoc */
getBarAttribute(...args) {
const data = super.getBarAttribute(...args);
if ( data && (data.attribute === "attributes.hp") ) {
const hp = this.actor.system.attributes.hp || {};
data.value += (hp.temp || 0);
data.max = Math.max(0, data.max + (hp.tempmax || 0));
}
return data;
}
/* -------------------------------------------- */
/** @inheritdoc */
static getTrackedAttributes(data, _path=[]) {
if ( !game.dnd5e.isV10 ) return super.getTrackedAttributes(data, _path);
if ( data instanceof foundry.abstract.DataModel ) return this._getTrackedAttributesFromSchema(data.schema, _path);
const attributes = super.getTrackedAttributes(data, _path);
if ( _path.length ) return attributes;
const allowed = CONFIG.DND5E.trackableAttributes;
attributes.value = attributes.value.filter(attrs => this._isAllowedAttribute(allowed, attrs));
return attributes;
}
/* -------------------------------------------- */
/** @inheritdoc */
static _getTrackedAttributesFromSchema(schema, _path=[]) {
const isSchema = field => field instanceof foundry.data.fields.SchemaField;
const isModel = field => field instanceof foundry.data.fields.EmbeddedDataField;
const attributes = {bar: [], value: []};
for ( const [name, field] of Object.entries(schema.fields) ) {
const p = _path.concat([name]);
if ( field instanceof foundry.data.fields.NumberField ) attributes.value.push(p);
if ( isSchema(field) || isModel(field) ) {
const schema = isModel(field) ? field.model.schema : field;
const isBar = schema.has("value") && schema.has("max");
if ( isBar ) attributes.bar.push(p);
else {
const inner = this._getTrackedAttributesFromSchema(schema, p);
attributes.bar.push(...inner.bar);
attributes.value.push(...inner.value);
}
}
if ( !(field instanceof MappingField) ) continue;
if ( !field.initialKeys || foundry.utils.isEmpty(field.initialKeys) ) continue;
if ( !isSchema(field.model) && !isModel(field.model) ) continue;
const keys = Array.isArray(field.initialKeys) ? field.initialKeys : Object.keys(field.initialKeys);
for ( const key of keys ) {
const inner = this._getTrackedAttributesFromSchema(field.model, p.concat([key]));
attributes.bar.push(...inner.bar);
attributes.value.push(...inner.value);
}
}
return attributes;
}
/* -------------------------------------------- */
/**
* Get an Array of attribute choices which are suitable for being consumed by an item usage.
* @param {object} data The actor data.
* @returns {{bar: string[], value: string[]}}
*/
static getConsumedAttributes(data) {
const attributes = super.getTrackedAttributes(data);
attributes.value.push(...Object.keys(CONFIG.DND5E.currencies).map(denom => ["currency", denom]));
const allowed = CONFIG.DND5E.consumableResources;
attributes.value = attributes.value.filter(attrs => this._isAllowedAttribute(allowed, attrs));
return attributes;
}
/* -------------------------------------------- */
/**
* Traverse the configured allowed attributes to see if the provided one matches.
* @param {object} allowed The allowed attributes structure.
* @param {string[]} attrs The attributes list to test.
* @returns {boolean} Whether the given attribute is allowed.
* @private
*/
static _isAllowedAttribute(allowed, attrs) {
let allow = allowed;
for ( const attr of attrs ) {
if ( allow === undefined ) return false;
if ( allow === true ) return true;
if ( allow["*"] !== undefined ) allow = allow["*"];
else allow = allow[attr];
}
return allow !== undefined;
}
}
/**
* Highlight critical success or failure on d20 rolls.
* @param {ChatMessage} message Message being prepared.
* @param {HTMLElement} html Rendered contents of the message.
* @param {object} data Configuration data passed to the message.
*/
function highlightCriticalSuccessFailure(message, html, data) {
if ( !message.isRoll || !message.isContentVisible || !message.rolls.length ) return;
// Highlight rolls where the first part is a d20 roll
let d20Roll = message.rolls.find(r => {
const d0 = r.dice[0];
return (d0?.faces === 20) && (d0?.values.length === 1);
});
if ( !d20Roll ) return;
d20Roll = dnd5e.dice.D20Roll.fromRoll(d20Roll);
const d = d20Roll.dice[0];
const isModifiedRoll = ("success" in d.results[0]) || d.options.marginSuccess || d.options.marginFailure;
if ( isModifiedRoll ) return;
// Highlight successes and failures
if ( d20Roll.isCritical ) html.find(".dice-total").addClass("critical");
else if ( d20Roll.isFumble ) html.find(".dice-total").addClass("fumble");
else if ( d.options.target ) {
if ( d20Roll.total >= d.options.target ) html.find(".dice-total").addClass("success");
else html.find(".dice-total").addClass("failure");
}
}
/* -------------------------------------------- */
/**
* Optionally hide the display of chat card action buttons which cannot be performed by the user
* @param {ChatMessage} message Message being prepared.
* @param {HTMLElement} html Rendered contents of the message.
* @param {object} data Configuration data passed to the message.
*/
function displayChatActionButtons(message, html, data) {
const chatCard = html.find(".dnd5e.chat-card");
if ( chatCard.length > 0 ) {
const flavor = html.find(".flavor-text");
if ( flavor.text() === html.find(".item-name").text() ) flavor.remove();
// If the user is the message author or the actor owner, proceed
let actor = game.actors.get(data.message.speaker.actor);
if ( actor && actor.isOwner ) return;
else if ( game.user.isGM || (data.author.id === game.user.id)) return;
// Otherwise conceal action buttons except for saving throw
const buttons = chatCard.find("button[data-action]");
buttons.each((i, btn) => {
if ( btn.dataset.action === "save" ) return;
btn.style.display = "none";
});
}
}
/* -------------------------------------------- */
/**
* This function is used to hook into the Chat Log context menu to add additional options to each message
* These options make it easy to conveniently apply damage to controlled tokens based on the value of a Roll
*
* @param {HTMLElement} html The Chat Message being rendered
* @param {object[]} options The Array of Context Menu options
*
* @returns {object[]} The extended options Array including new context choices
*/
function addChatMessageContextOptions(html, options) {
let canApply = li => {
const message = game.messages.get(li.data("messageId"));
return message?.isRoll && message?.isContentVisible && canvas.tokens?.controlled.length;
};
options.push(
{
name: game.i18n.localize("DND5E.ChatContextDamage"),
icon: ' ',
condition: canApply,
callback: li => applyChatCardDamage(li, 1)
},
{
name: game.i18n.localize("DND5E.ChatContextHealing"),
icon: ' ',
condition: canApply,
callback: li => applyChatCardDamage(li, -1)
},
{
name: game.i18n.localize("DND5E.ChatContextTempHP"),
icon: ' ',
condition: canApply,
callback: li => applyChatCardTemp(li)
},
{
name: game.i18n.localize("DND5E.ChatContextDoubleDamage"),
icon: ' ',
condition: canApply,
callback: li => applyChatCardDamage(li, 2)
},
{
name: game.i18n.localize("DND5E.ChatContextHalfDamage"),
icon: ' ',
condition: canApply,
callback: li => applyChatCardDamage(li, 0.5)
}
);
return options;
}
/* -------------------------------------------- */
/**
* Apply rolled dice damage to the token or tokens which are currently controlled.
* This allows for damage to be scaled by a multiplier to account for healing, critical hits, or resistance
*
* @param {HTMLElement} li The chat entry which contains the roll data
* @param {number} multiplier A damage multiplier to apply to the rolled damage.
* @returns {Promise}
*/
function applyChatCardDamage(li, multiplier) {
const message = game.messages.get(li.data("messageId"));
const roll = message.rolls[0];
return Promise.all(canvas.tokens.controlled.map(t => {
const a = t.actor;
return a.applyDamage(roll.total, multiplier);
}));
}
/* -------------------------------------------- */
/**
* Apply rolled dice as temporary hit points to the controlled token(s).
* @param {HTMLElement} li The chat entry which contains the roll data
* @returns {Promise}
*/
function applyChatCardTemp(li) {
const message = game.messages.get(li.data("messageId"));
const roll = message.rolls[0];
return Promise.all(canvas.tokens.controlled.map(t => {
const a = t.actor;
return a.applyTempHP(roll.total);
}));
}
/* -------------------------------------------- */
/**
* Handle rendering of a chat message to the log
* @param {ChatLog} app The ChatLog instance
* @param {jQuery} html Rendered chat message HTML
* @param {object} data Data passed to the render context
*/
function onRenderChatMessage(app, html, data) {
displayChatActionButtons(app, html, data);
highlightCriticalSuccessFailure(app, html);
if (game.settings.get("dnd5e", "autoCollapseItemCards")) html.find(".card-content").hide();
}
var chatMessage = /*#__PURE__*/Object.freeze({
__proto__: null,
addChatMessageContextOptions: addChatMessageContextOptions,
displayChatActionButtons: displayChatActionButtons,
highlightCriticalSuccessFailure: highlightCriticalSuccessFailure,
onRenderChatMessage: onRenderChatMessage
});
/**
* Override the core method for obtaining a Roll instance used for the Combatant.
* @see {Actor5e#getInitiativeRoll}
* @param {string} [formula] A formula to use if no Actor is defined
* @returns {D20Roll} The D20Roll instance which is used to determine initiative for the Combatant
*/
function getInitiativeRoll(formula="1d20") {
if ( !this.actor ) return new CONFIG.Dice.D20Roll(formula ?? "1d20", {});
return this.actor.getInitiativeRoll();
}
var combat = /*#__PURE__*/Object.freeze({
__proto__: null,
getInitiativeRoll: getInitiativeRoll
});
/**
* Attempt to create a macro from the dropped data. Will use an existing macro if one exists.
* @param {object} dropData The dropped data
* @param {number} slot The hotbar slot to use
*/
async function create5eMacro(dropData, slot) {
const macroData = { type: "script", scope: "actor" };
switch ( dropData.type ) {
case "Item":
const itemData = await Item.implementation.fromDropData(dropData);
if ( !itemData ) return ui.notifications.warn(game.i18n.localize("MACRO.5eUnownedWarn"));
foundry.utils.mergeObject(macroData, {
name: itemData.name,
img: itemData.img,
command: `dnd5e.documents.macro.rollItem("${itemData.name}")`,
flags: {"dnd5e.itemMacro": true}
});
break;
case "ActiveEffect":
const effectData = await ActiveEffect.implementation.fromDropData(dropData);
if ( !effectData ) return ui.notifications.warn(game.i18n.localize("MACRO.5eUnownedWarn"));
foundry.utils.mergeObject(macroData, {
name: effectData.label,
img: effectData.icon,
command: `dnd5e.documents.macro.toggleEffect("${effectData.label}")`,
flags: {"dnd5e.effectMacro": true}
});
break;
default:
return;
}
// Assign the macro to the hotbar
const macro = game.macros.find(m => (m.name === macroData.name) && (m.command === macroData.command)
&& m.author.isSelf) || await Macro.create(macroData);
game.user.assignHotbarMacro(macro, slot);
}
/* -------------------------------------------- */
/**
* Find a document of the specified name and type on an assigned or selected actor.
* @param {string} name Document name to locate.
* @param {string} documentType Type of embedded document (e.g. "Item" or "ActiveEffect").
* @returns {Document} Document if found, otherwise nothing.
*/
function getMacroTarget(name, documentType) {
let actor;
const speaker = ChatMessage.getSpeaker();
if ( speaker.token ) actor = game.actors.tokens[speaker.token];
actor ??= game.actors.get(speaker.actor);
if ( !actor ) return ui.notifications.warn(game.i18n.localize("MACRO.5eNoActorSelected"));
const collection = (documentType === "Item") ? actor.items : actor.effects;
const nameKeyPath = (documentType === "Item") ? "name" : "label";
// Find item in collection
const documents = collection.filter(i => foundry.utils.getProperty(i, nameKeyPath) === name);
const type = game.i18n.localize(`DOCUMENT.${documentType}`);
if ( documents.length === 0 ) {
return ui.notifications.warn(game.i18n.format("MACRO.5eMissingTargetWarn", { actor: actor.name, type, name }));
}
if ( documents.length > 1 ) {
ui.notifications.warn(game.i18n.format("MACRO.5eMultipleTargetsWarn", { actor: actor.name, type, name }));
}
return documents[0];
}
/* -------------------------------------------- */
/**
* Trigger an item to roll when a macro is clicked.
* @param {string} itemName Name of the item on the selected actor to trigger.
* @returns {Promise} Roll result.
*/
function rollItem(itemName) {
return getMacroTarget(itemName, "Item")?.use();
}
/* -------------------------------------------- */
/**
* Toggle an effect on and off when a macro is clicked.
* @param {string} effectName Name of the effect to be toggled.
* @returns {Promise} The effect after it has been toggled.
*/
function toggleEffect(effectName) {
const effect = getMacroTarget(effectName, "ActiveEffect");
return effect?.update({disabled: !effect.disabled});
}
var macro = /*#__PURE__*/Object.freeze({
__proto__: null,
create5eMacro: create5eMacro,
rollItem: rollItem,
toggleEffect: toggleEffect
});
// Document Classes
var documents = /*#__PURE__*/Object.freeze({
__proto__: null,
ActiveEffect5e: ActiveEffect5e,
Actor5e: Actor5e,
Item5e: Item5e,
Proficiency: Proficiency,
TokenDocument5e: TokenDocument5e,
Trait: trait,
advancement: _module$a,
chat: chatMessage,
combat: combat,
macro: macro
});
/**
* Perform a system migration for the entire World, applying migrations for Actors, Items, and Compendium packs
* @returns {Promise} A Promise which resolves once the migration is completed
*/
const migrateWorld = async function() {
const version = game.system.version;
ui.notifications.info(game.i18n.format("MIGRATION.5eBegin", {version}), {permanent: true});
const migrationData = await getMigrationData();
// Migrate World Actors
const actors = game.actors.map(a => [a, true])
.concat(Array.from(game.actors.invalidDocumentIds).map(id => [game.actors.getInvalid(id), false]));
for ( const [actor, valid] of actors ) {
try {
const source = valid ? actor.toObject() : game.data.actors.find(a => a._id === actor.id);
const updateData = migrateActorData(source, migrationData);
if ( !foundry.utils.isEmpty(updateData) ) {
console.log(`Migrating Actor document ${actor.name}`);
await actor.update(updateData, {enforceTypes: false, diff: valid});
}
} catch(err) {
err.message = `Failed dnd5e system migration for Actor ${actor.name}: ${err.message}`;
console.error(err);
}
}
// Migrate World Items
const items = game.items.map(i => [i, true])
.concat(Array.from(game.items.invalidDocumentIds).map(id => [game.items.getInvalid(id), false]));
for ( const [item, valid] of items ) {
try {
const source = valid ? item.toObject() : game.data.items.find(i => i._id === item.id);
const updateData = migrateItemData(source, migrationData);
if ( !foundry.utils.isEmpty(updateData) ) {
console.log(`Migrating Item document ${item.name}`);
await item.update(updateData, {enforceTypes: false, diff: valid});
}
} catch(err) {
err.message = `Failed dnd5e system migration for Item ${item.name}: ${err.message}`;
console.error(err);
}
}
// Migrate World Macros
for ( const m of game.macros ) {
try {
const updateData = migrateMacroData(m.toObject(), migrationData);
if ( !foundry.utils.isEmpty(updateData) ) {
console.log(`Migrating Macro document ${m.name}`);
await m.update(updateData, {enforceTypes: false});
}
} catch(err) {
err.message = `Failed dnd5e system migration for Macro ${m.name}: ${err.message}`;
console.error(err);
}
}
// Migrate Actor Override Tokens
for ( let s of game.scenes ) {
try {
const updateData = migrateSceneData(s, migrationData);
if ( !foundry.utils.isEmpty(updateData) ) {
console.log(`Migrating Scene document ${s.name}`);
await s.update(updateData, {enforceTypes: false});
// If we do not do this, then synthetic token actors remain in cache
// with the un-updated actorData.
s.tokens.forEach(t => t._actor = null);
}
} catch(err) {
err.message = `Failed dnd5e system migration for Scene ${s.name}: ${err.message}`;
console.error(err);
}
}
// Migrate World Compendium Packs
for ( let p of game.packs ) {
if ( p.metadata.packageType !== "world" ) continue;
if ( !["Actor", "Item", "Scene"].includes(p.documentName) ) continue;
await migrateCompendium(p);
}
// Set the migration as complete
game.settings.set("dnd5e", "systemMigrationVersion", game.system.version);
ui.notifications.info(game.i18n.format("MIGRATION.5eComplete", {version}), {permanent: true});
};
/* -------------------------------------------- */
/**
* Apply migration rules to all Documents within a single Compendium pack
* @param {CompendiumCollection} pack Pack to be migrated.
* @returns {Promise}
*/
const migrateCompendium = async function(pack) {
const documentName = pack.documentName;
if ( !["Actor", "Item", "Scene"].includes(documentName) ) return;
const migrationData = await getMigrationData();
// Unlock the pack for editing
const wasLocked = pack.locked;
await pack.configure({locked: false});
// Begin by requesting server-side data model migration and get the migrated content
await pack.migrate();
const documents = await pack.getDocuments();
// Iterate over compendium entries - applying fine-tuned migration functions
for ( let doc of documents ) {
let updateData = {};
try {
switch (documentName) {
case "Actor":
updateData = migrateActorData(doc.toObject(), migrationData);
break;
case "Item":
updateData = migrateItemData(doc.toObject(), migrationData);
break;
case "Scene":
updateData = migrateSceneData(doc.toObject(), migrationData);
break;
}
// Save the entry, if data was changed
if ( foundry.utils.isEmpty(updateData) ) continue;
await doc.update(updateData);
console.log(`Migrated ${documentName} document ${doc.name} in Compendium ${pack.collection}`);
}
// Handle migration failures
catch(err) {
err.message = `Failed dnd5e system migration for document ${doc.name} in pack ${pack.collection}: ${err.message}`;
console.error(err);
}
}
// Apply the original locked status for the pack
await pack.configure({locked: wasLocked});
console.log(`Migrated all ${documentName} documents from Compendium ${pack.collection}`);
};
/* -------------------------------------------- */
/**
* Update all compendium packs using the new system data model.
*/
async function refreshAllCompendiums() {
for ( const pack of game.packs ) {
await refreshCompendium(pack);
}
}
/* -------------------------------------------- */
/**
* Update all Documents in a compendium using the new system data model.
* @param {CompendiumCollection} pack Pack to refresh.
*/
async function refreshCompendium(pack) {
if ( !pack?.documentName ) return;
dnd5e.moduleArt.suppressArt = true;
const DocumentClass = CONFIG[pack.documentName].documentClass;
const wasLocked = pack.locked;
await pack.configure({locked: false});
await pack.migrate();
ui.notifications.info(`Beginning to refresh Compendium ${pack.collection}`);
const documents = await pack.getDocuments();
for ( const doc of documents ) {
const data = doc.toObject();
await doc.delete();
await DocumentClass.create(data, {keepId: true, keepEmbeddedIds: true, pack: pack.collection});
}
await pack.configure({locked: wasLocked});
dnd5e.moduleArt.suppressArt = false;
ui.notifications.info(`Refreshed all documents from Compendium ${pack.collection}`);
}
/* -------------------------------------------- */
/**
* Apply 'smart' AC migration to a given Actor compendium. This will perform the normal AC migration but additionally
* check to see if the actor has armor already equipped, and opt to use that instead.
* @param {CompendiumCollection|string} pack Pack or name of pack to migrate.
* @returns {Promise}
*/
const migrateArmorClass = async function(pack) {
if ( typeof pack === "string" ) pack = game.packs.get(pack);
if ( pack.documentName !== "Actor" ) return;
const wasLocked = pack.locked;
await pack.configure({locked: false});
const actors = await pack.getDocuments();
const updates = [];
const armor = new Set(Object.keys(CONFIG.DND5E.armorTypes));
for ( const actor of actors ) {
try {
console.log(`Migrating ${actor.name}...`);
const src = actor.toObject();
const update = {_id: actor.id};
// Perform the normal migration.
_migrateActorAC(src, update);
// TODO: See if AC migration within DataModel is enough to handle this
updates.push(update);
// CASE 1: Armor is equipped
const hasArmorEquipped = actor.itemTypes.equipment.some(e => {
return armor.has(e.system.armor?.type) && e.system.equipped;
});
if ( hasArmorEquipped ) update["system.attributes.ac.calc"] = "default";
// CASE 2: NPC Natural Armor
else if ( src.type === "npc" ) update["system.attributes.ac.calc"] = "natural";
} catch(e) {
console.warn(`Failed to migrate armor class for Actor ${actor.name}`, e);
}
}
await Actor.implementation.updateDocuments(updates, {pack: pack.collection});
await pack.getDocuments(); // Force a re-prepare of all actors.
await pack.configure({locked: wasLocked});
console.log(`Migrated the AC of all Actors from Compendium ${pack.collection}`);
};
/* -------------------------------------------- */
/* Document Type Migration Helpers */
/* -------------------------------------------- */
/**
* Migrate a single Actor document to incorporate latest data model changes
* Return an Object of updateData to be applied
* @param {object} actor The actor data object to update
* @param {object} [migrationData] Additional data to perform the migration
* @returns {object} The updateData to apply
*/
const migrateActorData = function(actor, migrationData) {
const updateData = {};
_migrateTokenImage(actor, updateData);
_migrateActorAC(actor, updateData);
// Migrate embedded effects
if ( actor.effects ) {
const effects = migrateEffects(actor, migrationData);
if ( effects.length > 0 ) updateData.effects = effects;
}
// Migrate Owned Items
if ( !actor.items ) return updateData;
const items = actor.items.reduce((arr, i) => {
// Migrate the Owned Item
const itemData = i instanceof CONFIG.Item.documentClass ? i.toObject() : i;
let itemUpdate = migrateItemData(itemData, migrationData);
// Prepared, Equipped, and Proficient for NPC actors
if ( actor.type === "npc" ) {
if (foundry.utils.getProperty(itemData.system, "preparation.prepared") === false) itemUpdate["system.preparation.prepared"] = true;
if (foundry.utils.getProperty(itemData.system, "equipped") === false) itemUpdate["system.equipped"] = true;
if (foundry.utils.getProperty(itemData.system, "proficient") === false) itemUpdate["system.proficient"] = true;
}
// Update the Owned Item
if ( !foundry.utils.isEmpty(itemUpdate) ) {
itemUpdate._id = itemData._id;
arr.push(foundry.utils.expandObject(itemUpdate));
}
// Update tool expertise.
if ( actor.system.tools ) {
const hasToolProf = itemData.system.baseItem in actor.system.tools;
if ( (itemData.type === "tool") && (itemData.system.proficient > 1) && hasToolProf ) {
updateData[`system.tools.${itemData.system.baseItem}.value`] = itemData.system.proficient;
}
}
return arr;
}, []);
if ( items.length > 0 ) updateData.items = items;
return updateData;
};
/* -------------------------------------------- */
/**
* Migrate a single Item document to incorporate latest data model changes
*
* @param {object} item Item data to migrate
* @param {object} [migrationData] Additional data to perform the migration
* @returns {object} The updateData to apply
*/
function migrateItemData(item, migrationData) {
const updateData = {};
_migrateDocumentIcon(item, updateData, migrationData);
// Migrate embedded effects
if ( item.effects ) {
const effects = migrateEffects(item, migrationData);
if ( effects.length > 0 ) updateData.effects = effects;
}
return updateData;
}
/* -------------------------------------------- */
/**
* Migrate any active effects attached to the provided parent.
* @param {object} parent Data of the parent being migrated.
* @param {object} [migrationData] Additional data to perform the migration.
* @returns {object[]} Updates to apply on the embedded effects.
*/
const migrateEffects = function(parent, migrationData) {
if ( !parent.effects ) return {};
return parent.effects.reduce((arr, e) => {
const effectData = e instanceof CONFIG.ActiveEffect.documentClass ? e.toObject() : e;
let effectUpdate = migrateEffectData(effectData, migrationData);
if ( !foundry.utils.isEmpty(effectUpdate) ) {
effectUpdate._id = effectData._id;
arr.push(foundry.utils.expandObject(effectUpdate));
}
return arr;
}, []);
};
/* -------------------------------------------- */
/**
* Migrate the provided active effect data.
* @param {object} effect Effect data to migrate.
* @param {object} [migrationData] Additional data to perform the migration.
* @returns {object} The updateData to apply.
*/
const migrateEffectData = function(effect, migrationData) {
const updateData = {};
_migrateDocumentIcon(effect, updateData, {...migrationData, field: "icon"});
_migrateEffectArmorClass(effect, updateData);
return updateData;
};
/* -------------------------------------------- */
/**
* Migrate a single Macro document to incorporate latest data model changes.
* @param {object} macro Macro data to migrate
* @param {object} [migrationData] Additional data to perform the migration
* @returns {object} The updateData to apply
*/
const migrateMacroData = function(macro, migrationData) {
const updateData = {};
_migrateDocumentIcon(macro, updateData, migrationData);
_migrateMacroCommands(macro, updateData);
return updateData;
};
/* -------------------------------------------- */
/**
* Migrate a single Scene document to incorporate changes to the data model of it's actor data overrides
* Return an Object of updateData to be applied
* @param {object} scene The Scene data to Update
* @param {object} [migrationData] Additional data to perform the migration
* @returns {object} The updateData to apply
*/
const migrateSceneData = function(scene, migrationData) {
const tokens = scene.tokens.map(token => {
const t = token instanceof foundry.abstract.DataModel ? token.toObject() : token;
const update = {};
_migrateTokenImage(t, update);
if ( Object.keys(update).length ) foundry.utils.mergeObject(t, update);
if ( !game.actors.has(t.actorId) ) t.actorId = null;
if ( !t.actorId || t.actorLink ) t.actorData = {};
else if ( !t.actorLink ) {
const actorData = token.delta?.toObject() ?? foundry.utils.deepClone(t.actorData);
actorData.type = token.actor?.type;
const update = migrateActorData(actorData, migrationData);
if ( game.dnd5e.isV10 ) {
["items", "effects"].forEach(embeddedName => {
if ( !update[embeddedName]?.length ) return;
const updates = new Map(update[embeddedName].map(u => [u._id, u]));
t.actorData[embeddedName].forEach(original => {
const update = updates.get(original._id);
if ( update ) foundry.utils.mergeObject(original, update);
});
delete update[embeddedName];
});
foundry.utils.mergeObject(t.actorData, update);
}
else t.delta = update;
}
return t;
});
return {tokens};
};
/* -------------------------------------------- */
/**
* Fetch bundled data for large-scale migrations.
* @returns {Promise} Object mapping original system icons to their core replacements.
*/
const getMigrationData = async function() {
const data = {};
try {
const icons = await fetch("systems/dnd5e/json/icon-migration.json");
const spellIcons = await fetch("systems/dnd5e/json/spell-icon-migration.json");
data.iconMap = {...await icons.json(), ...await spellIcons.json()};
} catch(err) {
console.warn(`Failed to retrieve icon migration data: ${err.message}`);
}
return data;
};
/* -------------------------------------------- */
/* Low level migration utilities
/* -------------------------------------------- */
/**
* Migrate the actor attributes.ac.value to the new ac.flat override field.
* @param {object} actorData Actor data being migrated.
* @param {object} updateData Existing updates being applied to actor. *Will be mutated.*
* @returns {object} Modified version of update data.
* @private
*/
function _migrateActorAC(actorData, updateData) {
const ac = actorData.system?.attributes?.ac;
// If the actor has a numeric ac.value, then their AC has not been migrated to the auto-calculation schema yet.
if ( Number.isNumeric(ac?.value) ) {
updateData["system.attributes.ac.flat"] = parseInt(ac.value);
updateData["system.attributes.ac.calc"] = actorData.type === "npc" ? "natural" : "flat";
updateData["system.attributes.ac.-=value"] = null;
return updateData;
}
// Migrate ac.base in custom formulas to ac.armor
if ( (typeof ac?.formula === "string") && ac?.formula.includes("@attributes.ac.base") ) {
updateData["system.attributes.ac.formula"] = ac.formula.replaceAll("@attributes.ac.base", "@attributes.ac.armor");
}
// Protect against string values created by character sheets or importers that don't enforce data types
if ( (typeof ac?.flat === "string") && Number.isNumeric(ac.flat) ) {
updateData["system.attributes.ac.flat"] = parseInt(ac.flat);
}
// Remove invalid AC formula strings.
if ( ac?.formula ) {
try {
const roll = new Roll(ac.formula);
Roll.safeEval(roll.formula);
} catch( e ) {
updateData["system.attributes.ac.formula"] = "";
}
}
return updateData;
}
/* -------------------------------------------- */
/**
* Migrate any system token images from PNG to WEBP.
* @param {object} actorData Actor or token data to migrate.
* @param {object} updateData Existing update to expand upon.
* @returns {object} The updateData to apply
* @private
*/
function _migrateTokenImage(actorData, updateData) {
const oldSystemPNG = /^systems\/dnd5e\/tokens\/([a-z]+)\/([A-z]+).png$/;
for ( const path of ["texture.src", "prototypeToken.texture.src"] ) {
const v = foundry.utils.getProperty(actorData, path);
if ( oldSystemPNG.test(v) ) {
const [type, fileName] = v.match(oldSystemPNG).slice(1);
updateData[path] = `systems/dnd5e/tokens/${type}/${fileName}.webp`;
}
}
return updateData;
}
/* -------------------------------------------- */
/**
* Convert system icons to use bundled core webp icons.
* @param {object} document Document data to migrate
* @param {object} updateData Existing update to expand upon
* @param {object} [migrationData={}] Additional data to perform the migration
* @param {Object} [migrationData.iconMap] A mapping of system icons to core foundry icons
* @param {string} [migrationData.field] The document field to migrate
* @returns {object} The updateData to apply
* @private
*/
function _migrateDocumentIcon(document, updateData, {iconMap, field="img"}={}) {
let path = document?.[field];
if ( path && iconMap ) {
if ( path.startsWith("/") || path.startsWith("\\") ) path = path.substring(1);
const rename = iconMap[path];
if ( rename ) updateData[field] = rename;
}
return updateData;
}
/* -------------------------------------------- */
/**
* Change active effects that target AC.
* @param {object} effect Effect data to migrate.
* @param {object} updateData Existing update to expand upon.
* @returns {object} The updateData to apply.
*/
function _migrateEffectArmorClass(effect, updateData) {
let containsUpdates = false;
const changes = (effect.changes || []).map(c => {
if ( c.key !== "system.attributes.ac.base" ) return c;
c.key = "system.attributes.ac.armor";
containsUpdates = true;
return c;
});
if ( containsUpdates ) updateData.changes = changes;
return updateData;
}
/* -------------------------------------------- */
/**
* Migrate macros from the old 'dnd5e.rollItemMacro' and 'dnd5e.macros' commands to the new location.
* @param {object} macro Macro data to migrate.
* @param {object} updateData Existing update to expand upon.
* @returns {object} The updateData to apply.
*/
function _migrateMacroCommands(macro, updateData) {
if ( macro.command.includes("game.dnd5e.rollItemMacro") ) {
updateData.command = macro.command.replaceAll("game.dnd5e.rollItemMacro", "dnd5e.documents.macro.rollItem");
} else if ( macro.command.includes("game.dnd5e.macros.") ) {
updateData.command = macro.command.replaceAll("game.dnd5e.macros.", "dnd5e.documents.macro.");
}
return updateData;
}
/* -------------------------------------------- */
/**
* A general tool to purge flags from all documents in a Compendium pack.
* @param {CompendiumCollection} pack The compendium pack to clean.
* @private
*/
async function purgeFlags(pack) {
const cleanFlags = flags => {
const flags5e = flags.dnd5e || null;
return flags5e ? {dnd5e: flags5e} : {};
};
await pack.configure({locked: false});
const content = await pack.getDocuments();
for ( let doc of content ) {
const update = {flags: cleanFlags(doc.flags)};
if ( pack.documentName === "Actor" ) {
update.items = doc.items.map(i => {
i.flags = cleanFlags(i.flags);
return i;
});
}
await doc.update(update, {recursive: false});
console.log(`Purged flags from ${doc.name}`);
}
await pack.configure({locked: true});
}
var migrations = /*#__PURE__*/Object.freeze({
__proto__: null,
getMigrationData: getMigrationData,
migrateActorData: migrateActorData,
migrateArmorClass: migrateArmorClass,
migrateCompendium: migrateCompendium,
migrateEffectData: migrateEffectData,
migrateEffects: migrateEffects,
migrateItemData: migrateItemData,
migrateMacroData: migrateMacroData,
migrateSceneData: migrateSceneData,
migrateWorld: migrateWorld,
purgeFlags: purgeFlags,
refreshAllCompendiums: refreshAllCompendiums,
refreshCompendium: refreshCompendium
});
/**
* The DnD5e game system for Foundry Virtual Tabletop
* A system for playing the fifth edition of the world's most popular role-playing game.
* Author: Atropos
* Software License: MIT
* Content License: https://www.dndbeyond.com/attachments/39j2li89/SRD5.1-CCBY4.0License.pdf
* Repository: https://github.com/foundryvtt/dnd5e
* Issue Tracker: https://github.com/foundryvtt/dnd5e/issues
*/
/* -------------------------------------------- */
/* Define Module Structure */
/* -------------------------------------------- */
globalThis.dnd5e = {
applications,
canvas: canvas$1,
config: DND5E,
dataModels,
dice,
documents,
migrations,
utils
};
/* -------------------------------------------- */
/* Foundry VTT Initialization */
/* -------------------------------------------- */
Hooks.once("init", function() {
globalThis.dnd5e = game.dnd5e = Object.assign(game.system, globalThis.dnd5e);
console.log(`DnD5e | Initializing the DnD5e Game System - Version ${dnd5e.version}\n${DND5E.ASCII}`);
// Record Configuration Values
CONFIG.DND5E = DND5E;
CONFIG.ActiveEffect.documentClass = ActiveEffect5e;
CONFIG.Actor.documentClass = Actor5e;
CONFIG.Item.documentClass = Item5e;
CONFIG.Token.documentClass = TokenDocument5e;
CONFIG.Token.objectClass = Token5e;
CONFIG.time.roundTime = 6;
CONFIG.Dice.DamageRoll = DamageRoll;
CONFIG.Dice.D20Roll = D20Roll;
CONFIG.MeasuredTemplate.defaults.angle = 53.13; // 5e cone RAW should be 53.13 degrees
CONFIG.ui.combat = CombatTracker5e;
CONFIG.compatibility.excludePatterns.push(/\bActiveEffect5e#label\b/); // backwards compatibility with v10
game.dnd5e.isV10 = game.release.generation < 11;
// Configure trackable attributes.
_configureTrackableAttributes();
// Register System Settings
registerSystemSettings();
// Validation strictness.
if ( game.dnd5e.isV10 ) _determineValidationStrictness();
// Configure module art.
game.dnd5e.moduleArt = new ModuleArt();
// Remove honor & sanity from configuration if they aren't enabled
if ( !game.settings.get("dnd5e", "honorScore") ) delete DND5E.abilities.hon;
if ( !game.settings.get("dnd5e", "sanityScore") ) delete DND5E.abilities.san;
// Patch Core Functions
Combatant.prototype.getInitiativeRoll = getInitiativeRoll;
// Register Roll Extensions
CONFIG.Dice.rolls.push(D20Roll);
CONFIG.Dice.rolls.push(DamageRoll);
// Hook up system data types
const modelType = game.dnd5e.isV10 ? "systemDataModels" : "dataModels";
CONFIG.Actor[modelType] = config$2;
CONFIG.Item[modelType] = config$1;
CONFIG.JournalEntryPage[modelType] = config;
// Register sheet application classes
Actors.unregisterSheet("core", ActorSheet);
Actors.registerSheet("dnd5e", ActorSheet5eCharacter, {
types: ["character"],
makeDefault: true,
label: "DND5E.SheetClassCharacter"
});
Actors.registerSheet("dnd5e", ActorSheet5eNPC, {
types: ["npc"],
makeDefault: true,
label: "DND5E.SheetClassNPC"
});
Actors.registerSheet("dnd5e", ActorSheet5eVehicle, {
types: ["vehicle"],
makeDefault: true,
label: "DND5E.SheetClassVehicle"
});
Actors.registerSheet("dnd5e", GroupActorSheet, {
types: ["group"],
makeDefault: true,
label: "DND5E.SheetClassGroup"
});
Items.unregisterSheet("core", ItemSheet);
Items.registerSheet("dnd5e", ItemSheet5e, {
makeDefault: true,
label: "DND5E.SheetClassItem"
});
DocumentSheetConfig.registerSheet(JournalEntryPage, "dnd5e", JournalClassPageSheet, {
label: "DND5E.SheetClassClassSummary",
types: ["class"]
});
// Preload Handlebars helpers & partials
registerHandlebarsHelpers();
preloadHandlebarsTemplates();
});
/**
* Determine if this is a 'legacy' world with permissive validation, or one where strict validation is enabled.
* @internal
*/
function _determineValidationStrictness() {
SystemDataModel._enableV10Validation = game.settings.get("dnd5e", "strictValidation");
}
/**
* Update the world's validation strictness setting based on whether validation errors were encountered.
* @internal
*/
async function _configureValidationStrictness() {
if ( !game.user.isGM ) return;
const invalidDocuments = game.actors.invalidDocumentIds.size + game.items.invalidDocumentIds.size
+ game.scenes.invalidDocumentIds.size;
const strictValidation = game.settings.get("dnd5e", "strictValidation");
if ( invalidDocuments && strictValidation ) {
await game.settings.set("dnd5e", "strictValidation", false);
game.socket.emit("reload");
foundry.utils.debouncedReload();
}
}
/**
* Configure explicit lists of attributes that are trackable on the token HUD and in the combat tracker.
* @internal
*/
function _configureTrackableAttributes() {
const common = {
bar: [],
value: [
...Object.keys(DND5E.abilities).map(ability => `abilities.${ability}.value`),
...Object.keys(DND5E.movementTypes).map(movement => `attributes.movement.${movement}`),
"attributes.ac.value", "attributes.init.total"
]
};
const creature = {
bar: [...common.bar, "attributes.hp"],
value: [
...common.value,
...Object.keys(DND5E.skills).map(skill => `skills.${skill}.passive`),
...Object.keys(DND5E.senses).map(sense => `attributes.senses.${sense}`),
"attributes.spelldc"
]
};
CONFIG.Actor.trackableAttributes = {
character: {
bar: [...creature.bar, "resources.primary", "resources.secondary", "resources.tertiary", "details.xp"],
value: [...creature.value]
},
npc: {
bar: [...creature.bar, "resources.legact", "resources.legres"],
value: [...creature.value, "details.cr", "details.spellLevel", "details.xp.value"]
},
vehicle: {
bar: [...common.bar, "attributes.hp"],
value: [...common.value]
},
group: {
bar: [],
value: []
}
};
}
/* -------------------------------------------- */
/* Foundry VTT Setup */
/* -------------------------------------------- */
/**
* Prepare attribute lists.
*/
Hooks.once("setup", function() {
CONFIG.DND5E.trackableAttributes = expandAttributeList(CONFIG.DND5E.trackableAttributes);
CONFIG.DND5E.consumableResources = expandAttributeList(CONFIG.DND5E.consumableResources);
game.dnd5e.moduleArt.registerModuleArt();
// Apply custom compendium styles to the SRD rules compendium.
if ( !game.dnd5e.isV10 ) {
const rules = game.packs.get("dnd5e.rules");
rules.applicationClass = SRDCompendium;
}
});
/* --------------------------------------------- */
/**
* Expand a list of attribute paths into an object that can be traversed.
* @param {string[]} attributes The initial attributes configuration.
* @returns {object} The expanded object structure.
*/
function expandAttributeList(attributes) {
return attributes.reduce((obj, attr) => {
foundry.utils.setProperty(obj, attr, true);
return obj;
}, {});
}
/* --------------------------------------------- */
/**
* Perform one-time pre-localization and sorting of some configuration objects
*/
Hooks.once("i18nInit", () => performPreLocalization(CONFIG.DND5E));
/* -------------------------------------------- */
/* Foundry VTT Ready */
/* -------------------------------------------- */
/**
* Once the entire VTT framework is initialized, check to see if we should perform a data migration
*/
Hooks.once("ready", function() {
if ( game.dnd5e.isV10 ) {
// Configure validation strictness.
_configureValidationStrictness();
// Apply custom compendium styles to the SRD rules compendium.
const rules = game.packs.get("dnd5e.rules");
rules.apps = [new SRDCompendium(rules)];
}
// Wait to register hotbar drop hook on ready so that modules could register earlier if they want to
Hooks.on("hotbarDrop", (bar, data, slot) => {
if ( ["Item", "ActiveEffect"].includes(data.type) ) {
create5eMacro(data, slot);
return false;
}
});
// Determine whether a system migration is required and feasible
if ( !game.user.isGM ) return;
const cv = game.settings.get("dnd5e", "systemMigrationVersion") || game.world.flags.dnd5e?.version;
const totalDocuments = game.actors.size + game.scenes.size + game.items.size;
if ( !cv && totalDocuments === 0 ) return game.settings.set("dnd5e", "systemMigrationVersion", game.system.version);
if ( cv && !isNewerVersion(game.system.flags.needsMigrationVersion, cv) ) return;
// Perform the migration
if ( cv && isNewerVersion(game.system.flags.compatibleMigrationVersion, cv) ) {
ui.notifications.error(game.i18n.localize("MIGRATION.5eVersionTooOldWarning"), {permanent: true});
}
migrateWorld();
});
/* -------------------------------------------- */
/* Canvas Initialization */
/* -------------------------------------------- */
Hooks.on("canvasInit", gameCanvas => {
gameCanvas.grid.diagonalRule = game.settings.get("dnd5e", "diagonalMovement");
SquareGrid.prototype.measureDistances = measureDistances;
});
/* -------------------------------------------- */
/* Other Hooks */
/* -------------------------------------------- */
Hooks.on("renderChatMessage", onRenderChatMessage);
Hooks.on("getChatLogEntryContext", addChatMessageContextOptions);
Hooks.on("renderChatLog", (app, html, data) => Item5e.chatListeners(html));
Hooks.on("renderChatPopout", (app, html, data) => Item5e.chatListeners(html));
Hooks.on("getActorDirectoryEntryContext", Actor5e.addDirectoryContextOptions);
export { DND5E, applications, canvas$1 as canvas, dataModels, dice, documents, migrations, utils };
//# sourceMappingURL=dnd5e-compiled.mjs.map