All user data for FoundryVTT. Includes worlds, systems, modules, and any asset in the "foundryuserdata" directory. Does NOT include the FoundryVTT installation itself.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

23993 lines
846 KiB

/**
* 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<Item5e>} 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;
}
/* -------------------------------------------- */
/**
* Set the retained data for this flow. This method gives the flow a chance to do any additional prep
* work required for the retained data before the application is rendered.
* @param {object} data Retained data associated with this flow.
*/
async retainData(data) {
this.retainedData = data;
}
/* -------------------------------------------- */
/** @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 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<string>}
* @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;
}
}
/* -------------------------------------------- */
/**
* Data Model variant that does not export fields with an `undefined` value during `toObject(true)`.
*/
class SparseDataModel extends foundry.abstract.DataModel {
/** @inheritdoc */
toObject(source=true) {
if ( !source ) return super.toObject(source);
const clone = foundry.utils.flattenObject(this._source);
// Remove any undefined keys from the source data
Object.keys(clone).filter(k => clone[k] === undefined).forEach(k => delete clone[k]);
return foundry.utils.expandObject(clone);
}
}
/**
* 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<Error>} 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 SparseDataModel {
/**
* 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$/, "");
}
/* -------------------------------------------- */
/** @inheritdoc */
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"
})
};
}
}
/**
* 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<Application>}
*/
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<string>} 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<Advancement>} 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 ability score improvements.
*/
class AbilityScoreImprovementConfig extends AdvancementConfig {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "systems/dnd5e/templates/advancement/ability-score-improvement-config.hbs"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
getData() {
const abilities = Object.entries(CONFIG.DND5E.abilities).reduce((obj, [key, data]) => {
if ( !this.advancement.canImprove(key) ) return obj;
const fixed = this.advancement.configuration.fixed[key] ?? 0;
obj[key] = {
key,
name: `configuration.fixed.${key}`,
label: data.label,
value: fixed,
canIncrease: true,
canDecrease: true
};
return obj;
}, {});
return foundry.utils.mergeObject(super.getData(), {
abilities,
points: {
key: "points",
name: "configuration.points",
label: game.i18n.localize("DND5E.AdvancementAbilityScoreImprovementPoints"),
min: 0,
value: this.advancement.configuration.points,
canIncrease: true,
canDecrease: this.advancement.configuration.points > 0
}
});
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".adjustment-button").click(this._onClickButton.bind(this));
}
/* -------------------------------------------- */
/**
* Handle clicking the plus and minus buttons.
* @param {Event} event Triggering click event.
*/
_onClickButton(event) {
event.preventDefault();
const action = event.currentTarget.dataset.action;
const input = event.currentTarget.closest("li").querySelector("input");
if ( action === "decrease" ) input.valueAsNumber -= 1;
else if ( action === "increase" ) input.valueAsNumber += 1;
this.submit();
}
}
/**
* Inline application that presents the player with a choice between ability score improvement and taking a feat.
*/
class AbilityScoreImprovementFlow extends AdvancementFlow {
/**
* Player assignments to abilities.
* @type {Object<string, number>}
*/
assignments = {};
/* -------------------------------------------- */
/**
* The dropped feat item.
* @type {Item5e}
*/
feat;
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
dragDrop: [{ dropSelector: "form" }],
template: "systems/dnd5e/templates/advancement/ability-score-improvement-flow.hbs"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
async retainData(data) {
await super.retainData(data);
this.assignments = this.retainedData.assignments ?? {};
const featUuid = Object.values(this.retainedData.feat ?? {})[0];
if ( featUuid ) this.feat = await fromUuid(featUuid);
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData() {
const points = {
assigned: Object.keys(CONFIG.DND5E.abilities).reduce((assigned, key) => {
if ( !this.advancement.canImprove(key) || this.advancement.configuration.fixed[key] ) return assigned;
return assigned + (this.assignments[key] ?? 0);
}, 0),
total: this.advancement.configuration.points
};
points.available = points.total - points.assigned;
const formatter = new Intl.NumberFormat(game.i18n.lang, { signDisplay: "always" });
const abilities = Object.entries(CONFIG.DND5E.abilities).reduce((obj, [key, data]) => {
if ( !this.advancement.canImprove(key) ) return obj;
const ability = this.advancement.actor.system.abilities[key];
const fixed = this.advancement.configuration.fixed[key] ?? 0;
const value = Math.min(ability.value + ((fixed || this.assignments[key]) ?? 0), ability.max);
const max = fixed ? value : Math.min(value + points.available, ability.max);
obj[key] = {
key, max, value,
name: `abilities.${key}`,
label: data.label,
initial: ability.value,
min: fixed ? max : ability.value,
delta: (value - ability.value) ? formatter.format(value - ability.value) : null,
showDelta: true,
isDisabled: !!this.feat,
isFixed: !!fixed,
canIncrease: (value < max) && !fixed && !this.feat,
canDecrease: (value > ability.value) && !fixed && !this.feat
};
return obj;
}, {});
const pluralRule = new Intl.PluralRules(game.i18n.lang).select(points.available);
return foundry.utils.mergeObject(super.getData(), {
abilities, points,
feat: this.feat,
staticIncrease: !this.advancement.configuration.points,
pointsRemaining: game.i18n.format(
`DND5E.AdvancementAbilityScoreImprovementPointsRemaining.${pluralRule}`, {points: points.available}
)
});
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find(".adjustment-button").click(this._onClickButton.bind(this));
html.find("a[data-uuid]").click(this._onClickFeature.bind(this));
html.find("[data-action='delete']").click(this._onItemDelete.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
_onChangeInput(event) {
super._onChangeInput(event);
const input = event.currentTarget;
const key = input.closest("[data-score]").dataset.score;
const clampedValue = Math.clamped(input.valueAsNumber, Number(input.min), Number(input.max));
this.assignments[key] = clampedValue - Number(input.dataset.initial);
this.render();
}
/* -------------------------------------------- */
/**
* Handle clicking the plus and minus buttons.
* @param {Event} event Triggering click event.
*/
_onClickButton(event) {
event.preventDefault();
const action = event.currentTarget.dataset.action;
const key = event.currentTarget.closest("li").dataset.score;
this.assignments[key] ??= 0;
if ( action === "decrease" ) this.assignments[key] -= 1;
else if ( action === "increase" ) this.assignments[key] += 1;
else return;
this.render();
}
/* -------------------------------------------- */
/**
* 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) {
// TODO: Pass through retained feat data
await this.advancement.apply(this.level, {
type: this.feat ? "feat" : "asi",
assignments: this.assignments,
featUuid: this.feat?.uuid,
retainedItems: this.retainedData?.retainedItems
});
}
/* -------------------------------------------- */
/* Drag & Drop */
/* -------------------------------------------- */
/**
* Handle deleting a dropped feat.
* @param {Event} event The originating click event.
* @protected
*/
async _onItemDelete(event) {
event.preventDefault();
this.feat = null;
this.render();
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onDrop(event) {
if ( !this.advancement.allowFeat ) 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);
if ( (item.type !== "feat") || (item.system.type.value !== "feat") ) return ui.notifications.error(
game.i18n.localize("DND5E.AdvancementAbilityScoreImprovementFeatWarning")
);
this.feat = item;
this.render();
}
}
/**
* Data model for the Ability Score Improvement advancement configuration.
*
* @property {number} points Number of points that can be assigned to any score.
* @property {Object<string, number>} fixed Number of points automatically assigned to a certain score.
*/
class AbilityScoreImprovementConfigurationData extends foundry.abstract.DataModel {
/** @inheritdoc */
static defineSchema() {
return {
// TODO: This should default to 2 if added to a class, or 0 if added to anything else
points: new foundry.data.fields.NumberField({
integer: true, min: 0, initial: 2,
label: "DND5E.AdvancementAbilityScoreImprovementPoints",
hint: "DND5E.AdvancementAbilityScoreImprovementPointsHint"
}),
fixed: new MappingField(
new foundry.data.fields.NumberField({nullable: false, integer: true, initial: 0}),
{label: "DND5E.AdvancementAbilityScoreImprovementFixed"}
)
};
}
}
/**
* Data model for the Ability Score Improvement advancement value.
*
* @property {string} type When on a class, whether the player chose ASI or a Feat.
* @property {Object<string, number>} Points assigned to individual scores.
* @property {Object<string, string>} Feat that was selected.
*/
class AbilityScoreImprovementValueData extends SparseDataModel {
/** @inheritdoc */
static defineSchema() {
return {
type: new foundry.data.fields.StringField({
required: true, initial: "asi", choices: ["asi", "feat"]
}),
assignments: new MappingField(new foundry.data.fields.NumberField({
nullable: false, integer: true
}), {required: false, initial: undefined}),
feat: new MappingField(new foundry.data.fields.StringField(), {
required: false, initial: undefined, label: "DND5E.Feature.Feat"
})
};
}
}
/**
* Advancement that presents the player with the option of improving their ability scores or selecting a feat.
*/
class AbilityScoreImprovementAdvancement extends Advancement {
/** @inheritdoc */
static get metadata() {
return foundry.utils.mergeObject(super.metadata, {
dataModels: {
configuration: AbilityScoreImprovementConfigurationData,
value: AbilityScoreImprovementValueData
},
order: 20,
icon: "systems/dnd5e/icons/svg/ability-score-improvement.svg",
title: game.i18n.localize("DND5E.AdvancementAbilityScoreImprovementTitle"),
hint: game.i18n.localize("DND5E.AdvancementAbilityScoreImprovementHint"),
validItemTypes: new Set(["background", "class"]),
apps: {
config: AbilityScoreImprovementConfig,
flow: AbilityScoreImprovementFlow
}
});
}
/* -------------------------------------------- */
/* Instance Properties */
/* -------------------------------------------- */
/**
* Does this advancement allow feats, or just ability score improvements?
* @type {boolean}
*/
get allowFeat() {
return (this.item.type === "class") && game.settings.get("dnd5e", "allowFeats");
}
/* -------------------------------------------- */
/**
* Information on the ASI points available.
* @type {{ assigned: number, total: number }}
*/
get points() {
return {
assigned: Object.entries(this.value.assignments ?? {}).reduce((n, [abl, c]) => {
if ( this.canImprove(abl) ) n += c;
return n;
}, 0),
total: this.configuration.points + Object.entries(this.configuration.fixed).reduce((t, [abl, v]) => {
if ( this.canImprove(abl) ) t += v;
return t;
}, 0)
};
}
/* -------------------------------------------- */
/* Instance Methods */
/* -------------------------------------------- */
/**
* Is this ability allowed to be improved?
* @param {string} ability The ability key.
* @returns {boolean}
*/
canImprove(ability) {
return CONFIG.DND5E.abilities[ability]?.improvement !== false;
}
/* -------------------------------------------- */
/* Display Methods */
/* -------------------------------------------- */
/** @inheritdoc */
titleForLevel(level, { configMode=false }={}) {
if ( this.value.selected !== "feat" ) return this.title;
return game.i18n.localize("DND5E.Feature.Feat");
}
/* -------------------------------------------- */
/** @inheritdoc */
summaryForLevel(level, { configMode=false }={}) {
if ( (this.value.type === "feat") && this.value.feat ) {
const id = Object.keys(this.value.feat)[0];
const feat = this.actor.items.get(id);
if ( feat ) return feat.toAnchor({classes: ["content-link"]}).outerHTML;
} else if ( (this.value.type === "asi") && this.value.assignments ) {
const formatter = new Intl.NumberFormat(game.i18n.lang, { signDisplay: "always" });
return Object.entries(this.value.assignments).reduce((html, [key, value]) => {
const name = CONFIG.DND5E.abilities[key]?.label ?? key;
html += `<span class="tag">${name} <strong>${formatter.format(value)}</strong></span>\n`;
return html;
}, "");
}
return "";
}
/* -------------------------------------------- */
/* Application Methods */
/* -------------------------------------------- */
/** @inheritdoc */
async apply(level, data) {
if ( data.type === "asi" ) {
const assignments = foundry.utils.mergeObject(this.configuration.fixed, data.assignments, {inplace: false});
const updates = {};
for ( const key of Object.keys(assignments) ) {
const ability = this.actor.system.abilities[key];
if ( !ability || !this.canImprove(key) ) continue;
assignments[key] = Math.min(assignments[key], ability.max - ability.value);
if ( assignments[key] ) updates[`system.abilities.${key}.value`] = ability.value + assignments[key];
else delete assignments[key];
}
data.assignments = assignments;
data.feat = null;
this.actor.updateSource(updates);
}
else {
let itemData = data.retainedItems?.[data.featUuid];
if ( !itemData ) {
const source = await fromUuid(data.featUuid);
if ( source ) {
itemData = source.clone({
_id: foundry.utils.randomID(),
"flags.dnd5e.sourceId": data.featUuid,
"flags.dnd5e.advancementOrigin": `${this.item.id}.${this.id}`
}, {keepId: true}).toObject();
}
}
data.assignments = null;
if ( itemData ) {
data.feat = { [itemData._id]: data.featUuid };
this.actor.updateSource({items: [itemData]});
}
}
this.updateSource({value: data});
}
/* -------------------------------------------- */
/** @inheritdoc */
restore(level, data) {
data.featUuid = Object.values(data.feat ?? {})[0];
this.apply(level, data);
}
/* -------------------------------------------- */
/** @inheritdoc */
reverse(level) {
const source = this.value.toObject();
if ( this.value.type === "asi" ) {
const updates = {};
for ( const [key, change] of Object.entries(this.value.assignments ?? {}) ) {
const ability = this.actor.system.abilities[key];
if ( !ability || !this.canImprove(key) ) continue;
updates[`system.abilities.${key}.value`] = ability.value - change;
}
this.actor.updateSource(updates);
}
else {
const [id, uuid] = Object.entries(this.value.feat ?? {})[0] ?? [];
const item = this.actor.items.get(id);
if ( item ) source.retainedItems = {[uuid]: item.toObject()};
this.actor.items.delete(id);
}
this.updateSource({ "value.assignments": null, "value.feat": null });
return source;
}
}
/**
* 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-ability-score-control.hbs",
"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<void>}
*/
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<string,Item5e>} 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}: <strong>${hp}</strong>`;
}
/* -------------------------------------------- */
/**
* 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 {
/** @inheritdoc */
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<string>}
*/
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<D20Roll|null>} 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<DamageRoll|null>} 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: '<i class="fas fa-bed"></i>',
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: '<i class="fas fa-times"></i>',
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: '<i class="fas fa-bed"></i>',
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: '<i class="fas fa-times"></i>',
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<string>} [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<Item5e>|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<Item5e>}
* @private
*/
_classes;
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* A mapping of classes belonging to this Actor.
* @type {Object<Item5e>}
*/
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 += `<p>${art.credit}</p>`;
}
}
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 ( !Number.isFinite(abl.max) ) abl.max = CONFIG.DND5E.maxAbilityScore;
// 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 level of Array.fromRange(Object.keys(CONFIG.DND5E.spellLevels).length - 1, 1) ) {
const slot = spells[`spell${level}`] ??= { value: 0 };
slot.max = Number.isNumeric(slot.override) ? Math.max(parseInt(slot.override), 0) : slots[level - 1] ?? 0;
}
}
/* -------------------------------------------- */
/**
* 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;
}
const [, pactConfig] = Object.entries(CONFIG.DND5E.pactCastingProgression)
.reverse().find(([l]) => Number(l) <= pactLevel) ?? [];
if ( pactConfig ) {
spells.pact.level = pactConfig.level;
if ( override === null ) spells.pact.max = pactConfig.slots;
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
const prototypeToken = {};
if ( "size" in (this.system.traits || {}) ) {
const size = CONFIG.DND5E.tokenSizes[this.system.traits.size || "med"];
if ( !foundry.utils.hasProperty(data, "prototypeToken.width") ) prototypeToken.width = size;
if ( !foundry.utils.hasProperty(data, "prototypeToken.height") ) prototypeToken.height = size;
}
if ( this.type === "character" ) Object.assign(prototypeToken, {
sight: { enabled: true }, actorLink: true, disposition: CONST.TOKEN_DISPOSITIONS.FRIENDLY
});
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<Actor5e>} 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<Actor5e>} 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<Actor5e>} 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<D20Roll>} 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<D20Roll>} 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[options.ability || (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.
const prof = options.prof ?? tool?.prof;
if ( prof?.hasProficiency ) {
parts.push("@prof");
data.prof = 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: `<p>${game.i18n.format("DND5E.AbilityPromptText", {ability: label})}</p>`,
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<D20Roll>} 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<D20Roll>} 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<D20Roll|null>} 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<void>} 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<Roll|null>} 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<Roll>} 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<Roll>} 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<RestResult>} 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<RestResult>} 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<RestResult>} 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<ChatMessage>} 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>} 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<object[]>} 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<Actor5e>}
*/
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 and spellcasting ability
* @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<Array<Token>>|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(foundry.utils.deepClone({
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
if ( keepSpells ) delete d.system.attributes.spellcasting; // Keep spellcasting ability if retaining spells.
// 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;
// Set saving throw proficiencies.
if ( keepSaves ) abilities[k].proficient = oa.proficient;
else if ( mergeSaves ) abilities[k].proficient = Math.max(prof, oa.proficient);
else abilities[k].proficient = source.system.abilities[k].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<Actor>|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: '<i class="fas fa-backward"></i>',
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 ) {
if ( !t.visible || !t.renderable ) continue;
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<string>}
*/
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} <em>(${game.i18n.localize("DND5E.AdvancementChoices")})</em>`;
}
/* -------------------------------------------- */
/** @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<string, *>} 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 "&frac18;";
case 0.25: return "&frac14;";
case 0.5: return "&frac12;";
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}: <strong>${value}</strong>`;
}
/* -------------------------------------------- */
/**
* 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$b = /*#__PURE__*/Object.freeze({
__proto__: null,
AbilityScoreImprovementAdvancement: AbilityScoreImprovementAdvancement,
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<string, number|string>} [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 },
improvement: false
},
san: {
label: "DND5E.AbilitySan",
abbreviation: "DND5E.AbilitySanAbbr",
type: "mental",
defaults: { npc: "wis", vehicle: 0 },
improvement: false
}
};
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 = {
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 basic ammunition types.
* @enum {string}
*/
DND5E.ammoIds = {
arrow: "3c7JXOzsv55gqJS5",
blowgunNeedle: "gBQ8xqTA5f8wP5iu",
crossbowBolt: "SItCnYBqhzqBoaWG",
slingBullet: "z9SbsMIBZzuhZOqT"
};
/* -------------------------------------------- */
/**
* 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 });
/* -------------------------------------------- */
/**
* Types of containers.
* @enum {string}
*/
DND5E.containerTypes = {
backpack: "H8YCd689ezlD26aT",
barrel: "7Yqbqg5EtVW16wfT",
basket: "Wv7HzD6dv1P0q78N",
boltcase: "eJtPBiZtr2pp6ynt",
bottle: "HZp69hhyNZUUCipF",
bucket: "mQVYcHmMSoCUnBnM",
case: "5mIeX824uMklU3xq",
chest: "2YbuclKfhDL0bU4u",
flask: "lHS63sC6bypENNlR",
jug: "0ZBWwjFz3nIAXMLW",
pot: "M8xM8BLK4tpUayEE",
pitcher: "nXWdGtzi8DXDLLsL",
pouch: "9bWTRRDym06PzSAf",
quiver: "4MtQKPn9qMWCFjDA",
sack: "CNdDj8dsXVpRVpXt",
saddlebags: "TmfaFUSZJAotndn9",
tankard: "uw6fINSmZ2j2o57A",
vial: "meJEfX3gZgtMX4x2"
};
/* -------------------------------------------- */
/**
* Configuration data for spellcasting foci.
*
* @typedef {object} SpellcastingFocusConfiguration
* @property {string} label Localized label for this category.
* @property {Object<string, string>} itemIds Item IDs or UUIDs.
*/
/**
* Type of spellcasting foci.
* @enum {SpellcastingFocusConfiguration}
*/
DND5E.focusTypes = {
arcane: {
label: "DND5E.Focus.Arcane",
itemIds: {
crystal: "uXOT4fYbgPY8DGdd",
orb: "tH5Rn0JVRG1zdmPa",
rod: "OojyyGfh91iViuMF",
staff: "BeKIrNIvNHRPQ4t5",
wand: "KA2P6I48iOWlnboO"
}
},
druidic: {
label: "DND5E.Focus.Druidic",
itemIds: {
mistletoe: "xDK9GQd2iqOGH8Sd",
totem: "PGL6aaM0wE5h0VN5",
woodenstaff: "FF1ktpb2YSiyv896",
yewwand: "t5yP0d7YaKwuKKiH"
}
},
holy: {
label: "DND5E.Focus.Holy",
itemIds: {
amulet: "paqlMjggWkBIAeCe",
emblem: "laVqttkGMW4B9654",
reliquary: "gP1URGq3kVIIFHJ7"
}
}
};
/* -------------------------------------------- */
/**
* Configuration data for an item with the "feature" type.
*
* @typedef {object} FeatureTypeConfiguration
* @property {string} label Localized label for this type.
* @property {Object<string, string>} [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: {
arcaneShot: "DND5E.ClassFeature.ArcaneShot",
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",
creatureOrObject: "DND5E.TargetCreatureOrObject",
any: "DND5E.TargetAny",
willing: "DND5E.TargetWilling"
};
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]
];
/* -------------------------------------------- */
/**
* Configuration data for pact casting progression.
*
* @typedef {object} PactProgressionConfig
* @property {number} slots Number of spell slots granted.
* @property {number} level Level of spells that can be cast.
*/
/**
* Define the pact slot & level progression by pact caster level.
* @enum {PactProgressionConfig}
*/
DND5E.pactCastingProgression = {
1: { slots: 1, level: 1 },
2: { slots: 2, level: 1 },
3: { slots: 2, level: 2 },
5: { slots: 2, level: 3 },
7: { slots: 2, level: 4 },
9: { slots: 2, level: 5 },
11: { slots: 3, level: 5 },
17: { slots: 4, level: 5 }
};
/* -------------------------------------------- */
/**
* 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<string, SpellcastingProgressionConfiguration>} [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: '<i class="fas fa-paw"></i>',
label: "DND5E.PolymorphWildShape",
options: {
keepBio: true,
keepClass: true,
keepMental: true,
mergeSaves: true,
mergeSkills: true,
keepEquipmentAE: false
}
},
polymorph: {
icon: '<i class="fas fa-pastafarianism"></i>',
label: "DND5E.Polymorph",
options: {
keepEquipmentAE: false,
keepClassAE: false,
keepFeatAE: false,
keepBackgroundAE: false
}
},
polymorphSelf: {
icon: '<i class="fas fa-eye"></i>',
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");
/* -------------------------------------------- */
/**
* Weapon and armor item proficiency levels.
* @enum {string}
*/
DND5E.weaponAndArmorProficiencyLevels = {
0: "DND5E.NotProficient",
1: "DND5E.Proficient"
};
preLocalize("weaponAndArmorProficiencyLevels");
/* -------------------------------------------- */
/**
* 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 = [
// Configured during init.
];
/* -------------------------------------------- */
/**
* 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;
/**
* Maximum ability score value allowed by default.
* @type {number}
*/
DND5E.maxAbilityScore = 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<string, string>} [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
},
tavernBrawlerFeat: {
name: "DND5E.FlagsTavernBrawler",
hint: "DND5E.FlagsTavernBrawlerHint",
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 = {
AbilityScoreImprovement: AbilityScoreImprovementAdvancement,
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<string, ModuleArtInfo>}
*/
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<void>}
*/
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 = `
<em>
Token artwork by
<a href="https://www.forgotten-adventures.net/" target="_blank" rel="noopener">Forgotten Adventures</a>.
</em>
`;
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<void>}
*/
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"
}
});
// Allow feats during Ability Score Improvements
game.settings.register("dnd5e", "allowFeats", {
name: "SETTINGS.5eFeatsN",
hint: "SETTINGS.5eFeatsL",
scope: "world",
config: true,
default: true,
type: Boolean
});
// 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: `<i class="fas ${icon}"></i>`,
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(Object.keys(CONFIG.DND5E.spellLevels).length).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));
}));
}
/* -------------------------------------------- */
/**
* Determine an item's proficiency level based on its parent actor's proficiencies.
* @protected
*/
_prepareProficiency() {
if ( !["spell", "weapon", "equipment", "tool", "feat", "consumable"].includes(this.type) ) return;
if ( !this.actor?.system.attributes?.prof ) {
this.system.prof = new Proficiency(0, 0);
return;
}
this.system.prof = new Proficiency(this.actor.system.attributes.prof, this.system.proficiencyMultiplier ?? 0);
}
/* -------------------------------------------- */
/**
* 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
this._prepareProficiency();
// 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
if ( this.system.ability !== "none" ) parts.push("@mod");
// Add proficiency bonus.
if ( this.system.prof?.hasProficiency ) {
parts.push("@prof");
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<ChatMessage|object|void>} 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<ChatMessage|object|void>} 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) {
Hooks.onError("Item5e#use", err, {
msg: game.i18n.localize("DND5E.PlaceTemplateError"),
log: "error",
notify: "error"
});
}
}
/**
* 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<D20Roll|null>} 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<DamageRoll>} 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<Roll>} 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<Roll>} 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<Roll>} 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,
prof: this.system.prof,
...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) {
Hooks.onError("Item5e._onChatCardAction", err, {
msg: game.i18n.localize("DND5E.PlaceTemplateError"),
log: "error",
notify: "error"
});
}
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<AdvancementConfig>|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>|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>|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<AdvancementConfig>|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 "weapon":
updates = this._onCreateOwnedWeapon(data, isNPC);
break;
case "feat":
updates = this._onCreateOwnedFeature(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
}
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 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) {
if ( !isNPC ) return;
// NPCs automatically equip items.
const updates = {};
if ( !foundry.utils.hasProperty(data, "system.equipped") ) updates["system.equipped"] = true;
return updates;
}
/**
* Pre-creation logic for the automatic configuration of owned feature 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
*/
_onCreateOwnedFeature(data, isNPC) {
const updates = {};
if ( isNPC && !foundry.utils.getProperty(data, "system.type.value") ) {
updates["system.type.value"] = "monster"; // Set features on NPCs to be 'monster features'.
}
return updates;
}
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* Create a consumable spell scroll Item from a spell Item.
* @param {Item5e|object} spell The spell or item data to be made into a scroll
* @param {object} [options] Additional options that modify the created scroll
* @returns {Item5e} The created scroll consumable item
*/
static async createScrollFromSpell(spell, options={}) {
// Get spell data
const itemData = (spell instanceof Item5e) ? spell.toObject() : spell;
let {
actionType, description, source, activation, duration, target,
range, damage, formula, save, level, attackBonus, ability, components
} = 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 = "</p>";
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
+ `<hr><h3>${itemData.name} (${game.i18n.format("DND5E.LevelNumber", {level})})</h3>`
+ (components.concentration ? `<p><em>${game.i18n.localize("DND5E.ScrollRequiresConcentration")}</em></p>` : "")
+ `<hr>${description.value}<hr>`
+ `<h3>${game.i18n.localize("DND5E.ScrollDetails")}</h3><hr>${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;
ability = "none";
}
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, ability
}
});
foundry.utils.mergeObject(spellScrollData, options);
/**
* A hook event that fires after the item data for a scroll is created but before the item is returned.
* @function dnd5e.createScrollFromSpell
* @memberof hookEvents
* @param {Item5e|object} spell The spell or item data to be made into a scroll.
* @param {object} spellScrollData The final item data used to make the scroll.
*/
Hooks.callAll("dnd5e.createScrollFromSpell", spell, spellScrollData);
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<object>}
* @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<boolean|null>} 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: '<i class="fas fa-trash"></i>',
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<boolean|null>} 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: '<i class="fas fa-sort-numeric-down-alt"></i>',
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<boolean|null>} 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: '<i class="fas fa-times"></i>',
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: '<i class="fas fa-times"></i>',
label: game.i18n.localize("DND5E.AdvancementManagerCloseButtonStop"),
callback: () => super.close(options)
},
continue: {
icon: '<i class="fas fa-chevron-right"></i>',
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;
const type = this.step.type;
// Apply changes based on step type
if ( (type === "delete") && this.step.item ) this.clone.items.delete(this.step.item.id);
else if ( (type === "delete") && this.step.advancement ) {
this.step.advancement.item.deleteAdvancement(this.step.advancement.id, { source: true });
}
else if ( type === "restore" ) await flow.advancement.restore(flow.level, flow.retainedData);
else if ( type === "reverse" ) await flow.retainData(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;
const type = this.step.type;
// Reverse step based on step type
if ( (type === "delete") && this.step.item ) this.clone.updateSource({items: [this.step.item]});
else if ( (type === "delete") && this.step.advancement ) this.advancement.item.createAdvancement(
this.advancement.typeName, this.advancement._source, { source: true }
);
else if ( type === "reverse" ) await flow.advancement.restore(flow.level, flow.retainedData);
else if ( flow ) await flow.retainData(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<string, Set>}
* @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<string>}
* @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<string>}
*/
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(),
overrides: {
attunement: foundry.utils.hasProperty(this.actor.overrides, "system.attributes.attunement.max")
}
};
// 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: "&infin;"};
// 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(Object.keys(CONFIG.DND5E.spellLevels).length - 1, 1).reduce((max, i) => {
const level = levels[`spell${i}`];
if ( level && (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<string>} 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: '<i class="far fa-circle"></i>',
0.5: '<i class="fas fa-adjust"></i>',
1: '<i class="fas fa-check"></i>',
2: '<i class="fas fa-check-double"></i>'
};
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(".item-quantity input").click(ev => ev.target.select()).change(this._onQuantityChange.bind(this));
html.find(".slot-max-override").click(this._onSpellSlotOverride.bind(this));
html.find(".attunement-max-override").click(this._onAttunementOverride.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: "<i class='fas fa-edit fa-fw'></i>",
callback: () => effect.sheet.render(true)
},
{
name: "DND5E.ContextMenuActionDuplicate",
icon: "<i class='fas fa-copy fa-fw'></i>",
callback: () => effect.clone({label: game.i18n.format("DOCUMENT.CopyOf", {name: effect.label})}, {save: true})
},
{
name: "DND5E.ContextMenuActionDelete",
icon: "<i class='fas fa-trash fa-fw'></i>",
callback: () => effect.deleteDialog()
},
{
name: effect.disabled ? "DND5E.ContextMenuActionEnable" : "DND5E.ContextMenuActionDisable",
icon: effect.disabled ? "<i class='fas fa-check fa-fw'></i>" : "<i class='fas fa-times fa-fw'></i>",
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: "<i class='fas fa-edit fa-fw'></i>",
callback: () => item.sheet.render(true)
},
{
name: "DND5E.ContextMenuActionDuplicate",
icon: "<i class='fas fa-copy fa-fw'></i>",
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: "<i class='fas fa-trash fa-fw'></i>",
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: "<i class='fas fa-sun fa-fw'></i>",
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: "<i class='fas fa-shield-alt fa-fw'></i>",
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: "<i class='fas fa-sun fa-fw'></i>",
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);
const item = this.actor.items.get(input.closest("[data-item-id]")?.dataset.itemId);
if ( item ) input.value = Number(foundry.utils.getProperty(item, input.dataset.name)) + delta;
else 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: '<i class="fas fa-check"></i>',
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: '<i class="fas fa-times"></i>',
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<object|boolean>} 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<Item5e>|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.
* @protected
*/
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";
input.addEventListener("focus", event => event.currentTarget.select());
// Replace the HTML
const parent = span.parentElement;
parent.removeChild(span);
parent.appendChild(input);
}
/* -------------------------------------------- */
/**
* Handle enabling editing for attunement maximum.
* @param {MouseEvent} event The originating click event.
* @private
*/
async _onAttunementOverride(event) {
const span = event.currentTarget.parentElement;
const input = document.createElement("INPUT");
input.type = "text";
input.name = "system.attributes.attunement.max";
input.value = this.actor.system.attributes.attunement.max;
input.placeholder = 3;
input.dataset.dtype = "Number";
input.addEventListener("focus", event => event.currentTarget.select());
// 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<Item5e>} Updated item.
* @protected
*/
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});
}
/* -------------------------------------------- */
/**
* Change the quantity of an Owned Item within the actor.
* @param {Event} event The triggering click event.
* @returns {Promise<Item5e>} Updated item.
* @protected
*/
async _onQuantityChange(event) {
event.preventDefault();
const itemId = event.currentTarget.closest(".item").dataset.itemId;
const item = this.actor.items.get(itemId);
const quantity = Math.max(0, parseInt(event.target.value));
event.target.value = quantity;
return item.update({"system.quantity": quantity});
}
/* -------------------------------------------- */
/**
* 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<Roll>} 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<Item5e[]>} 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<Item5e|AdvancementManager>|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<Roll>} 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<Actor5e>|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: `<p>${game.i18n.localize("DND5E.CurrencyConvertHint")}</p>`,
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<AdvancementManager|Item5e>} 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<Item5e>} 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<RestResult>} 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<RestResult>} 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<Actor5e>|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<Item5e>} 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<Item5e>} 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<Item5e>} 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<Item5e>} 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<string>}
* @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<string>}
*/
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")}`);
if ( !members.length ) return game.i18n.localize("DND5E.GroupSummaryEmpty");
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<string,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);
ctx.hasUses = item.hasLimitedUses;
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 ) {
// Input focus and update
const inputs = html.find("input");
inputs.focus(ev => ev.currentTarget.select());
inputs.addBack().find('[type="text"][data-dtype="Number"]').change(ActorSheet5e.prototype._onChangeInputDelta.bind(this));
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));
html.find(".item-quantity input, .item-uses input").change(this._onItemPropertyChange.bind(this));
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: `<p>${game.i18n.localize("DND5E.CurrencyConvertHint")}</p>`,
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);
}
/* -------------------------------------------- */
/**
* Change the quantity or limited uses of an Owned Item within the actor.
* @param {Event} event The triggering click event.
* @returns {Promise<Item5e>} Updated item.
* @protected
*/
async _onItemPropertyChange(event) {
const proto = game.system.applications.actor.ActorSheet5e.prototype;
const parent = event.currentTarget.parentElement;
if ( parent.classList.contains("item-quantity") ) return proto._onQuantityChange.call(this, event);
else if ( parent.classList.contains("item-uses") ) return proto._onUsesChange.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<object|boolean>} 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$a = /*#__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<Advancement[]|null>} 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: '<i class="fas fa-check"></i>',
label: game.i18n.localize("DND5E.AdvancementMigrationConfirm"),
callback: html => resolve(advancements.filter(a => html.querySelector(`[name="${a.id}"]`)?.checked))
},
cancel: {
icon: '<i class="fas fa-times"></i>',
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<AdvancementConfig|null>} 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$9 = /*#__PURE__*/Object.freeze({
__proto__: null,
AbilityScoreImprovementConfig: AbilityScoreImprovementConfig,
AbilityScoreImprovementFlow: AbilityScoreImprovementFlow,
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$8 = /*#__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[data-tab=details]",
".tab[data-tab=effects] .items-list",
".tab[data-tab=description] .editor-content",
".tab[data-tab=advancement] .items-list",
],
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>} 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<string>} 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;
return TokenDocument.implementation.getConsumedAttributes(attrData).reduce((obj, attr) => {
obj[attr] = attr;
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 CONFIG.DND5E.proficiencyLevels[this.item.system.prof?.multiplier || 0];
}
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 "consumable":
for ( const [k, v] of Object.entries(this.item.system.properties ?? {}) ) {
if ( v === true ) props.push(CONFIG.DND5E.physicalWeaponProperties[k]);
}
break;
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: "<i class='fas fa-edit fa-fw'></i>",
condition,
callback: li => this._onAdvancementAction(li[0], "edit")
},
{
name: "DND5E.AdvancementControlDuplicate",
icon: "<i class='fas fa-copy fa-fw'></i>",
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: "<i class='fas fa-trash fa-fw' style='color: rgb(255, 65, 65);'></i>",
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<Item5e>|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<ActiveEffect|boolean>} 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 = this.item.system.toObject().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$7 = /*#__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>} 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 "AbilityScoreImprovement":
features.push(game.i18n.localize("DND5E.AdvancementAbilityScoreImprovementTitle"));
continue;
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 || "&mdash;"};
}));
}
// 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<string>}
* @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$6 = /*#__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<string, SelectChoices>} 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<Item5e>|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$a,
advancement: _module$9,
combat: _module$8,
item: _module$7,
journal: _module$6
});
/**
* 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();
}
}
/**
* The detection mode for Blindsight.
*/
class DetectionModeBlindsight extends DetectionMode {
constructor() {
super({
id: "blindsight",
label: "DND5E.SenseBlindsight",
type: DetectionMode.DETECTION_TYPES.OTHER,
walls: true,
angle: false
});
}
/** @override */
static getDetectionFilter() {
return this._detectionFilter ??= OutlineOverlayFilter.create({
outlineColor: [1, 1, 1, 1],
knockout: true,
wave: true
});
}
/** @override */
_canDetect(visionSource, target) {
// Blindsight can detect anything.
return true;
}
/** @override */
_testLOS(visionSource, mode, target, test) {
const polygonBackend = foundry.utils.isNewerVersion(game.version, 11)
? CONFIG.Canvas.polygonBackends.sight
: CONFIG.Canvas.losBackend;
return !polygonBackend.testCollision(
{ x: visionSource.x, y: visionSource.y },
test.point,
{
type: "sight",
mode: "any",
source: visionSource,
// Blindsight is restricted by total cover and therefore cannot see
// through windows. So we do not want blindsight to see through
// a window as we get close to it. That's why we ignore thresholds.
// We make the assumption that all windows are configured as threshold
// walls. A move-based visibility check would also be an option to check
// for total cover, but this would have the undesirable side effect that
// blindsight wouldn't work through fences, portcullises, etc.
useThreshold: false
}
);
}
}
CONFIG.Canvas.detectionModes.blindsight = new DetectionModeBlindsight();
var _module$5 = /*#__PURE__*/Object.freeze({
__proto__: null,
DetectionModeBlindsight: DetectionModeBlindsight
});
/**
* 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.ceil(Math.abs(r.dx / d.size));
let ny = Math.ceil(Math.abs(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.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,
detectionModes: _module$5,
measureDistances: measureDistances
});
/**
* 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 {number} max Maximum possible score for the ability.
* @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<string, AbilityData>} 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, integer: true, min: 0, max: 1, initial: 0, label: "DND5E.ProficiencyLevel"
}),
max: new foundry.data.fields.NumberField({
required: true, integer: true, nullable: true, min: 0, initial: null, label: "DND5E.AbilityScoreMax"
}),
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<string, SkillData>} skills Actor's skills.
* @property {Object<string, SpellSlotData>} 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, nullable: false, 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, nullable: false, 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 CONFIG.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<string>} 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<string>} value Keys for currently selected traits.
* @property {Set<string>} 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<string>} 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<Actor5e>} 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<Actor5e>} 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 (?<size>[\w-]+) )?(?<type>[^(]+?)(?:\((?<subtype>[^)]+)\))?$/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,
AbilityScoreImprovementConfigurationData: AbilityScoreImprovementConfigurationData,
AbilityScoreImprovementValueData: AbilityScoreImprovementValueData,
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() {
if ( this.ability === "none" ) return null;
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);
}
}
/* -------------------------------------------- */
/**
* 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[this.prof?.multiplier || 0] : 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"
}),
properties: new MappingField(new foundry.data.fields.BooleanField(), {
required: false, label: "DND5E.ItemAmmoProperties"
}),
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";
}
/* -------------------------------------------- */
/**
* The proficiency multiplier for this item.
* @returns {number}
*/
get proficiencyMultiplier() {
const isProficient = this.parent?.actor?.getFlag("dnd5e", "tavernBrawlerFeat");
return isProficient ? 1 : 0;
}
}
/**
* 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 {number} 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.NumberField({
required: true, min: 0, max: 1, integer: true, initial: null, label: "DND5E.ProficiencyLevel"
})
});
}
/* -------------------------------------------- */
/* Migrations */
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(source) {
super.migrateData(source);
EquipmentData.#migrateArmor(source);
EquipmentData.#migrateStrength(source);
EquipmentData.#migrateProficient(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);
}
/* -------------------------------------------- */
/**
* Migrate the proficient field to convert boolean values.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateProficient(source) {
if ( typeof source.proficient === "boolean" ) source.proficient = Number(source.proficient);
}
/* -------------------------------------------- */
/* 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";
}
/* -------------------------------------------- */
/**
* The proficiency multiplier for this item.
* @returns {number}
*/
get proficiencyMultiplier() {
if ( Number.isFinite(this.proficient) ) return this.proficient;
const actor = this.parent.actor;
if ( !actor ) return 0;
if ( actor.type === "npc" ) return 1; // NPCs are always considered proficient with any armor in their stat block.
const config = CONFIG.DND5E.armorProficienciesMap;
const itemProf = config[this.armor?.type];
const actorProfs = actor.system.traits?.armorProf?.value ?? new Set();
const isProficient = (itemProf === true) || actorProfs.has(itemProf) || actorProfs.has(this.baseItem);
return Number(isProficient);
}
}
/**
* 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;
}
/* -------------------------------------------- */
/**
* The proficiency multiplier for this item.
* @returns {number}
*/
get proficiencyMultiplier() {
return 1;
}
}
/**
* 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;
}
/* -------------------------------------------- */
/**
* The proficiency multiplier for this item.
* @returns {number}
*/
get proficiencyMultiplier() {
return 1;
}
}
/**
* 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, initial: null, min: 0, max: 2, step: 0.5, 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";
}
/* -------------------------------------------- */
/**
* The proficiency multiplier for this item.
* @returns {number}
*/
get proficiencyMultiplier() {
if ( Number.isFinite(this.proficient) ) return this.proficient;
const actor = this.parent.actor;
if ( !actor ) return 0;
if ( actor.type === "npc" ) return 1;
const baseItemProf = actor.system.tools?.[this.baseItem];
const categoryProf = actor.system.tools?.[this.toolType];
return Math.max(baseItemProf?.value ?? 0, categoryProf?.value ?? 0);
}
}
/**
* 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 {number} 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.NumberField({
required: true, min: 0, max: 1, integer: true, initial: null, label: "DND5E.ProficiencyLevel"
})
});
}
/* -------------------------------------------- */
/* 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 convert boolean values.
* @param {object} source The candidate source data from which the model will be constructed.
*/
static #migrateProficient(source) {
if ( typeof source.proficient === "boolean" ) source.proficient = Number(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";
}
/* -------------------------------------------- */
/**
* The proficiency multiplier for this item.
* @returns {number}
*/
get proficiencyMultiplier() {
if ( Number.isFinite(this.proficient) ) return this.proficient;
const actor = this.parent.actor;
if ( !actor ) return 0;
if ( actor.type === "npc" ) return 1; // NPCs are always considered proficient with any weapon in their stat block.
const config = CONFIG.DND5E.weaponProficienciesMap;
const itemProf = config[this.weaponType];
const actorProfs = actor.system.traits?.weaponProf?.value ?? new Set();
const natural = this.weaponType === "natural";
const improvised = (this.weaponType === "improv") && !!actor.getFlag("dnd5e", "tavernBrawlerFeat");
const isProficient = natural || improvised || actorProfs.has(itemProf) || actorProfs.has(this.baseItem);
return Number(isProficient);
}
}
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<string>} 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,
SparseDataModel: SparseDataModel,
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<D20Roll|null>} 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<D20Roll|null>} 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 {string[]}
*/
static getConsumedAttributes(data) {
return CONFIG.DND5E.consumableResources;
}
/* -------------------------------------------- */
/**
* 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: '<i class="fas fa-user-minus"></i>',
condition: canApply,
callback: li => applyChatCardDamage(li, 1)
},
{
name: game.i18n.localize("DND5E.ChatContextHealing"),
icon: '<i class="fas fa-user-plus"></i>',
condition: canApply,
callback: li => applyChatCardDamage(li, -1)
},
{
name: game.i18n.localize("DND5E.ChatContextTempHP"),
icon: '<i class="fas fa-user-clock"></i>',
condition: canApply,
callback: li => applyChatCardTemp(li)
},
{
name: game.i18n.localize("DND5E.ChatContextDoubleDamage"),
icon: '<i class="fas fa-user-injured"></i>',
condition: canApply,
callback: li => applyChatCardDamage(li, 2)
},
{
name: game.i18n.localize("DND5E.ChatContextHalfDamage"),
icon: '<i class="fas fa-user-shield"></i>',
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 => {
return (m.name === macroData.name) && (m.command === macroData.command) && m.isAuthor;
}) || 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<ChatMessage|object>} 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<ActiveEffect>} 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$b,
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 World Roll Tables
for ( const table of game.tables ) {
try {
const updateData = migrateRollTableData(table.toObject(), migrationData);
if ( !foundry.utils.isEmpty(updateData) ) {
console.log(`Migrating RollTable document ${table.name}`);
await table.update(updateData, { enforceTypes: false });
}
} catch ( err ) {
err.message = `Failed dnd5e system migration for RollTable ${table.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;
}
// 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 RollTable document to incorporate the latest data model changes.
* @param {object} table Roll table data to migrate.
* @param {object} [migrationData] Additional data to perform the migration.
* @returns {object} The update delta to apply.
*/
function migrateRollTableData(table, migrationData) {
const updateData = {};
_migrateDocumentIcon(table, updateData, migrationData);
if ( !table.results?.length ) return updateData;
const results = table.results.reduce((arr, result) => {
const resultUpdate = {};
_migrateDocumentIcon(result, resultUpdate, migrationData);
if ( !foundry.utils.isEmpty(resultUpdate) ) {
resultUpdate._id = result._id;
arr.push(foundry.utils.expandObject(resultUpdate));
}
return arr;
}, []);
if ( results.length ) updateData.results = results;
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>} 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<string, string>} [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,
migrateRollTableData: migrateRollTableData,
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;
// 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;
// Configure trackable & consumable attributes.
_configureTrackableAttributes();
_configureConsumableAttributes();
// 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", "spells.pact"],
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: []
}
};
}
/**
* Configure which attributes are available for item consumption.
* @internal
*/
function _configureConsumableAttributes() {
CONFIG.DND5E.consumableResources = [
...Object.keys(DND5E.abilities).map(ability => `abilities.${ability}.value`),
"attributes.ac.flat",
"attributes.hp.value",
...Object.keys(DND5E.senses).map(sense => `attributes.senses.${sense}`),
...Object.keys(DND5E.movementTypes).map(type => `attributes.movement.${type}`),
...Object.keys(DND5E.currencies).map(denom => `currency.${denom}`),
"details.xp.value",
"resources.primary.value", "resources.secondary.value", "resources.tertiary.value",
"resources.legact.value", "resources.legres.value",
"spells.pact.value",
...Array.fromRange(Object.keys(DND5E.spellLevels).length - 1, 1).map(level => `spells.spell${level}.value`)
];
}
/* -------------------------------------------- */
/* Foundry VTT Setup */
/* -------------------------------------------- */
/**
* Prepare attribute lists.
*/
Hooks.once("setup", function() {
CONFIG.DND5E.trackableAttributes = expandAttributeList(CONFIG.DND5E.trackableAttributes);
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