|
|
- /**
- * 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;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- getData() {
- return {
- appId: this.id,
- advancement: this.advancement,
- type: this.advancement.constructor.typeName,
- title: this.title,
- summary: this.advancement.summaryForLevel(this.level),
- level: this.level
- };
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _updateObject(event, formData) {
- await this.advancement.apply(this.level, formData);
- }
-
- }
-
- /**
- * Data field that selects the appropriate advancement data model if available, otherwise defaults to generic
- * `ObjectField` to prevent issues with custom advancement types that aren't currently loaded.
- */
- class AdvancementField extends foundry.data.fields.ObjectField {
-
- /**
- * Get the BaseAdvancement definition for the specified advancement type.
- * @param {string} type The Advancement type.
- * @returns {typeof BaseAdvancement|null} The BaseAdvancement class, or null.
- */
- getModelForType(type) {
- return CONFIG.DND5E.advancementTypes[type] ?? null;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- _cleanType(value, options) {
- if ( !(typeof value === "object") ) value = {};
-
- const cls = this.getModelForType(value.type);
- if ( cls ) return cls.cleanData(value, options);
- return value;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- initialize(value, model, options={}) {
- const cls = this.getModelForType(value.type);
- if ( cls ) return new cls(value, {parent: model, ...options});
- return foundry.utils.deepClone(value);
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Data field that automatically selects the Advancement-specific configuration or value data models.
- *
- * @param {Advancement} advancementType Advancement class to which this field belongs.
- */
- class AdvancementDataField extends foundry.data.fields.ObjectField {
- constructor(advancementType, options={}) {
- super(options);
- this.advancementType = advancementType;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static get _defaults() {
- return foundry.utils.mergeObject(super._defaults, {required: true});
- }
-
- /**
- * Get the DataModel definition for the specified field as defined in metadata.
- * @returns {typeof DataModel|null} The DataModel class, or null.
- */
- getModel() {
- return this.advancementType.metadata?.dataModels?.[this.name];
- }
-
- /* -------------------------------------------- */
-
- /**
- * Get the defaults object for the specified field as defined in metadata.
- * @returns {object}
- */
- getDefaults() {
- return this.advancementType.metadata?.defaults?.[this.name] ?? {};
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- _cleanType(value, options) {
- if ( !(typeof value === "object") ) value = {};
-
- // Use a defined DataModel
- const cls = this.getModel();
- if ( cls ) return cls.cleanData(value, options);
- if ( options.partial ) return value;
-
- // Use the defined defaults
- const defaults = this.getDefaults();
- return foundry.utils.mergeObject(defaults, value, {inplace: false});
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- initialize(value, model, options={}) {
- const cls = this.getModel();
- if ( cls ) return new cls(value, {parent: model, ...options});
- return foundry.utils.deepClone(value);
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * @typedef {StringFieldOptions} FormulaFieldOptions
- * @property {boolean} [deterministic=false] Is this formula not allowed to have dice values?
- */
-
- /**
- * Special case StringField which represents a formula.
- *
- * @param {FormulaFieldOptions} [options={}] Options which configure the behavior of the field.
- * @property {boolean} deterministic=false Is this formula not allowed to have dice values?
- */
- class FormulaField extends foundry.data.fields.StringField {
-
- /** @inheritdoc */
- static get _defaults() {
- return foundry.utils.mergeObject(super._defaults, {
- deterministic: false
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- _validateType(value) {
- if ( this.options.deterministic ) {
- const roll = new Roll(value);
- if ( !roll.isDeterministic ) throw new Error("must not contain dice terms");
- Roll.safeEval(roll.formula);
- }
- else Roll.validate(value);
- super._validateType(value);
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Special case StringField that includes automatic validation for identifiers.
- */
- class IdentifierField extends foundry.data.fields.StringField {
- /** @override */
- _validateType(value) {
- if ( !dnd5e.utils.validators.isValidIdentifier(value) ) {
- throw new Error(game.i18n.localize("DND5E.IdentifierError"));
- }
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * @callback MappingFieldInitialValueBuilder
- * @param {string} key The key within the object where this new value is being generated.
- * @param {*} initial The generic initial data provided by the contained model.
- * @param {object} existing Any existing mapping data.
- * @returns {object} Value to use as default for this key.
- */
-
- /**
- * @typedef {DataFieldOptions} MappingFieldOptions
- * @property {string[]} [initialKeys] Keys that will be created if no data is provided.
- * @property {MappingFieldInitialValueBuilder} [initialValue] Function to calculate the initial value for a key.
- * @property {boolean} [initialKeysOnly=false] Should the keys in the initialized data be limited to the keys provided
- * by `options.initialKeys`?
- */
-
- /**
- * A subclass of ObjectField that represents a mapping of keys to the provided DataField type.
- *
- * @param {DataField} model The class of DataField which should be embedded in this field.
- * @param {MappingFieldOptions} [options={}] Options which configure the behavior of the field.
- * @property {string[]} [initialKeys] Keys that will be created if no data is provided.
- * @property {MappingFieldInitialValueBuilder} [initialValue] Function to calculate the initial value for a key.
- * @property {boolean} [initialKeysOnly=false] Should the keys in the initialized data be limited to the keys provided
- * by `options.initialKeys`?
- */
- class MappingField extends foundry.data.fields.ObjectField {
- constructor(model, options) {
- if ( !(model instanceof foundry.data.fields.DataField) ) {
- throw new Error("MappingField must have a DataField as its contained element");
- }
- super(options);
-
- /**
- * The embedded DataField definition which is contained in this field.
- * @type {DataField}
- */
- this.model = model;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static get _defaults() {
- return foundry.utils.mergeObject(super._defaults, {
- initialKeys: null,
- initialValue: null,
- initialKeysOnly: false
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- _cleanType(value, options) {
- Object.entries(value).forEach(([k, v]) => value[k] = this.model.clean(v, options));
- return value;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- getInitialValue(data) {
- let keys = this.initialKeys;
- const initial = super.getInitialValue(data);
- if ( !keys || !foundry.utils.isEmpty(initial) ) return initial;
- if ( !(keys instanceof Array) ) keys = Object.keys(keys);
- for ( const key of keys ) initial[key] = this._getInitialValueForKey(key);
- return initial;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Get the initial value for the provided key.
- * @param {string} key Key within the object being built.
- * @param {object} [object] Any existing mapping data.
- * @returns {*} Initial value based on provided field type.
- */
- _getInitialValueForKey(key, object) {
- const initial = this.model.getInitialValue();
- return this.initialValue?.(key, initial, object) ?? initial;
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- _validateType(value, options={}) {
- if ( foundry.utils.getType(value) !== "Object" ) throw new Error("must be an Object");
- const errors = this._validateValues(value, options);
- if ( !foundry.utils.isEmpty(errors) ) throw new foundry.data.fields.ModelValidationError(errors);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Validate each value of the object.
- * @param {object} value The object to validate.
- * @param {object} options Validation options.
- * @returns {Object<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 foundry.abstract.DataModel {
-
- /**
- * Name of this advancement type that will be stored in config and used for lookups.
- * @type {string}
- * @protected
- */
- static get typeName() {
- return this.name.replace(/Advancement$/, "");
- }
-
- /* -------------------------------------------- */
-
- static defineSchema() {
- return {
- _id: new foundry.data.fields.DocumentIdField({initial: () => foundry.utils.randomID()}),
- type: new foundry.data.fields.StringField({
- required: true, initial: this.typeName, validate: v => v === this.typeName,
- validationError: `must be the same as the Advancement type name ${this.typeName}`
- }),
- configuration: new AdvancementDataField(this, {required: true}),
- value: new AdvancementDataField(this, {required: true}),
- level: new foundry.data.fields.NumberField({
- integer: true, initial: this.metadata?.multiLevel ? undefined : 1, min: 0, label: "DND5E.Level"
- }),
- title: new foundry.data.fields.StringField({initial: undefined, label: "DND5E.AdvancementCustomTitle"}),
- icon: new foundry.data.fields.FilePathField({
- initial: undefined, categories: ["IMAGE"], label: "DND5E.AdvancementCustomIcon"
- }),
- classRestriction: new foundry.data.fields.StringField({
- initial: undefined, choices: ["primary", "secondary"], label: "DND5E.AdvancementClassRestriction"
- })
- };
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- toObject(source=true) {
- if ( !source ) return super.toObject(source);
- const clone = foundry.utils.deepClone(this._source);
- // Remove any undefined keys from the source data
- Object.keys(clone).filter(k => clone[k] === undefined).forEach(k => delete clone[k]);
- return clone;
- }
- }
-
- /**
- * Error that can be thrown during the advancement update preparation process.
- */
- class AdvancementError extends Error {
- constructor(...args) {
- super(...args);
- this.name = "AdvancementError";
- }
- }
-
- /**
- * Abstract base class which various advancement types can subclass.
- * @param {Item5e} item Item to which this advancement belongs.
- * @param {object} [data={}] Raw data stored in the advancement object.
- * @param {object} [options={}] Options which affect DataModel construction.
- * @abstract
- */
- class Advancement extends BaseAdvancement {
- constructor(data, {parent=null, ...options}={}) {
- if ( parent instanceof Item ) parent = parent.system;
- super(data, {parent, ...options});
-
- /**
- * A collection of Application instances which should be re-rendered whenever this document is updated.
- * The keys of this object are the application ids and the values are Application instances. Each
- * Application in this object will have its render method called by {@link Document#render}.
- * @type {Object<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 hit points.
- */
- class HitPointsConfig extends AdvancementConfig {
-
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- template: "systems/dnd5e/templates/advancement/hit-points-config.hbs"
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- getData() {
- return foundry.utils.mergeObject(super.getData(), {
- hitDie: this.advancement.hitDie
- });
- }
- }
-
- /**
- * Inline application that presents hit points selection upon level up.
- */
- class HitPointsFlow extends AdvancementFlow {
-
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- template: "systems/dnd5e/templates/advancement/hit-points-flow.hbs"
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- getData() {
- const source = this.retainedData ?? this.advancement.value;
- const value = source[this.level];
-
- // If value is empty, `useAverage` should default to the value selected at the previous level
- let useAverage = value === "avg";
- if ( !value ) {
- const lastValue = source[this.level - 1];
- if ( lastValue === "avg" ) useAverage = true;
- }
-
- return foundry.utils.mergeObject(super.getData(), {
- isFirstClassLevel: (this.level === 1) && this.advancement.item.isOriginalClass,
- hitDie: this.advancement.hitDie,
- dieValue: this.advancement.hitDieValue,
- data: {
- value: Number.isInteger(value) ? value : "",
- useAverage
- }
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- activateListeners(html) {
- this.form.querySelector(".averageCheckbox")?.addEventListener("change", event => {
- this.form.querySelector(".rollResult").disabled = event.target.checked;
- this.form.querySelector(".rollButton").disabled = event.target.checked;
- this._updateRollResult();
- });
- this.form.querySelector(".rollButton")?.addEventListener("click", async () => {
- const roll = await this.advancement.actor.rollClassHitPoints(this.advancement.item);
- this.form.querySelector(".rollResult").value = roll.total;
- });
- this._updateRollResult();
- }
-
- /* -------------------------------------------- */
-
- /**
- * Update the roll result display when the average result is taken.
- * @protected
- */
- _updateRollResult() {
- if ( !this.form.elements.useAverage?.checked ) return;
- this.form.elements.value.value = (this.advancement.hitDieValue / 2) + 1;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- _updateObject(event, formData) {
- let value;
- if ( formData.useMax ) value = "max";
- else if ( formData.useAverage ) value = "avg";
- else if ( Number.isInteger(formData.value) ) value = parseInt(formData.value);
-
- if ( value !== undefined ) return this.advancement.apply(this.level, { [this.level]: value });
-
- this.form.querySelector(".rollResult")?.classList.add("error");
- const errorType = formData.value ? "Invalid" : "Empty";
- throw new Advancement.ERROR(game.i18n.localize(`DND5E.AdvancementHitPoints${errorType}Error`));
- }
-
- }
-
- /* -------------------------------------------- */
- /* Formulas */
- /* -------------------------------------------- */
-
- /**
- * Convert a bonus value to a simple integer for displaying on the sheet.
- * @param {number|string|null} bonus Bonus formula.
- * @param {object} [data={}] Data to use for replacing @ strings.
- * @returns {number} Simplified bonus as an integer.
- * @protected
- */
- function simplifyBonus(bonus, data={}) {
- if ( !bonus ) return 0;
- if ( Number.isNumeric(bonus) ) return Number(bonus);
- try {
- const roll = new Roll(bonus, data);
- return roll.isDeterministic ? Roll.safeEval(roll.formula) : 0;
- } catch(error) {
- console.error(error);
- return 0;
- }
- }
-
- /* -------------------------------------------- */
- /* Object Helpers */
- /* -------------------------------------------- */
-
- /**
- * Sort the provided object by its values or by an inner sortKey.
- * @param {object} obj The object to sort.
- * @param {string} [sortKey] An inner key upon which to sort.
- * @returns {object} A copy of the original object that has been sorted.
- */
- function sortObjectEntries(obj, sortKey) {
- let sorted = Object.entries(obj);
- if ( sortKey ) sorted = sorted.sort((a, b) => a[1][sortKey].localeCompare(b[1][sortKey]));
- else sorted = sorted.sort((a, b) => a[1].localeCompare(b[1]));
- return Object.fromEntries(sorted);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Retrieve the indexed data for a Document using its UUID. Will never return a result for embedded documents.
- * @param {string} uuid The UUID of the Document index to retrieve.
- * @returns {object} Document's index if one could be found.
- */
- function indexFromUuid(uuid) {
- const parts = uuid.split(".");
- let index;
-
- // Compendium Documents
- if ( parts[0] === "Compendium" ) {
- const [, scope, packName, id] = parts;
- const pack = game.packs.get(`${scope}.${packName}`);
- index = pack?.index.get(id);
- }
-
- // World Documents
- else if ( parts.length < 3 ) {
- const [docName, id] = parts;
- const collection = CONFIG[docName].collection.instance;
- index = collection.get(id);
- }
-
- return index || null;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Creates an HTML document link for the provided UUID.
- * @param {string} uuid UUID for which to produce the link.
- * @returns {string} Link to the item or empty string if item wasn't found.
- */
- function linkForUuid(uuid) {
- return TextEditor._createContentLink(["", "UUID", uuid]).outerHTML;
- }
-
- /* -------------------------------------------- */
- /* Validators */
- /* -------------------------------------------- */
-
- /**
- * Ensure the provided string contains only the characters allowed in identifiers.
- * @param {string} identifier
- * @returns {boolean}
- */
- function isValidIdentifier(identifier) {
- return /^([a-z0-9_-]+)$/i.test(identifier);
- }
-
- const validators = {
- isValidIdentifier: isValidIdentifier
- };
-
- /* -------------------------------------------- */
- /* Handlebars Template Helpers */
- /* -------------------------------------------- */
-
- /**
- * Define a set of template paths to pre-load. Pre-loaded templates are compiled and cached for fast access when
- * rendering. These paths will also be available as Handlebars partials by using the file name
- * (e.g. "dnd5e.actor-traits").
- * @returns {Promise}
- */
- async function preloadHandlebarsTemplates() {
- const partials = [
- // Shared Partials
- "systems/dnd5e/templates/actors/parts/active-effects.hbs",
- "systems/dnd5e/templates/apps/parts/trait-list.hbs",
-
- // Actor Sheet Partials
- "systems/dnd5e/templates/actors/parts/actor-traits.hbs",
- "systems/dnd5e/templates/actors/parts/actor-inventory.hbs",
- "systems/dnd5e/templates/actors/parts/actor-features.hbs",
- "systems/dnd5e/templates/actors/parts/actor-spellbook.hbs",
- "systems/dnd5e/templates/actors/parts/actor-warnings.hbs",
-
- // Item Sheet Partials
- "systems/dnd5e/templates/items/parts/item-action.hbs",
- "systems/dnd5e/templates/items/parts/item-activation.hbs",
- "systems/dnd5e/templates/items/parts/item-advancement.hbs",
- "systems/dnd5e/templates/items/parts/item-description.hbs",
- "systems/dnd5e/templates/items/parts/item-mountable.hbs",
- "systems/dnd5e/templates/items/parts/item-spellcasting.hbs",
- "systems/dnd5e/templates/items/parts/item-summary.hbs",
-
- // Journal Partials
- "systems/dnd5e/templates/journal/parts/journal-table.hbs",
-
- // Advancement Partials
- "systems/dnd5e/templates/advancement/parts/advancement-controls.hbs",
- "systems/dnd5e/templates/advancement/parts/advancement-spell-config.hbs"
- ];
-
- const paths = {};
- for ( const path of partials ) {
- paths[path.replace(".hbs", ".html")] = path;
- paths[`dnd5e.${path.split("/").pop().replace(".hbs", "")}`] = path;
- }
-
- return loadTemplates(paths);
- }
-
- /* -------------------------------------------- */
-
- /**
- * A helper that fetch the appropriate item context from root and adds it to the first block parameter.
- * @param {object} context Current evaluation context.
- * @param {object} options Handlebars options.
- * @returns {string}
- */
- function itemContext(context, options) {
- if ( arguments.length !== 2 ) throw new Error("#dnd5e-itemContext requires exactly one argument");
- if ( foundry.utils.getType(context) === "function" ) context = context.call(this);
-
- const ctx = options.data.root.itemContext?.[context.id];
- if ( !ctx ) {
- const inverse = options.inverse(this);
- if ( inverse ) return options.inverse(this);
- }
-
- return options.fn(context, { data: options.data, blockParams: [ctx] });
- }
-
- /* -------------------------------------------- */
-
- /**
- * Register custom Handlebars helpers used by 5e.
- */
- function registerHandlebarsHelpers() {
- Handlebars.registerHelper({
- getProperty: foundry.utils.getProperty,
- "dnd5e-linkForUuid": linkForUuid,
- "dnd5e-itemContext": itemContext
- });
- }
-
- /* -------------------------------------------- */
- /* Config Pre-Localization */
- /* -------------------------------------------- */
-
- /**
- * Storage for pre-localization configuration.
- * @type {object}
- * @private
- */
- const _preLocalizationRegistrations = {};
-
- /**
- * Mark the provided config key to be pre-localized during the init stage.
- * @param {string} configKeyPath Key path within `CONFIG.DND5E` to localize.
- * @param {object} [options={}]
- * @param {string} [options.key] If each entry in the config enum is an object,
- * localize and sort using this property.
- * @param {string[]} [options.keys=[]] Array of localization keys. First key listed will be used for sorting
- * if multiple are provided.
- * @param {boolean} [options.sort=false] Sort this config enum, using the key if set.
- */
- function preLocalize(configKeyPath, { key, keys=[], sort=false }={}) {
- if ( key ) keys.unshift(key);
- _preLocalizationRegistrations[configKeyPath] = { keys, sort };
- }
-
- /* -------------------------------------------- */
-
- /**
- * Execute previously defined pre-localization tasks on the provided config object.
- * @param {object} config The `CONFIG.DND5E` object to localize and sort. *Will be mutated.*
- */
- function performPreLocalization(config) {
- for ( const [keyPath, settings] of Object.entries(_preLocalizationRegistrations) ) {
- const target = foundry.utils.getProperty(config, keyPath);
- _localizeObject(target, settings.keys);
- if ( settings.sort ) foundry.utils.setProperty(config, keyPath, sortObjectEntries(target, settings.keys[0]));
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Localize the values of a configuration object by translating them in-place.
- * @param {object} obj The configuration object to localize.
- * @param {string[]} [keys] List of inner keys that should be localized if this is an object.
- * @private
- */
- function _localizeObject(obj, keys) {
- for ( const [k, v] of Object.entries(obj) ) {
- const type = typeof v;
- if ( type === "string" ) {
- obj[k] = game.i18n.localize(v);
- continue;
- }
-
- if ( type !== "object" ) {
- console.error(new Error(
- `Pre-localized configuration values must be a string or object, ${type} found for "${k}" instead.`
- ));
- continue;
- }
- if ( !keys?.length ) {
- console.error(new Error(
- "Localization keys must be provided for pre-localizing when target is an object."
- ));
- continue;
- }
-
- for ( const key of keys ) {
- if ( !v[key] ) continue;
- v[key] = game.i18n.localize(v[key]);
- }
- }
- }
-
- /* -------------------------------------------- */
- /* Migration */
- /* -------------------------------------------- */
-
- /**
- * Synchronize the spells for all Actors in some collection with source data from an Item compendium pack.
- * @param {CompendiumCollection} actorPack An Actor compendium pack which will be updated
- * @param {CompendiumCollection} spellsPack An Item compendium pack which provides source data for spells
- * @returns {Promise<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 {
- 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 we merged saves when transforming, take the highest bonus here.
- if ( originalSaves && abl.proficient ) abl.save = Math.max(abl.save, originalSaves[id].save);
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare skill checks. Mutates the values of system.skills.
- * @param {object} bonusData Data produced by `getRollData` to be applied to bonus formulas.
- * @param {object} globalBonuses Global bonus data.
- * @param {number} checkBonus Global ability check bonus.
- * @param {object} originalSkills A transformed actor's original actor's skills.
- * @protected
- */
- _prepareSkills(bonusData, globalBonuses, checkBonus, originalSkills) {
- if ( this.type === "vehicle" ) return;
- const flags = this.flags.dnd5e ?? {};
-
- // Skill modifiers
- const feats = CONFIG.DND5E.characterFlags;
- const skillBonus = simplifyBonus(globalBonuses.skill, bonusData);
- for ( const [id, skl] of Object.entries(this.system.skills) ) {
- const ability = this.system.abilities[skl.ability];
- const baseBonus = simplifyBonus(skl.bonuses?.check, bonusData);
- let roundDown = true;
-
- // Remarkable Athlete
- if ( this._isRemarkableAthlete(skl.ability) && (skl.value < 0.5) ) {
- skl.value = 0.5;
- roundDown = false;
- }
-
- // Jack of All Trades
- else if ( flags.jackOfAllTrades && (skl.value < 0.5) ) {
- skl.value = 0.5;
- }
-
- // Polymorph Skill Proficiencies
- if ( originalSkills ) {
- skl.value = Math.max(skl.value, originalSkills[id].value);
- }
-
- // Compute modifier
- const checkBonusAbl = simplifyBonus(ability?.bonuses?.check, bonusData);
- skl.bonus = baseBonus + checkBonus + checkBonusAbl + skillBonus;
- skl.mod = ability?.mod ?? 0;
- skl.prof = new Proficiency(this.system.attributes.prof, skl.value, roundDown);
- skl.proficient = skl.value;
- skl.total = skl.mod + skl.bonus;
- if ( Number.isNumeric(skl.prof.term) ) skl.total += skl.prof.flat;
-
- // Compute passive bonus
- const passive = flags.observantFeat && (feats.observantFeat.skills.includes(id)) ? 5 : 0;
- const passiveBonus = simplifyBonus(skl.bonuses?.passive, bonusData);
- skl.passive = 10 + skl.mod + skl.bonus + skl.prof.flat + passive + passiveBonus;
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare tool checks. Mutates the values of system.tools.
- * @param {object} bonusData Data produced by `getRollData` to be applied to bonus formulae.
- * @param {object} globalBonuses Global bonus data.
- * @param {number} checkBonus Global ability check bonus.
- * @protected
- */
- _prepareTools(bonusData, globalBonuses, checkBonus) {
- if ( this.type === "vehicle" ) return;
- const flags = this.flags.dnd5e ?? {};
- for ( const tool of Object.values(this.system.tools) ) {
- const ability = this.system.abilities[tool.ability];
- const baseBonus = simplifyBonus(tool.bonuses.check, bonusData);
- let roundDown = true;
-
- // Remarkable Athlete.
- if ( this._isRemarkableAthlete(tool.ability) && (tool.value < 0.5) ) {
- tool.value = 0.5;
- roundDown = false;
- }
-
- // Jack of All Trades.
- else if ( flags.jackOfAllTrades && (tool.value < 0.5) ) tool.value = 0.5;
-
- const checkBonusAbl = simplifyBonus(ability?.bonuses?.check, bonusData);
- tool.bonus = baseBonus + checkBonus + checkBonusAbl;
- tool.mod = ability?.mod ?? 0;
- tool.prof = new Proficiency(this.system.attributes.prof, tool.value, roundDown);
- tool.total = tool.mod + tool.bonus;
- if ( Number.isNumeric(tool.prof.term) ) tool.total += tool.prof.flat;
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare a character's AC value from their equipped armor and shield.
- * Mutates the value of the `system.attributes.ac` object.
- */
- _prepareArmorClass() {
- const ac = this.system.attributes.ac;
-
- // Apply automatic migrations for older data structures
- let cfg = CONFIG.DND5E.armorClasses[ac.calc];
- if ( !cfg ) {
- ac.calc = "flat";
- if ( Number.isNumeric(ac.value) ) ac.flat = Number(ac.value);
- cfg = CONFIG.DND5E.armorClasses.flat;
- }
-
- // Identify Equipped Items
- const armorTypes = new Set(Object.keys(CONFIG.DND5E.armorTypes));
- const {armors, shields} = this.itemTypes.equipment.reduce((obj, equip) => {
- const armor = equip.system.armor;
- if ( !equip.system.equipped || !armorTypes.has(armor?.type) ) return obj;
- if ( armor.type === "shield" ) obj.shields.push(equip);
- else obj.armors.push(equip);
- return obj;
- }, {armors: [], shields: []});
-
- // Determine base AC
- switch ( ac.calc ) {
-
- // Flat AC (no additional bonuses)
- case "flat":
- ac.value = Number(ac.flat);
- return;
-
- // Natural AC (includes bonuses)
- case "natural":
- ac.base = Number(ac.flat);
- break;
-
- default:
- let formula = ac.calc === "custom" ? ac.formula : cfg.formula;
- if ( armors.length ) {
- if ( armors.length > 1 ) this._preparationWarnings.push({
- message: game.i18n.localize("DND5E.WarnMultipleArmor"), type: "warning"
- });
- const armorData = armors[0].system.armor;
- const isHeavy = armorData.type === "heavy";
- ac.armor = armorData.value ?? ac.armor;
- ac.dex = isHeavy ? 0 : Math.min(armorData.dex ?? Infinity, this.system.abilities.dex?.mod ?? 0);
- ac.equippedArmor = armors[0];
- }
- else ac.dex = this.system.abilities.dex?.mod ?? 0;
-
- const rollData = this.getRollData({ deterministic: true });
- rollData.attributes.ac = ac;
- try {
- const replaced = Roll.replaceFormulaData(formula, rollData);
- ac.base = Roll.safeEval(replaced);
- } catch(err) {
- this._preparationWarnings.push({
- message: game.i18n.localize("DND5E.WarnBadACFormula"), link: "armor", type: "error"
- });
- const replaced = Roll.replaceFormulaData(CONFIG.DND5E.armorClasses.default.formula, rollData);
- ac.base = Roll.safeEval(replaced);
- }
- break;
- }
-
- // Equipped Shield
- if ( shields.length ) {
- if ( shields.length > 1 ) this._preparationWarnings.push({
- message: game.i18n.localize("DND5E.WarnMultipleShields"), type: "warning"
- });
- ac.shield = shields[0].system.armor.value ?? 0;
- ac.equippedShield = shields[0];
- }
-
- // Compute total AC and return
- ac.value = ac.base + ac.shield + ac.bonus + ac.cover;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare the level and percentage of encumbrance for an Actor.
- * Optionally include the weight of carried currency by applying the standard rule from the PHB pg. 143.
- * Mutates the value of the `system.attributes.encumbrance` object.
- * @protected
- */
- _prepareEncumbrance() {
- const encumbrance = this.system.attributes.encumbrance ??= {};
-
- // Get the total weight from items
- const physicalItems = ["weapon", "equipment", "consumable", "tool", "backpack", "loot"];
- let weight = this.items.reduce((weight, i) => {
- if ( !physicalItems.includes(i.type) ) return weight;
- const q = i.system.quantity || 0;
- const w = i.system.weight || 0;
- return weight + (q * w);
- }, 0);
-
- // [Optional] add Currency Weight (for non-transformed actors)
- const currency = this.system.currency;
- if ( game.settings.get("dnd5e", "currencyWeight") && currency ) {
- const numCoins = Object.values(currency).reduce((val, denom) => val + Math.max(denom, 0), 0);
- const currencyPerWeight = game.settings.get("dnd5e", "metricWeightUnits")
- ? CONFIG.DND5E.encumbrance.currencyPerWeight.metric
- : CONFIG.DND5E.encumbrance.currencyPerWeight.imperial;
- weight += numCoins / currencyPerWeight;
- }
-
- // Determine the Encumbrance size class
- let mod = {tiny: 0.5, sm: 1, med: 1, lg: 2, huge: 4, grg: 8}[this.system.traits.size] || 1;
- if ( this.flags.dnd5e?.powerfulBuild ) mod = Math.min(mod * 2, 8);
-
- const strengthMultiplier = game.settings.get("dnd5e", "metricWeightUnits")
- ? CONFIG.DND5E.encumbrance.strMultiplier.metric
- : CONFIG.DND5E.encumbrance.strMultiplier.imperial;
-
- // Populate final Encumbrance values
- encumbrance.value = weight.toNearest(0.1);
- encumbrance.max = ((this.system.abilities.str?.value ?? 10) * strengthMultiplier * mod).toNearest(0.1);
- encumbrance.pct = Math.clamped((encumbrance.value * 100) / encumbrance.max, 0, 100);
- encumbrance.encumbered = encumbrance.pct > (200 / 3);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare hit points for characters.
- * @param {object} rollData Data produced by `getRollData` to be applied to bonus formulas.
- * @protected
- */
- _prepareHitPoints(rollData) {
- if ( this.type !== "character" || (this.system._source.attributes.hp.max !== null) ) return;
- const hp = this.system.attributes.hp;
-
- const abilityId = CONFIG.DND5E.hitPointsAbility || "con";
- const abilityMod = (this.system.abilities[abilityId]?.mod ?? 0);
- const base = Object.values(this.classes).reduce((total, item) => {
- const advancement = item.advancement.byType.HitPoints?.[0];
- return total + (advancement?.getAdjustedTotal(abilityMod) ?? 0);
- }, 0);
- const levelBonus = simplifyBonus(hp.bonuses.level, rollData) * this.system.details.level;
- const overallBonus = simplifyBonus(hp.bonuses.overall, rollData);
-
- hp.max = base + levelBonus + overallBonus;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare the initiative data for an actor.
- * Mutates the value of the system.attributes.init object.
- * @param {object} bonusData Data produced by getRollData to be applied to bonus formulas
- * @param {number} globalCheckBonus Global ability check bonus
- * @protected
- */
- _prepareInitiative(bonusData, globalCheckBonus=0) {
- const init = this.system.attributes.init ??= {};
- const flags = this.flags.dnd5e || {};
-
- // Compute initiative modifier
- const abilityId = init.ability || CONFIG.DND5E.initiativeAbility;
- const ability = this.system.abilities?.[abilityId] || {};
- init.mod = ability.mod ?? 0;
-
- // Initiative proficiency
- const prof = this.system.attributes.prof ?? 0;
- const ra = flags.remarkableAthlete && ["str", "dex", "con"].includes(abilityId);
- init.prof = new Proficiency(prof, (flags.jackOfAllTrades || ra) ? 0.5 : 0, !ra);
-
- // Total initiative includes all numeric terms
- const initBonus = simplifyBonus(init.bonus, bonusData);
- const abilityBonus = simplifyBonus(ability.bonuses?.check, bonusData);
- init.total = init.mod + initBonus + abilityBonus + globalCheckBonus
- + (flags.initiativeAlert ? 5 : 0)
- + (Number.isNumeric(init.prof.term) ? init.prof.flat : 0);
- }
-
- /* -------------------------------------------- */
- /* Spellcasting Preparation */
- /* -------------------------------------------- */
-
- /**
- * Prepare data related to the spell-casting capabilities of the Actor.
- * Mutates the value of the system.spells object.
- * @protected
- */
- _prepareSpellcasting() {
- if ( !this.system.spells ) return;
-
- // Spellcasting DC
- const spellcastingAbility = this.system.abilities[this.system.attributes.spellcasting];
- this.system.attributes.spelldc = spellcastingAbility ? spellcastingAbility.dc : 8 + this.system.attributes.prof;
-
- // Translate the list of classes into spellcasting progression
- const progression = { slot: 0, pact: 0 };
- const types = {};
-
- // NPCs don't get spell levels from classes
- if ( this.type === "npc" ) {
- progression.slot = this.system.details.spellLevel ?? 0;
- types.leveled = 1;
- }
-
- else {
- // Grab all classes with spellcasting
- const classes = this.items.filter(cls => {
- if ( cls.type !== "class" ) return false;
- const type = cls.spellcasting.type;
- if ( !type ) return false;
- types[type] ??= 0;
- types[type] += 1;
- return true;
- });
-
- for ( const cls of classes ) this.constructor.computeClassProgression(
- progression, cls, { actor: this, count: types[cls.spellcasting.type] }
- );
- }
-
- for ( const type of Object.keys(CONFIG.DND5E.spellcastingTypes) ) {
- this.constructor.prepareSpellcastingSlots(this.system.spells, type, progression, { actor: this });
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Contribute to the actor's spellcasting progression.
- * @param {object} progression Spellcasting progression data. *Will be mutated.*
- * @param {Item5e} cls Class for whom this progression is being computed.
- * @param {object} [config={}]
- * @param {Actor5e|null} [config.actor] Actor for whom the data is being prepared.
- * @param {SpellcastingDescription} [config.spellcasting] Spellcasting descriptive object.
- * @param {number} [config.count=1] Number of classes with this type of spellcasting.
- */
- static computeClassProgression(progression, cls, {actor, spellcasting, count=1}={}) {
- const type = cls.spellcasting.type;
- spellcasting = spellcasting ?? cls.spellcasting;
-
- /**
- * A hook event that fires while computing the spellcasting progression for each class on each actor.
- * The actual hook names include the spellcasting type (e.g. `dnd5e.computeLeveledProgression`).
- * @param {object} progression Spellcasting progression data. *Will be mutated.*
- * @param {Actor5e|null} [actor] Actor for whom the data is being prepared.
- * @param {Item5e} cls Class for whom this progression is being computed.
- * @param {SpellcastingDescription} spellcasting Spellcasting descriptive object.
- * @param {number} count Number of classes with this type of spellcasting.
- * @returns {boolean} Explicitly return false to prevent default progression from being calculated.
- * @function dnd5e.computeSpellcastingProgression
- * @memberof hookEvents
- */
- const allowed = Hooks.call(
- `dnd5e.compute${type.capitalize()}Progression`, progression, actor, cls, spellcasting, count
- );
-
- if ( allowed && (type === "pact") ) {
- this.computePactProgression(progression, actor, cls, spellcasting, count);
- } else if ( allowed && (type === "leveled") ) {
- this.computeLeveledProgression(progression, actor, cls, spellcasting, count);
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Contribute to the actor's spellcasting progression for a class with leveled spellcasting.
- * @param {object} progression Spellcasting progression data. *Will be mutated.*
- * @param {Actor5e} actor Actor for whom the data is being prepared.
- * @param {Item5e} cls Class for whom this progression is being computed.
- * @param {SpellcastingDescription} spellcasting Spellcasting descriptive object.
- * @param {number} count Number of classes with this type of spellcasting.
- */
- static computeLeveledProgression(progression, actor, cls, spellcasting, count) {
- const prog = CONFIG.DND5E.spellcastingTypes.leveled.progression[spellcasting.progression];
- if ( !prog ) return;
- const rounding = prog.roundUp ? Math.ceil : Math.floor;
- progression.slot += rounding(spellcasting.levels / prog.divisor ?? 1);
- // Single-classed, non-full progression rounds up, rather than down.
- if ( (count === 1) && (prog.divisor > 1) && progression.slot ) {
- progression.slot = Math.ceil(spellcasting.levels / prog.divisor);
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Contribute to the actor's spellcasting progression for a class with pact spellcasting.
- * @param {object} progression Spellcasting progression data. *Will be mutated.*
- * @param {Actor5e} actor Actor for whom the data is being prepared.
- * @param {Item5e} cls Class for whom this progression is being computed.
- * @param {SpellcastingDescription} spellcasting Spellcasting descriptive object.
- * @param {number} count Number of classes with this type of spellcasting.
- */
- static computePactProgression(progression, actor, cls, spellcasting, count) {
- progression.pact += spellcasting.levels;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare actor's spell slots using progression data.
- * @param {object} spells The `data.spells` object within actor's data. *Will be mutated.*
- * @param {string} type Type of spellcasting slots being prepared.
- * @param {object} progression Spellcasting progression data.
- * @param {object} [config]
- * @param {Actor5e} [config.actor] Actor for whom the data is being prepared.
- */
- static prepareSpellcastingSlots(spells, type, progression, {actor}={}) {
- /**
- * A hook event that fires to convert the provided spellcasting progression into spell slots.
- * The actual hook names include the spellcasting type (e.g. `dnd5e.prepareLeveledSlots`).
- * @param {object} spells The `data.spells` object within actor's data. *Will be mutated.*
- * @param {Actor5e} actor Actor for whom the data is being prepared.
- * @param {object} progression Spellcasting progression data.
- * @returns {boolean} Explicitly return false to prevent default preparation from being performed.
- * @function dnd5e.prepareSpellcastingSlots
- * @memberof hookEvents
- */
- const allowed = Hooks.call(`dnd5e.prepare${type.capitalize()}Slots`, spells, actor, progression);
-
- if ( allowed && (type === "pact") ) this.preparePactSlots(spells, actor, progression);
- else if ( allowed && (type === "leveled") ) this.prepareLeveledSlots(spells, actor, progression);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare leveled spell slots using progression data.
- * @param {object} spells The `data.spells` object within actor's data. *Will be mutated.*
- * @param {Actor5e} actor Actor for whom the data is being prepared.
- * @param {object} progression Spellcasting progression data.
- */
- static prepareLeveledSlots(spells, actor, progression) {
- const levels = Math.clamped(progression.slot, 0, CONFIG.DND5E.maxLevel);
- const slots = CONFIG.DND5E.SPELL_SLOT_TABLE[Math.min(levels, CONFIG.DND5E.SPELL_SLOT_TABLE.length) - 1] ?? [];
- for ( const [n, slot] of Object.entries(spells) ) {
- const level = parseInt(n.slice(-1));
- if ( Number.isNaN(level) ) continue;
- slot.max = Number.isNumeric(slot.override) ? Math.max(parseInt(slot.override), 0) : slots[level - 1] ?? 0;
- slot.value = parseInt(slot.value); // TODO: DataModels should remove the need for this
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare pact spell slots using progression data.
- * @param {object} spells The `data.spells` object within actor's data. *Will be mutated.*
- * @param {Actor5e} actor Actor for whom the data is being prepared.
- * @param {object} progression Spellcasting progression data.
- */
- static preparePactSlots(spells, actor, progression) {
- // Pact spell data:
- // - pact.level: Slot level for pact casting
- // - pact.max: Total number of pact slots
- // - pact.value: Currently available pact slots
- // - pact.override: Override number of available spell slots
-
- let pactLevel = Math.clamped(progression.pact, 0, CONFIG.DND5E.maxLevel);
- spells.pact ??= {};
- const override = Number.isNumeric(spells.pact.override) ? parseInt(spells.pact.override) : null;
-
- // Pact slot override
- if ( (pactLevel === 0) && (actor.type === "npc") && (override !== null) ) {
- pactLevel = actor.system.details.spellLevel;
- }
-
- // TODO: Allow pact level and slot count to be configured
- if ( pactLevel > 0 ) {
- spells.pact.level = Math.ceil(Math.min(10, pactLevel) / 2); // TODO: Allow custom max pact level
- if ( override === null ) {
- spells.pact.max = Math.max(1, Math.min(pactLevel, 2), Math.min(pactLevel - 8, 3), Math.min(pactLevel - 13, 4));
- } else {
- spells.pact.max = Math.max(override, 1);
- }
- spells.pact.value = Math.min(spells.pact.value, spells.pact.max);
- }
-
- else {
- spells.pact.max = override || 0;
- spells.pact.level = spells.pact.max > 0 ? 1 : 0;
- }
- }
-
- /* -------------------------------------------- */
- /* Event Handlers */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _preCreate(data, options, user) {
- await super._preCreate(data, options, user);
- const sourceId = this.getFlag("core", "sourceId");
- if ( sourceId?.startsWith("Compendium.") ) return;
-
- // Configure prototype token settings
- if ( "size" in (this.system.traits || {}) ) {
- const s = CONFIG.DND5E.tokenSizes[this.system.traits.size || "med"];
- const prototypeToken = {width: s, height: s};
- if ( this.type === "character" ) Object.assign(prototypeToken, {
- sight: { enabled: true }, actorLink: true, disposition: 1
- });
- this.updateSource({prototypeToken});
- }
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _preUpdate(changed, options, user) {
- await super._preUpdate(changed, options, user);
-
- // Apply changes in Actor size to Token width/height
- if ( "size" in (this.system.traits || {}) ) {
- const newSize = foundry.utils.getProperty(changed, "system.traits.size");
- if ( newSize && (newSize !== this.system.traits?.size) ) {
- let size = CONFIG.DND5E.tokenSizes[newSize];
- if ( !foundry.utils.hasProperty(changed, "prototypeToken.width") ) {
- changed.prototypeToken ||= {};
- changed.prototypeToken.height = size;
- changed.prototypeToken.width = size;
- }
- }
- }
-
- // Reset death save counters
- if ( "hp" in (this.system.attributes || {}) ) {
- const isDead = this.system.attributes.hp.value <= 0;
- if ( isDead && (foundry.utils.getProperty(changed, "system.attributes.hp.value") > 0) ) {
- foundry.utils.setProperty(changed, "system.attributes.death.success", 0);
- foundry.utils.setProperty(changed, "system.attributes.death.failure", 0);
- }
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Assign a class item as the original class for the Actor based on which class has the most levels.
- * @returns {Promise<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[tool?.ability ?? "int"];
- const globalBonuses = this.system.bonuses?.abilities ?? {};
- const parts = ["@mod", "@abilityCheckBonus"];
- const data = this.getRollData();
-
- // Add ability modifier.
- data.mod = tool?.mod ?? 0;
- data.defaultAbility = options.ability || (tool?.ability ?? "int");
-
- // Add proficiency.
- if ( tool?.prof.hasProficiency ) {
- parts.push("@prof");
- data.prof = tool.prof.term;
- }
-
- // Global ability check bonus.
- if ( globalBonuses.check ) {
- parts.push("@checkBonus");
- data.checkBonus = Roll.replaceFormulaData(globalBonuses.check, data);
- }
-
- // Ability-specific check bonus.
- if ( ability?.bonuses.check ) data.abilityCheckBonus = Roll.replaceFormulaData(ability.bonuses.check, data);
- else data.abilityCheckBonus = 0;
-
- // Tool-specific check bonus.
- if ( tool?.bonuses.check || options.bonus ) {
- parts.push("@toolBonus");
- const bonus = [];
- if ( tool?.bonuses.check ) bonus.push(Roll.replaceFormulaData(tool.bonuses.check, data));
- if ( options.bonus ) bonus.push(Roll.replaceFormulaData(options.bonus, data));
- data.toolBonus = bonus.join(" + ");
- }
-
- const flavor = game.i18n.format("DND5E.ToolPromptTitle", {tool: keyLabel("tool", toolId) ?? ""});
- const rollData = foundry.utils.mergeObject({
- data, flavor,
- title: `${flavor}: ${this.name}`,
- chooseModifier: true,
- halflingLucky: this.getFlag("dnd5e", "halflingLucky"),
- messageData: {
- speaker: options.speaker || ChatMessage.implementation.getSpeaker({actor: this}),
- "flags.dnd5e.roll": {type: "tool", toolId}
- }
- }, options);
- rollData.parts = parts.concat(options.parts ?? []);
-
- /**
- * A hook event that fires before a tool check is rolled for an Actor.
- * @function dnd5e.preRollRool
- * @memberof hookEvents
- * @param {Actor5e} actor Actor for which the tool check is being rolled.
- * @param {D20RollConfiguration} config Configuration data for the pending roll.
- * @param {string} toolId Identifier of the tool being rolled.
- * @returns {boolean} Explicitly return `false` to prevent skill check from being rolled.
- */
- if ( Hooks.call("dnd5e.preRollToolCheck", this, rollData, toolId) === false ) return;
-
- const roll = await d20Roll(rollData);
-
- /**
- * A hook event that fires after a tool check has been rolled for an Actor.
- * @function dnd5e.rollTool
- * @memberof hookEvents
- * @param {Actor5e} actor Actor for which the tool check has been rolled.
- * @param {D20Roll} roll The resulting roll.
- * @param {string} toolId Identifier of the tool that was rolled.
- */
- if ( roll ) Hooks.callAll("dnd5e.rollToolCheck", this, roll, toolId);
-
- return roll;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Roll a generic ability test or saving throw.
- * Prompt the user for input on which variety of roll they want to do.
- * @param {string} abilityId The ability id (e.g. "str")
- * @param {object} options Options which configure how ability tests or saving throws are rolled
- */
- rollAbility(abilityId, options={}) {
- const label = CONFIG.DND5E.abilities[abilityId]?.label ?? "";
- new Dialog({
- title: `${game.i18n.format("DND5E.AbilityPromptTitle", {ability: label})}: ${this.name}`,
- content: `<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
- * @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({
- type: o.type, // Remain the same actor type
- name: `${o.name} (${source.name})`, // Append the new shape to your old name
- system: source.system, // Get the systemdata model of your new form
- items: source.items, // Get the items of your new form
- effects: o.effects.concat(source.effects), // Combine active effects from both forms
- img: source.img, // New appearance
- ownership: o.ownership, // Use the original actor permissions
- folder: o.folder, // Be displayed in the same sidebar folder
- flags: o.flags, // Use the original actor flags
- prototypeToken: { name: `${o.name} (${source.name})`, texture: {}, sight: {}, detectionModes: [] } // Set a new empty token
- }, keepSelf ? o : {}); // Keeps most of original actor
-
- // Specifically delete some data attributes
- delete d.system.resources; // Don't change your resource pools
- delete d.system.currency; // Don't lose currency
- delete d.system.bonuses; // Don't lose global bonuses
-
- // Specific additional adjustments
- d.system.details.alignment = o.system.details.alignment; // Don't change alignment
- d.system.attributes.exhaustion = o.system.attributes.exhaustion; // Keep your prior exhaustion level
- d.system.attributes.inspiration = o.system.attributes.inspiration; // Keep inspiration
- d.system.spells = o.system.spells; // Keep spell slots
- d.system.attributes.ac.flat = target.system.attributes.ac.value; // Override AC
-
- // Token appearance updates
- for ( const k of ["width", "height", "alpha", "lockRotation"] ) {
- d.prototypeToken[k] = source.prototypeToken[k];
- }
- for ( const k of ["offsetX", "offsetY", "scaleX", "scaleY", "src", "tint"] ) {
- d.prototypeToken.texture[k] = source.prototypeToken.texture[k];
- }
- for ( const k of ["bar1", "bar2", "displayBars", "displayName", "disposition", "rotation", "elevation"] ) {
- d.prototypeToken[k] = o.prototypeToken[k];
- }
-
- if ( !keepSelf ) {
- const sightSource = keepVision ? o.prototypeToken : source.prototypeToken;
- for ( const k of ["range", "angle", "visionMode", "color", "attenuation", "brightness", "saturation", "contrast", "enabled"] ) {
- d.prototypeToken.sight[k] = sightSource.sight[k];
- }
- d.prototypeToken.detectionModes = sightSource.detectionModes;
-
- // Transfer ability scores
- const abilities = d.system.abilities;
- for ( let k of Object.keys(abilities) ) {
- const oa = o.system.abilities[k];
- const prof = abilities[k].proficient;
- const type = CONFIG.DND5E.abilities[k]?.type;
- if ( keepPhysical && (type === "physical") ) abilities[k] = oa;
- else if ( keepMental && (type === "mental") ) abilities[k] = oa;
- if ( keepSaves ) abilities[k].proficient = oa.proficient;
- else if ( mergeSaves ) abilities[k].proficient = Math.max(prof, oa.proficient);
- }
-
- // Transfer skills
- if ( keepSkills ) d.system.skills = o.system.skills;
- else if ( mergeSkills ) {
- for ( let [k, s] of Object.entries(d.system.skills) ) {
- s.value = Math.max(s.value, o.system.skills[k].value);
- }
- }
-
- // Keep specific items from the original data
- d.items = d.items.concat(o.items.filter(i => {
- if ( ["class", "subclass"].includes(i.type) ) return keepClass;
- else if ( i.type === "feat" ) return keepFeats;
- else if ( i.type === "spell" ) return keepSpells;
- else return keepItems;
- }));
-
- // Transfer classes for NPCs
- if ( !keepClass && d.system.details.cr ) {
- const cls = new dnd5e.dataModels.item.ClassData({levels: d.system.details.cr});
- d.items.push({
- type: "class",
- name: game.i18n.localize("DND5E.PolymorphTmpClass"),
- system: cls.toObject()
- });
- }
-
- // Keep biography
- if ( keepBio ) d.system.details.biography = o.system.details.biography;
-
- // Keep senses
- if ( keepVision ) d.system.traits.senses = o.system.traits.senses;
-
- // Remove active effects
- const oEffects = foundry.utils.deepClone(d.effects);
- const originEffectIds = new Set(oEffects.filter(effect => {
- return !effect.origin || effect.origin === this.uuid;
- }).map(e => e._id));
- d.effects = d.effects.filter(e => {
- if ( keepAE ) return true;
- const origin = e.origin?.startsWith("Actor") || e.origin?.startsWith("Item") ? fromUuidSync(e.origin) : {};
- const originIsSelf = origin?.parent?.uuid === this.uuid;
- const isOriginEffect = originEffectIds.has(e._id);
- if ( isOriginEffect ) return keepOriginAE;
- if ( !isOriginEffect && !originIsSelf ) return keepOtherOriginAE;
- if ( origin.type === "spell" ) return keepSpellAE;
- if ( origin.type === "feat" ) return keepFeatAE;
- if ( origin.type === "background" ) return keepBackgroundAE;
- if ( ["subclass", "class"].includes(origin.type) ) return keepClassAE;
- if ( ["equipment", "weapon", "tool", "loot", "backpack"].includes(origin.type) ) return keepEquipmentAE;
- return true;
- });
- }
-
- // Set a random image if source is configured that way
- if ( source.prototypeToken.randomImg ) {
- const images = await target.getTokenImages();
- d.prototypeToken.texture.src = images[Math.floor(Math.random() * images.length)];
- }
-
- // Set new data flags
- if ( !this.isPolymorphed || !d.flags.dnd5e.originalActor ) d.flags.dnd5e.originalActor = this.id;
- d.flags.dnd5e.isPolymorphed = true;
-
- // Gather previous actor data
- const previousActorIds = this.getFlag("dnd5e", "previousActorIds") || [];
- previousActorIds.push(this._id);
- foundry.utils.setProperty(d.flags, "dnd5e.previousActorIds", previousActorIds);
-
- // Update unlinked Tokens, and grab a copy of any actorData adjustments to re-apply
- if ( this.isToken ) {
- const tokenData = d.prototypeToken;
- delete d.prototypeToken;
- let previousActorData;
- if ( game.dnd5e.isV10 ) {
- tokenData.actorData = d;
- previousActorData = this.token.toObject().actorData;
- } else {
- tokenData.delta = d;
- previousActorData = this.token.delta.toObject();
- }
- foundry.utils.setProperty(tokenData, "flags.dnd5e.previousActorData", previousActorData);
- await this.sheet?.close();
- const update = await this.token.update(tokenData);
- if ( renderSheet ) this.sheet?.render(true);
- return update;
- }
-
- // Close sheet for non-transformed Actor
- await this.sheet?.close();
-
- /**
- * A hook event that fires just before the actor is transformed.
- * @function dnd5e.transformActor
- * @memberof hookEvents
- * @param {Actor5e} actor The original actor before transformation.
- * @param {Actor5e} target The target actor into which to transform.
- * @param {object} data The data that will be used to create the new transformed actor.
- * @param {TransformationOptions} options Options that determine how the transformation is performed.
- * @param {object} [options]
- */
- Hooks.callAll("dnd5e.transformActor", this, target, d, {
- keepPhysical, keepMental, keepSaves, keepSkills, mergeSaves, mergeSkills, keepClass, keepFeats, keepSpells,
- keepItems, keepBio, keepVision, keepSelf, keepAE, keepOriginAE, keepOtherOriginAE, keepSpellAE,
- keepEquipmentAE, keepFeatAE, keepClassAE, keepBackgroundAE, transformTokens
- }, {renderSheet});
-
- // Create new Actor with transformed data
- const newActor = await this.constructor.create(d, {renderSheet});
-
- // Update placed Token instances
- if ( !transformTokens ) return;
- const tokens = this.getActiveTokens(true);
- const updates = tokens.map(t => {
- const newTokenData = foundry.utils.deepClone(d.prototypeToken);
- newTokenData._id = t.id;
- newTokenData.actorId = newActor.id;
- newTokenData.actorLink = true;
-
- const dOriginalActor = foundry.utils.getProperty(d, "flags.dnd5e.originalActor");
- foundry.utils.setProperty(newTokenData, "flags.dnd5e.originalActor", dOriginalActor);
- foundry.utils.setProperty(newTokenData, "flags.dnd5e.isPolymorphed", true);
- return newTokenData;
- });
- return canvas.scene?.updateEmbeddedDocuments("Token", updates);
- }
-
- /* -------------------------------------------- */
-
- /**
- * If this actor was transformed with transformTokens enabled, then its
- * active tokens need to be returned to their original state. If not, then
- * we can safely just delete this actor.
- * @param {object} [options]
- * @param {boolean} [options.renderSheet=true] Render Sheet after revert the transformation.
- * @returns {Promise<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 ) {
- 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 "⅛";
- case 0.25: return "¼";
- case 0.5: return "½";
- default: return super.display;
- }
- }
- }
-
-
- /**
- * Scale value data type that stores dice values.
- *
- * @property {number} number Number of dice.
- * @property {number} faces Die faces.
- */
- class ScaleValueTypeDice extends ScaleValueType {
- /** @inheritdoc */
- static defineSchema() {
- return {
- number: new foundry.data.fields.NumberField({nullable: true, integer: true, positive: true}),
- faces: new foundry.data.fields.NumberField({required: true, integer: true, positive: true})
- };
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static get metadata() {
- return foundry.utils.mergeObject(super.metadata, {
- label: "DND5E.AdvancementScaleValueTypeDice",
- hint: "DND5E.AdvancementScaleValueTypeHintDice"
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * List of die faces that can be chosen.
- * @type {number[]}
- */
- static FACES = [2, 3, 4, 6, 8, 10, 12, 20, 100];
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static convertFrom(original, options) {
- const [number, faces] = (original.formula ?? "").split("d");
- if ( !faces || !Number.isNumeric(number) || !Number.isNumeric(faces) ) return null;
- return new this({number: Number(number) || null, faces: Number(faces)}, options);
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get formula() {
- if ( !this.faces ) return null;
- return `${this.number ?? ""}${this.die}`;
- }
-
- /* -------------------------------------------- */
-
- /**
- * The die value to be rolled with the leading "d" (e.g. "d4").
- * @type {string}
- */
- get die() {
- if ( !this.faces ) return "";
- return `d${this.faces}`;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- if ( source.n ) source.number = source.n;
- if ( source.die ) source.faces = source.die;
- }
- }
-
-
- /**
- * Scale value data type that stores distance values.
- *
- * @property {number} value Numeric value.
- */
- class ScaleValueTypeDistance extends ScaleValueTypeNumber {
- /** @inheritdoc */
- static get metadata() {
- return foundry.utils.mergeObject(super.metadata, {
- label: "DND5E.AdvancementScaleValueTypeDistance",
- hint: "DND5E.AdvancementScaleValueTypeHintDistance"
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get display() {
- return `${this.value} ${CONFIG.DND5E.movementUnits[this.parent.configuration.distance?.units ?? "ft"]}`;
- }
- }
-
-
- /**
- * The available types of scaling value.
- * @enum {ScaleValueType}
- */
- const TYPES = {
- string: ScaleValueType,
- number: ScaleValueTypeNumber,
- cr: ScaleValueTypeCR,
- dice: ScaleValueTypeDice,
- distance: ScaleValueTypeDistance
- };
-
- var scaleValue = /*#__PURE__*/Object.freeze({
- __proto__: null,
- ScaleValueConfigurationData: ScaleValueConfigurationData,
- ScaleValueEntryField: ScaleValueEntryField,
- ScaleValueType: ScaleValueType,
- ScaleValueTypeCR: ScaleValueTypeCR,
- ScaleValueTypeDice: ScaleValueTypeDice,
- ScaleValueTypeDistance: ScaleValueTypeDistance,
- ScaleValueTypeNumber: ScaleValueTypeNumber,
- TYPES: TYPES
- });
-
- /**
- * Configuration application for scale values.
- */
- class ScaleValueConfig extends AdvancementConfig {
-
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- classes: ["dnd5e", "advancement", "scale-value", "two-column"],
- template: "systems/dnd5e/templates/advancement/scale-value-config.hbs",
- width: 540
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- getData() {
- const config = this.advancement.configuration;
- const type = TYPES[config.type];
- return foundry.utils.mergeObject(super.getData(), {
- classIdentifier: this.item.identifier,
- previewIdentifier: config.identifier || this.advancement.title?.slugify()
- || this.advancement.constructor.metadata.title.slugify(),
- type: type.metadata,
- types: Object.fromEntries(
- Object.entries(TYPES).map(([key, d]) => [key, game.i18n.localize(d.metadata.label)])
- ),
- faces: Object.fromEntries(TYPES.dice.FACES.map(die => [die, `d${die}`])),
- levels: this._prepareLevelData(),
- movementUnits: CONFIG.DND5E.movementUnits
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare the data to display at each of the scale levels.
- * @returns {object}
- * @protected
- */
- _prepareLevelData() {
- let lastValue = null;
- return Array.fromRange(CONFIG.DND5E.maxLevel + 1).slice(1).reduce((obj, level) => {
- obj[level] = { placeholder: this._formatPlaceholder(lastValue), value: null };
- const value = this.advancement.configuration.scale[level];
- if ( value ) {
- this._mergeScaleValues(value, lastValue);
- obj[level].className = "new-scale-value";
- obj[level].value = value;
- lastValue = value;
- }
- return obj;
- }, {});
- }
-
- /* -------------------------------------------- */
-
- /**
- * Formats the placeholder for this scale value.
- * @param {*} placeholder
- * @returns {object}
- * @protected
- */
- _formatPlaceholder(placeholder) {
- if ( this.advancement.configuration.type === "dice" ) {
- return { number: placeholder?.number ?? "", faces: placeholder?.faces ? `d${placeholder.faces}` : "" };
- }
- return { value: placeholder?.value ?? "" };
- }
-
- /* -------------------------------------------- */
-
- /**
- * For scale values with multiple properties, have missing properties inherit from earlier filled-in values.
- * @param {*} value The primary value.
- * @param {*} lastValue The previous value.
- */
- _mergeScaleValues(value, lastValue) {
- for ( const k of Object.keys(lastValue ?? {}) ) {
- if ( value[k] == null ) value[k] = lastValue[k];
- }
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static _cleanedObject(object) {
- return Object.entries(object).reduce((obj, [key, value]) => {
- if ( Object.keys(value ?? {}).some(k => value[k]) ) obj[key] = value;
- else obj[`-=${key}`] = null;
- return obj;
- }, {});
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- prepareConfigurationUpdate(configuration) {
- // Ensure multiple values in a row are not the same
- let lastValue = null;
- for ( const [lvl, value] of Object.entries(configuration.scale) ) {
- if ( this.advancement.testEquality(lastValue, value) ) configuration.scale[lvl] = null;
- else if ( Object.keys(value ?? {}).some(k => value[k]) ) {
- this._mergeScaleValues(value, lastValue);
- lastValue = value;
- }
- }
- configuration.scale = this.constructor._cleanedObject(configuration.scale);
- return configuration;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- activateListeners(html) {
- super.activateListeners(html);
- this.form.querySelector("input[name='title']").addEventListener("input", this._onChangeTitle.bind(this));
- this.form.querySelector(".identifier-hint-copy").addEventListener("click", this._onIdentifierHintCopy.bind(this));
- }
-
- /* -------------------------------------------- */
-
- /**
- * Copies the full scale identifier hint to the clipboard.
- * @param {Event} event The triggering click event.
- * @protected
- */
- _onIdentifierHintCopy(event) {
- const data = this.getData();
- game.clipboard.copyPlainText(`@scale.${data.classIdentifier}.${data.previewIdentifier}`);
- game.tooltip.activate(event.target, {text: game.i18n.localize("DND5E.IdentifierCopied"), direction: "UP"});
- }
-
- /* -------------------------------------------- */
-
- /**
- * If no identifier is manually entered, slugify the custom title and display as placeholder.
- * @param {Event} event Change event to the title input.
- */
- _onChangeTitle(event) {
- const slug = (event.target.value || this.advancement.constructor.metadata.title).slugify();
- this.form.querySelector("input[name='configuration.identifier']").placeholder = slug;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _updateObject(event, formData) {
- const updates = foundry.utils.expandObject(formData);
- const typeChange = "configuration.type" in formData;
- if ( typeChange && (updates.configuration.type !== this.advancement.configuration.type) ) {
- // Clear existing scale value data to prevent error during type update
- await this.advancement.update(Array.fromRange(CONFIG.DND5E.maxLevel, 1).reduce((obj, lvl) => {
- obj[`configuration.scale.-=${lvl}`] = null;
- return obj;
- }, {}));
- updates.configuration.scale ??= {};
- const OriginalType = TYPES[this.advancement.configuration.type];
- const NewType = TYPES[updates.configuration.type];
- for ( const [lvl, data] of Object.entries(updates.configuration.scale) ) {
- const original = new OriginalType(data, { parent: this.advancement });
- updates.configuration.scale[lvl] = NewType.convertFrom(original)?.toObject();
- }
- }
- return super._updateObject(event, foundry.utils.flattenObject(updates));
- }
- }
-
- /**
- * Inline application that displays any changes to a scale value.
- */
- class ScaleValueFlow extends AdvancementFlow {
-
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- template: "systems/dnd5e/templates/advancement/scale-value-flow.hbs"
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- getData() {
- return foundry.utils.mergeObject(super.getData(), {
- initial: this.advancement.valueForLevel(this.level - 1)?.display,
- final: this.advancement.valueForLevel(this.level).display
- });
- }
- }
-
- /**
- * Advancement that represents a value that scales with class level. **Can only be added to classes or subclasses.**
- */
- class ScaleValueAdvancement extends Advancement {
-
- /** @inheritdoc */
- static get metadata() {
- return foundry.utils.mergeObject(super.metadata, {
- dataModels: {
- configuration: ScaleValueConfigurationData
- },
- order: 60,
- icon: "systems/dnd5e/icons/svg/scale-value.svg",
- title: game.i18n.localize("DND5E.AdvancementScaleValueTitle"),
- hint: game.i18n.localize("DND5E.AdvancementScaleValueHint"),
- multiLevel: true,
- validItemTypes: new Set(["class", "subclass"]),
- apps: {
- config: ScaleValueConfig,
- flow: ScaleValueFlow
- }
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * The available types of scaling value.
- * @enum {ScaleValueType}
- */
- static TYPES = TYPES;
-
- /* -------------------------------------------- */
- /* Instance Properties */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get levels() {
- return Array.from(Object.keys(this.configuration.scale).map(l => Number(l)));
- }
-
- /* -------------------------------------------- */
-
- /**
- * Identifier for this scale value, either manual value or the slugified title.
- * @type {string}
- */
- get identifier() {
- return this.configuration.identifier || this.title.slugify();
- }
-
- /* -------------------------------------------- */
- /* Display Methods */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- titleForLevel(level, { configMode=false }={}) {
- const value = this.valueForLevel(level)?.display;
- if ( !value ) return this.title;
- return `${this.title}: <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$a = /*#__PURE__*/Object.freeze({
- __proto__: null,
- Advancement: Advancement,
- HitPointsAdvancement: HitPointsAdvancement,
- ItemChoiceAdvancement: ItemChoiceAdvancement,
- ItemGrantAdvancement: ItemGrantAdvancement,
- ScaleValueAdvancement: ScaleValueAdvancement
- });
-
- // Namespace Configuration Values
- const DND5E = {};
-
- // ASCII Artwork
- DND5E.ASCII = `_______________________________
- ______ ______ _____ _____
- | _ \\___ | _ \\ ___| ___|
- | | | ( _ ) | | | |___ \\| |__
- | | | / _ \\/\\ | | | \\ \\ __|
- | |/ / (_> < |/ //\\__/ / |___
- |___/ \\___/\\/___/ \\____/\\____/
- _______________________________`;
-
- /**
- * Configuration data for abilities.
- *
- * @typedef {object} AbilityConfiguration
- * @property {string} label Localized label.
- * @property {string} abbreviation Localized abbreviation.
- * @property {string} [type] Whether this is a "physical" or "mental" ability.
- * @property {Object<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 }
- },
- san: {
- label: "DND5E.AbilitySan",
- abbreviation: "DND5E.AbilitySanAbbr",
- type: "mental",
- defaults: { npc: "wis", vehicle: 0 }
- }
- };
- preLocalize("abilities", { keys: ["label", "abbreviation"] });
- patchConfig("abilities", "label", { since: 2.2, until: 2.4 });
-
- Object.defineProperty(DND5E, "abilityAbbreviations", {
- get() {
- foundry.utils.logCompatibilityWarning(
- "The `abilityAbbreviations` configuration object has been merged with `abilities`.",
- { since: "DnD5e 2.2", until: "DnD5e 2.4" }
- );
- return Object.fromEntries(Object.entries(DND5E.abilities).map(([k, v]) => [k, v.abbreviation]));
- }
- });
-
- /**
- * Configure which ability score is used as the default modifier for initiative rolls.
- * @type {string}
- */
- DND5E.initiativeAbility = "dex";
-
- /**
- * Configure which ability score is used when calculating hit points per level.
- * @type {string}
- */
- DND5E.hitPointsAbility = "con";
-
- /* -------------------------------------------- */
-
- /**
- * Configuration data for skills.
- *
- * @typedef {object} SkillConfiguration
- * @property {string} label Localized label.
- * @property {string} ability Key for the default ability used by this skill.
- */
-
- /**
- * The set of skill which can be trained with their default ability scores.
- * @enum {SkillConfiguration}
- */
- DND5E.skills = {
- acr: { label: "DND5E.SkillAcr", ability: "dex" },
- ani: { label: "DND5E.SkillAni", ability: "wis" },
- arc: { label: "DND5E.SkillArc", ability: "int" },
- ath: { label: "DND5E.SkillAth", ability: "str" },
- dec: { label: "DND5E.SkillDec", ability: "cha" },
- his: { label: "DND5E.SkillHis", ability: "int" },
- ins: { label: "DND5E.SkillIns", ability: "wis" },
- itm: { label: "DND5E.SkillItm", ability: "cha" },
- inv: { label: "DND5E.SkillInv", ability: "int" },
- med: { label: "DND5E.SkillMed", ability: "wis" },
- nat: { label: "DND5E.SkillNat", ability: "int" },
- prc: { label: "DND5E.SkillPrc", ability: "wis" },
- prf: { label: "DND5E.SkillPrf", ability: "cha" },
- per: { label: "DND5E.SkillPer", ability: "cha" },
- rel: { label: "DND5E.SkillRel", ability: "int" },
- slt: { label: "DND5E.SkillSlt", ability: "dex" },
- ste: { label: "DND5E.SkillSte", ability: "dex" },
- sur: { label: "DND5E.SkillSur", ability: "wis" }
- };
- preLocalize("skills", { key: "label", sort: true });
-
- /* -------------------------------------------- */
-
- /**
- * Character alignment options.
- * @enum {string}
- */
- DND5E.alignments = {
- lg: "DND5E.AlignmentLG",
- ng: "DND5E.AlignmentNG",
- cg: "DND5E.AlignmentCG",
- ln: "DND5E.AlignmentLN",
- tn: "DND5E.AlignmentTN",
- cn: "DND5E.AlignmentCN",
- le: "DND5E.AlignmentLE",
- ne: "DND5E.AlignmentNE",
- ce: "DND5E.AlignmentCE"
- };
- preLocalize("alignments");
-
- /* -------------------------------------------- */
-
- /**
- * An enumeration of item attunement types.
- * @enum {number}
- */
- DND5E.attunementTypes = {
- NONE: 0,
- REQUIRED: 1,
- ATTUNED: 2
- };
-
- /**
- * An enumeration of item attunement states.
- * @type {{"0": string, "1": string, "2": string}}
- */
- DND5E.attunements = {
- 0: "DND5E.AttunementNone",
- 1: "DND5E.AttunementRequired",
- 2: "DND5E.AttunementAttuned"
- };
- preLocalize("attunements");
-
- /* -------------------------------------------- */
-
- /**
- * General weapon categories.
- * @enum {string}
- */
- DND5E.weaponProficiencies = {
- sim: "DND5E.WeaponSimpleProficiency",
- mar: "DND5E.WeaponMartialProficiency"
- };
- preLocalize("weaponProficiencies");
-
- /**
- * A mapping between `DND5E.weaponTypes` and `DND5E.weaponProficiencies` that
- * is used to determine if character has proficiency when adding an item.
- * @enum {(boolean|string)}
- */
- DND5E.weaponProficienciesMap = {
- natural: true,
- simpleM: "sim",
- simpleR: "sim",
- martialM: "mar",
- martialR: "mar"
- };
-
- /**
- * The basic weapon types in 5e. This enables specific weapon proficiencies or
- * starting equipment provided by classes and backgrounds.
- * @enum {string}
- */
- DND5E.weaponIds = {
- battleaxe: "I0WocDSuNpGJayPb",
- blowgun: "wNWK6yJMHG9ANqQV",
- club: "nfIRTECQIG81CvM4",
- dagger: "0E565kQUBmndJ1a2",
- dart: "3rCO8MTIdPGSW6IJ",
- flail: "UrH3sMdnUDckIHJ6",
- glaive: "rOG1OM2ihgPjOvFW",
- greataxe: "1Lxk6kmoRhG8qQ0u",
- greatclub: "QRCsxkCwWNwswL9o",
- greatsword: "xMkP8BmFzElcsMaR",
- halberd: "DMejWAc8r8YvDPP1",
- handaxe: "eO7Fbv5WBk5zvGOc",
- handcrossbow: "qaSro7kFhxD6INbZ",
- heavycrossbow: "RmP0mYRn2J7K26rX",
- javelin: "DWLMnODrnHn8IbAG",
- lance: "RnuxdHUAIgxccVwj",
- lightcrossbow: "ddWvQRLmnnIS0eLF",
- lighthammer: "XVK6TOL4sGItssAE",
- longbow: "3cymOVja8jXbzrdT",
- longsword: "10ZP2Bu3vnCuYMIB",
- mace: "Ajyq6nGwF7FtLhDQ",
- maul: "DizirD7eqjh8n95A",
- morningstar: "dX8AxCh9o0A9CkT3",
- net: "aEiM49V8vWpWw7rU",
- pike: "tC0kcqZT9HHAO0PD",
- quarterstaff: "g2dWN7PQiMRYWzyk",
- rapier: "Tobce1hexTnDk4sV",
- scimitar: "fbC0Mg1a73wdFbqO",
- shortsword: "osLzOwQdPtrK3rQH",
- sickle: "i4NeNZ30ycwPDHMx",
- spear: "OG4nBBydvmfWYXIk",
- shortbow: "GJv6WkD7D2J6rP6M",
- sling: "3gynWO9sN4OLGMWD",
- trident: "F65ANO66ckP8FDMa",
- warpick: "2YdfjN1PIIrSHZii",
- warhammer: "F0Df164Xv1gWcYt0",
- whip: "QKTyxoO0YDnAsbYe"
- };
-
- /* -------------------------------------------- */
-
- /**
- * The categories into which Tool items can be grouped.
- *
- * @enum {string}
- */
- DND5E.toolTypes = {
- art: "DND5E.ToolArtisans",
- game: "DND5E.ToolGamingSet",
- music: "DND5E.ToolMusicalInstrument"
- };
- preLocalize("toolTypes", { sort: true });
-
- /**
- * The categories of tool proficiencies that a character can gain.
- *
- * @enum {string}
- */
- DND5E.toolProficiencies = {
- ...DND5E.toolTypes,
- vehicle: "DND5E.ToolVehicle"
- };
- preLocalize("toolProficiencies", { sort: true });
-
- /**
- * The basic tool types in 5e. This enables specific tool proficiencies or
- * starting equipment provided by classes and backgrounds.
- * @enum {string}
- */
- DND5E.toolIds = {
- alchemist: "SztwZhbhZeCqyAes",
- bagpipes: "yxHi57T5mmVt0oDr",
- brewer: "Y9S75go1hLMXUD48",
- calligrapher: "jhjo20QoiD5exf09",
- card: "YwlHI3BVJapz4a3E",
- carpenter: "8NS6MSOdXtUqD7Ib",
- cartographer: "fC0lFK8P4RuhpfaU",
- chess: "23y8FvWKf9YLcnBL",
- cobbler: "hM84pZnpCqKfi8XH",
- cook: "Gflnp29aEv5Lc1ZM",
- dice: "iBuTM09KD9IoM5L8",
- disg: "IBhDAr7WkhWPYLVn",
- drum: "69Dpr25pf4BjkHKb",
- dulcimer: "NtdDkjmpdIMiX7I2",
- flute: "eJOrPcAz9EcquyRQ",
- forg: "cG3m4YlHfbQlLEOx",
- glassblower: "rTbVrNcwApnuTz5E",
- herb: "i89okN7GFTWHsvPy",
- horn: "aa9KuBy4dst7WIW9",
- jeweler: "YfBwELTgPFHmQdHh",
- leatherworker: "PUMfwyVUbtyxgYbD",
- lute: "qBydtUUIkv520DT7",
- lyre: "EwG1EtmbgR3bM68U",
- mason: "skUih6tBvcBbORzA",
- navg: "YHCmjsiXxZ9UdUhU",
- painter: "ccm5xlWhx74d6lsK",
- panflute: "G5m5gYIx9VAUWC3J",
- pois: "il2GNi8C0DvGLL9P",
- potter: "hJS8yEVkqgJjwfWa",
- shawm: "G3cqbejJpfB91VhP",
- smith: "KndVe2insuctjIaj",
- thief: "woWZ1sO5IUVGzo58",
- tinker: "0d08g1i5WXnNrCNA",
- viol: "baoe3U5BfMMMxhCU",
- weaver: "ap9prThUB2y9lDyj",
- woodcarver: "xKErqkLo4ASYr5EP"
- };
-
- /* -------------------------------------------- */
-
- /**
- * Time periods that accept a numeric value.
- * @enum {string}
- */
- DND5E.scalarTimePeriods = {
- turn: "DND5E.TimeTurn",
- round: "DND5E.TimeRound",
- minute: "DND5E.TimeMinute",
- hour: "DND5E.TimeHour",
- day: "DND5E.TimeDay",
- month: "DND5E.TimeMonth",
- year: "DND5E.TimeYear"
- };
- preLocalize("scalarTimePeriods");
-
- /* -------------------------------------------- */
-
- /**
- * Time periods for spells that don't have a defined ending.
- * @enum {string}
- */
- DND5E.permanentTimePeriods = {
- disp: "DND5E.TimeDisp",
- dstr: "DND5E.TimeDispTrig",
- perm: "DND5E.TimePerm"
- };
- preLocalize("permanentTimePeriods");
-
- /* -------------------------------------------- */
-
- /**
- * Time periods that don't accept a numeric value.
- * @enum {string}
- */
- DND5E.specialTimePeriods = {
- inst: "DND5E.TimeInst",
- spec: "DND5E.Special"
- };
- preLocalize("specialTimePeriods");
-
- /* -------------------------------------------- */
-
- /**
- * The various lengths of time over which effects can occur.
- * @enum {string}
- */
- DND5E.timePeriods = {
- ...DND5E.specialTimePeriods,
- ...DND5E.permanentTimePeriods,
- ...DND5E.scalarTimePeriods
- };
- preLocalize("timePeriods");
-
- /* -------------------------------------------- */
-
- /**
- * Various ways in which an item or ability can be activated.
- * @enum {string}
- */
- DND5E.abilityActivationTypes = {
- action: "DND5E.Action",
- bonus: "DND5E.BonusAction",
- reaction: "DND5E.Reaction",
- minute: DND5E.timePeriods.minute,
- hour: DND5E.timePeriods.hour,
- day: DND5E.timePeriods.day,
- special: DND5E.timePeriods.spec,
- legendary: "DND5E.LegendaryActionLabel",
- mythic: "DND5E.MythicActionLabel",
- lair: "DND5E.LairActionLabel",
- crew: "DND5E.VehicleCrewAction"
- };
- preLocalize("abilityActivationTypes");
-
- /* -------------------------------------------- */
-
- /**
- * Different things that an ability can consume upon use.
- * @enum {string}
- */
- DND5E.abilityConsumptionTypes = {
- ammo: "DND5E.ConsumeAmmunition",
- attribute: "DND5E.ConsumeAttribute",
- hitDice: "DND5E.ConsumeHitDice",
- material: "DND5E.ConsumeMaterial",
- charges: "DND5E.ConsumeCharges"
- };
- preLocalize("abilityConsumptionTypes", { sort: true });
-
- /* -------------------------------------------- */
-
- /**
- * Creature sizes.
- * @enum {string}
- */
- DND5E.actorSizes = {
- tiny: "DND5E.SizeTiny",
- sm: "DND5E.SizeSmall",
- med: "DND5E.SizeMedium",
- lg: "DND5E.SizeLarge",
- huge: "DND5E.SizeHuge",
- grg: "DND5E.SizeGargantuan"
- };
- preLocalize("actorSizes");
-
- /**
- * Default token image size for the values of `DND5E.actorSizes`.
- * @enum {number}
- */
- DND5E.tokenSizes = {
- tiny: 0.5,
- sm: 1,
- med: 1,
- lg: 2,
- huge: 3,
- grg: 4
- };
-
- /**
- * Colors used to visualize temporary and temporary maximum HP in token health bars.
- * @enum {number}
- */
- DND5E.tokenHPColors = {
- damage: 0xFF0000,
- healing: 0x00FF00,
- temp: 0x66CCFF,
- tempmax: 0x440066,
- negmax: 0x550000
- };
-
- /* -------------------------------------------- */
-
- /**
- * Default types of creatures.
- * *Note: Not pre-localized to allow for easy fetching of pluralized forms.*
- * @enum {string}
- */
- DND5E.creatureTypes = {
- aberration: "DND5E.CreatureAberration",
- beast: "DND5E.CreatureBeast",
- celestial: "DND5E.CreatureCelestial",
- construct: "DND5E.CreatureConstruct",
- dragon: "DND5E.CreatureDragon",
- elemental: "DND5E.CreatureElemental",
- fey: "DND5E.CreatureFey",
- fiend: "DND5E.CreatureFiend",
- giant: "DND5E.CreatureGiant",
- humanoid: "DND5E.CreatureHumanoid",
- monstrosity: "DND5E.CreatureMonstrosity",
- ooze: "DND5E.CreatureOoze",
- plant: "DND5E.CreaturePlant",
- undead: "DND5E.CreatureUndead"
- };
-
- /* -------------------------------------------- */
-
- /**
- * Classification types for item action types.
- * @enum {string}
- */
- DND5E.itemActionTypes = {
- mwak: "DND5E.ActionMWAK",
- rwak: "DND5E.ActionRWAK",
- msak: "DND5E.ActionMSAK",
- rsak: "DND5E.ActionRSAK",
- save: "DND5E.ActionSave",
- heal: "DND5E.ActionHeal",
- abil: "DND5E.ActionAbil",
- util: "DND5E.ActionUtil",
- other: "DND5E.ActionOther"
- };
- preLocalize("itemActionTypes");
-
- /* -------------------------------------------- */
-
- /**
- * Different ways in which item capacity can be limited.
- * @enum {string}
- */
- DND5E.itemCapacityTypes = {
- items: "DND5E.ItemContainerCapacityItems",
- weight: "DND5E.ItemContainerCapacityWeight"
- };
- preLocalize("itemCapacityTypes", { sort: true });
-
- /* -------------------------------------------- */
-
- /**
- * List of various item rarities.
- * @enum {string}
- */
- DND5E.itemRarity = {
- common: "DND5E.ItemRarityCommon",
- uncommon: "DND5E.ItemRarityUncommon",
- rare: "DND5E.ItemRarityRare",
- veryRare: "DND5E.ItemRarityVeryRare",
- legendary: "DND5E.ItemRarityLegendary",
- artifact: "DND5E.ItemRarityArtifact"
- };
- preLocalize("itemRarity");
-
- /* -------------------------------------------- */
-
- /**
- * Enumerate the lengths of time over which an item can have limited use ability.
- * @enum {string}
- */
- DND5E.limitedUsePeriods = {
- sr: "DND5E.ShortRest",
- lr: "DND5E.LongRest",
- day: "DND5E.Day",
- charges: "DND5E.Charges"
- };
- preLocalize("limitedUsePeriods");
-
- /* -------------------------------------------- */
-
- /**
- * Specific equipment types that modify base AC.
- * @enum {string}
- */
- DND5E.armorTypes = {
- light: "DND5E.EquipmentLight",
- medium: "DND5E.EquipmentMedium",
- heavy: "DND5E.EquipmentHeavy",
- natural: "DND5E.EquipmentNatural",
- shield: "DND5E.EquipmentShield"
- };
- preLocalize("armorTypes");
-
- /* -------------------------------------------- */
-
- /**
- * Equipment types that aren't armor.
- * @enum {string}
- */
- DND5E.miscEquipmentTypes = {
- clothing: "DND5E.EquipmentClothing",
- trinket: "DND5E.EquipmentTrinket",
- vehicle: "DND5E.EquipmentVehicle"
- };
- preLocalize("miscEquipmentTypes", { sort: true });
-
- /* -------------------------------------------- */
-
- /**
- * The set of equipment types for armor, clothing, and other objects which can be worn by the character.
- * @enum {string}
- */
- DND5E.equipmentTypes = {
- ...DND5E.miscEquipmentTypes,
- ...DND5E.armorTypes
- };
- preLocalize("equipmentTypes", { sort: true });
-
- /* -------------------------------------------- */
-
- /**
- * The various types of vehicles in which characters can be proficient.
- * @enum {string}
- */
- DND5E.vehicleTypes = {
- air: "DND5E.VehicleTypeAir",
- land: "DND5E.VehicleTypeLand",
- space: "DND5E.VehicleTypeSpace",
- water: "DND5E.VehicleTypeWater"
- };
- preLocalize("vehicleTypes", { sort: true });
-
- /* -------------------------------------------- */
-
- /**
- * The set of Armor Proficiencies which a character may have.
- * @type {object}
- */
- DND5E.armorProficiencies = {
- lgt: DND5E.equipmentTypes.light,
- med: DND5E.equipmentTypes.medium,
- hvy: DND5E.equipmentTypes.heavy,
- shl: "DND5E.EquipmentShieldProficiency"
- };
- preLocalize("armorProficiencies");
-
- /**
- * A mapping between `DND5E.equipmentTypes` and `DND5E.armorProficiencies` that
- * is used to determine if character has proficiency when adding an item.
- * @enum {(boolean|string)}
- */
- DND5E.armorProficienciesMap = {
- natural: true,
- clothing: true,
- light: "lgt",
- medium: "med",
- heavy: "hvy",
- shield: "shl"
- };
-
- /**
- * The basic armor types in 5e. This enables specific armor proficiencies,
- * automated AC calculation in NPCs, and starting equipment.
- * @enum {string}
- */
- DND5E.armorIds = {
- breastplate: "SK2HATQ4abKUlV8i",
- chainmail: "rLMflzmxpe8JGTOA",
- chainshirt: "p2zChy24ZJdVqMSH",
- halfplate: "vsgmACFYINloIdPm",
- hide: "n1V07puo0RQxPGuF",
- leather: "WwdpHLXGX5r8uZu5",
- padded: "GtKV1b5uqFQqpEni",
- plate: "OjkIqlW2UpgFcjZa",
- ringmail: "nsXZejlmgalj4he9",
- scalemail: "XmnlF5fgIO3tg6TG",
- splint: "cKpJmsJmU8YaiuqG",
- studded: "TIV3B1vbrVHIhQAm"
- };
-
- /**
- * The basic shield in 5e.
- * @enum {string}
- */
- DND5E.shieldIds = {
- shield: "sSs3hSzkKBMNBgTs"
- };
-
- /**
- * Common armor class calculations.
- * @enum {{ label: string, [formula]: string }}
- */
- DND5E.armorClasses = {
- flat: {
- label: "DND5E.ArmorClassFlat",
- formula: "@attributes.ac.flat"
- },
- natural: {
- label: "DND5E.ArmorClassNatural",
- formula: "@attributes.ac.flat"
- },
- default: {
- label: "DND5E.ArmorClassEquipment",
- formula: "@attributes.ac.armor + @attributes.ac.dex"
- },
- mage: {
- label: "DND5E.ArmorClassMage",
- formula: "13 + @abilities.dex.mod"
- },
- draconic: {
- label: "DND5E.ArmorClassDraconic",
- formula: "13 + @abilities.dex.mod"
- },
- unarmoredMonk: {
- label: "DND5E.ArmorClassUnarmoredMonk",
- formula: "10 + @abilities.dex.mod + @abilities.wis.mod"
- },
- unarmoredBarb: {
- label: "DND5E.ArmorClassUnarmoredBarbarian",
- formula: "10 + @abilities.dex.mod + @abilities.con.mod"
- },
- custom: {
- label: "DND5E.ArmorClassCustom"
- }
- };
- preLocalize("armorClasses", { key: "label" });
-
- /* -------------------------------------------- */
-
- /**
- * Enumerate the valid consumable types which are recognized by the system.
- * @enum {string}
- */
- DND5E.consumableTypes = {
- ammo: "DND5E.ConsumableAmmo",
- potion: "DND5E.ConsumablePotion",
- poison: "DND5E.ConsumablePoison",
- food: "DND5E.ConsumableFood",
- scroll: "DND5E.ConsumableScroll",
- wand: "DND5E.ConsumableWand",
- rod: "DND5E.ConsumableRod",
- trinket: "DND5E.ConsumableTrinket"
- };
- preLocalize("consumableTypes", { sort: true });
-
- /* -------------------------------------------- */
-
- /**
- * Configuration data for an item with the "feature" type.
- *
- * @typedef {object} FeatureTypeConfiguration
- * @property {string} label Localized label for this type.
- * @property {Object<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: {
- artificerInfusion: "DND5E.ClassFeature.ArtificerInfusion",
- channelDivinity: "DND5E.ClassFeature.ChannelDivinity",
- defensiveTactic: "DND5E.ClassFeature.DefensiveTactic",
- eldritchInvocation: "DND5E.ClassFeature.EldritchInvocation",
- elementalDiscipline: "DND5E.ClassFeature.ElementalDiscipline",
- fightingStyle: "DND5E.ClassFeature.FightingStyle",
- huntersPrey: "DND5E.ClassFeature.HuntersPrey",
- ki: "DND5E.ClassFeature.Ki",
- maneuver: "DND5E.ClassFeature.Maneuver",
- metamagic: "DND5E.ClassFeature.Metamagic",
- multiattack: "DND5E.ClassFeature.Multiattack",
- pact: "DND5E.ClassFeature.PactBoon",
- psionicPower: "DND5E.ClassFeature.PsionicPower",
- rune: "DND5E.ClassFeature.Rune",
- superiorHuntersDefense: "DND5E.ClassFeature.SuperiorHuntersDefense"
- }
- },
- monster: {
- label: "DND5E.Feature.Monster"
- },
- race: {
- label: "DND5E.Feature.Race"
- },
- feat: {
- label: "DND5E.Feature.Feat"
- }
- };
- preLocalize("featureTypes", { key: "label" });
- preLocalize("featureTypes.class.subtypes", { sort: true });
-
- /* -------------------------------------------- */
-
- /**
- * @typedef {object} CurrencyConfiguration
- * @property {string} label Localized label for the currency.
- * @property {string} abbreviation Localized abbreviation for the currency.
- * @property {number} conversion Number by which this currency should be multiplied to arrive at a standard value.
- */
-
- /**
- * The valid currency denominations with localized labels, abbreviations, and conversions.
- * The conversion number defines how many of that currency are equal to one GP.
- * @enum {CurrencyConfiguration}
- */
- DND5E.currencies = {
- pp: {
- label: "DND5E.CurrencyPP",
- abbreviation: "DND5E.CurrencyAbbrPP",
- conversion: 0.1
- },
- gp: {
- label: "DND5E.CurrencyGP",
- abbreviation: "DND5E.CurrencyAbbrGP",
- conversion: 1
- },
- ep: {
- label: "DND5E.CurrencyEP",
- abbreviation: "DND5E.CurrencyAbbrEP",
- conversion: 2
- },
- sp: {
- label: "DND5E.CurrencySP",
- abbreviation: "DND5E.CurrencyAbbrSP",
- conversion: 10
- },
- cp: {
- label: "DND5E.CurrencyCP",
- abbreviation: "DND5E.CurrencyAbbrCP",
- conversion: 100
- }
- };
- preLocalize("currencies", { keys: ["label", "abbreviation"] });
-
- /* -------------------------------------------- */
- /* Damage Types */
- /* -------------------------------------------- */
-
- /**
- * Types of damage that are considered physical.
- * @enum {string}
- */
- DND5E.physicalDamageTypes = {
- bludgeoning: "DND5E.DamageBludgeoning",
- piercing: "DND5E.DamagePiercing",
- slashing: "DND5E.DamageSlashing"
- };
- preLocalize("physicalDamageTypes", { sort: true });
-
- /* -------------------------------------------- */
-
- /**
- * Types of damage the can be caused by abilities.
- * @enum {string}
- */
- DND5E.damageTypes = {
- ...DND5E.physicalDamageTypes,
- acid: "DND5E.DamageAcid",
- cold: "DND5E.DamageCold",
- fire: "DND5E.DamageFire",
- force: "DND5E.DamageForce",
- lightning: "DND5E.DamageLightning",
- necrotic: "DND5E.DamageNecrotic",
- poison: "DND5E.DamagePoison",
- psychic: "DND5E.DamagePsychic",
- radiant: "DND5E.DamageRadiant",
- thunder: "DND5E.DamageThunder"
- };
- preLocalize("damageTypes", { sort: true });
-
- /* -------------------------------------------- */
-
- /**
- * Types of damage to which an actor can possess resistance, immunity, or vulnerability.
- * @enum {string}
- * @deprecated
- */
- DND5E.damageResistanceTypes = {
- ...DND5E.damageTypes,
- physical: "DND5E.DamagePhysical"
- };
- preLocalize("damageResistanceTypes", { sort: true });
-
- /* -------------------------------------------- */
- /* Movement */
- /* -------------------------------------------- */
-
- /**
- * Different types of healing that can be applied using abilities.
- * @enum {string}
- */
- DND5E.healingTypes = {
- healing: "DND5E.Healing",
- temphp: "DND5E.HealingTemp"
- };
- preLocalize("healingTypes");
-
- /* -------------------------------------------- */
-
- /**
- * The valid units of measure for movement distances in the game system.
- * By default this uses the imperial units of feet and miles.
- * @enum {string}
- */
- DND5E.movementTypes = {
- burrow: "DND5E.MovementBurrow",
- climb: "DND5E.MovementClimb",
- fly: "DND5E.MovementFly",
- swim: "DND5E.MovementSwim",
- walk: "DND5E.MovementWalk"
- };
- preLocalize("movementTypes", { sort: true });
-
- /* -------------------------------------------- */
- /* Measurement */
- /* -------------------------------------------- */
-
- /**
- * The valid units of measure for movement distances in the game system.
- * By default this uses the imperial units of feet and miles.
- * @enum {string}
- */
- DND5E.movementUnits = {
- ft: "DND5E.DistFt",
- mi: "DND5E.DistMi",
- m: "DND5E.DistM",
- km: "DND5E.DistKm"
- };
- preLocalize("movementUnits");
-
- /* -------------------------------------------- */
-
- /**
- * The types of range that are used for measuring actions and effects.
- * @enum {string}
- */
- DND5E.rangeTypes = {
- self: "DND5E.DistSelf",
- touch: "DND5E.DistTouch",
- spec: "DND5E.Special",
- any: "DND5E.DistAny"
- };
- preLocalize("rangeTypes");
-
- /* -------------------------------------------- */
-
- /**
- * The valid units of measure for the range of an action or effect. A combination of `DND5E.movementUnits` and
- * `DND5E.rangeUnits`.
- * @enum {string}
- */
- DND5E.distanceUnits = {
- ...DND5E.movementUnits,
- ...DND5E.rangeTypes
- };
- preLocalize("distanceUnits");
-
- /* -------------------------------------------- */
-
- /**
- * Configure aspects of encumbrance calculation so that it could be configured by modules.
- * @enum {{ imperial: number, metric: number }}
- */
- DND5E.encumbrance = {
- currencyPerWeight: {
- imperial: 50,
- metric: 110
- },
- strMultiplier: {
- imperial: 15,
- metric: 6.8
- },
- vehicleWeightMultiplier: {
- imperial: 2000, // 2000 lbs in an imperial ton
- metric: 1000 // 1000 kg in a metric ton
- }
- };
-
- /* -------------------------------------------- */
- /* Targeting */
- /* -------------------------------------------- */
-
- /**
- * Targeting types that apply to one or more distinct targets.
- * @enum {string}
- */
- DND5E.individualTargetTypes = {
- self: "DND5E.TargetSelf",
- ally: "DND5E.TargetAlly",
- enemy: "DND5E.TargetEnemy",
- creature: "DND5E.TargetCreature",
- object: "DND5E.TargetObject",
- space: "DND5E.TargetSpace"
- };
- preLocalize("individualTargetTypes");
-
- /* -------------------------------------------- */
-
- /**
- * Information needed to represent different area of effect target types.
- *
- * @typedef {object} AreaTargetDefinition
- * @property {string} label Localized label for this type.
- * @property {string} template Type of `MeasuredTemplate` create for this target type.
- */
-
- /**
- * Targeting types that cover an area.
- * @enum {AreaTargetDefinition}
- */
- DND5E.areaTargetTypes = {
- radius: {
- label: "DND5E.TargetRadius",
- template: "circle"
- },
- sphere: {
- label: "DND5E.TargetSphere",
- template: "circle"
- },
- cylinder: {
- label: "DND5E.TargetCylinder",
- template: "circle"
- },
- cone: {
- label: "DND5E.TargetCone",
- template: "cone"
- },
- square: {
- label: "DND5E.TargetSquare",
- template: "rect"
- },
- cube: {
- label: "DND5E.TargetCube",
- template: "rect"
- },
- line: {
- label: "DND5E.TargetLine",
- template: "ray"
- },
- wall: {
- label: "DND5E.TargetWall",
- template: "ray"
- }
- };
- preLocalize("areaTargetTypes", { key: "label", sort: true });
-
- /* -------------------------------------------- */
-
- /**
- * The types of single or area targets which can be applied to abilities.
- * @enum {string}
- */
- DND5E.targetTypes = {
- ...DND5E.individualTargetTypes,
- ...Object.fromEntries(Object.entries(DND5E.areaTargetTypes).map(([k, v]) => [k, v.label]))
- };
- preLocalize("targetTypes", { sort: true });
-
- /* -------------------------------------------- */
-
- /**
- * Denominations of hit dice which can apply to classes.
- * @type {string[]}
- */
- DND5E.hitDieTypes = ["d4", "d6", "d8", "d10", "d12"];
-
- /* -------------------------------------------- */
-
- /**
- * The set of possible sensory perception types which an Actor may have.
- * @enum {string}
- */
- DND5E.senses = {
- blindsight: "DND5E.SenseBlindsight",
- darkvision: "DND5E.SenseDarkvision",
- tremorsense: "DND5E.SenseTremorsense",
- truesight: "DND5E.SenseTruesight"
- };
- preLocalize("senses", { sort: true });
-
- /* -------------------------------------------- */
- /* Spellcasting */
- /* -------------------------------------------- */
-
- /**
- * Define the standard slot progression by character level.
- * The entries of this array represent the spell slot progression for a full spell-caster.
- * @type {number[][]}
- */
- DND5E.SPELL_SLOT_TABLE = [
- [2],
- [3],
- [4, 2],
- [4, 3],
- [4, 3, 2],
- [4, 3, 3],
- [4, 3, 3, 1],
- [4, 3, 3, 2],
- [4, 3, 3, 3, 1],
- [4, 3, 3, 3, 2],
- [4, 3, 3, 3, 2, 1],
- [4, 3, 3, 3, 2, 1],
- [4, 3, 3, 3, 2, 1, 1],
- [4, 3, 3, 3, 2, 1, 1],
- [4, 3, 3, 3, 2, 1, 1, 1],
- [4, 3, 3, 3, 2, 1, 1, 1],
- [4, 3, 3, 3, 2, 1, 1, 1, 1],
- [4, 3, 3, 3, 3, 1, 1, 1, 1],
- [4, 3, 3, 3, 3, 2, 1, 1, 1],
- [4, 3, 3, 3, 3, 2, 2, 1, 1]
- ];
-
- /* -------------------------------------------- */
-
- /**
- * Various different ways a spell can be prepared.
- */
- DND5E.spellPreparationModes = {
- prepared: "DND5E.SpellPrepPrepared",
- pact: "DND5E.PactMagic",
- always: "DND5E.SpellPrepAlways",
- atwill: "DND5E.SpellPrepAtWill",
- innate: "DND5E.SpellPrepInnate"
- };
- preLocalize("spellPreparationModes");
-
- /* -------------------------------------------- */
-
- /**
- * Subset of `DND5E.spellPreparationModes` that consume spell slots.
- * @type {boolean[]}
- */
- DND5E.spellUpcastModes = ["always", "pact", "prepared"];
-
- /* -------------------------------------------- */
-
- /**
- * Configuration data for different types of spellcasting supported.
- *
- * @typedef {object} SpellcastingTypeConfiguration
- * @property {string} label Localized label.
- * @property {Object<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");
-
- /* -------------------------------------------- */
-
- /**
- * The amount of cover provided by an object. In cases where multiple pieces
- * of cover are in play, we take the highest value.
- * @enum {string}
- */
- DND5E.cover = {
- 0: "DND5E.None",
- .5: "DND5E.CoverHalf",
- .75: "DND5E.CoverThreeQuarters",
- 1: "DND5E.CoverTotal"
- };
- preLocalize("cover");
-
- /* -------------------------------------------- */
-
- /**
- * A selection of actor attributes that can be tracked on token resource bars.
- * @type {string[]}
- * @deprecated since v10
- */
- DND5E.trackableAttributes = [
- "attributes.ac.value", "attributes.init.bonus", "attributes.movement", "attributes.senses", "attributes.spelldc",
- "attributes.spellLevel", "details.cr", "details.spellLevel", "details.xp.value", "skills.*.passive",
- "abilities.*.value"
- ];
-
- /* -------------------------------------------- */
-
- /**
- * A selection of actor and item attributes that are valid targets for item resource consumption.
- * @type {string[]}
- */
- DND5E.consumableResources = [
- "item.quantity", "item.weight", "item.duration.value", "currency", "details.xp.value", "abilities.*.value",
- "attributes.senses", "attributes.movement", "attributes.ac.flat", "item.armor.value", "item.target", "item.range",
- "item.save.dc"
- ];
-
- /* -------------------------------------------- */
-
- /**
- * Conditions that can affect an actor.
- * @enum {string}
- */
- DND5E.conditionTypes = {
- blinded: "DND5E.ConBlinded",
- charmed: "DND5E.ConCharmed",
- deafened: "DND5E.ConDeafened",
- diseased: "DND5E.ConDiseased",
- exhaustion: "DND5E.ConExhaustion",
- frightened: "DND5E.ConFrightened",
- grappled: "DND5E.ConGrappled",
- incapacitated: "DND5E.ConIncapacitated",
- invisible: "DND5E.ConInvisible",
- paralyzed: "DND5E.ConParalyzed",
- petrified: "DND5E.ConPetrified",
- poisoned: "DND5E.ConPoisoned",
- prone: "DND5E.ConProne",
- restrained: "DND5E.ConRestrained",
- stunned: "DND5E.ConStunned",
- unconscious: "DND5E.ConUnconscious"
- };
- preLocalize("conditionTypes", { sort: true });
-
- /**
- * Languages a character can learn.
- * @enum {string}
- */
- DND5E.languages = {
- common: "DND5E.LanguagesCommon",
- aarakocra: "DND5E.LanguagesAarakocra",
- abyssal: "DND5E.LanguagesAbyssal",
- aquan: "DND5E.LanguagesAquan",
- auran: "DND5E.LanguagesAuran",
- celestial: "DND5E.LanguagesCelestial",
- deep: "DND5E.LanguagesDeepSpeech",
- draconic: "DND5E.LanguagesDraconic",
- druidic: "DND5E.LanguagesDruidic",
- dwarvish: "DND5E.LanguagesDwarvish",
- elvish: "DND5E.LanguagesElvish",
- giant: "DND5E.LanguagesGiant",
- gith: "DND5E.LanguagesGith",
- gnomish: "DND5E.LanguagesGnomish",
- goblin: "DND5E.LanguagesGoblin",
- gnoll: "DND5E.LanguagesGnoll",
- halfling: "DND5E.LanguagesHalfling",
- ignan: "DND5E.LanguagesIgnan",
- infernal: "DND5E.LanguagesInfernal",
- orc: "DND5E.LanguagesOrc",
- primordial: "DND5E.LanguagesPrimordial",
- sylvan: "DND5E.LanguagesSylvan",
- terran: "DND5E.LanguagesTerran",
- cant: "DND5E.LanguagesThievesCant",
- undercommon: "DND5E.LanguagesUndercommon"
- };
- preLocalize("languages", { sort: true });
-
- /**
- * Maximum allowed character level.
- * @type {number}
- */
- DND5E.maxLevel = 20;
-
- /**
- * XP required to achieve each character level.
- * @type {number[]}
- */
- DND5E.CHARACTER_EXP_LEVELS = [
- 0, 300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, 85000, 100000,
- 120000, 140000, 165000, 195000, 225000, 265000, 305000, 355000
- ];
-
- /**
- * XP granted for each challenge rating.
- * @type {number[]}
- */
- DND5E.CR_EXP_LEVELS = [
- 10, 200, 450, 700, 1100, 1800, 2300, 2900, 3900, 5000, 5900, 7200, 8400, 10000, 11500, 13000, 15000, 18000,
- 20000, 22000, 25000, 33000, 41000, 50000, 62000, 75000, 90000, 105000, 120000, 135000, 155000
- ];
-
- /**
- * @typedef {object} CharacterFlagConfig
- * @property {string} name
- * @property {string} hint
- * @property {string} section
- * @property {typeof boolean|string|number} type
- * @property {string} placeholder
- * @property {string[]} [abilities]
- * @property {Object<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
- },
- powerfulBuild: {
- name: "DND5E.FlagsPowerfulBuild",
- hint: "DND5E.FlagsPowerfulBuildHint",
- section: "DND5E.RacialTraits",
- type: Boolean
- },
- reliableTalent: {
- name: "DND5E.FlagsReliableTalent",
- hint: "DND5E.FlagsReliableTalentHint",
- section: "DND5E.Feats",
- type: Boolean
- },
- remarkableAthlete: {
- name: "DND5E.FlagsRemarkableAthlete",
- hint: "DND5E.FlagsRemarkableAthleteHint",
- abilities: ["str", "dex", "con"],
- section: "DND5E.Feats",
- type: Boolean
- },
- weaponCriticalThreshold: {
- name: "DND5E.FlagsWeaponCritThreshold",
- hint: "DND5E.FlagsWeaponCritThresholdHint",
- section: "DND5E.Feats",
- type: Number,
- placeholder: 20
- },
- spellCriticalThreshold: {
- name: "DND5E.FlagsSpellCritThreshold",
- hint: "DND5E.FlagsSpellCritThresholdHint",
- section: "DND5E.Feats",
- type: Number,
- placeholder: 20
- },
- meleeCriticalDamageDice: {
- name: "DND5E.FlagsMeleeCriticalDice",
- hint: "DND5E.FlagsMeleeCriticalDiceHint",
- section: "DND5E.Feats",
- type: Number,
- placeholder: 0
- }
- };
- preLocalize("characterFlags", { keys: ["name", "hint", "section"] });
-
- /**
- * Flags allowed on actors. Any flags not in the list may be deleted during a migration.
- * @type {string[]}
- */
- DND5E.allowedActorFlags = ["isPolymorphed", "originalActor"].concat(Object.keys(DND5E.characterFlags));
-
- /* -------------------------------------------- */
-
- /**
- * Advancement types that can be added to items.
- * @enum {*}
- */
- DND5E.advancementTypes = {
- HitPoints: HitPointsAdvancement,
- ItemChoice: ItemChoiceAdvancement,
- ItemGrant: ItemGrantAdvancement,
- ScaleValue: ScaleValueAdvancement
- };
-
- /* -------------------------------------------- */
-
- /**
- * Patch an existing config enum to allow conversion from string values to object values without
- * breaking existing modules that are expecting strings.
- * @param {string} key Key within DND5E that has been replaced with an enum of objects.
- * @param {string} fallbackKey Key within the new config object from which to get the fallback value.
- * @param {object} [options] Additional options passed through to logCompatibilityWarning.
- */
- function patchConfig(key, fallbackKey, options) {
- /** @override */
- function toString() {
- const message = `The value of CONFIG.DND5E.${key} has been changed to an object.`
- +` The former value can be acccessed from .${fallbackKey}.`;
- foundry.utils.logCompatibilityWarning(message, options);
- return this[fallbackKey];
- }
-
- Object.values(DND5E[key]).forEach(o => o.toString = toString);
- }
-
- /**
- * @typedef {object} ModuleArtInfo
- * @property {string} actor The path to the actor's portrait image.
- * @property {string|object} token The path to the token image, or a richer object specifying additional token
- * adjustments.
- */
-
- /**
- * A class responsible for managing module-provided art in compendia.
- */
- class ModuleArt {
- constructor() {
- /**
- * The stored map of actor UUIDs to their art information.
- * @type {Map<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"
- }
- });
-
- // 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(10).reduce((arr, i) => {
- if ( i < lvl ) return arr;
- const label = CONFIG.DND5E.spellLevels[i];
- const l = actorData.spells[`spell${i}`] || {max: 0, override: null};
- let max = parseInt(l.override || l.max || 0);
- let slots = Math.clamped(parseInt(l.value || 0), 0, max);
- if ( max > 0 ) lmax = i;
- arr.push({
- level: i,
- label: i > 0 ? game.i18n.format("DND5E.SpellLevelSlot", {level: label, n: slots}) : label,
- canCast: max > 0,
- hasSlots: slots > 0
- });
- return arr;
- }, []).filter(sl => sl.level <= lmax);
-
- // If this character has pact slots, present them as an option for casting the spell.
- const pact = actorData.spells.pact;
- if (pact.level >= lvl) {
- spellLevels.push({
- level: "pact",
- label: `${game.i18n.format("DND5E.SpellLevelPact", {level: pact.level, n: pact.value})}`,
- canCast: true,
- hasSlots: pact.value > 0
- });
- }
- const canCast = spellLevels.some(l => l.hasSlots);
- if ( !canCast ) data.errors.push(game.i18n.format("DND5E.SpellCastNoSlots", {
- level: CONFIG.DND5E.spellLevels[lvl],
- name: data.item.name
- }));
-
- // Merge spell casting data
- return foundry.utils.mergeObject(data, { isSpell: true, consumeSpellSlot, spellLevels });
- }
-
- /* -------------------------------------------- */
-
- /**
- * Get the ability usage note that is displayed.
- * @param {object} item Data for the item being used.
- * @param {{value: number, max: number, per: string}} uses Object uses and recovery configuration.
- * @param {{charged: boolean, value: string}} recharge Object recharge configuration.
- * @returns {string} Localized string indicating available uses.
- * @private
- */
- static _getAbilityUseNote(item, uses, recharge) {
-
- // Zero quantity
- const quantity = item.system.quantity;
- if ( quantity <= 0 ) return game.i18n.localize("DND5E.AbilityUseUnavailableHint");
-
- // Abilities which use Recharge
- if ( recharge.value ) {
- return game.i18n.format(recharge.charged ? "DND5E.AbilityUseChargedHint" : "DND5E.AbilityUseRechargeHint", {
- type: game.i18n.localize(CONFIG.Item.typeLabels[item.type])
- });
- }
-
- // Does not use any resource
- if ( !uses.per || !uses.max ) return "";
-
- // Consumables
- if ( item.type === "consumable" ) {
- let str = "DND5E.AbilityUseNormalHint";
- if ( uses.value > 1 ) str = "DND5E.AbilityUseConsumableChargeHint";
- else if ( item.system.quantity === 1 && uses.autoDestroy ) str = "DND5E.AbilityUseConsumableDestroyHint";
- else if ( item.system.quantity > 1 ) str = "DND5E.AbilityUseConsumableQuantityHint";
- return game.i18n.format(str, {
- type: game.i18n.localize(`DND5E.Consumable${item.system.consumableType.capitalize()}`),
- value: uses.value,
- quantity: item.system.quantity,
- max: uses.max,
- per: CONFIG.DND5E.limitedUsePeriods[uses.per]
- });
- }
-
- // Other Items
- else {
- return game.i18n.format("DND5E.AbilityUseNormalHint", {
- type: game.i18n.localize(CONFIG.Item.typeLabels[item.type]),
- value: uses.value,
- max: uses.max,
- per: CONFIG.DND5E.limitedUsePeriods[uses.per]
- });
- }
- }
- }
-
- /**
- * Override and extend the basic Item implementation.
- */
- class Item5e extends Item {
-
- /**
- * Caches an item linked to this one, such as a subclass associated with a class.
- * @type {Item5e}
- * @private
- */
- _classLink;
-
- /* -------------------------------------------- */
- /* Item Properties */
- /* -------------------------------------------- */
-
- /**
- * Which ability score modifier is used by this item?
- * @type {string|null}
- * @see {@link ActionTemplate#abilityMod}
- */
- get abilityMod() {
- return this.system.abilityMod ?? null;
- }
-
- /* --------------------------------------------- */
-
- /**
- * What is the critical hit threshold for this item, if applicable?
- * @type {number|null}
- * @see {@link ActionTemplate#criticalThreshold}
- */
- get criticalThreshold() {
- return this.system.criticalThreshold ?? null;
- }
-
- /* --------------------------------------------- */
-
- /**
- * Does the Item implement an ability check as part of its usage?
- * @type {boolean}
- * @see {@link ActionTemplate#hasAbilityCheck}
- */
- get hasAbilityCheck() {
- return this.system.hasAbilityCheck ?? false;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does this item support advancement and have advancements defined?
- * @type {boolean}
- */
- get hasAdvancement() {
- return !!this.system.advancement?.length;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item have an area of effect target?
- * @type {boolean}
- * @see {@link ActivatedEffectTemplate#hasAreaTarget}
- */
- get hasAreaTarget() {
- return this.system.hasAreaTarget ?? false;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item implement an attack roll as part of its usage?
- * @type {boolean}
- * @see {@link ActionTemplate#hasAttack}
- */
- get hasAttack() {
- return this.system.hasAttack ?? false;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item implement a damage roll as part of its usage?
- * @type {boolean}
- * @see {@link ActionTemplate#hasDamage}
- */
- get hasDamage() {
- return this.system.hasDamage ?? false;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item target one or more distinct targets?
- * @type {boolean}
- * @see {@link ActivatedEffectTemplate#hasIndividualTarget}
- */
- get hasIndividualTarget() {
- return this.system.hasIndividualTarget ?? false;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Is this Item limited in its ability to be used by charges or by recharge?
- * @type {boolean}
- * @see {@link ActivatedEffectTemplate#hasLimitedUses}
- * @see {@link FeatData#hasLimitedUses}
- */
- get hasLimitedUses() {
- return this.system.hasLimitedUses ?? false;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item implement a saving throw as part of its usage?
- * @type {boolean}
- * @see {@link ActionTemplate#hasSave}
- */
- get hasSave() {
- return this.system.hasSave ?? false;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item have a target?
- * @type {boolean}
- * @see {@link ActivatedEffectTemplate#hasTarget}
- */
- get hasTarget() {
- return this.system.hasTarget ?? false;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Return an item's identifier.
- * @type {string}
- */
- get identifier() {
- return this.system.identifier || this.name.slugify({strict: true});
- }
-
- /* -------------------------------------------- */
-
- /**
- * Is this item any of the armor subtypes?
- * @type {boolean}
- * @see {@link EquipmentTemplate#isArmor}
- */
- get isArmor() {
- return this.system.isArmor ?? false;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the item provide an amount of healing instead of conventional damage?
- * @type {boolean}
- * @see {@link ActionTemplate#isHealing}
- */
- get isHealing() {
- return this.system.isHealing ?? false;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Is this item a separate large object like a siege engine or vehicle component that is
- * usually mounted on fixtures rather than equipped, and has its own AC and HP?
- * @type {boolean}
- * @see {@link EquipmentData#isMountable}
- * @see {@link WeaponData#isMountable}
- */
- get isMountable() {
- return this.system.isMountable ?? false;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Is this class item the original class for the containing actor? If the item is not a class or it is not
- * embedded in an actor then this will return `null`.
- * @type {boolean|null}
- */
- get isOriginalClass() {
- if ( this.type !== "class" || !this.isEmbedded ) return null;
- return this.id === this.parent.system.details.originalClass;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item implement a versatile damage roll as part of its usage?
- * @type {boolean}
- * @see {@link ActionTemplate#isVersatile}
- */
- get isVersatile() {
- return this.system.isVersatile ?? false;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Class associated with this subclass. Always returns null on non-subclass or non-embedded items.
- * @type {Item5e|null}
- */
- get class() {
- if ( !this.isEmbedded || (this.type !== "subclass") ) return null;
- const cid = this.system.classIdentifier;
- return this._classLink ??= this.parent.items.find(i => (i.type === "class") && (i.identifier === cid));
- }
-
- /* -------------------------------------------- */
-
- /**
- * Subclass associated with this class. Always returns null on non-class or non-embedded items.
- * @type {Item5e|null}
- */
- get subclass() {
- if ( !this.isEmbedded || (this.type !== "class") ) return null;
- const items = this.parent.items;
- const cid = this.identifier;
- return this._classLink ??= items.find(i => (i.type === "subclass") && (i.system.classIdentifier === cid));
- }
-
- /* -------------------------------------------- */
-
- /**
- * Retrieve scale values for current level from advancement data.
- * @type {object}
- */
- get scaleValues() {
- if ( !["class", "subclass"].includes(this.type) || !this.advancement.byType.ScaleValue ) return {};
- const level = this.type === "class" ? this.system.levels : this.class?.system.levels ?? 0;
- return this.advancement.byType.ScaleValue.reduce((obj, advancement) => {
- obj[advancement.identifier] = advancement.valueForLevel(level);
- return obj;
- }, {});
- }
-
- /* -------------------------------------------- */
-
- /**
- * Spellcasting details for a class or subclass.
- *
- * @typedef {object} SpellcastingDescription
- * @property {string} type Spellcasting type as defined in ``CONFIG.DND5E.spellcastingTypes`.
- * @property {string|null} progression Progression within the specified spellcasting type if supported.
- * @property {string} ability Ability used when casting spells from this class or subclass.
- * @property {number|null} levels Number of levels of this class or subclass's class if embedded.
- */
-
- /**
- * Retrieve the spellcasting for a class or subclass. For classes, this will return the spellcasting
- * of the subclass if it overrides the class. For subclasses, this will return the class's spellcasting
- * if no spellcasting is defined on the subclass.
- * @type {SpellcastingDescription|null} Spellcasting object containing progression & ability.
- */
- get spellcasting() {
- const spellcasting = this.system.spellcasting;
- if ( !spellcasting ) return null;
- const isSubclass = this.type === "subclass";
- const classSC = isSubclass ? this.class?.system.spellcasting : spellcasting;
- const subclassSC = isSubclass ? spellcasting : this.subclass?.system.spellcasting;
- const finalSC = foundry.utils.deepClone(
- ( subclassSC && (subclassSC.progression !== "none") ) ? subclassSC : classSC
- );
- if ( !finalSC ) return null;
- finalSC.levels = this.isEmbedded ? (this.system.levels ?? this.class?.system.levels) : null;
-
- // Temp method for determining spellcasting type until this data is available directly using advancement
- if ( CONFIG.DND5E.spellcastingTypes[finalSC.progression] ) finalSC.type = finalSC.progression;
- else finalSC.type = Object.entries(CONFIG.DND5E.spellcastingTypes).find(([type, data]) => {
- return !!data.progression?.[finalSC.progression];
- })?.[0];
-
- return finalSC;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Should this item's active effects be suppressed.
- * @type {boolean}
- */
- get areEffectsSuppressed() {
- const requireEquipped = (this.type !== "consumable")
- || ["rod", "trinket", "wand"].includes(this.system.consumableType);
- if ( requireEquipped && (this.system.equipped === false) ) return true;
- return this.system.attunement === CONFIG.DND5E.attunementTypes.REQUIRED;
- }
-
- /* -------------------------------------------- */
- /* Data Preparation */
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- prepareDerivedData() {
- super.prepareDerivedData();
- this.labels = {};
-
- // Clear out linked item cache
- this._classLink = undefined;
-
- // Advancement
- this._prepareAdvancement();
-
- // Specialized preparation per Item type
- switch ( this.type ) {
- case "equipment":
- this._prepareEquipment(); break;
- case "feat":
- this._prepareFeat(); break;
- case "spell":
- this._prepareSpell(); break;
- }
-
- // Activated Items
- this._prepareActivation();
- this._prepareAction();
-
- // Un-owned items can have their final preparation done here, otherwise this needs to happen in the owning Actor
- if ( !this.isOwned ) this.prepareFinalAttributes();
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare derived data for an equipment-type item and define labels.
- * @protected
- */
- _prepareEquipment() {
- this.labels.armor = this.system.armor.value ? `${this.system.armor.value} ${game.i18n.localize("DND5E.AC")}` : "";
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare derived data for a feat-type item and define labels.
- * @protected
- */
- _prepareFeat() {
- const act = this.system.activation;
- const types = CONFIG.DND5E.abilityActivationTypes;
- if ( act?.type === types.legendary ) this.labels.featType = game.i18n.localize("DND5E.LegendaryActionLabel");
- else if ( act?.type === types.lair ) this.labels.featType = game.i18n.localize("DND5E.LairActionLabel");
- else if ( act?.type ) {
- this.labels.featType = game.i18n.localize(this.system.damage.length ? "DND5E.Attack" : "DND5E.Action");
- }
- else this.labels.featType = game.i18n.localize("DND5E.Passive");
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare derived data for a spell-type item and define labels.
- * @protected
- */
- _prepareSpell() {
- const tags = Object.fromEntries(Object.entries(CONFIG.DND5E.spellTags).map(([k, v]) => {
- v.tag = true;
- return [k, v];
- }));
- const attributes = {...CONFIG.DND5E.spellComponents, ...tags};
- this.system.preparation.mode ||= "prepared";
- this.labels.level = CONFIG.DND5E.spellLevels[this.system.level];
- this.labels.school = CONFIG.DND5E.spellSchools[this.system.school];
- this.labels.components = Object.entries(this.system.components).reduce((obj, [c, active]) => {
- const config = attributes[c];
- if ( !config || (active !== true) ) return obj;
- obj.all.push({abbr: config.abbr, tag: config.tag});
- if ( config.tag ) obj.tags.push(config.label);
- else obj.vsm.push(config.abbr);
- return obj;
- }, {all: [], vsm: [], tags: []});
- this.labels.components.vsm = new Intl.ListFormat(game.i18n.lang, { style: "narrow", type: "conjunction" })
- .format(this.labels.components.vsm);
- this.labels.materials = this.system?.materials?.value ?? null;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare derived data for activated items and define labels.
- * @protected
- */
- _prepareActivation() {
- if ( !("activation" in this.system) ) return;
- const C = CONFIG.DND5E;
-
- // Ability Activation Label
- const act = this.system.activation ?? {};
- if ( ["none", ""].includes(act.type) ) act.type = null; // Backwards compatibility
- this.labels.activation = act.type ? [act.cost, C.abilityActivationTypes[act.type]].filterJoin(" ") : "";
-
- // Target Label
- let tgt = this.system.target ?? {};
- if ( ["none", ""].includes(tgt.type) ) tgt.type = null; // Backwards compatibility
- if ( [null, "self"].includes(tgt.type) ) tgt.value = tgt.units = null;
- else if ( tgt.units === "touch" ) tgt.value = null;
- this.labels.target = tgt.type
- ? [tgt.value, C.distanceUnits[tgt.units], C.targetTypes[tgt.type]].filterJoin(" ") : "";
-
- // Range Label
- let rng = this.system.range ?? {};
- if ( ["none", ""].includes(rng.units) ) rng.units = null; // Backwards compatibility
- if ( [null, "touch", "self"].includes(rng.units) ) rng.value = rng.long = null;
- this.labels.range = rng.units
- ? [rng.value, rng.long ? `/ ${rng.long}` : null, C.distanceUnits[rng.units]].filterJoin(" ") : "";
-
- // Recharge Label
- let chg = this.system.recharge ?? {};
- const chgSuffix = `${chg.value}${parseInt(chg.value) < 6 ? "+" : ""}`;
- this.labels.recharge = `${game.i18n.localize("DND5E.Recharge")} [${chgSuffix}]`;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare derived data and labels for items which have an action which deals damage.
- * @protected
- */
- _prepareAction() {
- if ( !("actionType" in this.system) ) return;
- let dmg = this.system.damage || {};
- if ( dmg.parts ) {
- const types = CONFIG.DND5E.damageTypes;
- this.labels.damage = dmg.parts.map(d => d[0]).join(" + ").replace(/\+ -/g, "- ");
- this.labels.damageTypes = dmg.parts.map(d => types[d[1]]).join(", ");
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare advancement objects from stored advancement data.
- * @protected
- */
- _prepareAdvancement() {
- const minAdvancementLevel = ["class", "subclass"].includes(this.type) ? 1 : 0;
- this.advancement = {
- byId: {},
- byLevel: Object.fromEntries(
- Array.fromRange(CONFIG.DND5E.maxLevel + 1).slice(minAdvancementLevel).map(l => [l, []])
- ),
- byType: {},
- needingConfiguration: []
- };
- for ( const advancement of this.system.advancement ?? [] ) {
- if ( !(advancement instanceof Advancement) ) continue;
- this.advancement.byId[advancement.id] = advancement;
- this.advancement.byType[advancement.type] ??= [];
- this.advancement.byType[advancement.type].push(advancement);
- advancement.levels.forEach(l => this.advancement.byLevel[l].push(advancement));
- if ( !advancement.levels.length ) this.advancement.needingConfiguration.push(advancement);
- }
- Object.entries(this.advancement.byLevel).forEach(([lvl, data]) => data.sort((a, b) => {
- return a.sortingValueForLevel(lvl).localeCompare(b.sortingValueForLevel(lvl));
- }));
- }
-
- /* -------------------------------------------- */
-
- /**
- * Compute item attributes which might depend on prepared actor data. If this item is embedded this method will
- * be called after the actor's data is prepared.
- * Otherwise, it will be called at the end of `Item5e#prepareDerivedData`.
- */
- prepareFinalAttributes() {
-
- // Proficiency
- if ( this.actor?.system.attributes?.prof ) {
- const isProficient = (this.type === "spell") || this.system.proficient; // Always proficient in spell attacks.
- this.system.prof = new Proficiency(this.actor?.system.attributes.prof, isProficient);
- }
-
- // Class data
- if ( this.type === "class" ) this.system.isOriginalClass = this.isOriginalClass;
-
- // Action usage
- if ( "actionType" in this.system ) {
- this.labels.abilityCheck = game.i18n.format("DND5E.AbilityPromptTitle", {
- ability: CONFIG.DND5E.abilities[this.system.ability]?.label ?? ""
- });
-
- // Saving throws
- this.getSaveDC();
-
- // To Hit
- this.getAttackToHit();
-
- // Limited Uses
- this.prepareMaxUses();
-
- // Duration
- this.prepareDurationValue();
-
- // Damage Label
- this.getDerivedDamageLabel();
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Populate a label with the compiled and simplified damage formula based on owned item
- * actor data. This is only used for display purposes and is not related to `Item5e#rollDamage`.
- * @returns {{damageType: string, formula: string, label: string}[]}
- */
- getDerivedDamageLabel() {
- if ( !this.hasDamage || !this.isOwned ) return [];
- const rollData = this.getRollData();
- const damageLabels = { ...CONFIG.DND5E.damageTypes, ...CONFIG.DND5E.healingTypes };
- const derivedDamage = this.system.damage?.parts?.map(damagePart => {
- let formula;
- try {
- const roll = new Roll(damagePart[0], rollData);
- formula = simplifyRollFormula(roll.formula, { preserveFlavor: true });
- }
- catch(err) {
- console.warn(`Unable to simplify formula for ${this.name}: ${err}`);
- }
- const damageType = damagePart[1];
- return { formula, damageType, label: `${formula} ${damageLabels[damageType] ?? ""}` };
- });
- return this.labels.derivedDamage = derivedDamage;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Update the derived spell DC for an item that requires a saving throw.
- * @returns {number|null}
- */
- getSaveDC() {
- if ( !this.hasSave ) return null;
- const save = this.system.save;
-
- // Actor spell-DC based scaling
- if ( save.scaling === "spell" ) {
- save.dc = this.isOwned ? this.actor.system.attributes.spelldc : null;
- }
-
- // Ability-score based scaling
- else if ( save.scaling !== "flat" ) {
- save.dc = this.isOwned ? this.actor.system.abilities[save.scaling].dc : null;
- }
-
- // Update labels
- const abl = CONFIG.DND5E.abilities[save.ability]?.label ?? "";
- this.labels.save = game.i18n.format("DND5E.SaveDC", {dc: save.dc || "", ability: abl});
- return save.dc;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Update a label to the Item detailing its total to hit bonus from the following sources:
- * - item document's innate attack bonus
- * - item's actor's proficiency bonus if applicable
- * - item's actor's global bonuses to the given item type
- * - item's ammunition if applicable
- * @returns {{rollData: object, parts: string[]}|null} Data used in the item's Attack roll.
- */
- getAttackToHit() {
- if ( !this.hasAttack ) return null;
- const rollData = this.getRollData();
- const parts = [];
-
- // Include the item's innate attack bonus as the initial value and label
- const ab = this.system.attackBonus;
- if ( ab ) {
- parts.push(ab);
- this.labels.toHit = !/^[+-]/.test(ab) ? `+ ${ab}` : ab;
- }
-
- // Take no further action for un-owned items
- if ( !this.isOwned ) return {rollData, parts};
-
- // Ability score modifier
- parts.push("@mod");
-
- // Add proficiency bonus if an explicit proficiency flag is present or for non-item features
- if ( !["weapon", "consumable"].includes(this.type) || this.system.proficient ) {
- parts.push("@prof");
- if ( this.system.prof?.hasProficiency ) rollData.prof = this.system.prof.term;
- }
-
- // Actor-level global bonus to attack rolls
- const actorBonus = this.actor.system.bonuses?.[this.system.actionType] || {};
- if ( actorBonus.attack ) parts.push(actorBonus.attack);
-
- // One-time bonus provided by consumed ammunition
- if ( (this.system.consume?.type === "ammo") && this.actor.items ) {
- const ammoItem = this.actor.items.get(this.system.consume.target);
- if ( ammoItem ) {
- const ammoItemQuantity = ammoItem.system.quantity;
- const ammoCanBeConsumed = ammoItemQuantity && (ammoItemQuantity - (this.system.consume.amount ?? 0) >= 0);
- const ammoItemAttackBonus = ammoItem.system.attackBonus;
- const ammoIsTypeConsumable = (ammoItem.type === "consumable") && (ammoItem.system.consumableType === "ammo");
- if ( ammoCanBeConsumed && ammoItemAttackBonus && ammoIsTypeConsumable ) {
- parts.push("@ammo");
- rollData.ammo = ammoItemAttackBonus;
- }
- }
- }
-
- // Condense the resulting attack bonus formula into a simplified label
- const roll = new Roll(parts.join("+"), rollData);
- const formula = simplifyRollFormula(roll.formula) || "0";
- this.labels.toHit = !/^[+-]/.test(formula) ? `+ ${formula}` : formula;
- return {rollData, parts};
- }
-
- /* -------------------------------------------- */
-
- /**
- * Populates the max uses of an item.
- * If the item is an owned item and the `max` is not numeric, calculate based on actor data.
- */
- prepareMaxUses() {
- const uses = this.system.uses;
- if ( !uses?.max ) return;
- let max = uses.max;
- if ( this.isOwned && !Number.isNumeric(max) ) {
- const property = game.i18n.localize("DND5E.UsesMax");
- try {
- const rollData = this.getRollData({ deterministic: true });
- max = Roll.safeEval(this.replaceFormulaData(max, rollData, { property }));
- } catch(e) {
- const message = game.i18n.format("DND5E.FormulaMalformedError", { property, name: this.name });
- this.actor._preparationWarnings.push({ message, link: this.uuid, type: "error" });
- console.error(message, e);
- return;
- }
- }
- uses.max = Number(max);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Populate the duration value of an item. If the item is an owned item and the
- * duration value is not numeric, calculate based on actor data.
- */
- prepareDurationValue() {
- const duration = this.system.duration;
- if ( !duration?.value ) return;
- let value = duration.value;
-
- // If this is an owned item and the value is not numeric, we need to calculate it
- if ( this.isOwned && !Number.isNumeric(value) ) {
- const property = game.i18n.localize("DND5E.Duration");
- try {
- const rollData = this.getRollData({ deterministic: true });
- value = Roll.safeEval(this.replaceFormulaData(value, rollData, { property }));
- } catch(e) {
- const message = game.i18n.format("DND5E.FormulaMalformedError", { property, name: this.name });
- this.actor._preparationWarnings.push({ message, link: this.uuid, type: "error" });
- console.error(message, e);
- return;
- }
- }
- duration.value = Number(value);
-
- // Now that duration value is a number, set the label
- if ( ["inst", "perm"].includes(duration.units) ) duration.value = null;
- this.labels.duration = [duration.value, CONFIG.DND5E.timePeriods[duration.units]].filterJoin(" ");
- }
-
- /* -------------------------------------------- */
-
- /**
- * Replace referenced data attributes in the roll formula with values from the provided data.
- * If the attribute is not found in the provided data, display a warning on the actor.
- * @param {string} formula The original formula within which to replace.
- * @param {object} data The data object which provides replacements.
- * @param {object} options
- * @param {string} options.property Name of the property to which this formula belongs.
- * @returns {string} Formula with replaced data.
- */
- replaceFormulaData(formula, data, { property }) {
- const dataRgx = new RegExp(/@([a-z.0-9_-]+)/gi);
- const missingReferences = new Set();
- formula = formula.replace(dataRgx, (match, term) => {
- let value = foundry.utils.getProperty(data, term);
- if ( value == null ) {
- missingReferences.add(match);
- return "0";
- }
- return String(value).trim();
- });
- if ( (missingReferences.size > 0) && this.actor ) {
- const listFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "conjunction" });
- const message = game.i18n.format("DND5E.FormulaMissingReferenceWarn", {
- property, name: this.name, references: listFormatter.format(missingReferences)
- });
- this.actor._preparationWarnings.push({ message, link: this.uuid, type: "warning" });
- }
- return formula;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Configuration data for an item usage being prepared.
- *
- * @typedef {object} ItemUseConfiguration
- * @property {boolean} createMeasuredTemplate Trigger a template creation
- * @property {boolean} consumeQuantity Should the item's quantity be consumed?
- * @property {boolean} consumeRecharge Should a recharge be consumed?
- * @property {boolean} consumeResource Should a linked (non-ammo) resource be consumed?
- * @property {number|string|null} consumeSpellLevel Specific spell level to consume, or "pact" for pact level.
- * @property {boolean} consumeSpellSlot Should any spell slot be consumed?
- * @property {boolean} consumeUsage Should limited uses be consumed?
- * @property {boolean} needsConfiguration Is user-configuration needed?
- */
-
- /**
- * Additional options used for configuring item usage.
- *
- * @typedef {object} ItemUseOptions
- * @property {boolean} configureDialog Display a configuration dialog for the item usage, if applicable?
- * @property {string} rollMode The roll display mode with which to display (or not) the card.
- * @property {boolean} createMessage Whether to automatically create a chat message (if true) or simply return
- * the prepared chat message data (if false).
- * @property {object} flags Additional flags added to the chat message.
- * @property {Event} event The browser event which triggered the item usage, if any.
- */
-
- /**
- * Trigger an item usage, optionally creating a chat message with followup actions.
- * @param {ItemUseOptions} [options] Options used for configuring item usage.
- * @returns {Promise<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) {}
- }
-
- /**
- * 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,
- ...options
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * @inheritdoc
- * @param {object} [options]
- * @param {boolean} [options.deterministic] Whether to force deterministic values for data properties that could be
- * either a die term or a flat term.
- */
- getRollData({ deterministic=false }={}) {
- if ( !this.actor ) return null;
- const actorRollData = this.actor.getRollData({ deterministic });
- const rollData = {
- ...actorRollData,
- item: this.toObject().system
- };
-
- // Include an ability score modifier if one exists
- const abl = this.abilityMod;
- if ( abl && ("abilities" in rollData) ) {
- const ability = rollData.abilities[abl];
- if ( !ability ) {
- console.warn(`Item ${this.name} in Actor ${this.actor.name} has an invalid item ability modifier of ${abl} defined`);
- }
- rollData.mod = ability?.mod ?? 0;
- }
- return rollData;
- }
-
- /* -------------------------------------------- */
- /* Chat Message Helpers */
- /* -------------------------------------------- */
-
- /**
- * Apply listeners to chat messages.
- * @param {HTML} html Rendered chat message.
- */
- static chatListeners(html) {
- html.on("click", ".card-buttons button", this._onChatCardAction.bind(this));
- html.on("click", ".item-name", this._onChatCardToggleContent.bind(this));
- }
-
- /* -------------------------------------------- */
-
- /**
- * Handle execution of a chat card action via a click event on one of the card buttons
- * @param {Event} event The originating click event
- * @returns {Promise} A promise which resolves once the handler workflow is complete
- * @private
- */
- static async _onChatCardAction(event) {
- event.preventDefault();
-
- // Extract card data
- const button = event.currentTarget;
- button.disabled = true;
- const card = button.closest(".chat-card");
- const messageId = card.closest(".message").dataset.messageId;
- const message = game.messages.get(messageId);
- const action = button.dataset.action;
-
- // Recover the actor for the chat card
- const actor = await this._getChatCardActor(card);
- if ( !actor ) return;
-
- // Validate permission to proceed with the roll
- const isTargetted = action === "save";
- if ( !( isTargetted || game.user.isGM || actor.isOwner ) ) return;
-
- // Get the Item from stored flag data or by the item ID on the Actor
- const storedData = message.getFlag("dnd5e", "itemData");
- const item = storedData ? new this(storedData, {parent: actor}) : actor.items.get(card.dataset.itemId);
- if ( !item ) {
- const err = game.i18n.format("DND5E.ActionWarningNoItem", {item: card.dataset.itemId, name: actor.name});
- return ui.notifications.error(err);
- }
- const spellLevel = parseInt(card.dataset.spellLevel) || null;
-
- // Handle different actions
- let targets;
- switch ( action ) {
- case "attack":
- await item.rollAttack({
- event: event,
- spellLevel: spellLevel
- });
- break;
- case "damage":
- case "versatile":
- await item.rollDamage({
- event: event,
- spellLevel: spellLevel,
- versatile: action === "versatile"
- });
- break;
- case "formula":
- await item.rollFormula({event, spellLevel}); break;
- case "save":
- targets = this._getChatCardTargets(card);
- for ( let token of targets ) {
- const speaker = ChatMessage.getSpeaker({scene: canvas.scene, token: token.document});
- await token.actor.rollAbilitySave(button.dataset.ability, { event, speaker });
- }
- break;
- case "toolCheck":
- await item.rollToolCheck({event}); break;
- case "placeTemplate":
- try {
- await dnd5e.canvas.AbilityTemplate.fromItem(item)?.drawPreview();
- } catch(err) {}
- break;
- case "abilityCheck":
- targets = this._getChatCardTargets(card);
- for ( let token of targets ) {
- const speaker = ChatMessage.getSpeaker({scene: canvas.scene, token: token.document});
- await token.actor.rollAbilityTest(button.dataset.ability, { event, speaker });
- }
- break;
- }
-
- // Re-enable the button
- button.disabled = false;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Handle toggling the visibility of chat card content when the name is clicked
- * @param {Event} event The originating click event
- * @private
- */
- static _onChatCardToggleContent(event) {
- event.preventDefault();
- const header = event.currentTarget;
- const card = header.closest(".chat-card");
- const content = card.querySelector(".card-content");
- content.style.display = content.style.display === "none" ? "block" : "none";
- }
-
- /* -------------------------------------------- */
-
- /**
- * Get the Actor which is the author of a chat card
- * @param {HTMLElement} card The chat card being used
- * @returns {Actor|null} The Actor document or null
- * @private
- */
- static async _getChatCardActor(card) {
-
- // Case 1 - a synthetic actor from a Token
- if ( card.dataset.tokenId ) {
- const token = await fromUuid(card.dataset.tokenId);
- if ( !token ) return null;
- return token.actor;
- }
-
- // Case 2 - use Actor ID directory
- const actorId = card.dataset.actorId;
- return game.actors.get(actorId) || null;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Get the Actor which is the author of a chat card
- * @param {HTMLElement} card The chat card being used
- * @returns {Actor[]} An Array of Actor documents, if any
- * @private
- */
- static _getChatCardTargets(card) {
- let targets = canvas.tokens.controlled.filter(t => !!t.actor);
- if ( !targets.length && game.user.character ) targets = targets.concat(game.user.character.getActiveTokens());
- if ( !targets.length ) ui.notifications.warn(game.i18n.localize("DND5E.ActionWarningNoToken"));
- return targets;
- }
-
- /* -------------------------------------------- */
- /* Advancements */
- /* -------------------------------------------- */
-
- /**
- * Create a new advancement of the specified type.
- * @param {string} type Type of advancement to create.
- * @param {object} [data] Data to use when creating the advancement.
- * @param {object} [options]
- * @param {boolean} [options.showConfig=true] Should the new advancement's configuration application be shown?
- * @param {boolean} [options.source=false] Should a source-only update be performed?
- * @returns {Promise<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 "tool":
- updates = this._onCreateOwnedTool(data, isNPC);
- break;
- case "weapon":
- updates = this._onCreateOwnedWeapon(data, isNPC);
- break;
- }
- if ( updates ) return this.updateSource(updates);
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _onCreate(data, options, userId) {
- super._onCreate(data, options, userId);
- if ( (userId !== game.user.id) || !this.parent ) return;
-
- // Assign a new original class
- if ( (this.parent.type === "character") && (this.type === "class") ) {
- const pc = this.parent.items.get(this.parent.system.details.originalClass);
- if ( !pc ) await this.parent._assignPrimaryClass();
- }
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _preUpdate(changed, options, user) {
- await super._preUpdate(changed, options, user);
- if ( (this.type !== "class") || !("levels" in (changed.system || {})) ) return;
-
- // Check to make sure the updated class level isn't below zero
- if ( changed.system.levels <= 0 ) {
- ui.notifications.warn(game.i18n.localize("DND5E.MaxClassLevelMinimumWarn"));
- changed.system.levels = 1;
- }
-
- // Check to make sure the updated class level doesn't exceed level cap
- if ( changed.system.levels > CONFIG.DND5E.maxLevel ) {
- ui.notifications.warn(game.i18n.format("DND5E.MaxClassLevelExceededWarn", {max: CONFIG.DND5E.maxLevel}));
- changed.system.levels = CONFIG.DND5E.maxLevel;
- }
- if ( !this.isEmbedded || (this.parent.type !== "character") ) return;
-
- // Check to ensure the updated character doesn't exceed level cap
- const newCharacterLevel = this.actor.system.details.level + (changed.system.levels - this.system.levels);
- if ( newCharacterLevel > CONFIG.DND5E.maxLevel ) {
- ui.notifications.warn(game.i18n.format("DND5E.MaxCharacterLevelExceededWarn", {max: CONFIG.DND5E.maxLevel}));
- changed.system.levels -= newCharacterLevel - CONFIG.DND5E.maxLevel;
- }
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- _onDelete(options, userId) {
- super._onDelete(options, userId);
- if ( (userId !== game.user.id) || !this.parent ) return;
-
- // Assign a new original class
- if ( (this.type === "class") && (this.id === this.parent.system.details.originalClass) ) {
- this.parent._assignPrimaryClass();
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Pre-creation logic for the automatic configuration of owned equipment type Items.
- *
- * @param {object} data Data for the newly created item.
- * @param {boolean} isNPC Is this actor an NPC?
- * @returns {object} Updates to apply to the item data.
- * @private
- */
- _onCreateOwnedEquipment(data, isNPC) {
- const updates = {};
- if ( foundry.utils.getProperty(data, "system.equipped") === undefined ) {
- updates["system.equipped"] = isNPC; // NPCs automatically equip equipment
- }
- if ( foundry.utils.getProperty(data, "system.proficient") === undefined ) {
- if ( isNPC ) {
- updates["system.proficient"] = true; // NPCs automatically have equipment proficiency
- } else {
- const armorProf = CONFIG.DND5E.armorProficienciesMap[this.system.armor?.type]; // Player characters check proficiency
- const actorArmorProfs = this.parent.system.traits?.armorProf?.value || new Set();
- updates["system.proficient"] = (armorProf === true) || actorArmorProfs.has(armorProf)
- || actorArmorProfs.has(this.system.baseItem);
- }
- }
- return updates;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Pre-creation logic for the automatic configuration of owned spell type Items.
- *
- * @param {object} data Data for the newly created item.
- * @param {boolean} isNPC Is this actor an NPC?
- * @returns {object} Updates to apply to the item data.
- * @private
- */
- _onCreateOwnedSpell(data, isNPC) {
- const updates = {};
- if ( foundry.utils.getProperty(data, "system.preparation.prepared") === undefined ) {
- updates["system.preparation.prepared"] = isNPC; // NPCs automatically prepare spells
- }
- return updates;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Pre-creation logic for the automatic configuration of owned tool type Items.
- * @param {object} data Data for the newly created item.
- * @param {boolean} isNPC Is this actor an NPC?
- * @returns {object} Updates to apply to the item data.
- * @private
- */
- _onCreateOwnedTool(data, isNPC) {
- const updates = {};
- if ( data.system?.proficient === undefined ) {
- if ( isNPC ) updates["system.proficient"] = 1;
- else {
- const actorToolProfs = this.parent.system.tools || {};
- const toolProf = actorToolProfs[this.system.baseItem]?.value;
- const generalProf = actorToolProfs[this.system.toolType]?.value;
- updates["system.proficient"] = toolProf ?? generalProf ?? 0;
- }
- }
- return updates;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Pre-creation logic for the automatic configuration of owned weapon type Items.
- * @param {object} data Data for the newly created item.
- * @param {boolean} isNPC Is this actor an NPC?
- * @returns {object} Updates to apply to the item data.
- * @private
- */
- _onCreateOwnedWeapon(data, isNPC) {
-
- // NPCs automatically equip items and are proficient with them
- if ( isNPC ) {
- const updates = {};
- if ( !foundry.utils.hasProperty(data, "system.equipped") ) updates["system.equipped"] = true;
- if ( !foundry.utils.hasProperty(data, "system.proficient") ) updates["system.proficient"] = true;
- return updates;
- }
- if ( data.system?.proficient !== undefined ) return {};
-
- // Some weapon types are always proficient
- const weaponProf = CONFIG.DND5E.weaponProficienciesMap[this.system.weaponType];
- const updates = {};
- if ( weaponProf === true ) updates["system.proficient"] = true;
-
- // Characters may have proficiency in this weapon type (or specific base weapon)
- else {
- const actorProfs = this.parent.system.traits?.weaponProf?.value || new Set();
- updates["system.proficient"] = actorProfs.has(weaponProf) || actorProfs.has(this.system.baseItem);
- }
- return updates;
- }
-
- /* -------------------------------------------- */
- /* Factory Methods */
- /* -------------------------------------------- */
-
- /**
- * Create a consumable spell scroll Item from a spell Item.
- * @param {Item5e} spell The spell to be made into a scroll
- * @returns {Item5e} The created scroll consumable item
- */
- static async createScrollFromSpell(spell) {
-
- // Get spell data
- const itemData = (spell instanceof Item5e) ? spell.toObject() : spell;
- let {
- actionType, description, source, activation, duration, target, range, damage, formula, save, level, attackBonus
- } = itemData.system;
-
- // Get scroll data
- const scrollUuid = `Compendium.${CONFIG.DND5E.sourcePacks.ITEMS}.${CONFIG.DND5E.spellScrollIds[level]}`;
- const scrollItem = await fromUuid(scrollUuid);
- const scrollData = scrollItem.toObject();
- delete scrollData._id;
-
- // Split the scroll description into an intro paragraph and the remaining details
- const scrollDescription = scrollData.system.description.value;
- const pdel = "</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} (Level ${level})</h3><hr/>${description.value}<hr/><h3>Scroll Details</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} - @mod`;
- }
- if ( save.ability ) {
- save.scaling = "flat";
- save.dc = scrollData.system.save.dc;
- }
-
- // Create the spell scroll data
- const spellScrollData = foundry.utils.mergeObject(scrollData, {
- name: `${game.i18n.localize("DND5E.SpellScroll")}: ${itemData.name}`,
- img: itemData.img,
- system: {
- description: {value: desc.trim()}, source, actionType, activation, duration, target, range, damage, formula,
- save, level, attackBonus
- }
- });
- return new this(spellScrollData);
- }
-
- /* -------------------------------------------- */
- /* Deprecations */
- /* -------------------------------------------- */
-
- /**
- * Retrieve an item's critical hit threshold. Uses the smallest value from among the following sources:
- * - item document
- * - item document's actor (if it has one)
- * - item document's ammunition (if it has any)
- * - the constant '20'
- * @returns {number|null} The minimum value that must be rolled to be considered a critical hit.
- * @deprecated since dnd5e 2.2, targeted for removal in 2.4
- */
- getCriticalThreshold() {
- foundry.utils.logCompatibilityWarning(
- "Item5e#getCriticalThreshold has been replaced with the Item5e#criticalThreshold getter.",
- { since: "DnD5e 2.2", until: "DnD5e 2.4" }
- );
- return this.criticalThreshold;
- }
- }
-
- /**
- * An abstract class containing common functionality between actor sheet configuration apps.
- * @extends {DocumentSheet}
- * @abstract
- */
- class BaseConfigSheet extends DocumentSheet {
- /** @inheritdoc */
- activateListeners(html) {
- super.activateListeners(html);
- if ( this.isEditable ) {
- for ( const override of this._getActorOverrides() ) {
- html.find(`input[name="${override}"],select[name="${override}"]`).each((i, el) => {
- el.disabled = true;
- el.dataset.tooltip = "DND5E.ActiveEffectOverrideWarning";
- });
- }
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Retrieve the list of fields that are currently modified by Active Effects on the Actor.
- * @returns {string[]}
- * @protected
- */
- _getActorOverrides() {
- return Object.keys(foundry.utils.flattenObject(this.object.overrides || {}));
- }
- }
-
- /**
- * A simple form to set save throw configuration for a given ability score.
- *
- * @param {Actor5e} actor The Actor instance being displayed within the sheet.
- * @param {ApplicationOptions} options Additional application configuration options.
- * @param {string} abilityId The ability key as defined in CONFIG.DND5E.abilities.
- */
- class ActorAbilityConfig extends BaseConfigSheet {
- constructor(actor, options, abilityId) {
- super(actor, options);
- this._abilityId = abilityId;
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- classes: ["dnd5e"],
- template: "systems/dnd5e/templates/apps/ability-config.hbs",
- width: 500,
- height: "auto"
- });
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- get title() {
- return `${game.i18n.format("DND5E.AbilityConfigureTitle", {
- ability: CONFIG.DND5E.abilities[this._abilityId].label})}: ${this.document.name}`;
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- getData(options) {
- const src = this.document.toObject();
- const ability = CONFIG.DND5E.abilities[this._abilityId].label;
- return {
- ability: src.system.abilities[this._abilityId] ?? this.document.system.abilities[this._abilityId] ?? {},
- labelSaves: game.i18n.format("DND5E.AbilitySaveConfigure", {ability}),
- labelChecks: game.i18n.format("DND5E.AbilityCheckConfigure", {ability}),
- abilityId: this._abilityId,
- proficiencyLevels: {
- 0: CONFIG.DND5E.proficiencyLevels[0],
- 1: CONFIG.DND5E.proficiencyLevels[1]
- },
- bonusGlobalSave: src.system.bonuses?.abilities?.save,
- bonusGlobalCheck: src.system.bonuses?.abilities?.check
- };
- }
- }
-
- /**
- * Interface for managing a character's armor calculation.
- */
- class ActorArmorConfig extends BaseConfigSheet {
- constructor(...args) {
- super(...args);
-
- /**
- * Cloned copy of the actor for previewing changes.
- * @type {Actor5e}
- */
- this.clone = this.document.clone();
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- classes: ["dnd5e", "actor-armor-config"],
- template: "systems/dnd5e/templates/apps/actor-armor.hbs",
- width: 320,
- height: "auto",
- sheetConfig: false
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get title() {
- return `${game.i18n.localize("DND5E.ArmorConfig")}: ${this.document.name}`;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async getData() {
- const ac = this.clone.system.attributes.ac;
- const isFlat = ["flat", "natural"].includes(ac.calc);
-
- // Get configuration data for the calculation mode, reset to flat if configuration is unavailable
- let cfg = CONFIG.DND5E.armorClasses[ac.calc];
- if ( !cfg ) {
- ac.calc = "flat";
- cfg = CONFIG.DND5E.armorClasses.flat;
- this.clone.updateSource({ "system.attributes.ac.calc": "flat" });
- }
-
- return {
- ac, isFlat,
- calculations: CONFIG.DND5E.armorClasses,
- valueDisabled: !isFlat,
- formula: ac.calc === "custom" ? ac.formula : cfg.formula,
- formulaDisabled: ac.calc !== "custom"
- };
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- _getActorOverrides() {
- return Object.keys(foundry.utils.flattenObject(this.object.overrides?.system?.attributes || {}));
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _updateObject(event, formData) {
- const ac = foundry.utils.expandObject(formData).ac;
- return this.document.update({"system.attributes.ac": ac});
- }
-
- /* -------------------------------------------- */
- /* Event Listeners and Handlers */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _onChangeInput(event) {
- await super._onChangeInput(event);
-
- // Update clone with new data & re-render
- this.clone.updateSource({ [`system.attributes.${event.currentTarget.name}`]: event.currentTarget.value });
- this.render();
- }
- }
-
- /**
- * A simple form to set actor hit dice amounts.
- */
- class ActorHitDiceConfig extends BaseConfigSheet {
-
- /** @inheritDoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- classes: ["dnd5e", "hd-config", "dialog"],
- template: "systems/dnd5e/templates/apps/hit-dice-config.hbs",
- width: 360,
- height: "auto"
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- get title() {
- return `${game.i18n.localize("DND5E.HitDiceConfig")}: ${this.object.name}`;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- getData(options) {
- return {
- classes: this.object.items.reduce((classes, item) => {
- if (item.type === "class") {
- classes.push({
- classItemId: item.id,
- name: item.name,
- diceDenom: item.system.hitDice,
- currentHitDice: item.system.levels - item.system.hitDiceUsed,
- maxHitDice: item.system.levels,
- canRoll: (item.system.levels - item.system.hitDiceUsed) > 0
- });
- }
- return classes;
- }, []).sort((a, b) => parseInt(b.diceDenom.slice(1)) - parseInt(a.diceDenom.slice(1)))
- };
- }
-
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- activateListeners(html) {
- super.activateListeners(html);
-
- // Hook up -/+ buttons to adjust the current value in the form
- html.find("button.increment,button.decrement").click(event => {
- const button = event.currentTarget;
- const current = button.parentElement.querySelector(".current");
- const max = button.parentElement.querySelector(".max");
- const direction = button.classList.contains("increment") ? 1 : -1;
- current.value = Math.clamped(parseInt(current.value) + direction, 0, parseInt(max.value));
- });
-
- html.find("button.roll-hd").click(this._onRollHitDie.bind(this));
- }
-
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- async _updateObject(event, formData) {
- const actorItems = this.object.items;
- const classUpdates = Object.entries(formData).map(([id, hd]) => ({
- _id: id,
- "system.hitDiceUsed": actorItems.get(id).system.levels - hd
- }));
- return this.object.updateEmbeddedDocuments("Item", classUpdates);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Rolls the hit die corresponding with the class row containing the event's target button.
- * @param {MouseEvent} event Triggering click event.
- * @protected
- */
- async _onRollHitDie(event) {
- event.preventDefault();
- const button = event.currentTarget;
- await this.object.rollHitDie(button.dataset.hdDenom);
-
- // Re-render dialog to reflect changed hit dice quantities
- this.render();
- }
- }
-
- /**
- * A form for configuring actor hit points and bonuses.
- */
- class ActorHitPointsConfig extends BaseConfigSheet {
- constructor(...args) {
- super(...args);
-
- /**
- * Cloned copy of the actor for previewing changes.
- * @type {Actor5e}
- */
- this.clone = this.object.clone();
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- classes: ["dnd5e", "actor-hit-points-config"],
- template: "systems/dnd5e/templates/apps/hit-points-config.hbs",
- width: 320,
- height: "auto",
- sheetConfig: false
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get title() {
- return `${game.i18n.localize("DND5E.HitPointsConfig")}: ${this.document.name}`;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- getData(options) {
- return {
- hp: this.clone.system.attributes.hp,
- source: this.clone.toObject().system.attributes.hp,
- isCharacter: this.document.type === "character"
- };
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- _getActorOverrides() {
- return Object.keys(foundry.utils.flattenObject(this.object.overrides?.system?.attributes || {}));
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _updateObject(event, formData) {
- const hp = foundry.utils.expandObject(formData).hp;
- this.clone.updateSource({"system.attributes.hp": hp});
- const maxDelta = this.clone.system.attributes.hp.max - this.document.system.attributes.hp.max;
- hp.value = Math.max(this.document.system.attributes.hp.value + maxDelta, 0);
- return this.document.update({"system.attributes.hp": hp});
- }
-
- /* -------------------------------------------- */
- /* Event Listeners and Handlers */
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- activateListeners(html) {
- super.activateListeners(html);
- html.find(".roll-hit-points").click(this._onRollHPFormula.bind(this));
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _onChangeInput(event) {
- await super._onChangeInput(event);
- const t = event.currentTarget;
-
- // Update clone with new data & re-render
- this.clone.updateSource({ [`system.attributes.${t.name}`]: t.value || null });
- if ( t.name !== "hp.formula" ) this.render();
- }
-
- /* -------------------------------------------- */
-
- /**
- * Handle rolling NPC health values using the provided formula.
- * @param {Event} event The original click event.
- * @protected
- */
- async _onRollHPFormula(event) {
- event.preventDefault();
- try {
- const roll = await this.clone.rollNPCHitPoints();
- this.clone.updateSource({"system.attributes.hp.max": roll.total});
- this.render();
- } catch(error) {
- ui.notifications.error(game.i18n.localize("DND5E.HPFormulaError"));
- throw error;
- }
- }
- }
-
- /**
- * A simple sub-application of the ActorSheet which is used to configure properties related to initiative.
- */
- class ActorInitiativeConfig extends BaseConfigSheet {
-
- /** @override */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- classes: ["dnd5e"],
- template: "systems/dnd5e/templates/apps/initiative-config.hbs",
- width: 360,
- height: "auto"
- });
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- get title() {
- return `${game.i18n.localize("DND5E.InitiativeConfig")}: ${this.document.name}`;
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- getData(options={}) {
- const source = this.document.toObject();
- const init = source.system.attributes.init || {};
- const flags = source.flags.dnd5e || {};
- return {
- ability: init.ability,
- abilities: CONFIG.DND5E.abilities,
- bonus: init.bonus,
- initiativeAlert: flags.initiativeAlert,
- initiativeAdv: flags.initiativeAdv
- };
- }
-
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- _getSubmitData(updateData={}) {
- const formData = super._getSubmitData(updateData);
- formData.flags = {dnd5e: {}};
- for ( const flag of ["initiativeAlert", "initiativeAdv"] ) {
- const k = `flags.dnd5e.${flag}`;
- if ( formData[k] ) formData.flags.dnd5e[flag] = true;
- else formData.flags.dnd5e[`-=${flag}`] = null;
- delete formData[k];
- }
- return formData;
- }
- }
-
- /**
- * A simple form to set actor movement speeds.
- */
- class ActorMovementConfig extends BaseConfigSheet {
-
- /** @override */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- classes: ["dnd5e"],
- template: "systems/dnd5e/templates/apps/movement-config.hbs",
- width: 300,
- height: "auto"
- });
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- get title() {
- return `${game.i18n.localize("DND5E.MovementConfig")}: ${this.document.name}`;
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- getData(options={}) {
- const source = this.document.toObject();
-
- // Current movement values
- const movement = source.system.attributes?.movement || {};
- for ( let [k, v] of Object.entries(movement) ) {
- if ( ["units", "hover"].includes(k) ) continue;
- movement[k] = Number.isNumeric(v) ? v.toNearest(0.1) : 0;
- }
-
- // Allowed speeds
- const speeds = source.type === "group" ? {
- land: "DND5E.MovementLand",
- water: "DND5E.MovementWater",
- air: "DND5E.MovementAir"
- } : {
- walk: "DND5E.MovementWalk",
- burrow: "DND5E.MovementBurrow",
- climb: "DND5E.MovementClimb",
- fly: "DND5E.MovementFly",
- swim: "DND5E.MovementSwim"
- };
-
- // Return rendering context
- return {
- speeds,
- movement,
- selectUnits: source.type !== "group",
- canHover: source.type !== "group",
- units: CONFIG.DND5E.movementUnits
- };
- }
- }
-
- /**
- * A simple form to configure Actor senses.
- */
- class ActorSensesConfig extends BaseConfigSheet {
-
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- classes: ["dnd5e"],
- template: "systems/dnd5e/templates/apps/senses-config.hbs",
- width: 300,
- height: "auto"
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get title() {
- return `${game.i18n.localize("DND5E.SensesConfig")}: ${this.document.name}`;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- getData(options) {
- const source = this.document.toObject().system.attributes?.senses || {};
- const data = {
- senses: {},
- special: source.special ?? "",
- units: source.units, movementUnits: CONFIG.DND5E.movementUnits
- };
- for ( let [name, label] of Object.entries(CONFIG.DND5E.senses) ) {
- const v = Number(source[name]);
- data.senses[name] = {
- label: game.i18n.localize(label),
- value: Number.isNumeric(v) ? v.toNearest(0.1) : 0
- };
- }
- return data;
- }
- }
-
- /**
- * An application class which provides advanced configuration for special character flags which modify an Actor.
- */
- class ActorSheetFlags extends BaseConfigSheet {
-
- /** @inheritDoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- id: "actor-flags",
- classes: ["dnd5e"],
- template: "systems/dnd5e/templates/apps/actor-flags.hbs",
- width: 500,
- closeOnSubmit: true
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- get title() {
- return `${game.i18n.localize("DND5E.FlagsTitle")}: ${this.object.name}`;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- getData() {
- const data = {};
- data.actor = this.object;
- data.classes = this._getClasses();
- data.flags = this._getFlags();
- data.bonuses = this._getBonuses();
- return data;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare an object of sorted classes.
- * @returns {object}
- * @private
- */
- _getClasses() {
- const classes = this.object.items.filter(i => i.type === "class");
- return classes.sort((a, b) => a.name.localeCompare(b.name)).reduce((obj, i) => {
- obj[i.id] = i.name;
- return obj;
- }, {});
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare an object of flags data which groups flags by section
- * Add some additional data for rendering
- * @returns {object}
- * @private
- */
- _getFlags() {
- const flags = {};
- const baseData = this.document.toJSON();
- for ( let [k, v] of Object.entries(CONFIG.DND5E.characterFlags) ) {
- if ( !flags.hasOwnProperty(v.section) ) flags[v.section] = {};
- let flag = foundry.utils.deepClone(v);
- flag.type = v.type.name;
- flag.isCheckbox = v.type === Boolean;
- flag.isSelect = v.hasOwnProperty("choices");
- flag.value = foundry.utils.getProperty(baseData.flags, `dnd5e.${k}`);
- flags[v.section][`flags.dnd5e.${k}`] = flag;
- }
- return flags;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Get the bonuses fields and their localization strings
- * @returns {Array<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;
-
- // Apply changes based on step type
- if ( (this.step.type === "delete") && this.step.item ) this.clone.items.delete(this.step.item.id);
- else if ( (this.step.type === "delete") && this.step.advancement ) {
- this.step.advancement.item.deleteAdvancement(this.step.advancement.id, { source: true });
- }
- else if ( this.step.type === "restore" ) await flow.advancement.restore(flow.level, flow.retainedData);
- else if ( this.step.type === "reverse" ) flow.retainedData = await flow.advancement.reverse(flow.level);
- else if ( flow ) await flow._updateObject(event, flow._getSubmitData());
-
- this._stepIndex++;
-
- // Ensure the level on the class item matches the specified level
- if ( this.step?.class ) {
- let level = this.step.class.level;
- if ( this.step.type === "reverse" ) level -= 1;
- this.step.class.item.updateSource({"system.levels": level});
- }
- this.clone.reset();
- } while ( this.step?.automatic );
- } catch(error) {
- if ( !(error instanceof Advancement.ERROR) ) throw error;
- ui.notifications.error(error.message);
- this.step.automatic = false;
- if ( this.step.type === "restore" ) this.step.type = "forward";
- } finally {
- this._advancing = false;
- }
-
- if ( this.step ) this.render(true);
- else this._complete();
- }
-
- /* -------------------------------------------- */
-
- /**
- * Reverse through the steps until one requiring user interaction is encountered.
- * @param {Event} [event] Triggering click event if one occurred.
- * @param {object} [options] Additional options to configure behavior.
- * @param {boolean} [options.render=true] Whether to render the Application after the step has been reversed. Used
- * by the restart workflow.
- * @returns {Promise}
- * @private
- */
- async _backward(event, { render=true }={}) {
- this._advancing = true;
- try {
- do {
- this._stepIndex--;
- if ( !this.step ) break;
- const flow = this.step.flow;
-
- // Reverse step based on step type
- if ( (this.step.type === "delete") && this.step.item ) this.clone.updateSource({items: [this.step.item]});
- else if ( (this.step.type === "delete") && this.step.advancement ) this.advancement.item.createAdvancement(
- this.advancement.typeName, this.advancement._source, { source: true }
- );
- else if ( this.step.type === "reverse" ) await flow.advancement.restore(flow.level, flow.retainedData);
- else if ( flow ) flow.retainedData = await flow.advancement.reverse(flow.level);
- this.clone.reset();
- } while ( this.step?.automatic );
- } catch(error) {
- if ( !(error instanceof Advancement.ERROR) ) throw error;
- ui.notifications.error(error.message);
- this.step.automatic = false;
- } finally {
- this._advancing = false;
- }
-
- if ( !render ) return;
- if ( this.step ) this.render(true);
- else this.close({ skipConfirmation: true });
- }
-
- /* -------------------------------------------- */
-
- /**
- * Reset back to the manager's initial state.
- * @param {MouseEvent} [event] The triggering click event if one occurred.
- * @returns {Promise}
- * @private
- */
- async _restart(event) {
- const restart = await Dialog.confirm({
- title: game.i18n.localize("DND5E.AdvancementManagerRestartConfirmTitle"),
- content: game.i18n.localize("DND5E.AdvancementManagerRestartConfirm")
- });
- if ( !restart ) return;
- // While there is still a renderable step.
- while ( this.steps.slice(0, this._stepIndex).some(s => !s.automatic) ) {
- await this._backward(event, {render: false});
- }
- this.render(true);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Apply changes to actual actor after all choices have been made.
- * @param {Event} event Button click that triggered the change.
- * @returns {Promise}
- * @private
- */
- async _complete(event) {
- const updates = this.clone.toObject();
- const items = updates.items;
- delete updates.items;
-
- // Gather changes to embedded items
- const { toCreate, toUpdate, toDelete } = items.reduce((obj, item) => {
- if ( !this.actor.items.get(item._id) ) {
- obj.toCreate.push(item);
- } else {
- obj.toUpdate.push(item);
- obj.toDelete.findSplice(id => id === item._id);
- }
- return obj;
- }, { toCreate: [], toUpdate: [], toDelete: this.actor.items.map(i => i.id) });
-
- /**
- * A hook event that fires at the final stage of a character's advancement process, before actor and item updates
- * are applied.
- * @function dnd5e.preAdvancementManagerComplete
- * @memberof hookEvents
- * @param {AdvancementManager} advancementManager The advancement manager.
- * @param {object} actorUpdates Updates to the actor.
- * @param {object[]} toCreate Items that will be created on the actor.
- * @param {object[]} toUpdate Items that will be updated on the actor.
- * @param {string[]} toDelete IDs of items that will be deleted on the actor.
- */
- if ( Hooks.call("dnd5e.preAdvancementManagerComplete", this, updates, toCreate, toUpdate, toDelete) === false ) {
- console.log("AdvancementManager completion was prevented by the 'preAdvancementManagerComplete' hook.");
- return this.close({ skipConfirmation: true });
- }
-
- // Apply changes from clone to original actor
- await Promise.all([
- this.actor.update(updates, { isAdvancement: true }),
- this.actor.createEmbeddedDocuments("Item", toCreate, { keepId: true, isAdvancement: true }),
- this.actor.updateEmbeddedDocuments("Item", toUpdate, { isAdvancement: true }),
- this.actor.deleteEmbeddedDocuments("Item", toDelete, { isAdvancement: true })
- ]);
-
- /**
- * A hook event that fires when an AdvancementManager is done modifying an actor.
- * @function dnd5e.advancementManagerComplete
- * @memberof hookEvents
- * @param {AdvancementManager} advancementManager The advancement manager that just completed
- */
- Hooks.callAll("dnd5e.advancementManagerComplete", this);
-
- // Close prompt
- return this.close({ skipConfirmation: true });
- }
-
- }
-
- /**
- * Description for a single part of a property attribution.
- * @typedef {object} AttributionDescription
- * @property {string} label Descriptive label that will be displayed. If the label is in the form
- * of an @ property, the system will try to turn it into a human-readable label.
- * @property {number} mode Application mode for this step as defined in
- * [CONST.ACTIVE_EFFECT_MODES](https://foundryvtt.com/api/module-constants.html#.ACTIVE_EFFECT_MODES).
- * @property {number} value Value of this step.
- */
-
- /**
- * Interface for viewing what factors went into determining a specific property.
- *
- * @param {Document} object The Document that owns the property being attributed.
- * @param {AttributionDescription[]} attributions An array of all the attribution data.
- * @param {string} property Dot separated path to the property.
- * @param {object} [options={}] Application rendering options.
- */
- class PropertyAttribution extends Application {
- constructor(object, attributions, property, options={}) {
- super(options);
- this.object = object;
- this.attributions = attributions;
- this.property = property;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- id: "property-attribution",
- classes: ["dnd5e", "property-attribution"],
- template: "systems/dnd5e/templates/apps/property-attribution.hbs",
- width: 320,
- height: "auto"
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * Render this view as a tooltip rather than a whole window.
- * @param {HTMLElement} element The element to which the tooltip should be attached.
- */
- async renderTooltip(element) {
- const data = this.getData(this.options);
- const text = (await this._renderInner(data))[0].outerHTML;
- game.tooltip.activate(element, { text, cssClass: "property-attribution" });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- getData() {
- const property = foundry.utils.getProperty(this.object.system, this.property);
- let total;
- if ( Number.isNumeric(property)) total = property;
- else if ( typeof property === "object" && Number.isNumeric(property.value) ) total = property.value;
- const sources = foundry.utils.duplicate(this.attributions);
- return {
- caption: this.options.title,
- sources: sources.map(entry => {
- if ( entry.label.startsWith("@") ) entry.label = this.getPropertyLabel(entry.label.slice(1));
- if ( (entry.mode === CONST.ACTIVE_EFFECT_MODES.ADD) && (entry.value < 0) ) {
- entry.negative = true;
- entry.value = entry.value * -1;
- }
- return entry;
- }),
- total: total
- };
- }
-
- /* -------------------------------------------- */
-
- /**
- * Produce a human-readable and localized name for the provided property.
- * @param {string} property Dot separated path to the property.
- * @returns {string} Property name for display.
- */
- getPropertyLabel(property) {
- const parts = property.split(".");
- if ( parts[0] === "abilities" && parts[1] ) {
- return CONFIG.DND5E.abilities[parts[1]]?.label ?? property;
- } else if ( (property === "attributes.ac.dex") && CONFIG.DND5E.abilities.dex ) {
- return CONFIG.DND5E.abilities.dex.label;
- } else if ( (parts[0] === "prof") || (property === "attributes.prof") ) {
- return game.i18n.localize("DND5E.Proficiency");
- }
- return property;
- }
- }
-
- /**
- * A specialized application used to modify actor traits.
- *
- * @param {Actor5e} actor Actor for whose traits are being edited.
- * @param {string} trait Trait key as defined in CONFIG.traits.
- * @param {object} [options={}]
- * @param {boolean} [options.allowCustom=true] Support user custom trait entries.
- */
- let TraitSelector$1 = class TraitSelector extends BaseConfigSheet {
- constructor(actor, trait, options={}) {
- if ( !CONFIG.DND5E.traits[trait] ) throw new Error(
- `Cannot instantiate TraitSelector with a trait not defined in CONFIG.DND5E.traits: ${trait}.`
- );
- if ( ["saves", "skills"].includes(trait) ) throw new Error(
- `TraitSelector does not support selection of ${trait}. That should be handled through `
- + "that type's more specialized configuration application."
- );
-
- super(actor, options);
-
- /**
- * Trait key as defined in CONFIG.traits.
- * @type {string}
- */
- this.trait = trait;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- id: "trait-selector",
- classes: ["dnd5e", "trait-selector", "subconfig"],
- template: "systems/dnd5e/templates/apps/trait-selector.hbs",
- width: 320,
- height: "auto",
- sheetConfig: false,
- allowCustom: true
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get id() {
- return `${this.constructor.name}-${this.trait}-Actor-${this.document.id}`;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get title() {
- return `${this.document.name}: ${traitLabel(this.trait)}`;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async getData() {
- const path = `system.${actorKeyPath(this.trait)}`;
- const data = foundry.utils.getProperty(this.document, path);
- if ( !data ) return super.getData();
-
- return {
- ...super.getData(),
- choices: await choices(this.trait, data.value),
- custom: data.custom,
- customPath: "custom" in data ? `${path}.custom` : null,
- bypasses: "bypasses" in data ? Object.entries(CONFIG.DND5E.physicalWeaponProperties).reduce((obj, [k, v]) => {
- obj[k] = { label: v, chosen: data.bypasses.has(k) };
- return obj;
- }, {}) : null,
- bypassesPath: "bypasses" in data ? `${path}.bypasses` : null
- };
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- activateListeners(html) {
- super.activateListeners(html);
-
- for ( const checkbox of html[0].querySelectorAll("input[type='checkbox']") ) {
- if ( checkbox.checked ) this._onToggleCategory(checkbox);
- }
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- _getActorOverrides() {
- const overrides = super._getActorOverrides();
- const path = `system.${actorKeyPath(this.trait)}.value`;
- const src = new Set(foundry.utils.getProperty(this.document._source, path));
- const current = foundry.utils.getProperty(this.document, path);
- const delta = current.difference(src);
- for ( const choice of delta ) {
- overrides.push(`choices.${choice}`);
- }
- return overrides;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _onChangeInput(event) {
- super._onChangeInput(event);
-
- if ( event.target.name?.startsWith("choices") ) this._onToggleCategory(event.target);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Enable/disable all children when a category is checked.
- * @param {HTMLElement} checkbox Checkbox that was changed.
- * @protected
- */
- _onToggleCategory(checkbox) {
- const children = checkbox.closest("li")?.querySelector("ol");
- if ( !children ) return;
-
- for ( const child of children.querySelectorAll("input[type='checkbox']") ) {
- child.checked = child.disabled = checkbox.checked;
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Filter a list of choices that begin with the provided key for update.
- * @param {string} prefix They initial form prefix under which the choices are grouped.
- * @param {string} path Path in actor data where the final choices will be saved.
- * @param {object} formData Form data being prepared. *Will be mutated.*
- * @protected
- */
- _prepareChoices(prefix, path, formData) {
- const chosen = [];
- for ( const key of Object.keys(formData).filter(k => k.startsWith(`${prefix}.`)) ) {
- if ( formData[key] ) chosen.push(key.replace(`${prefix}.`, ""));
- delete formData[key];
- }
- formData[path] = chosen;
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- async _updateObject(event, formData) {
- const path = `system.${actorKeyPath(this.trait)}`;
- const data = foundry.utils.getProperty(this.document, path);
-
- this._prepareChoices("choices", `${path}.value`, formData);
- if ( "bypasses" in data ) this._prepareChoices("bypasses", `${path}.bypasses`, formData);
-
- return this.object.update(formData);
- }
- };
-
- /**
- * @typedef {FormApplicationOptions} ProficiencyConfigOptions
- * @property {string} key The ID of the skill or tool being configured.
- * @property {string} property The property on the actor being configured, either 'skills', or 'tools'.
- */
-
- /**
- * An application responsible for configuring proficiencies and bonuses in tools and skills.
- *
- * @param {Actor5e} actor The Actor being configured.
- * @param {ProficiencyConfigOptions} options Additional configuration options.
- */
- class ProficiencyConfig extends BaseConfigSheet {
-
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- classes: ["dnd5e"],
- template: "systems/dnd5e/templates/apps/proficiency-config.hbs",
- width: 500,
- height: "auto"
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * Are we configuring a tool?
- * @returns {boolean}
- */
- get isTool() {
- return this.options.property === "tools";
- }
-
- /* -------------------------------------------- */
-
- /**
- * Are we configuring a skill?
- * @returns {boolean}
- */
- get isSkill() {
- return this.options.property === "skills";
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get title() {
- const label = this.isSkill ? CONFIG.DND5E.skills[this.options.key].label : keyLabel("tool", this.options.key);
- return `${game.i18n.format("DND5E.ProficiencyConfigureTitle", {label})}: ${this.document.name}`;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get id() {
- return `ProficiencyConfig-${this.document.documentName}-${this.document.id}-${this.options.key}`;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- getData(options={}) {
- return {
- abilities: CONFIG.DND5E.abilities,
- proficiencyLevels: CONFIG.DND5E.proficiencyLevels,
- entry: this.document.system[this.options.property]?.[this.options.key],
- isTool: this.isTool,
- isSkill: this.isSkill,
- key: this.options.key,
- property: this.options.property
- };
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _updateObject(event, formData) {
- if ( this.isTool ) return super._updateObject(event, formData);
- const passive = formData[`system.skills.${this.options.key}.bonuses.passive`];
- const passiveRoll = new Roll(passive);
- if ( !passiveRoll.isDeterministic ) {
- const message = game.i18n.format("DND5E.FormulaCannotContainDiceError", {
- name: game.i18n.localize("DND5E.SkillBonusPassive")
- });
- ui.notifications.error(message);
- throw new Error(message);
- }
- return super._updateObject(event, formData);
- }
- }
-
- /**
- * A specialized version of the TraitSelector used for selecting tool and vehicle proficiencies.
- * @extends {TraitSelector}
- */
- class ToolSelector extends TraitSelector$1 {
- /** @inheritdoc */
- async getData() {
- return {
- ...super.getData(),
- choices: await choices(this.trait, Object.keys(this.document.system.tools))
- };
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- _getActorOverrides() {
- return Object.keys(foundry.utils.flattenObject(this.document.overrides));
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _updateObject(event, formData) {
- return this.document.update(Object.entries(formData).reduce((obj, [k, v]) => {
- const [, key] = k.split(".");
- const tool = this.document.system.tools[key];
- if ( tool && !v ) obj[`system.tools.-=${key}`] = null;
- else if ( !tool && v ) obj[`system.tools.${key}`] = {value: 1};
- return obj;
- }, {}));
- }
- }
-
- /**
- * Extend the basic ActorSheet class to suppose system-specific logic and functionality.
- * @abstract
- */
- class ActorSheet5e extends ActorSheet {
-
- /**
- * Track the set of item filters which are applied
- * @type {Object<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()
- };
-
- // Sort Owned Items
- context.items.sort((a, b) => (a.sort || 0) - (b.sort || 0));
-
- // Temporary HP
- const hp = {...context.system.attributes.hp};
- if ( hp.temp === 0 ) delete hp.temp;
- if ( hp.tempmax === 0 ) delete hp.tempmax;
- context.hp = hp;
-
- // Ability Scores
- for ( const [a, abl] of Object.entries(context.abilities) ) {
- abl.icon = this._getProficiencyIcon(abl.proficient);
- abl.hover = CONFIG.DND5E.proficiencyLevels[abl.proficient];
- abl.label = CONFIG.DND5E.abilities[a]?.label;
- abl.baseProf = source.system.abilities[a]?.proficient ?? 0;
- }
-
- // Skills & tools.
- ["skills", "tools"].forEach(prop => {
- for ( const [key, entry] of Object.entries(context[prop]) ) {
- entry.abbreviation = CONFIG.DND5E.abilities[entry.ability]?.abbreviation;
- entry.icon = this._getProficiencyIcon(entry.value);
- entry.hover = CONFIG.DND5E.proficiencyLevels[entry.value];
- entry.label = prop === "skills" ? CONFIG.DND5E.skills[key]?.label : keyLabel("tool", key);
- entry.baseValue = source.system[prop]?.[key]?.value ?? 0;
- }
- });
-
- // Update traits
- context.traits = this._prepareTraits(context.system);
-
- // Prepare owned items
- this._prepareItems(context);
- context.expandedData = {};
- for ( const id of this._expanded ) {
- const item = this.actor.items.get(id);
- if ( item ) context.expandedData[id] = await item.getChatData({secrets: this.actor.isOwner});
- }
-
- // Biography HTML enrichment
- context.biographyHTML = await TextEditor.enrichHTML(context.system.details.biography.value, {
- secrets: this.actor.isOwner,
- rollData: context.rollData,
- async: true,
- relativeTo: this.actor
- });
-
- return context;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare labels object for the context.
- * @returns {object} Object containing various labels.
- * @protected
- */
- _getLabels() {
- const labels = {...this.actor.labels};
-
- // Currency Labels
- labels.currencies = Object.entries(CONFIG.DND5E.currencies).reduce((obj, [k, c]) => {
- obj[k] = c.label;
- return obj;
- }, {});
-
- // Proficiency
- labels.proficiency = game.settings.get("dnd5e", "proficiencyModifier") === "dice"
- ? `d${this.actor.system.attributes.prof * 2}`
- : `+${this.actor.system.attributes.prof}`;
-
- return labels;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare the display of movement speed data for the Actor.
- * @param {object} systemData System data for the Actor being prepared.
- * @param {boolean} [largestPrimary=false] Show the largest movement speed as "primary", otherwise show "walk".
- * @returns {{primary: string, special: string}}
- * @protected
- */
- _getMovementSpeed(systemData, largestPrimary=false) {
- const movement = systemData.attributes.movement ?? {};
-
- // Prepare an array of available movement speeds
- let speeds = [
- [movement.burrow, `${game.i18n.localize("DND5E.MovementBurrow")} ${movement.burrow}`],
- [movement.climb, `${game.i18n.localize("DND5E.MovementClimb")} ${movement.climb}`],
- [movement.fly, `${game.i18n.localize("DND5E.MovementFly")} ${movement.fly}${movement.hover ? ` (${game.i18n.localize("DND5E.MovementHover")})` : ""}`],
- [movement.swim, `${game.i18n.localize("DND5E.MovementSwim")} ${movement.swim}`]
- ];
- if ( largestPrimary ) {
- speeds.push([movement.walk, `${game.i18n.localize("DND5E.MovementWalk")} ${movement.walk}`]);
- }
-
- // Filter and sort speeds on their values
- speeds = speeds.filter(s => s[0]).sort((a, b) => b[0] - a[0]);
-
- // Case 1: Largest as primary
- if ( largestPrimary ) {
- let primary = speeds.shift();
- return {
- primary: `${primary ? primary[1] : "0"} ${movement.units}`,
- special: speeds.map(s => s[1]).join(", ")
- };
- }
-
- // Case 2: Walk as primary
- else {
- return {
- primary: `${movement.walk || 0} ${movement.units}`,
- special: speeds.length ? speeds.map(s => s[1]).join(", ") : ""
- };
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare senses object for display.
- * @param {object} systemData System data for the Actor being prepared.
- * @returns {object} Senses grouped by key with localized and formatted string.
- * @protected
- */
- _getSenses(systemData) {
- const senses = systemData.attributes.senses ?? {};
- const tags = {};
- for ( let [k, label] of Object.entries(CONFIG.DND5E.senses) ) {
- const v = senses[k] ?? 0;
- if ( v === 0 ) continue;
- tags[k] = `${game.i18n.localize(label)} ${v} ${senses.units}`;
- }
- if ( senses.special ) tags.special = senses.special;
- return tags;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async activateEditor(name, options={}, initialContent="") {
- options.relativeLinks = true;
- return super.activateEditor(name, options, initialContent);
- }
-
- /* --------------------------------------------- */
- /* Property Attribution */
- /* --------------------------------------------- */
-
- /**
- * Break down all of the Active Effects affecting a given target property.
- * @param {string} target The data property being targeted.
- * @returns {AttributionDescription[]} Any active effects that modify that property.
- * @protected
- */
- _prepareActiveEffectAttributions(target) {
- return this.actor.effects.reduce((arr, e) => {
- let source = e.sourceName;
- if ( e.origin === this.actor.uuid ) source = e.label;
- if ( !source || e.disabled || e.isSuppressed ) return arr;
- const value = e.changes.reduce((n, change) => {
- if ( (change.key !== target) || !Number.isNumeric(change.value) ) return n;
- if ( change.mode !== CONST.ACTIVE_EFFECT_MODES.ADD ) return n;
- return n + Number(change.value);
- }, 0);
- if ( !value ) return arr;
- arr.push({value, label: source, mode: CONST.ACTIVE_EFFECT_MODES.ADD});
- return arr;
- }, []);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Produce a list of armor class attribution objects.
- * @param {object} rollData Data provided by Actor5e#getRollData
- * @returns {AttributionDescription[]} List of attribution descriptions.
- * @protected
- */
- _prepareArmorClassAttribution(rollData) {
- const ac = rollData.attributes.ac;
- const cfg = CONFIG.DND5E.armorClasses[ac.calc];
- const attribution = [];
-
- // Base AC Attribution
- switch ( ac.calc ) {
-
- // Flat AC
- case "flat":
- return [{
- label: game.i18n.localize("DND5E.ArmorClassFlat"),
- mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
- value: ac.flat
- }];
-
- // Natural armor
- case "natural":
- attribution.push({
- label: game.i18n.localize("DND5E.ArmorClassNatural"),
- mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
- value: ac.flat
- });
- break;
-
- default:
- const formula = ac.calc === "custom" ? ac.formula : cfg.formula;
- let base = ac.base;
- const dataRgx = new RegExp(/@([a-z.0-9_-]+)/gi);
- for ( const [match, term] of formula.matchAll(dataRgx) ) {
- const value = String(foundry.utils.getProperty(rollData, term));
- if ( (term === "attributes.ac.armor") || (value === "0") ) continue;
- if ( Number.isNumeric(value) ) base -= Number(value);
- attribution.push({
- label: match,
- mode: CONST.ACTIVE_EFFECT_MODES.ADD,
- value
- });
- }
- const armorInFormula = formula.includes("@attributes.ac.armor");
- let label = game.i18n.localize("DND5E.PropertyBase");
- if ( armorInFormula ) label = this.actor.armor?.name ?? game.i18n.localize("DND5E.ArmorClassUnarmored");
- attribution.unshift({
- label,
- mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
- value: base
- });
- break;
- }
-
- // Shield
- if ( ac.shield !== 0 ) attribution.push({
- label: this.actor.shield?.name ?? game.i18n.localize("DND5E.EquipmentShield"),
- mode: CONST.ACTIVE_EFFECT_MODES.ADD,
- value: ac.shield
- });
-
- // Bonus
- if ( ac.bonus !== 0 ) attribution.push(...this._prepareActiveEffectAttributions("system.attributes.ac.bonus"));
-
- // Cover
- if ( ac.cover !== 0 ) attribution.push({
- label: game.i18n.localize("DND5E.Cover"),
- mode: CONST.ACTIVE_EFFECT_MODES.ADD,
- value: ac.cover
- });
- return attribution;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare the data structure for traits data like languages, resistances & vulnerabilities, and proficiencies.
- * @param {object} systemData System data for the Actor being prepared.
- * @returns {object} Prepared trait data.
- * @protected
- */
- _prepareTraits(systemData) {
- const traits = {};
- for ( const [trait$1, traitConfig] of Object.entries(CONFIG.DND5E.traits) ) {
- const key = traitConfig.actorKeyPath ?? `traits.${trait$1}`;
- const data = foundry.utils.deepClone(foundry.utils.getProperty(systemData, key));
- const choices = CONFIG.DND5E[traitConfig.configKey];
- if ( !data ) continue;
-
- foundry.utils.setProperty(traits, key, data);
- let values = data.value;
- if ( !values ) values = [];
- else if ( values instanceof Set ) values = Array.from(values);
- else if ( !Array.isArray(values) ) values = [values];
-
- // Split physical damage types from others if bypasses is set
- const physical = [];
- if ( data.bypasses?.size ) {
- values = values.filter(t => {
- if ( !CONFIG.DND5E.physicalDamageTypes[t] ) return true;
- physical.push(t);
- return false;
- });
- }
-
- data.selected = values.reduce((obj, key) => {
- obj[key] = keyLabel(trait$1, key) ?? key;
- return obj;
- }, {});
-
- // Display bypassed damage types
- if ( physical.length ) {
- const damageTypesFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "conjunction" });
- const bypassFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "disjunction" });
- data.selected.physical = game.i18n.format("DND5E.DamagePhysicalBypasses", {
- damageTypes: damageTypesFormatter.format(physical.map(t => choices[t])),
- bypassTypes: bypassFormatter.format(data.bypasses.map(t => CONFIG.DND5E.physicalWeaponProperties[t]))
- });
- }
-
- // Add custom entries
- if ( data.custom ) data.custom.split(";").forEach((c, i) => data.selected[`custom${i+1}`] = c.trim());
- data.cssClass = !foundry.utils.isEmpty(data.selected) ? "" : "inactive";
- }
- return traits;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare the data structure for items which appear on the actor sheet.
- * Each subclass overrides this method to implement type-specific logic.
- * @protected
- */
- _prepareItems() {}
-
- /* -------------------------------------------- */
-
- /**
- * Insert a spell into the spellbook object when rendering the character sheet.
- * @param {object} context Sheet rendering context data being prepared for render.
- * @param {object[]} spells Spells to be included in the spellbook.
- * @returns {object[]} Spellbook sections in the proper order.
- * @protected
- */
- _prepareSpellbook(context, spells) {
- const owner = this.actor.isOwner;
- const levels = context.actor.system.spells;
- const spellbook = {};
-
- // Define section and label mappings
- const sections = {atwill: -20, innate: -10, pact: 0.5 };
- const useLabels = {"-20": "-", "-10": "-", 0: "∞"};
-
- // Format a spellbook entry for a certain indexed level
- const registerSection = (sl, i, label, {prepMode="prepared", value, max, override}={}) => {
- const aeOverride = foundry.utils.hasProperty(this.actor.overrides, `system.spells.spell${i}.override`);
- spellbook[i] = {
- order: i,
- label: label,
- usesSlots: i > 0,
- canCreate: owner,
- canPrepare: (context.actor.type === "character") && (i >= 1),
- spells: [],
- uses: useLabels[i] || value || 0,
- slots: useLabels[i] || max || 0,
- override: override || 0,
- dataset: {type: "spell", level: prepMode in sections ? 1 : i, "preparation.mode": prepMode},
- prop: sl,
- editable: context.editable && !aeOverride
- };
- };
-
- // Determine the maximum spell level which has a slot
- const maxLevel = Array.fromRange(10).reduce((max, i) => {
- if ( i === 0 ) return max;
- const level = levels[`spell${i}`];
- if ( (level.max || level.override ) && ( i > max ) ) max = i;
- return max;
- }, 0);
-
- // Level-based spellcasters have cantrips and leveled slots
- if ( maxLevel > 0 ) {
- registerSection("spell0", 0, CONFIG.DND5E.spellLevels[0]);
- for (let lvl = 1; lvl <= maxLevel; lvl++) {
- const sl = `spell${lvl}`;
- registerSection(sl, lvl, CONFIG.DND5E.spellLevels[lvl], levels[sl]);
- }
- }
-
- // Pact magic users have cantrips and a pact magic section
- if ( levels.pact && levels.pact.max ) {
- if ( !spellbook["0"] ) registerSection("spell0", 0, CONFIG.DND5E.spellLevels[0]);
- const l = levels.pact;
- const config = CONFIG.DND5E.spellPreparationModes.pact;
- const level = game.i18n.localize(`DND5E.SpellLevel${levels.pact.level}`);
- const label = `${config} — ${level}`;
- registerSection("pact", sections.pact, label, {
- prepMode: "pact",
- value: l.value,
- max: l.max,
- override: l.override
- });
- }
-
- // Iterate over every spell item, adding spells to the spellbook by section
- spells.forEach(spell => {
- const mode = spell.system.preparation.mode || "prepared";
- let s = spell.system.level || 0;
- const sl = `spell${s}`;
-
- // Specialized spellcasting modes (if they exist)
- if ( mode in sections ) {
- s = sections[mode];
- if ( !spellbook[s] ) {
- const l = levels[mode] || {};
- const config = CONFIG.DND5E.spellPreparationModes[mode];
- registerSection(mode, s, config, {
- prepMode: mode,
- value: l.value,
- max: l.max,
- override: l.override
- });
- }
- }
-
- // Sections for higher-level spells which the caster "should not" have, but spell items exist for
- else if ( !spellbook[s] ) {
- registerSection(sl, s, CONFIG.DND5E.spellLevels[s], {levels: levels[sl]});
- }
-
- // Add the spell to the relevant heading
- spellbook[s].spells.push(spell);
- });
-
- // Sort the spellbook by section level
- const sorted = Object.values(spellbook);
- sorted.sort((a, b) => a.order - b.order);
- return sorted;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Determine whether an Owned Item will be shown based on the current set of filters.
- * @param {object[]} items Copies of item data to be filtered.
- * @param {Set<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(".slot-max-override").click(this._onSpellSlotOverride.bind(this));
-
- // Active Effect management
- html.find(".effect-control").click(ev => ActiveEffect5e.onManageActiveEffect(ev, this.actor));
- this._disableOverriddenFields(html);
- }
-
- // Owner Only Listeners
- if ( this.actor.isOwner ) {
-
- // Ability Checks
- html.find(".ability-name").click(this._onRollAbilityTest.bind(this));
-
- // Roll Skill Checks
- html.find(".skill-name").click(this._onRollSkillCheck.bind(this));
-
- // Roll Tool Checks.
- html.find(".tool-name").on("click", this._onRollToolCheck.bind(this));
-
- // Item Rolling
- html.find(".rollable .item-image").click(event => this._onItemUse(event));
- html.find(".item .item-recharge").click(event => this._onItemRecharge(event));
-
- // Item Context Menu
- new ContextMenu(html, ".item-list .item", [], {onOpen: this._onItemContext.bind(this)});
- }
-
- // Otherwise, remove rollable classes
- else {
- html.find(".rollable").each((i, el) => el.classList.remove("rollable"));
- }
-
- // Handle default listeners last so system listeners are triggered first
- super.activateListeners(html);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Disable any fields that are overridden by active effects and display an informative tooltip.
- * @param {jQuery} html The sheet's rendered HTML.
- * @protected
- */
- _disableOverriddenFields(html) {
- const proficiencyToggles = {
- ability: /system\.abilities\.([^.]+)\.proficient/,
- skill: /system\.skills\.([^.]+)\.value/,
- tool: /system\.tools\.([^.]+)\.value/
- };
-
- for ( const override of Object.keys(foundry.utils.flattenObject(this.actor.overrides)) ) {
- html.find(`input[name="${override}"],select[name="${override}"]`).each((i, el) => {
- el.disabled = true;
- el.dataset.tooltip = "DND5E.ActiveEffectOverrideWarning";
- });
-
- for ( const [key, regex] of Object.entries(proficiencyToggles) ) {
- const [, match] = override.match(regex) || [];
- if ( match ) {
- const toggle = html.find(`li[data-${key}="${match}"] .proficiency-toggle`);
- toggle.addClass("disabled");
- toggle.attr("data-tooltip", "DND5E.ActiveEffectOverrideWarning");
- }
- }
-
- const [, spell] = override.match(/system\.spells\.(spell\d)\.override/) || [];
- if ( spell ) {
- html.find(`.spell-max[data-level="${spell}"]`).attr("data-tooltip", "DND5E.ActiveEffectOverrideWarning");
- }
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Handle activation of a context menu for an embedded Item or ActiveEffect document.
- * Dynamically populate the array of context menu options.
- * @param {HTMLElement} element The HTML element for which the context menu is activated
- * @protected
- */
- _onItemContext(element) {
-
- // Active Effects
- if ( element.classList.contains("effect") ) {
- const effect = this.actor.effects.get(element.dataset.effectId);
- if ( !effect ) return;
- ui.context.menuItems = this._getActiveEffectContextOptions(effect);
- Hooks.call("dnd5e.getActiveEffectContextOptions", effect, ui.context.menuItems);
- }
-
- // Items
- else {
- const item = this.actor.items.get(element.dataset.itemId);
- if ( !item ) return;
- ui.context.menuItems = this._getItemContextOptions(item);
- Hooks.call("dnd5e.getItemContextOptions", item, ui.context.menuItems);
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare an array of context menu options which are available for owned ActiveEffect documents.
- * @param {ActiveEffect5e} effect The ActiveEffect for which the context menu is activated
- * @returns {ContextMenuEntry[]} An array of context menu options offered for the ActiveEffect
- * @protected
- */
- _getActiveEffectContextOptions(effect) {
- return [
- {
- name: "DND5E.ContextMenuActionEdit",
- icon: "<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);
- 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.
- * @private
- */
- async _onSpellSlotOverride(event) {
- const span = event.currentTarget.parentElement;
- const level = span.dataset.level;
- const override = this.actor.system.spells[level].override || span.dataset.slots;
- const input = document.createElement("INPUT");
- input.type = "text";
- input.name = `system.spells.${level}.override`;
- input.value = override;
- input.placeholder = span.dataset.slots;
- input.dataset.dtype = "Number";
-
- // Replace the HTML
- const parent = span.parentElement;
- parent.removeChild(span);
- parent.appendChild(input);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Change the uses amount of an Owned Item within the Actor.
- * @param {Event} event The triggering click event.
- * @returns {Promise<Item5e>} Updated item.
- * @private
- */
- async _onUsesChange(event) {
- event.preventDefault();
- const itemId = event.currentTarget.closest(".item").dataset.itemId;
- const item = this.actor.items.get(itemId);
- const uses = Math.clamped(0, parseInt(event.target.value), item.system.uses.max);
- event.target.value = uses;
- return item.update({"system.uses.value": uses});
- }
-
- /* -------------------------------------------- */
-
- /**
- * Handle using an item from the Actor sheet, obtaining the Item instance, and dispatching to its use method.
- * @param {Event} event The triggering click event.
- * @returns {Promise} Results of the usage.
- * @protected
- */
- _onItemUse(event) {
- event.preventDefault();
- const itemId = event.currentTarget.closest(".item").dataset.itemId;
- const item = this.actor.items.get(itemId);
- return item.use({}, {event});
- }
-
- /* -------------------------------------------- */
-
- /**
- * Handle attempting to recharge an item usage by rolling a recharge check.
- * @param {Event} event The originating click event.
- * @returns {Promise<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")}`);
- 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);
- if ( (item.type in sections) && (item.type !== "loot") ) sections[item.type].items.push(item);
- else sections.loot.items.push(item);
- }
- return sections;
- }
-
- /* -------------------------------------------- */
- /* Rendering Workflow */
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- async _render(force, options={}) {
- for ( const member of this.object.system.members) {
- member.apps[this.id] = this;
- }
- return super._render(force, options);
- }
-
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- async close(options={}) {
- for ( const member of this.object.system.members ) {
- delete member.apps[this.id];
- }
- return super.close(options);
- }
-
- /* -------------------------------------------- */
- /* Event Listeners and Handlers */
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- activateListeners(html) {
- super.activateListeners(html);
- html.find(".group-member .name").click(this._onClickMemberName.bind(this));
- if ( this.isEditable ) {
- html.find(".action-button").click(this._onClickActionButton.bind(this));
- html.find(".item-control").click(this._onClickItemControl.bind(this));
- html.find(".item .rollable h4").click(event => this._onClickItemName(event));
- new ContextMenu(html, ".item-list .item", [], {onOpen: this._onItemContext.bind(this)});
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Handle clicks to action buttons on the group sheet.
- * @param {PointerEvent} event The initiating click event
- * @protected
- */
- _onClickActionButton(event) {
- event.preventDefault();
- const button = event.currentTarget;
- switch ( button.dataset.action ) {
- case "convertCurrency":
- Dialog.confirm({
- title: `${game.i18n.localize("DND5E.CurrencyConvert")}`,
- content: `<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);
- }
-
- /* -------------------------------------------- */
-
- /** @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$9 = /*#__PURE__*/Object.freeze({
- __proto__: null,
- ActorAbilityConfig: ActorAbilityConfig,
- ActorArmorConfig: ActorArmorConfig,
- ActorHitDiceConfig: ActorHitDiceConfig,
- ActorHitPointsConfig: ActorHitPointsConfig,
- ActorInitiativeConfig: ActorInitiativeConfig,
- ActorMovementConfig: ActorMovementConfig,
- ActorSensesConfig: ActorSensesConfig,
- ActorSheet5e: ActorSheet5e,
- ActorSheet5eCharacter: ActorSheet5eCharacter,
- ActorSheet5eNPC: ActorSheet5eNPC,
- ActorSheet5eVehicle: ActorSheet5eVehicle,
- ActorSheetFlags: ActorSheetFlags,
- ActorSkillConfig: ActorSkillConfig,
- ActorTypeConfig: ActorTypeConfig,
- BaseConfigSheet: BaseConfigSheet,
- GroupActorSheet: GroupActorSheet,
- LongRestDialog: LongRestDialog,
- ProficiencyConfig: ProficiencyConfig,
- ShortRestDialog: ShortRestDialog,
- ToolSelector: ToolSelector,
- TraitSelector: TraitSelector$1
- });
-
- /**
- * Dialog to select which new advancements should be added to an item.
- */
- class AdvancementMigrationDialog extends Dialog {
-
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- classes: ["dnd5e", "advancement-migration", "dialog"],
- jQuery: false,
- width: 500
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * A helper constructor function which displays the migration dialog.
- * @param {Item5e} item Item to which the advancements are being added.
- * @param {Advancement[]} advancements New advancements that should be displayed in the prompt.
- * @returns {Promise<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$8 = /*#__PURE__*/Object.freeze({
- __proto__: null,
- AdvancementConfig: AdvancementConfig,
- AdvancementConfirmationDialog: AdvancementConfirmationDialog,
- AdvancementFlow: AdvancementFlow,
- AdvancementManager: AdvancementManager,
- AdvancementMigrationDialog: AdvancementMigrationDialog,
- AdvancementSelection: AdvancementSelection,
- HitPointsConfig: HitPointsConfig,
- HitPointsFlow: HitPointsFlow,
- ItemChoiceConfig: ItemChoiceConfig,
- ItemChoiceFlow: ItemChoiceFlow,
- ItemGrantConfig: ItemGrantConfig,
- ItemGrantFlow: ItemGrantFlow,
- ScaleValueConfig: ScaleValueConfig,
- ScaleValueFlow: ScaleValueFlow
- });
-
- /**
- * An extension of the base CombatTracker class to provide some 5e-specific functionality.
- * @extends {CombatTracker}
- */
- class CombatTracker5e extends CombatTracker {
- /** @inheritdoc */
- async _onCombatantControl(event) {
- const btn = event.currentTarget;
- const combatantId = btn.closest(".combatant").dataset.combatantId;
- const combatant = this.viewed.combatants.get(combatantId);
- if ( (btn.dataset.control === "rollInitiative") && combatant?.actor ) return combatant.actor.rollInitiativeDialog();
- return super._onCombatantControl(event);
- }
- }
-
- var _module$7 = /*#__PURE__*/Object.freeze({
- __proto__: null,
- CombatTracker5e: CombatTracker5e
- });
-
- /**
- * A specialized form used to select from a checklist of attributes, traits, or properties.
- * @deprecated since dnd5e 2.1, targeted for removal in 2.3
- */
- class TraitSelector extends DocumentSheet {
- constructor(...args) {
- super(...args);
-
- if ( !this.options.suppressWarning ) foundry.utils.logCompatibilityWarning(
- `${this.constructor.name} has been deprecated in favor of a more specialized TraitSelector `
- + "available at 'dnd5e.applications.actor.TraitSelector'. Support for the old application will "
- + "be removed in a future version.",
- { since: "DnD5e 2.1", until: "DnD5e 2.3" }
- );
- }
-
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- id: "trait-selector",
- classes: ["dnd5e", "trait-selector", "subconfig"],
- title: "Actor Trait Selection",
- template: "systems/dnd5e/templates/apps/trait-selector.hbs",
- width: 320,
- height: "auto",
- choices: {},
- allowCustom: true,
- minimum: 0,
- maximum: null,
- labelKey: null,
- valueKey: "value",
- customKey: "custom"
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get title() {
- return this.options.title || super.title;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Return a reference to the target attribute
- * @type {string}
- */
- get attribute() {
- return this.options.name;
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- getData() {
- const attr = foundry.utils.getProperty(this.object, this.attribute);
- const o = this.options;
- const value = (o.valueKey) ? foundry.utils.getProperty(attr, o.valueKey) ?? [] : attr;
- const custom = (o.customKey) ? foundry.utils.getProperty(attr, o.customKey) ?? "" : "";
-
- // Populate choices
- const choices = Object.entries(o.choices).reduce((obj, e) => {
- let [k, v] = e;
- const label = o.labelKey ? foundry.utils.getProperty(v, o.labelKey) ?? v : v;
- obj[k] = { label, chosen: attr ? value.includes(k) : false };
- return obj;
- }, {});
-
- // Return data
- return {
- choices: choices,
- custom: custom,
- customPath: o.allowCustom ? "custom" : null
- };
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare the update data to include choices in the provided object.
- * @param {object} formData Form data to search for choices.
- * @returns {object} Updates to apply to target.
- */
- _prepareUpdateData(formData) {
- const o = this.options;
- formData = foundry.utils.expandObject(formData);
-
- // Obtain choices
- const chosen = Object.entries(formData.choices).filter(([, v]) => v).map(([k]) => k);
-
- // Object including custom data
- const updateData = {};
- if ( o.valueKey ) updateData[`${this.attribute}.${o.valueKey}`] = chosen;
- else updateData[this.attribute] = chosen;
- if ( o.allowCustom ) updateData[`${this.attribute}.${o.customKey}`] = formData.custom;
-
- // Validate the number chosen
- if ( o.minimum && (chosen.length < o.minimum) ) {
- return ui.notifications.error(`You must choose at least ${o.minimum} options`);
- }
- if ( o.maximum && (chosen.length > o.maximum) ) {
- return ui.notifications.error(`You may choose no more than ${o.maximum} options`);
- }
-
- return updateData;
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- async _updateObject(event, formData) {
- const updateData = this._prepareUpdateData(formData);
- if ( updateData ) this.object.update(updateData);
- }
- }
-
- /**
- * Override and extend the core ItemSheet implementation to handle specific item types.
- */
- class ItemSheet5e extends ItemSheet {
- constructor(...args) {
- super(...args);
-
- // Expand the default size of the class sheet
- if ( this.object.type === "class" ) {
- this.options.width = this.position.width = 600;
- this.options.height = this.position.height = 680;
- }
- else if ( this.object.type === "subclass" ) {
- this.options.height = this.position.height = 540;
- }
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- width: 560,
- height: 400,
- classes: ["dnd5e", "sheet", "item"],
- resizable: true,
- scrollY: [".tab.details"],
- tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}],
- dragDrop: [
- {dragSelector: "[data-effect-id]", dropSelector: ".effects-list"},
- {dragSelector: ".advancement-item", dropSelector: ".advancement"}
- ]
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * Whether advancements on embedded items should be configurable.
- * @type {boolean}
- */
- advancementConfigurationMode = false;
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get template() {
- return `systems/dnd5e/templates/items/${this.item.type}.hbs`;
- }
-
- /* -------------------------------------------- */
- /* Context Preparation */
- /* -------------------------------------------- */
-
- /** @override */
- async getData(options) {
- const context = await super.getData(options);
- const item = context.item;
- const source = item.toObject();
-
- // Game system configuration
- context.config = CONFIG.DND5E;
-
- // Item rendering data
- foundry.utils.mergeObject(context, {
- source: source.system,
- system: item.system,
- labels: item.labels,
- isEmbedded: item.isEmbedded,
- advancementEditable: (this.advancementConfigurationMode || !item.isEmbedded) && context.editable,
- rollData: this.item.getRollData(),
-
- // Item Type, Status, and Details
- itemType: game.i18n.localize(CONFIG.Item.typeLabels[this.item.type]),
- itemStatus: this._getItemStatus(),
- itemProperties: this._getItemProperties(),
- baseItems: await this._getItemBaseTypes(),
- isPhysical: item.system.hasOwnProperty("quantity"),
-
- // Action Details
- isHealing: item.system.actionType === "heal",
- isFlatDC: item.system.save?.scaling === "flat",
- isLine: ["line", "wall"].includes(item.system.target?.type),
-
- // Vehicles
- isCrewed: item.system.activation?.type === "crew",
-
- // Armor Class
- hasDexModifier: item.isArmor && (item.system.armor?.type !== "shield"),
-
- // Advancement
- advancement: this._getItemAdvancement(item),
-
- // Prepare Active Effects
- effects: ActiveEffect5e.prepareActiveEffectCategories(item.effects)
- });
- context.abilityConsumptionTargets = this._getItemConsumptionTargets();
-
- // Special handling for specific item types
- switch ( item.type ) {
- case "feat":
- const featureType = CONFIG.DND5E.featureTypes[item.system.type?.value];
- if ( featureType ) {
- context.itemType = featureType.label;
- context.featureSubtypes = featureType.subtypes;
- }
- break;
- case "spell":
- context.spellComponents = {...CONFIG.DND5E.spellComponents, ...CONFIG.DND5E.spellTags};
- break;
- }
-
- // Enrich HTML description
- context.descriptionHTML = await TextEditor.enrichHTML(item.system.description.value, {
- secrets: item.isOwner,
- async: true,
- relativeTo: this.item,
- rollData: context.rollData
- });
- return context;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Get the display object used to show the advancement tab.
- * @param {Item5e} item The item for which the advancement is being prepared.
- * @returns {object} Object with advancement data grouped by levels.
- */
- _getItemAdvancement(item) {
- if ( !item.system.advancement ) return {};
- const advancement = {};
- const configMode = !item.parent || this.advancementConfigurationMode;
- const maxLevel = !configMode
- ? (item.system.levels ?? item.class?.system.levels ?? item.parent.system.details?.level ?? -1) : -1;
-
- // Improperly configured advancements
- if ( item.advancement.needingConfiguration.length ) {
- advancement.unconfigured = {
- items: item.advancement.needingConfiguration.map(a => ({
- id: a.id,
- order: a.constructor.order,
- title: a.title,
- icon: a.icon,
- classRestriction: a.classRestriction,
- configured: false
- })),
- configured: "partial"
- };
- }
-
- // All other advancements by level
- for ( let [level, advancements] of Object.entries(item.advancement.byLevel) ) {
- if ( !configMode ) advancements = advancements.filter(a => a.appliesToClass);
- const items = advancements.map(advancement => ({
- id: advancement.id,
- order: advancement.sortingValueForLevel(level),
- title: advancement.titleForLevel(level, { configMode }),
- icon: advancement.icon,
- classRestriction: advancement.classRestriction,
- summary: advancement.summaryForLevel(level, { configMode }),
- configured: advancement.configuredForLevel(level)
- }));
- if ( !items.length ) continue;
- advancement[level] = {
- items: items.sort((a, b) => a.order.localeCompare(b.order)),
- configured: (level > maxLevel) ? false : items.some(a => !a.configured) ? "partial" : "full"
- };
- }
- return advancement;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Get the base weapons and tools based on the selected type.
- * @returns {Promise<object>} 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;
- const attributes = TokenDocument.implementation.getConsumedAttributes(attrData);
- attributes.bar.forEach(a => a.push("value"));
- return attributes.bar.concat(attributes.value).reduce((obj, a) => {
- let k = a.join(".");
- obj[k] = k;
- return obj;
- }, {});
- }
-
- // Hit Dice
- else if ( consume.type === "hitDice" ) {
- return {
- smallest: game.i18n.localize("DND5E.ConsumeHitDiceSmallest"),
- ...CONFIG.DND5E.hitDieTypes.reduce((obj, hd) => { obj[hd] = hd; return obj; }, {}),
- largest: game.i18n.localize("DND5E.ConsumeHitDiceLargest")
- };
- }
-
- // Materials
- else if ( consume.type === "material" ) {
- return actor.items.reduce((obj, i) => {
- if ( ["consumable", "loot"].includes(i.type) && !i.system.activation ) {
- obj[i.id] = `${i.name} (${i.system.quantity})`;
- }
- return obj;
- }, {});
- }
-
- // Charges
- else if ( consume.type === "charges" ) {
- return actor.items.reduce((obj, i) => {
-
- // Limited-use items
- const uses = i.system.uses || {};
- if ( uses.per && uses.max ) {
- const label = uses.per === "charges"
- ? ` (${game.i18n.format("DND5E.AbilityUseChargesLabel", {value: uses.value})})`
- : ` (${game.i18n.format("DND5E.AbilityUseConsumableLabel", {max: uses.max, per: uses.per})})`;
- obj[i.id] = i.name + label;
- }
-
- // Recharging items
- const recharge = i.system.recharge || {};
- if ( recharge.value ) obj[i.id] = `${i.name} (${game.i18n.format("DND5E.Recharge")})`;
- return obj;
- }, {});
- }
- else return {};
- }
-
- /* -------------------------------------------- */
-
- /**
- * Get the text item status which is shown beneath the Item type in the top-right corner of the sheet.
- * @returns {string|null} Item status string if applicable to item's type.
- * @protected
- */
- _getItemStatus() {
- switch ( this.item.type ) {
- case "class":
- return game.i18n.format("DND5E.LevelCount", {ordinal: this.item.system.levels.ordinalString()});
- case "equipment":
- case "weapon":
- return game.i18n.localize(this.item.system.equipped ? "DND5E.Equipped" : "DND5E.Unequipped");
- case "feat":
- const typeConfig = CONFIG.DND5E.featureTypes[this.item.system.type.value];
- if ( typeConfig?.subtypes ) return typeConfig.subtypes[this.item.system.type.subtype] ?? null;
- break;
- case "spell":
- return CONFIG.DND5E.spellPreparationModes[this.item.system.preparation];
- case "tool":
- return game.i18n.localize(this.item.system.proficient ? "DND5E.Proficient" : "DND5E.NotProficient");
- }
- return null;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Get the Array of item properties which are used in the small sidebar of the description tab.
- * @returns {string[]} List of property labels to be shown.
- * @private
- */
- _getItemProperties() {
- const props = [];
- const labels = this.item.labels;
- switch ( this.item.type ) {
- case "equipment":
- props.push(CONFIG.DND5E.equipmentTypes[this.item.system.armor.type]);
- if ( this.item.isArmor || this.item.isMountable ) props.push(labels.armor);
- break;
- case "feat":
- props.push(labels.featType);
- break;
- case "spell":
- props.push(labels.components.vsm, labels.materials, ...labels.components.tags);
- break;
- case "weapon":
- for ( const [k, v] of Object.entries(this.item.system.properties) ) {
- if ( v === true ) props.push(CONFIG.DND5E.weaponProperties[k]);
- }
- break;
- }
-
- // Action type
- if ( this.item.system.actionType ) {
- props.push(CONFIG.DND5E.itemActionTypes[this.item.system.actionType]);
- }
-
- // Action usage
- if ( (this.item.type !== "weapon") && !foundry.utils.isEmpty(this.item.system.activation) ) {
- props.push(labels.activation, labels.range, labels.target, labels.duration);
- }
- return props.filter(p => !!p);
- }
-
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- setPosition(position={}) {
- if ( !(this._minimized || position.height) ) {
- position.height = (this._tabs[0].active === "details") ? "auto" : Math.max(this.height, this.options.height);
- }
- return super.setPosition(position);
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async activateEditor(name, options={}, initialContent="") {
- options.relativeLinks = true;
- options.plugins = {
- menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
- compact: true,
- destroyOnSave: true,
- onSave: () => this.saveEditor(name, {remove: true})
- })
- };
- return super.activateEditor(name, options, initialContent);
- }
-
- /* -------------------------------------------- */
- /* Form Submission */
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- _getSubmitData(updateData={}) {
- const formData = foundry.utils.expandObject(super._getSubmitData(updateData));
-
- // Handle Damage array
- const damage = formData.system?.damage;
- if ( damage ) damage.parts = Object.values(damage?.parts || {}).map(d => [d[0] || "", d[1] || ""]);
-
- // Check max uses formula
- const uses = formData.system?.uses;
- if ( uses?.max ) {
- const maxRoll = new Roll(uses.max);
- if ( !maxRoll.isDeterministic ) {
- uses.max = this.item._source.system.uses.max;
- this.form.querySelector("input[name='system.uses.max']").value = uses.max;
- return ui.notifications.error(game.i18n.format("DND5E.FormulaCannotContainDiceError", {
- name: game.i18n.localize("DND5E.LimitedUses")
- }));
- }
- }
-
- // Check duration value formula
- const duration = formData.system?.duration;
- if ( duration?.value ) {
- const durationRoll = new Roll(duration.value);
- if ( !durationRoll.isDeterministic ) {
- duration.value = this.item._source.system.duration.value;
- this.form.querySelector("input[name='system.duration.value']").value = duration.value;
- return ui.notifications.error(game.i18n.format("DND5E.FormulaCannotContainDiceError", {
- name: game.i18n.localize("DND5E.Duration")
- }));
- }
- }
-
- // Check class identifier
- if ( formData.system?.identifier && !dnd5e.utils.validators.isValidIdentifier(formData.system.identifier) ) {
- formData.system.identifier = this.item._source.system.identifier;
- this.form.querySelector("input[name='system.identifier']").value = formData.system.identifier;
- return ui.notifications.error(game.i18n.localize("DND5E.IdentifierError"));
- }
-
- // Return the flattened submission data
- return foundry.utils.flattenObject(formData);
- }
-
- /* -------------------------------------------- */
-
- /** @inheritDoc */
- activateListeners(html) {
- super.activateListeners(html);
- if ( this.isEditable ) {
- html.find(".damage-control").click(this._onDamageControl.bind(this));
- html.find(".trait-selector").click(this._onConfigureTraits.bind(this));
- html.find(".effect-control").click(ev => {
- const unsupported = game.dnd5e.isV10 && this.item.isOwned;
- if ( unsupported ) return ui.notifications.warn("Managing Active Effects within an Owned Item is not currently supported and will be added in a subsequent update.");
- ActiveEffect5e.onManageActiveEffect(ev, this.item);
- });
- html.find(".advancement .item-control").click(event => {
- const t = event.currentTarget;
- if ( t.dataset.action ) this._onAdvancementAction(t, t.dataset.action);
- });
- }
-
- // Advancement context menu
- const contextOptions = this._getAdvancementContextMenuOptions();
- /**
- * A hook event that fires when the context menu for the advancements list is constructed.
- * @function dnd5e.getItemAdvancementContext
- * @memberof hookEvents
- * @param {jQuery} html The HTML element to which the context options are attached.
- * @param {ContextMenuEntry[]} entryOptions The context menu entries.
- */
- Hooks.call("dnd5e.getItemAdvancementContext", html, contextOptions);
- if ( contextOptions ) new ContextMenu(html, ".advancement-item", contextOptions);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Get the set of ContextMenu options which should be applied for advancement entries.
- * @returns {ContextMenuEntry[]} Context menu entries.
- * @protected
- */
- _getAdvancementContextMenuOptions() {
- const condition = li => (this.advancementConfigurationMode || !this.isEmbedded) && this.isEditable;
- return [
- {
- name: "DND5E.AdvancementControlEdit",
- icon: "<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 = foundry.utils.deepClone(this.item.system.advancement);
- advancementArray.push(...advancements.map(a => a.toObject()));
- this.item.update({"system.advancement": advancementArray});
- }
-
- /* -------------------------------------------- */
-
- /**
- * Handle spawning the TraitSelector application for selection various options.
- * @param {Event} event The click event which originated the selection.
- * @private
- */
- _onConfigureTraits(event) {
- event.preventDefault();
- const a = event.currentTarget;
- const options = {
- name: a.dataset.target,
- title: a.parentElement.innerText,
- choices: [],
- allowCustom: false,
- suppressWarning: true
- };
- switch (a.dataset.options) {
- case "saves":
- options.choices = CONFIG.DND5E.abilities;
- options.valueKey = null;
- options.labelKey = "label";
- break;
- case "skills.choices":
- options.choices = CONFIG.DND5E.skills;
- options.valueKey = null;
- options.labelKey = "label";
- break;
- case "skills":
- const skills = this.item.system.skills;
- const choices = skills.choices?.length ? skills.choices : Object.keys(CONFIG.DND5E.skills);
- options.choices = Object.fromEntries(Object.entries(CONFIG.DND5E.skills).filter(([s]) => choices.includes(s)));
- options.maximum = skills.number;
- options.labelKey = "label";
- break;
- }
- new TraitSelector(this.item, options).render(true);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Handle one of the advancement actions from the buttons or context menu.
- * @param {Element} target Button or context menu entry that triggered this action.
- * @param {string} action Action being triggered.
- * @returns {Promise|void}
- */
- _onAdvancementAction(target, action) {
- const id = target.closest(".advancement-item")?.dataset.id;
- const advancement = this.item.advancement.byId[id];
- let manager;
- if ( ["edit", "delete", "duplicate"].includes(action) && !advancement ) return;
- switch (action) {
- case "add": return game.dnd5e.applications.advancement.AdvancementSelection.createDialog(this.item);
- case "edit": return new advancement.constructor.metadata.apps.config(advancement).render(true);
- case "delete":
- if ( this.item.isEmbedded && !game.settings.get("dnd5e", "disableAdvancements") ) {
- manager = AdvancementManager.forDeletedAdvancement(this.item.actor, this.item.id, id);
- if ( manager.steps.length ) return manager.render(true);
- }
- return this.item.deleteAdvancement(id);
- case "duplicate": return this.item.duplicateAdvancement(id);
- case "modify-choices":
- const level = target.closest("li")?.dataset.level;
- manager = AdvancementManager.forModifyChoices(this.item.actor, this.item.id, Number(level));
- if ( manager.steps.length ) manager.render(true);
- return;
- case "toggle-configuration":
- this.advancementConfigurationMode = !this.advancementConfigurationMode;
- return this.render();
- }
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _onSubmit(...args) {
- if ( this._tabs[0].active === "details" ) this.position.height = "auto";
- await super._onSubmit(...args);
- }
- }
-
- var _module$6 = /*#__PURE__*/Object.freeze({
- __proto__: null,
- AbilityUseDialog: AbilityUseDialog,
- ItemSheet5e: ItemSheet5e
- });
-
- /**
- * Pop out ProseMirror editor window for journal entries with multiple text areas that need editing.
- *
- * @param {JournalEntryPage} document Journal entry page to be edited.
- * @param {object} options
- * @param {string} options.textKeyPath The path to the specific HTML field being edited.
- */
- class JournalEditor extends DocumentSheet {
-
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- classes: ["journal-editor"],
- template: "systems/dnd5e/templates/journal/journal-editor.hbs",
- width: 550,
- height: 640,
- textKeyPath: null,
- resizable: true
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get title() {
- if ( this.options.title ) return `${this.document.name}: ${this.options.title}`;
- else return this.document.name;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async getData() {
- const data = super.getData();
- const rawText = foundry.utils.getProperty(this.document, this.options.textKeyPath) ?? "";
- return foundry.utils.mergeObject(data, {
- enriched: await TextEditor.enrichHTML(rawText, {
- relativeTo: this.document, secrets: this.document.isOwner, async: true
- })
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- _updateObject(event, formData) {
- this.document.update(formData);
- }
-
- }
-
- /**
- * Journal entry page that displays an automatically generated summary of a class along with additional description.
- */
- class JournalClassPageSheet extends JournalPageSheet {
-
- /** @inheritdoc */
- static get defaultOptions() {
- const options = foundry.utils.mergeObject(super.defaultOptions, {
- dragDrop: [{dropSelector: ".drop-target"}],
- submitOnChange: true
- });
- options.classes.push("class-journal");
- return options;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get template() {
- return `systems/dnd5e/templates/journal/page-class-${this.isEditable ? "edit" : "view"}.hbs`;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- toc = {};
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async getData(options) {
- const context = super.getData(options);
- context.system = context.document.system;
-
- context.title = Object.fromEntries(
- Array.fromRange(4, 1).map(n => [`level${n}`, context.data.title.level + n - 1])
- );
-
- const linked = await fromUuid(this.document.system.item);
- context.subclasses = await this._getSubclasses(this.document.system.subclassItems);
-
- if ( !linked ) return context;
- context.linked = {
- document: linked,
- name: linked.name,
- lowercaseName: linked.name.toLowerCase()
- };
-
- context.advancement = this._getAdvancement(linked);
- context.enriched = await this._getDescriptions(context.document);
- context.table = await this._getTable(linked);
- context.optionalTable = await this._getOptionalTable(linked);
- context.features = await this._getFeatures(linked);
- context.optionalFeatures = await this._getFeatures(linked, true);
- context.subclasses?.sort((lhs, rhs) => lhs.name.localeCompare(rhs.name));
-
- return context;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare features granted by various advancement types.
- * @param {Item5e} item Class item belonging to this journal.
- * @returns {object} Prepared advancement section.
- */
- _getAdvancement(item) {
- const advancement = {};
-
- const hp = item.advancement.byType.HitPoints?.[0];
- if ( hp ) {
- advancement.hp = {
- hitDice: `1${hp.hitDie}`,
- max: hp.hitDieValue,
- average: Math.floor(hp.hitDieValue / 2) + 1
- };
- }
-
- return advancement;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Enrich all of the entries within the descriptions object on the sheet's system data.
- * @param {JournalEntryPage} page Journal page being enriched.
- * @returns {Promise<object>} Object with enriched descriptions.
- */
- async _getDescriptions(page) {
- const descriptions = await Promise.all(Object.entries(page.system.description ?? {})
- .map(async ([id, text]) => {
- const enriched = await TextEditor.enrichHTML(text, {
- relativeTo: this.object,
- secrets: this.object.isOwner,
- async: true
- });
- return [id, enriched];
- })
- );
- return Object.fromEntries(descriptions);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare table based on non-optional GrantItem advancement & ScaleValue advancement.
- * @param {Item5e} item Class item belonging to this journal.
- * @param {number} [initialLevel=1] Level at which the table begins.
- * @returns {object} Prepared table.
- */
- async _getTable(item, initialLevel=1) {
- const hasFeatures = !!item.advancement.byType.ItemGrant;
- const scaleValues = (item.advancement.byType.ScaleValue ?? []);
- const spellProgression = await this._getSpellProgression(item);
-
- const headers = [[{content: game.i18n.localize("DND5E.Level")}]];
- if ( item.type === "class" ) headers[0].push({content: game.i18n.localize("DND5E.ProficiencyBonus")});
- if ( hasFeatures ) headers[0].push({content: game.i18n.localize("DND5E.Features")});
- headers[0].push(...scaleValues.map(a => ({content: a.title})));
- if ( spellProgression ) {
- if ( spellProgression.headers.length > 1 ) {
- headers[0].forEach(h => h.rowSpan = 2);
- headers[0].push(...spellProgression.headers[0]);
- headers[1] = spellProgression.headers[1];
- } else {
- headers[0].push(...spellProgression.headers[0]);
- }
- }
-
- const cols = [{ class: "level", span: 1 }];
- if ( item.type === "class" ) cols.push({class: "prof", span: 1});
- if ( hasFeatures ) cols.push({class: "features", span: 1});
- if ( scaleValues.length ) cols.push({class: "scale", span: scaleValues.length});
- if ( spellProgression ) cols.push(...spellProgression.cols);
-
- const makeLink = async uuid => (await fromUuid(uuid))?.toAnchor({classes: ["content-link"]}).outerHTML;
-
- const rows = [];
- for ( const level of Array.fromRange((CONFIG.DND5E.maxLevel - (initialLevel - 1)), initialLevel) ) {
- const features = [];
- for ( const advancement of item.advancement.byLevel[level] ) {
- switch ( advancement.constructor.typeName ) {
- case "ItemGrant":
- if ( advancement.configuration.optional ) continue;
- features.push(...await Promise.all(advancement.configuration.items.map(makeLink)));
- break;
- }
- }
-
- // Level & proficiency bonus
- const cells = [{class: "level", content: level.ordinalString()}];
- if ( item.type === "class" ) cells.push({class: "prof", content: `+${Proficiency.calculateMod(level)}`});
- if ( hasFeatures ) cells.push({class: "features", content: features.join(", ")});
- scaleValues.forEach(s => cells.push({class: "scale", content: s.valueForLevel(level)?.display}));
- const spellCells = spellProgression?.rows[rows.length];
- if ( spellCells ) cells.push(...spellCells);
-
- // Skip empty rows on subclasses
- if ( (item.type === "subclass") && !features.length && !scaleValues.length && !spellCells ) continue;
-
- rows.push(cells);
- }
-
- return { headers, cols, rows };
- }
-
- /* -------------------------------------------- */
-
- /**
- * Build out the spell progression data.
- * @param {Item5e} item Class item belonging to this journal.
- * @returns {object} Prepared spell progression table.
- */
- async _getSpellProgression(item) {
- const spellcasting = foundry.utils.deepClone(item.spellcasting);
- if ( !spellcasting || (spellcasting.progression === "none") ) return null;
-
- const table = { rows: [] };
-
- if ( spellcasting.type === "leveled" ) {
- const spells = {};
- const maxSpellLevel = CONFIG.DND5E.SPELL_SLOT_TABLE[CONFIG.DND5E.SPELL_SLOT_TABLE.length - 1].length;
- Array.fromRange(maxSpellLevel, 1).forEach(l => spells[`spell${l}`] = {});
-
- let largestSlot;
- for ( const level of Array.fromRange(CONFIG.DND5E.maxLevel, 1).reverse() ) {
- const progression = { slot: 0 };
- spellcasting.levels = level;
- Actor5e.computeClassProgression(progression, item, { spellcasting });
- Actor5e.prepareSpellcastingSlots(spells, "leveled", progression);
-
- if ( !largestSlot ) largestSlot = Object.entries(spells).reduce((slot, [key, data]) => {
- if ( !data.max ) return slot;
- const level = parseInt(key.slice(5));
- if ( !Number.isNaN(level) && (level > slot) ) return level;
- return slot;
- }, -1);
-
- table.rows.push(Array.fromRange(largestSlot, 1).map(spellLevel => {
- return {class: "spell-slots", content: spells[`spell${spellLevel}`]?.max || "—"};
- }));
- }
-
- // Prepare headers & columns
- table.headers = [
- [{content: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.SpellSlotsPerSpellLevel"), colSpan: largestSlot}],
- Array.fromRange(largestSlot, 1).map(spellLevel => ({content: spellLevel.ordinalString()}))
- ];
- table.cols = [{class: "spellcasting", span: largestSlot}];
- table.rows.reverse();
- }
-
- else if ( spellcasting.type === "pact" ) {
- const spells = { pact: {} };
-
- table.headers = [[
- { content: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.SpellSlots") },
- { content: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.SpellSlotLevel") }
- ]];
- table.cols = [{class: "spellcasting", span: 2}];
-
- // Loop through each level, gathering "Spell Slots" & "Slot Level" for each one
- for ( const level of Array.fromRange(CONFIG.DND5E.maxLevel, 1) ) {
- const progression = { pact: 0 };
- spellcasting.levels = level;
- Actor5e.computeClassProgression(progression, item, { spellcasting });
- Actor5e.prepareSpellcastingSlots(spells, "pact", progression);
- table.rows.push([
- { class: "spell-slots", content: `${spells.pact.max}` },
- { class: "slot-level", content: spells.pact.level.ordinalString() }
- ]);
- }
- }
-
- else {
- /**
- * A hook event that fires to generate the table for custom spellcasting types.
- * The actual hook names include the spellcasting type (e.g. `dnd5e.buildPsionicSpellcastingTable`).
- * @param {object} table Table definition being built. *Will be mutated.*
- * @param {Item5e} item Class for which the spellcasting table is being built.
- * @param {SpellcastingDescription} spellcasting Spellcasting descriptive object.
- * @function dnd5e.buildSpellcastingTable
- * @memberof hookEvents
- */
- Hooks.callAll(
- `dnd5e.build${spellcasting.type.capitalize()}SpellcastingTable`, table, item, spellcasting
- );
- }
-
- return table;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare options table based on optional GrantItem advancement.
- * @param {Item5e} item Class item belonging to this journal.
- * @returns {object|null} Prepared optional features table.
- */
- async _getOptionalTable(item) {
- const headers = [[
- { content: game.i18n.localize("DND5E.Level") },
- { content: game.i18n.localize("DND5E.Features") }
- ]];
-
- const cols = [
- { class: "level", span: 1 },
- { class: "features", span: 1 }
- ];
-
- const makeLink = async uuid => (await fromUuid(uuid))?.toAnchor({classes: ["content-link"]}).outerHTML;
-
- const rows = [];
- for ( const level of Array.fromRange(CONFIG.DND5E.maxLevel, 1) ) {
- const features = [];
- for ( const advancement of item.advancement.byLevel[level] ) {
- switch ( advancement.constructor.typeName ) {
- case "ItemGrant":
- if ( !advancement.configuration.optional ) continue;
- features.push(...await Promise.all(advancement.configuration.items.map(makeLink)));
- break;
- }
- }
- if ( !features.length ) continue;
-
- // Level & proficiency bonus
- const cells = [
- { class: "level", content: level.ordinalString() },
- { class: "features", content: features.join(", ") }
- ];
- rows.push(cells);
- }
- if ( !rows.length ) return null;
-
- return { headers, cols, rows };
- }
-
- /* -------------------------------------------- */
-
- /**
- * Fetch data for each class feature listed.
- * @param {Item5e} item Class or subclass item belonging to this journal.
- * @param {boolean} [optional=false] Should optional features be fetched rather than required features?
- * @returns {object[]} Prepared features.
- */
- async _getFeatures(item, optional=false) {
- const prepareFeature = async uuid => {
- const document = await fromUuid(uuid);
- return {
- document,
- name: document.name,
- description: await TextEditor.enrichHTML(document.system.description.value, {
- relativeTo: item, secrets: false, async: true
- })
- };
- };
-
- let features = [];
- for ( const advancement of item.advancement.byType.ItemGrant ?? [] ) {
- if ( !!advancement.configuration.optional !== optional ) continue;
- features.push(...advancement.configuration.items.map(prepareFeature));
- }
- features = await Promise.all(features);
- return features;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Fetch each subclass and their features.
- * @param {string[]} uuids UUIDs for the subclasses to fetch.
- * @returns {object[]|null} Prepared subclasses.
- */
- async _getSubclasses(uuids) {
- const prepareSubclass = async uuid => {
- const document = await fromUuid(uuid);
- return this._getSubclass(document);
- };
-
- const subclasses = await Promise.all(uuids.map(prepareSubclass));
- return subclasses.length ? subclasses : null;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare data for the provided subclass.
- * @param {Item5e} item Subclass item being prepared.
- * @returns {object} Presentation data for this subclass.
- */
- async _getSubclass(item) {
- const initialLevel = Object.entries(item.advancement.byLevel).find(([lvl, d]) => d.length)?.[0] ?? 1;
- return {
- document: item,
- name: item.name,
- description: await TextEditor.enrichHTML(item.system.description.value, {
- relativeTo: item, secrets: false, async: true
- }),
- features: await this._getFeatures(item),
- table: await this._getTable(item, parseInt(initialLevel))
- };
- }
-
- /* -------------------------------------------- */
- /* Rendering */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _renderInner(...args) {
- const html = await super._renderInner(...args);
- this.toc = JournalEntryPage.buildTOC(html.get());
- return html;
- }
-
- /* -------------------------------------------- */
- /* Event Handlers */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- activateListeners(html) {
- super.activateListeners(html);
- html[0].querySelectorAll(".item-delete").forEach(e => {
- e.addEventListener("click", this._onDeleteItem.bind(this));
- });
- html[0].querySelectorAll(".launch-text-editor").forEach(e => {
- e.addEventListener("click", this._onLaunchTextEditor.bind(this));
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * Handle deleting a dropped item.
- * @param {Event} event The triggering click event.
- * @returns {JournalClassSummary5ePageSheet}
- */
- async _onDeleteItem(event) {
- event.preventDefault();
- const container = event.currentTarget.closest("[data-item-uuid]");
- const uuidToDelete = container?.dataset.itemUuid;
- if ( !uuidToDelete ) return;
- switch (container.dataset.itemType) {
- case "class":
- await this.document.update({"system.item": ""});
- return this.render();
- case "subclass":
- const itemSet = this.document.system.subclassItems;
- itemSet.delete(uuidToDelete);
- await this.document.update({"system.subclassItems": Array.from(itemSet)});
- return this.render();
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Handle launching the individual text editing window.
- * @param {Event} event The triggering click event.
- */
- _onLaunchTextEditor(event) {
- event.preventDefault();
- const textKeyPath = event.currentTarget.dataset.target;
- const label = event.target.closest(".form-group").querySelector("label");
- const editor = new JournalEditor(this.document, { textKeyPath, title: label?.innerText });
- editor.render(true);
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _onDrop(event) {
- const data = TextEditor.getDragEventData(event);
-
- if ( data?.type !== "Item" ) return false;
- const item = await Item.implementation.fromDropData(data);
- switch ( item.type ) {
- case "class":
- await this.document.update({"system.item": item.uuid});
- return this.render();
- case "subclass":
- const itemSet = this.document.system.subclassItems;
- itemSet.add(item.uuid);
- await this.document.update({"system.subclassItems": Array.from(itemSet)});
- return this.render();
- default:
- return false;
- }
- }
- }
-
- class SRDCompendium extends Compendium {
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- classes: ["srd-compendium"],
- template: "systems/dnd5e/templates/journal/srd-compendium.hbs",
- width: 800,
- height: 950,
- resizable: true
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * The IDs of some special pages that we use when configuring the display of the compendium.
- * @type {Object<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$5 = /*#__PURE__*/Object.freeze({
- __proto__: null,
- JournalClassPageSheet: JournalClassPageSheet,
- JournalEditor: JournalEditor,
- SRDCompendium: SRDCompendium
- });
-
- /**
- * @deprecated since dnd5e 2.1, targeted for removal in 2.3
- */
- class DamageTraitSelector extends TraitSelector {
-
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- template: "systems/dnd5e/templates/apps/damage-trait-selector.hbs"
- });
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- getData() {
- const data = super.getData();
- const attr = foundry.utils.getProperty(this.object, this.attribute);
-
- data.bypasses = Object.entries(this.options.bypasses).reduce((obj, [k, v]) => {
- obj[k] = { label: v, chosen: attr ? attr.bypasses.includes(k) : false };
- return obj;
- }, {});
-
- return data;
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- async _updateObject(event, formData) {
- const data = foundry.utils.expandObject(formData);
- const updateData = this._prepareUpdateData(data.choices);
- if ( !updateData ) return;
- updateData[`${this.attribute}.bypasses`] = Object.entries(data.bypasses).filter(([, v]) => v).map(([k]) => k);
- this.object.update(updateData);
- }
-
- }
-
- /**
- * An application for selecting proficiencies with categories that can contain children.
- * @deprecated since dnd5e 2.1, targeted for removal in 2.3
- */
- class ProficiencySelector extends TraitSelector {
-
- /** @inheritdoc */
- static get defaultOptions() {
- return foundry.utils.mergeObject(super.defaultOptions, {
- title: "Actor Proficiency Selection",
- type: ""
- });
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async getData() {
- const attr = foundry.utils.getProperty(this.object, this.attribute);
- const chosen = (this.options.valueKey) ? foundry.utils.getProperty(attr, this.options.valueKey) ?? [] : attr;
-
- const data = super.getData();
- data.choices = await choices(this.options.type, chosen);
- return data;
- }
-
- /* -------------------------------------------- */
-
- /**
- * A static helper method to get a list of choices for a proficiency type.
- *
- * @param {string} type Proficiency type to select, either `armor`, `tool`, or `weapon`.
- * @param {string[]} [chosen] Optional list of items to be marked as chosen.
- * @returns {Object<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$9,
- advancement: _module$8,
- combat: _module$7,
- item: _module$6,
- journal: _module$5
- });
-
- /**
- * A helper class for building MeasuredTemplates for 5e spells and abilities
- */
- class AbilityTemplate extends MeasuredTemplate {
-
- /**
- * Track the timestamp when the last mouse move event was captured.
- * @type {number}
- */
- #moveTime = 0;
-
- /* -------------------------------------------- */
-
- /**
- * The initially active CanvasLayer to re-activate after the workflow is complete.
- * @type {CanvasLayer}
- */
- #initialLayer;
-
- /* -------------------------------------------- */
-
- /**
- * Track the bound event handlers so they can be properly canceled later.
- * @type {object}
- */
- #events;
-
- /* -------------------------------------------- */
-
- /**
- * A factory method to create an AbilityTemplate instance using provided data from an Item5e instance
- * @param {Item5e} item The Item object for which to construct the template
- * @returns {AbilityTemplate|null} The template object, or null if the item does not produce a template
- */
- static fromItem(item) {
- const target = item.system.target ?? {};
- const templateShape = dnd5e.config.areaTargetTypes[target.type]?.template;
- if ( !templateShape ) return null;
-
- // Prepare template data
- const templateData = {
- t: templateShape,
- user: game.user.id,
- distance: target.value,
- direction: 0,
- x: 0,
- y: 0,
- fillColor: game.user.color,
- flags: { dnd5e: { origin: item.uuid } }
- };
-
- // Additional type-specific data
- switch ( templateShape ) {
- case "cone":
- templateData.angle = CONFIG.MeasuredTemplate.defaults.angle;
- break;
- case "rect": // 5e rectangular AoEs are always cubes
- templateData.distance = Math.hypot(target.value, target.value);
- templateData.width = target.value;
- templateData.direction = 45;
- break;
- case "ray": // 5e rays are most commonly 1 square (5 ft) in width
- templateData.width = target.width ?? canvas.dimensions.distance;
- break;
- }
-
- // Return the template constructed from the item data
- const cls = CONFIG.MeasuredTemplate.documentClass;
- const template = new cls(templateData, {parent: canvas.scene});
- const object = new this(template);
- object.item = item;
- object.actorSheet = item.actor?.sheet || null;
- return object;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Creates a preview of the spell template.
- * @returns {Promise} A promise that resolves with the final measured template if created.
- */
- drawPreview() {
- const initialLayer = canvas.activeLayer;
-
- // Draw the template and switch to the template layer
- this.draw();
- this.layer.activate();
- this.layer.preview.addChild(this);
-
- // Hide the sheet that originated the preview
- this.actorSheet?.minimize();
-
- // Activate interactivity
- return this.activatePreviewListeners(initialLayer);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Activate listeners for the template preview
- * @param {CanvasLayer} initialLayer The initially active CanvasLayer to re-activate after the workflow is complete
- * @returns {Promise} A promise that resolves with the final measured template if created.
- */
- activatePreviewListeners(initialLayer) {
- return new Promise((resolve, reject) => {
- this.#initialLayer = initialLayer;
- this.#events = {
- cancel: this._onCancelPlacement.bind(this),
- confirm: this._onConfirmPlacement.bind(this),
- move: this._onMovePlacement.bind(this),
- resolve,
- reject,
- rotate: this._onRotatePlacement.bind(this)
- };
-
- // Activate listeners
- canvas.stage.on("mousemove", this.#events.move);
- canvas.stage.on("mousedown", this.#events.confirm);
- canvas.app.view.oncontextmenu = this.#events.cancel;
- canvas.app.view.onwheel = this.#events.rotate;
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * Shared code for when template placement ends by being confirmed or canceled.
- * @param {Event} event Triggering event that ended the placement.
- */
- async _finishPlacement(event) {
- this.layer._onDragLeftCancel(event);
- canvas.stage.off("mousemove", this.#events.move);
- canvas.stage.off("mousedown", this.#events.confirm);
- canvas.app.view.oncontextmenu = null;
- canvas.app.view.onwheel = null;
- this.#initialLayer.activate();
- await this.actorSheet?.maximize();
- }
-
- /* -------------------------------------------- */
-
- /**
- * Move the template preview when the mouse moves.
- * @param {Event} event Triggering mouse event.
- */
- _onMovePlacement(event) {
- event.stopPropagation();
- const now = Date.now(); // Apply a 20ms throttle
- if ( now - this.#moveTime <= 20 ) return;
- const center = event.data.getLocalPosition(this.layer);
- const interval = canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ? 0 : 2;
- const snapped = canvas.grid.getSnappedPosition(center.x, center.y, interval);
- this.document.updateSource({x: snapped.x, y: snapped.y});
- this.refresh();
- this.#moveTime = now;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Rotate the template preview by 3˚ increments when the mouse wheel is rotated.
- * @param {Event} event Triggering mouse event.
- */
- _onRotatePlacement(event) {
- if ( event.ctrlKey ) event.preventDefault(); // Avoid zooming the browser window
- event.stopPropagation();
- const delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15;
- const snap = event.shiftKey ? delta : 5;
- const update = {direction: this.document.direction + (snap * Math.sign(event.deltaY))};
- this.document.updateSource(update);
- this.refresh();
- }
-
- /* -------------------------------------------- */
-
- /**
- * Confirm placement when the left mouse button is clicked.
- * @param {Event} event Triggering mouse event.
- */
- async _onConfirmPlacement(event) {
- await this._finishPlacement(event);
- const interval = canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ? 0 : 2;
- const destination = canvas.grid.getSnappedPosition(this.document.x, this.document.y, interval);
- this.document.updateSource(destination);
- this.#events.resolve(canvas.scene.createEmbeddedDocuments("MeasuredTemplate", [this.document.toObject()]));
- }
-
- /* -------------------------------------------- */
-
- /**
- * Cancel placement when the right mouse button is clicked.
- * @param {Event} event Triggering mouse event.
- */
- async _onCancelPlacement(event) {
- await this._finishPlacement(event);
- this.#events.reject();
- }
-
- }
-
- /**
- * Extend the base Token class to implement additional system-specific logic.
- */
- class Token5e extends Token {
-
- /** @inheritdoc */
- _drawBar(number, bar, data) {
- if ( data.attribute === "attributes.hp" ) return this._drawHPBar(number, bar, data);
- return super._drawBar(number, bar, data);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Specialized drawing function for HP bars.
- * @param {number} number The Bar number
- * @param {PIXI.Graphics} bar The Bar container
- * @param {object} data Resource data for this bar
- * @private
- */
- _drawHPBar(number, bar, data) {
-
- // Extract health data
- let {value, max, temp, tempmax} = this.document.actor.system.attributes.hp;
- temp = Number(temp || 0);
- tempmax = Number(tempmax || 0);
-
- // Differentiate between effective maximum and displayed maximum
- const effectiveMax = Math.max(0, max + tempmax);
- let displayMax = max + (tempmax > 0 ? tempmax : 0);
-
- // Allocate percentages of the total
- const tempPct = Math.clamped(temp, 0, displayMax) / displayMax;
- const colorPct = Math.clamped(value, 0, effectiveMax) / displayMax;
- const hpColor = dnd5e.documents.Actor5e.getHPColor(value, effectiveMax);
-
- // Determine colors to use
- const blk = 0x000000;
- const c = CONFIG.DND5E.tokenHPColors;
-
- // Determine the container size (logic borrowed from core)
- const w = this.w;
- let h = Math.max((canvas.dimensions.size / 12), 8);
- if ( this.document.height >= 2 ) h *= 1.6;
- const bs = Math.clamped(h / 8, 1, 2);
- const bs1 = bs+1;
-
- // Overall bar container
- bar.clear();
- bar.beginFill(blk, 0.5).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, w, h, 3);
-
- // Temporary maximum HP
- if (tempmax > 0) {
- const pct = max / effectiveMax;
- bar.beginFill(c.tempmax, 1.0).lineStyle(1, blk, 1.0).drawRoundedRect(pct*w, 0, (1-pct)*w, h, 2);
- }
-
- // Maximum HP penalty
- else if (tempmax < 0) {
- const pct = (max + tempmax) / max;
- bar.beginFill(c.negmax, 1.0).lineStyle(1, blk, 1.0).drawRoundedRect(pct*w, 0, (1-pct)*w, h, 2);
- }
-
- // Health bar
- bar.beginFill(hpColor, 1.0).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, colorPct*w, h, 2);
-
- // Temporary hit points
- if ( temp > 0 ) {
- bar.beginFill(c.temp, 1.0).lineStyle(0).drawRoundedRect(bs1, bs1, (tempPct*w)-(2*bs1), h-(2*bs1), 1);
- }
-
- // Set position
- let posY = (number === 0) ? (this.h - h) : 0;
- bar.position.set(0, posY);
- }
- }
-
- /** @inheritDoc */
- function measureDistances(segments, options={}) {
- if ( !options.gridSpaces ) return BaseGrid.prototype.measureDistances.call(this, segments, options);
-
- // Track the total number of diagonals
- let nDiagonal = 0;
- const rule = this.parent.diagonalRule;
- const d = canvas.dimensions;
-
- // Iterate over measured segments
- return segments.map(s => {
- let r = s.ray;
-
- // Determine the total distance traveled
- let nx = Math.abs(Math.ceil(r.dx / d.size));
- let ny = Math.abs(Math.ceil(r.dy / d.size));
-
- // Determine the number of straight and diagonal moves
- let nd = Math.min(nx, ny);
- let ns = Math.abs(ny - nx);
- nDiagonal += nd;
-
- // Alternative DMG Movement
- if (rule === "5105") {
- let nd10 = Math.floor(nDiagonal / 2) - Math.floor((nDiagonal - nd) / 2);
- let spaces = (nd10 * 2) + (nd - nd10) + ns;
- return spaces * canvas.dimensions.distance;
- }
-
- // Euclidean Measurement
- else if (rule === "EUCL") {
- return Math.round(Math.hypot(nx, ny) * canvas.scene.grid.distance);
- }
-
- // Standard PHB Movement
- else return (ns + nd) * canvas.scene.grid.distance;
- });
- }
-
- var canvas$1 = /*#__PURE__*/Object.freeze({
- __proto__: null,
- AbilityTemplate: AbilityTemplate,
- Token5e: Token5e,
- measureDistances: measureDistances
- });
-
- /**
- * Data Model variant with some extra methods to support template mix-ins.
- *
- * **Note**: This uses some advanced Javascript techniques that are not necessary for most data models.
- * Please refer to the [advancement data models]{@link BaseAdvancement} for an example of a more typical usage.
- *
- * In template.json, each Actor or Item type can incorporate several templates which are chunks of data that are
- * common across all the types that use them. One way to represent them in the schema for a given Document type is to
- * duplicate schema definitions for the templates and write them directly into the Data Model for the Document type.
- * This works fine for small templates or systems that do not need many Document types but for more complex systems
- * this boilerplate can become prohibitive.
- *
- * Here we have opted to instead create a separate Data Model for each template available. These define their own
- * schemas which are then mixed-in to the final schema for the Document type's Data Model. A Document type Data Model
- * can define its own schema unique to it, and then add templates in direct correspondence to those in template.json
- * via SystemDataModel.mixin.
- */
- class SystemDataModel extends foundry.abstract.DataModel {
-
- /** @inheritdoc */
- static _enableV10Validation = true;
-
- /**
- * System type that this system data model represents (e.g. "character", "npc", "vehicle").
- * @type {string}
- */
- static _systemType;
-
- /* -------------------------------------------- */
-
- /**
- * Base templates used for construction.
- * @type {*[]}
- * @private
- */
- static _schemaTemplates = [];
-
- /* -------------------------------------------- */
-
- /**
- * A list of properties that should not be mixed-in to the final type.
- * @type {Set<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;
- }
- }
-
- /**
- * Shared contents of the attributes schema between various actor types.
- */
- class AttributesFields {
- /**
- * Fields shared between characters, NPCs, and vehicles.
- *
- * @type {object}
- * @property {object} init
- * @property {number} init.value Calculated initiative modifier.
- * @property {number} init.bonus Fixed bonus provided to initiative rolls.
- * @property {object} movement
- * @property {number} movement.burrow Actor burrowing speed.
- * @property {number} movement.climb Actor climbing speed.
- * @property {number} movement.fly Actor flying speed.
- * @property {number} movement.swim Actor swimming speed.
- * @property {number} movement.walk Actor walking speed.
- * @property {string} movement.units Movement used to measure the various speeds.
- * @property {boolean} movement.hover Is this flying creature able to hover in place.
- */
- static get common() {
- return {
- init: new foundry.data.fields.SchemaField({
- ability: new foundry.data.fields.StringField({label: "DND5E.AbilityModifier"}),
- bonus: new FormulaField({label: "DND5E.InitiativeBonus"})
- }, { label: "DND5E.Initiative" }),
- movement: new foundry.data.fields.SchemaField({
- burrow: new foundry.data.fields.NumberField({
- nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementBurrow"
- }),
- climb: new foundry.data.fields.NumberField({
- nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementClimb"
- }),
- fly: new foundry.data.fields.NumberField({
- nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementFly"
- }),
- swim: new foundry.data.fields.NumberField({
- nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementSwim"
- }),
- walk: new foundry.data.fields.NumberField({
- nullable: false, min: 0, step: 0.1, initial: 30, label: "DND5E.MovementWalk"
- }),
- units: new foundry.data.fields.StringField({initial: "ft", label: "DND5E.MovementUnits"}),
- hover: new foundry.data.fields.BooleanField({label: "DND5E.MovementHover"})
- }, {label: "DND5E.Movement"})
- };
- }
-
- /* -------------------------------------------- */
-
- /**
- * Fields shared between characters and NPCs.
- *
- * @type {object}
- * @property {object} attunement
- * @property {number} attunement.max Maximum number of attuned items.
- * @property {object} senses
- * @property {number} senses.darkvision Creature's darkvision range.
- * @property {number} senses.blindsight Creature's blindsight range.
- * @property {number} senses.tremorsense Creature's tremorsense range.
- * @property {number} senses.truesight Creature's truesight range.
- * @property {string} senses.units Distance units used to measure senses.
- * @property {string} senses.special Description of any special senses or restrictions.
- * @property {string} spellcasting Primary spellcasting ability.
- */
- static get creature() {
- return {
- attunement: new foundry.data.fields.SchemaField({
- max: new foundry.data.fields.NumberField({
- required: true, nullable: false, integer: true, min: 0, initial: 3, label: "DND5E.AttunementMax"
- })
- }, {label: "DND5E.Attunement"}),
- senses: new foundry.data.fields.SchemaField({
- darkvision: new foundry.data.fields.NumberField({
- required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SenseDarkvision"
- }),
- blindsight: new foundry.data.fields.NumberField({
- required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SenseBlindsight"
- }),
- tremorsense: new foundry.data.fields.NumberField({
- required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SenseTremorsense"
- }),
- truesight: new foundry.data.fields.NumberField({
- required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SenseTruesight"
- }),
- units: new foundry.data.fields.StringField({required: true, initial: "ft", label: "DND5E.SenseUnits"}),
- special: new foundry.data.fields.StringField({required: true, label: "DND5E.SenseSpecial"})
- }, {label: "DND5E.Senses"}),
- spellcasting: new foundry.data.fields.StringField({
- required: true, blank: true, initial: "int", label: "DND5E.SpellAbility"
- })
- };
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the old init.value and incorporate it into init.bonus.
- * @param {object} source The source attributes object.
- * @internal
- */
- static _migrateInitiative(source) {
- const init = source?.init;
- if ( !init?.value || (typeof init?.bonus === "string") ) return;
- if ( init.bonus ) init.bonus += init.value < 0 ? ` - ${init.value * -1}` : ` + ${init.value}`;
- else init.bonus = `${init.value}`;
- }
- }
-
- /**
- * A template for currently held currencies.
- *
- * @property {object} currency Object containing currencies as numbers.
- * @mixin
- */
- class CurrencyTemplate extends foundry.abstract.DataModel {
- /** @inheritdoc */
- static defineSchema() {
- return {
- currency: new MappingField(new foundry.data.fields.NumberField({
- required: true, nullable: false, integer: true, min: 0, initial: 0
- }), {initialKeys: CONFIG.DND5E.currencies, initialKeysOnly: true, label: "DND5E.Currency"})
- };
- }
- }
-
- /**
- * @typedef {object} AbilityData
- * @property {number} value Ability score.
- * @property {number} proficient Proficiency value for saves.
- * @property {object} bonuses Bonuses that modify ability checks and saves.
- * @property {string} bonuses.check Numeric or dice bonus to ability checks.
- * @property {string} bonuses.save Numeric or dice bonus to ability saving throws.
- */
-
- /**
- * A template for all actors that share the common template.
- *
- * @property {Object<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, initial: 0, label: "DND5E.ProficiencyLevel"}),
- bonuses: new foundry.data.fields.SchemaField({
- check: new FormulaField({required: true, label: "DND5E.AbilityCheckBonus"}),
- save: new FormulaField({required: true, label: "DND5E.SaveBonus"})
- }, {label: "DND5E.AbilityBonuses"})
- }), {
- initialKeys: CONFIG.DND5E.abilities, initialValue: this._initialAbilityValue.bind(this),
- initialKeysOnly: true, label: "DND5E.Abilities"
- })
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * Populate the proper initial value for abilities.
- * @param {string} key Key for which the initial data will be created.
- * @param {object} initial The initial skill object created by SkillData.
- * @param {object} existing Any existing mapping data.
- * @returns {object} Initial ability object.
- * @private
- */
- static _initialAbilityValue(key, initial, existing) {
- const config = CONFIG.DND5E.abilities[key];
- if ( config ) {
- let defaultValue = config.defaults?.[this._systemType] ?? initial.value;
- if ( typeof defaultValue === "string" ) defaultValue = existing?.[defaultValue]?.value ?? initial.value;
- initial.value = defaultValue;
- }
- return initial;
- }
-
- /* -------------------------------------------- */
- /* Migrations */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- super.migrateData(source);
- CommonTemplate.#migrateACData(source);
- CommonTemplate.#migrateMovementData(source);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the actor ac.value to new ac.flat override field.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateACData(source) {
- if ( !source.attributes?.ac ) return;
- const ac = source.attributes.ac;
-
- // If the actor has a numeric ac.value, then their AC has not been migrated to the auto-calculation schema yet.
- if ( Number.isNumeric(ac.value) ) {
- ac.flat = parseInt(ac.value);
- ac.calc = this._systemType === "npc" ? "natural" : "flat";
- return;
- }
-
- // Migrate ac.base in custom formulas to ac.armor
- if ( (typeof ac.formula === "string") && ac.formula.includes("@attributes.ac.base") ) {
- ac.formula = ac.formula.replaceAll("@attributes.ac.base", "@attributes.ac.armor");
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the actor speed string to movement object.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateMovementData(source) {
- const original = source.attributes?.speed?.value ?? source.attributes?.speed;
- if ( (typeof original !== "string") || (source.attributes.movement?.walk !== undefined) ) return;
- source.attributes.movement ??= {};
- const s = original.split(" ");
- if ( s.length > 0 ) source.attributes.movement.walk = Number.isNumeric(s[0]) ? parseInt(s[0]) : 0;
- }
- }
-
- /**
- * @typedef {object} SkillData
- * @property {number} value Proficiency level creature has in this skill.
- * @property {string} ability Default ability used for this skill.
- * @property {object} bonuses Bonuses for this skill.
- * @property {string} bonuses.check Numeric or dice bonus to skill's check.
- * @property {string} bonuses.passive Numeric bonus to skill's passive check.
- */
-
- /**
- * A template for all actors that are creatures
- *
- * @property {object} bonuses
- * @property {AttackBonusesData} bonuses.mwak Bonuses to melee weapon attacks.
- * @property {AttackBonusesData} bonuses.rwak Bonuses to ranged weapon attacks.
- * @property {AttackBonusesData} bonuses.msak Bonuses to melee spell attacks.
- * @property {AttackBonusesData} bonuses.rsak Bonuses to ranged spell attacks.
- * @property {object} bonuses.abilities Bonuses to ability scores.
- * @property {string} bonuses.abilities.check Numeric or dice bonus to ability checks.
- * @property {string} bonuses.abilities.save Numeric or dice bonus to ability saves.
- * @property {string} bonuses.abilities.skill Numeric or dice bonus to skill checks.
- * @property {object} bonuses.spell Bonuses to spells.
- * @property {string} bonuses.spell.dc Numeric bonus to spellcasting DC.
- * @property {Object<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, min: 0, max: 2, step: 0.5, initial: 0, label: "DND5E.ProficiencyLevel"
- }),
- ability: new foundry.data.fields.StringField({required: true, initial: "dex", label: "DND5E.Ability"}),
- bonuses: new foundry.data.fields.SchemaField({
- check: new FormulaField({required: true, label: "DND5E.SkillBonusCheck"}),
- passive: new FormulaField({required: true, label: "DND5E.SkillBonusPassive"})
- }, {label: "DND5E.SkillBonuses"})
- }), {
- initialKeys: CONFIG.DND5E.skills, initialValue: this._initialSkillValue,
- initialKeysOnly: true, label: "DND5E.Skills"
- }),
- tools: new MappingField(new foundry.data.fields.SchemaField({
- value: new foundry.data.fields.NumberField({
- required: true, min: 0, max: 2, step: 0.5, initial: 1, label: "DND5E.ProficiencyLevel"
- }),
- ability: new foundry.data.fields.StringField({required: true, initial: "int", label: "DND5E.Ability"}),
- bonuses: new foundry.data.fields.SchemaField({
- check: new FormulaField({required: true, label: "DND5E.CheckBonus"})
- }, {label: "DND5E.ToolBonuses"})
- })),
- spells: new MappingField(new foundry.data.fields.SchemaField({
- value: new foundry.data.fields.NumberField({
- nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SpellProgAvailable"
- }),
- override: new foundry.data.fields.NumberField({
- integer: true, min: 0, label: "DND5E.SpellProgOverride"
- })
- }), {initialKeys: this._spellLevels, label: "DND5E.SpellLevels"})
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * Populate the proper initial abilities for the skills.
- * @param {string} key Key for which the initial data will be created.
- * @param {object} initial The initial skill object created by SkillData.
- * @returns {object} Initial skills object with the ability defined.
- * @private
- */
- static _initialSkillValue(key, initial) {
- if ( CONFIG.DND5E.skills[key]?.ability ) initial.ability = CONFIG.DND5E.skills[key].ability;
- return initial;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Helper for building the default list of spell levels.
- * @type {string[]}
- * @private
- */
- static get _spellLevels() {
- const levels = Object.keys(CONFIG.DND5E.spellLevels).filter(a => a !== "0").map(l => `spell${l}`);
- return [...levels, "pact"];
- }
-
- /* -------------------------------------------- */
- /* Migrations */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- super.migrateData(source);
- CreatureTemplate.#migrateSensesData(source);
- CreatureTemplate.#migrateToolData(source);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the actor traits.senses string to attributes.senses object.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateSensesData(source) {
- const original = source.traits?.senses;
- if ( (original === undefined) || (typeof original !== "string") ) return;
- source.attributes ??= {};
- source.attributes.senses ??= {};
-
- // Try to match old senses with the format like "Darkvision 60 ft, Blindsight 30 ft"
- const pattern = /([A-z]+)\s?([0-9]+)\s?([A-z]+)?/;
- let wasMatched = false;
-
- // Match each comma-separated term
- for ( let s of original.split(",") ) {
- s = s.trim();
- const match = s.match(pattern);
- if ( !match ) continue;
- const type = match[1].toLowerCase();
- if ( (type in CONFIG.DND5E.senses) && !(type in source.attributes.senses) ) {
- source.attributes.senses[type] = Number(match[2]).toNearest(0.5);
- wasMatched = true;
- }
- }
-
- // If nothing was matched, but there was an old string - put the whole thing in "special"
- if ( !wasMatched && original ) source.attributes.senses.special = original;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate traits.toolProf to the tools field.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateToolData(source) {
- const original = source.traits?.toolProf;
- if ( !original || foundry.utils.isEmpty(original.value) ) return;
- source.tools ??= {};
- for ( const prof of original.value ) {
- const validProf = (prof in CONFIG.DND5E.toolProficiencies) || (prof in CONST.DND5E.toolIds);
- if ( !validProf || (prof in source.tools) ) continue;
- source.tools[prof] = {
- value: 1,
- ability: "int",
- bonuses: {check: ""}
- };
- }
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Data on configuration of a specific spell slot.
- *
- * @typedef {object} SpellSlotData
- * @property {number} value Currently available spell slots.
- * @property {number} override Number to replace auto-calculated max slots.
- */
-
- /* -------------------------------------------- */
-
- /**
- * Data structure for actor's attack bonuses.
- *
- * @typedef {object} AttackBonusesData
- * @property {string} attack Numeric or dice bonus to attack rolls.
- * @property {string} damage Numeric or dice bonus to damage rolls.
- */
-
- /**
- * Produce the schema field for a simple trait.
- * @param {object} schemaOptions Options passed to the outer schema.
- * @returns {AttackBonusesData}
- */
- function makeAttackBonuses(schemaOptions={}) {
- return new foundry.data.fields.SchemaField({
- attack: new FormulaField({required: true, label: "DND5E.BonusAttack"}),
- damage: new FormulaField({required: true, label: "DND5E.BonusDamage"})
- }, schemaOptions);
- }
-
- /**
- * Shared contents of the details schema between various actor types.
- */
- class DetailsField {
- /**
- * Fields shared between characters, NPCs, and vehicles.
- *
- * @type {object}
- * @property {object} biography Actor's biography data.
- * @property {string} biography.value Full HTML biography information.
- * @property {string} biography.public Biography that will be displayed to players with observer privileges.
- */
- static get common() {
- return {
- biography: new foundry.data.fields.SchemaField({
- value: new foundry.data.fields.HTMLField({label: "DND5E.Biography"}),
- public: new foundry.data.fields.HTMLField({label: "DND5E.BiographyPublic"})
- }, {label: "DND5E.Biography"})
- };
- }
-
- /* -------------------------------------------- */
-
- /**
- * Fields shared between characters and NPCs.
- *
- * @type {object}
- * @property {string} alignment Creature's alignment.
- * @property {string} race Creature's race.
- */
- static get creature() {
- return {
- alignment: new foundry.data.fields.StringField({required: true, label: "DND5E.Alignment"}),
- race: new foundry.data.fields.StringField({required: true, label: "DND5E.Race"})
- };
- }
- }
-
- /**
- * Shared contents of the traits schema between various actor types.
- */
- class TraitsField {
- /**
- * Data structure for a standard actor trait.
- *
- * @typedef {object} SimpleTraitData
- * @property {Set<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,
- BaseAdvancement: BaseAdvancement,
- ItemChoiceConfigurationData: ItemChoiceConfigurationData,
- ItemGrantConfigurationData: ItemGrantConfigurationData,
- SpellConfigurationData: SpellConfigurationData,
- scaleValue: scaleValue
- });
-
- /**
- * Data model template with item description & source.
- *
- * @property {object} description Various item descriptions.
- * @property {string} description.value Full item description.
- * @property {string} description.chat Description displayed in chat card.
- * @property {string} description.unidentified Description displayed if item is unidentified.
- * @property {string} source Adventure or sourcebook where this item originated.
- * @mixin
- */
- class ItemDescriptionTemplate extends foundry.abstract.DataModel {
- /** @inheritdoc */
- static defineSchema() {
- return {
- description: new foundry.data.fields.SchemaField({
- value: new foundry.data.fields.HTMLField({required: true, nullable: true, label: "DND5E.Description"}),
- chat: new foundry.data.fields.HTMLField({required: true, nullable: true, label: "DND5E.DescriptionChat"}),
- unidentified: new foundry.data.fields.HTMLField({
- required: true, nullable: true, label: "DND5E.DescriptionUnidentified"
- })
- }),
- source: new foundry.data.fields.StringField({required: true, label: "DND5E.Source"})
- };
- }
-
- /* -------------------------------------------- */
- /* Migrations */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- ItemDescriptionTemplate.#migrateSource(source);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Convert null source to the blank string.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateSource(source) {
- if ( source.source === null ) source.source = "";
- }
- }
-
- /**
- * Data definition for Background items.
- * @mixes ItemDescriptionTemplate
- *
- * @property {object[]} advancement Advancement objects for this background.
- */
- class BackgroundData extends SystemDataModel.mixin(ItemDescriptionTemplate) {
- /** @inheritdoc */
- static defineSchema() {
- return this.mergeSchema(super.defineSchema(), {
- advancement: new foundry.data.fields.ArrayField(new AdvancementField(), {label: "DND5E.AdvancementTitle"})
- });
- }
- }
-
- /**
- * Data definition for Class items.
- * @mixes ItemDescriptionTemplate
- *
- * @property {string} identifier Identifier slug for this class.
- * @property {number} levels Current number of levels in this class.
- * @property {string} hitDice Denomination of hit dice available as defined in `DND5E.hitDieTypes`.
- * @property {number} hitDiceUsed Number of hit dice consumed.
- * @property {object[]} advancement Advancement objects for this class.
- * @property {string[]} saves Savings throws in which this class grants proficiency.
- * @property {object} skills Available class skills and selected skills.
- * @property {number} skills.number Number of skills selectable by the player.
- * @property {string[]} skills.choices List of skill keys that are valid to be chosen.
- * @property {string[]} skills.value List of skill keys the player has chosen.
- * @property {object} spellcasting Details on class's spellcasting ability.
- * @property {string} spellcasting.progression Spell progression granted by class as from `DND5E.spellProgression`.
- * @property {string} spellcasting.ability Ability score to use for spellcasting.
- */
- class ClassData extends SystemDataModel.mixin(ItemDescriptionTemplate) {
- /** @inheritdoc */
- static defineSchema() {
- return this.mergeSchema(super.defineSchema(), {
- identifier: new IdentifierField({required: true, label: "DND5E.Identifier"}),
- levels: new foundry.data.fields.NumberField({
- required: true, nullable: false, integer: true, min: 0, initial: 1, label: "DND5E.ClassLevels"
- }),
- hitDice: new foundry.data.fields.StringField({
- required: true, initial: "d6", blank: false, label: "DND5E.HitDice",
- validate: v => /d\d+/.test(v), validationError: "must be a dice value in the format d#"
- }),
- hitDiceUsed: new foundry.data.fields.NumberField({
- required: true, nullable: false, integer: true, initial: 0, min: 0, label: "DND5E.HitDiceUsed"
- }),
- advancement: new foundry.data.fields.ArrayField(new AdvancementField(), {label: "DND5E.AdvancementTitle"}),
- saves: new foundry.data.fields.ArrayField(new foundry.data.fields.StringField(), {label: "DND5E.ClassSaves"}),
- skills: new foundry.data.fields.SchemaField({
- number: new foundry.data.fields.NumberField({
- required: true, nullable: false, integer: true, min: 0, initial: 2, label: "DND5E.ClassSkillsNumber"
- }),
- choices: new foundry.data.fields.ArrayField(
- new foundry.data.fields.StringField(), {label: "DND5E.ClassSkillsEligible"}
- ),
- value: new foundry.data.fields.ArrayField(
- new foundry.data.fields.StringField(), {label: "DND5E.ClassSkillsChosen"}
- )
- }),
- spellcasting: new foundry.data.fields.SchemaField({
- progression: new foundry.data.fields.StringField({
- required: true, initial: "none", blank: false, label: "DND5E.SpellProgression"
- }),
- ability: new foundry.data.fields.StringField({required: true, label: "DND5E.SpellAbility"})
- }, {label: "DND5E.Spellcasting"})
- });
- }
-
- /* -------------------------------------------- */
- /* Migrations */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- super.migrateData(source);
- ClassData.#migrateLevels(source);
- ClassData.#migrateSpellcastingData(source);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the class levels.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateLevels(source) {
- if ( typeof source.levels !== "string" ) return;
- if ( source.levels === "" ) source.levels = 1;
- else if ( Number.isNumeric(source.levels) ) source.levels = Number(source.levels);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the class's spellcasting string to object.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateSpellcastingData(source) {
- if ( source.spellcasting?.progression === "" ) source.spellcasting.progression = "none";
- if ( typeof source.spellcasting !== "string" ) return;
- source.spellcasting = {
- progression: source.spellcasting,
- ability: ""
- };
- }
- }
-
- /**
- * Data model template for item actions.
- *
- * @property {string} ability Ability score to use when determining modifier.
- * @property {string} actionType Action type as defined in `DND5E.itemActionTypes`.
- * @property {string} attackBonus Numeric or dice bonus to attack rolls.
- * @property {string} chatFlavor Extra text displayed in chat.
- * @property {object} critical Information on how critical hits are handled.
- * @property {number} critical.threshold Minimum number on the dice to roll a critical hit.
- * @property {string} critical.damage Extra damage on critical hit.
- * @property {object} damage Item damage formulas.
- * @property {string[][]} damage.parts Array of damage formula and types.
- * @property {string} damage.versatile Special versatile damage formula.
- * @property {string} formula Other roll formula.
- * @property {object} save Item saving throw data.
- * @property {string} save.ability Ability required for the save.
- * @property {number} save.dc Custom saving throw value.
- * @property {string} save.scaling Method for automatically determining saving throw DC.
- * @mixin
- */
- class ActionTemplate extends foundry.abstract.DataModel {
- /** @inheritdoc */
- static defineSchema() {
- return {
- ability: new foundry.data.fields.StringField({
- required: true, nullable: true, initial: null, label: "DND5E.AbilityModifier"
- }),
- actionType: new foundry.data.fields.StringField({
- required: true, nullable: true, initial: null, label: "DND5E.ItemActionType"
- }),
- attackBonus: new FormulaField({required: true, label: "DND5E.ItemAttackBonus"}),
- chatFlavor: new foundry.data.fields.StringField({required: true, label: "DND5E.ChatFlavor"}),
- critical: new foundry.data.fields.SchemaField({
- threshold: new foundry.data.fields.NumberField({
- required: true, integer: true, initial: null, positive: true, label: "DND5E.ItemCritThreshold"
- }),
- damage: new FormulaField({required: true, label: "DND5E.ItemCritExtraDamage"})
- }),
- damage: new foundry.data.fields.SchemaField({
- parts: new foundry.data.fields.ArrayField(new foundry.data.fields.ArrayField(
- new foundry.data.fields.StringField({nullable: true})
- ), {required: true}),
- versatile: new FormulaField({required: true, label: "DND5E.VersatileDamage"})
- }, {label: "DND5E.Damage"}),
- formula: new FormulaField({required: true, label: "DND5E.OtherFormula"}),
- save: new foundry.data.fields.SchemaField({
- ability: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.Ability"}),
- dc: new foundry.data.fields.NumberField({
- required: true, min: 0, integer: true, label: "DND5E.AbbreviationDC"
- }),
- scaling: new foundry.data.fields.StringField({
- required: true, blank: false, initial: "spell", label: "DND5E.ScalingFormula"
- })
- }, {label: "DND5E.SavingThrow"})
- };
- }
-
- /* -------------------------------------------- */
- /* Migrations */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- ActionTemplate.#migrateAbility(source);
- ActionTemplate.#migrateAttackBonus(source);
- ActionTemplate.#migrateCritical(source);
- ActionTemplate.#migrateSave(source);
- ActionTemplate.#migrateDamage(source);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the ability field.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateAbility(source) {
- if ( Array.isArray(source.ability) ) source.ability = source.ability[0];
- }
-
- /* -------------------------------------------- */
-
- /**
- * Ensure a 0 or null in attack bonus is converted to an empty string rather than "0".
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateAttackBonus(source) {
- if ( [0, "0", null].includes(source.attackBonus) ) source.attackBonus = "";
- else if ( typeof source.attackBonus === "number" ) source.attackBonus = source.attackBonus.toString();
- }
-
- /* -------------------------------------------- */
-
- /**
- * Ensure the critical field is an object.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateCritical(source) {
- if ( !("critical" in source) ) return;
- if ( (typeof source.critical !== "object") || (source.critical === null) ) source.critical = {
- threshold: null,
- damage: ""
- };
- if ( source.critical.damage === null ) source.critical.damage = "";
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the save field.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateSave(source) {
- if ( !("save" in source) ) return;
- source.save ??= {};
- if ( source.save.scaling === "" ) source.save.scaling = "spell";
- if ( source.save.ability === null ) source.save.ability = "";
- if ( typeof source.save.dc === "string" ) {
- if ( source.save.dc === "" ) source.save.dc = null;
- else if ( Number.isNumeric(source.save.dc) ) source.save.dc = Number(source.save.dc);
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate damage parts.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateDamage(source) {
- if ( !("damage" in source) ) return;
- source.damage ??= {};
- source.damage.parts ??= [];
- }
-
- /* -------------------------------------------- */
- /* Getters */
- /* -------------------------------------------- */
-
- /**
- * Which ability score modifier is used by this item?
- * @type {string|null}
- */
- get abilityMod() {
- return this.ability || this._typeAbilityMod || {
- mwak: "str",
- rwak: "dex",
- msak: this.parent?.actor?.system.attributes.spellcasting || "int",
- rsak: this.parent?.actor?.system.attributes.spellcasting || "int"
- }[this.actionType] || null;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Default ability key defined for this type.
- * @type {string|null}
- * @internal
- */
- get _typeAbilityMod() {
- return null;
- }
-
- /* -------------------------------------------- */
-
- /**
- * What is the critical hit threshold for this item? Uses the smallest value from among the following sources:
- * - `critical.threshold` defined on the item
- * - `critical.threshold` defined on ammunition, if consumption mode is set to ammo
- * - Type-specific critical threshold
- * @type {number|null}
- */
- get criticalThreshold() {
- if ( !this.hasAttack ) return null;
- let ammoThreshold = Infinity;
- if ( this.consume?.type === "ammo" ) {
- ammoThreshold = this.parent?.actor?.items.get(this.consume.target).system.critical.threshold ?? Infinity;
- }
- const threshold = Math.min(this.critical.threshold ?? Infinity, this._typeCriticalThreshold, ammoThreshold);
- return threshold < Infinity ? threshold : 20;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Default critical threshold for this type.
- * @type {number}
- * @internal
- */
- get _typeCriticalThreshold() {
- return Infinity;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item implement an ability check as part of its usage?
- * @type {boolean}
- */
- get hasAbilityCheck() {
- return (this.actionType === "abil") && !!this.ability;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item implement an attack roll as part of its usage?
- * @type {boolean}
- */
- get hasAttack() {
- return ["mwak", "rwak", "msak", "rsak"].includes(this.actionType);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item implement a damage roll as part of its usage?
- * @type {boolean}
- */
- get hasDamage() {
- return this.actionType && (this.damage.parts.length > 0);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item implement a saving throw as part of its usage?
- * @type {boolean}
- */
- get hasSave() {
- return this.actionType && !!(this.save.ability && this.save.scaling);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item provide an amount of healing instead of conventional damage?
- * @type {boolean}
- */
- get isHealing() {
- return (this.actionType === "heal") && this.hasDamage;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item implement a versatile damage roll as part of its usage?
- * @type {boolean}
- */
- get isVersatile() {
- return this.actionType && !!(this.hasDamage && this.damage.versatile);
- }
-
- }
-
- /**
- * Data model template for items that can be used as some sort of action.
- *
- * @property {object} activation Effect's activation conditions.
- * @property {string} activation.type Activation type as defined in `DND5E.abilityActivationTypes`.
- * @property {number} activation.cost How much of the activation type is needed to use this item's effect.
- * @property {string} activation.condition Special conditions required to activate the item.
- * @property {object} duration Effect's duration.
- * @property {number} duration.value How long the effect lasts.
- * @property {string} duration.units Time duration period as defined in `DND5E.timePeriods`.
- * @property {number} cover Amount of cover does this item affords to its crew on a vehicle.
- * @property {object} target Effect's valid targets.
- * @property {number} target.value Length or radius of target depending on targeting mode selected.
- * @property {number} target.width Width of line when line type is selected.
- * @property {string} target.units Units used for value and width as defined in `DND5E.distanceUnits`.
- * @property {string} target.type Targeting mode as defined in `DND5E.targetTypes`.
- * @property {object} range Effect's range.
- * @property {number} range.value Regular targeting distance for item's effect.
- * @property {number} range.long Maximum targeting distance for features that have a separate long range.
- * @property {string} range.units Units used for value and long as defined in `DND5E.distanceUnits`.
- * @property {object} uses Effect's limited uses.
- * @property {number} uses.value Current available uses.
- * @property {string} uses.max Maximum possible uses or a formula to derive that number.
- * @property {string} uses.per Recharge time for limited uses as defined in `DND5E.limitedUsePeriods`.
- * @property {object} consume Effect's resource consumption.
- * @property {string} consume.type Type of resource to consume as defined in `DND5E.abilityConsumptionTypes`.
- * @property {string} consume.target Item ID or resource key path of resource to consume.
- * @property {number} consume.amount Quantity of the resource to consume per use.
- * @mixin
- */
- class ActivatedEffectTemplate extends foundry.abstract.DataModel {
- /** @inheritdoc */
- static defineSchema() {
- return {
- activation: new foundry.data.fields.SchemaField({
- type: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.ItemActivationType"}),
- cost: new foundry.data.fields.NumberField({required: true, label: "DND5E.ItemActivationCost"}),
- condition: new foundry.data.fields.StringField({required: true, label: "DND5E.ItemActivationCondition"})
- }, {label: "DND5E.ItemActivation"}),
- duration: new foundry.data.fields.SchemaField({
- value: new FormulaField({required: true, deterministic: true, label: "DND5E.Duration"}),
- units: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.DurationType"})
- }, {label: "DND5E.Duration"}),
- cover: new foundry.data.fields.NumberField({
- required: true, nullable: true, min: 0, max: 1, label: "DND5E.Cover"
- }),
- crewed: new foundry.data.fields.BooleanField({label: "DND5E.Crewed"}),
- target: new foundry.data.fields.SchemaField({
- value: new foundry.data.fields.NumberField({required: true, min: 0, label: "DND5E.TargetValue"}),
- width: new foundry.data.fields.NumberField({required: true, min: 0, label: "DND5E.TargetWidth"}),
- units: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.TargetUnits"}),
- type: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.TargetType"})
- }, {label: "DND5E.Target"}),
- range: new foundry.data.fields.SchemaField({
- value: new foundry.data.fields.NumberField({required: true, min: 0, label: "DND5E.RangeNormal"}),
- long: new foundry.data.fields.NumberField({required: true, min: 0, label: "DND5E.RangeLong"}),
- units: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.RangeUnits"})
- }, {label: "DND5E.Range"}),
- uses: new this.ItemUsesField({}, {label: "DND5E.LimitedUses"}),
- consume: new foundry.data.fields.SchemaField({
- type: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.ConsumeType"}),
- target: new foundry.data.fields.StringField({
- required: true, nullable: true, initial: null, label: "DND5E.ConsumeTarget"
- }),
- amount: new foundry.data.fields.NumberField({required: true, integer: true, label: "DND5E.ConsumeAmount"})
- }, {label: "DND5E.ConsumeTitle"})
- };
- }
-
- /* -------------------------------------------- */
-
- /**
- * Extension of SchemaField used to track item uses.
- * @internal
- */
- static ItemUsesField = class ItemUsesField extends foundry.data.fields.SchemaField {
- constructor(extraSchema, options) {
- super(SystemDataModel.mergeSchema({
- value: new foundry.data.fields.NumberField({
- required: true, min: 0, integer: true, label: "DND5E.LimitedUsesAvailable"
- }),
- max: new FormulaField({required: true, deterministic: true, label: "DND5E.LimitedUsesMax"}),
- per: new foundry.data.fields.StringField({
- required: true, nullable: true, blank: false, initial: null, label: "DND5E.LimitedUsesPer"
- }),
- recovery: new FormulaField({required: true, label: "DND5E.RecoveryFormula"})
- }, extraSchema), options);
- }
- };
-
- /* -------------------------------------------- */
- /* Migrations */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- ActivatedEffectTemplate.#migrateFormulaFields(source);
- ActivatedEffectTemplate.#migrateRanges(source);
- ActivatedEffectTemplate.#migrateTargets(source);
- ActivatedEffectTemplate.#migrateUses(source);
- ActivatedEffectTemplate.#migrateConsume(source);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Ensure a 0 or null in max uses & durations are converted to an empty string rather than "0". Convert numbers into
- * strings.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateFormulaFields(source) {
- if ( [0, "0", null].includes(source.uses?.max) ) source.uses.max = "";
- else if ( typeof source.uses?.max === "number" ) source.uses.max = source.uses.max.toString();
- if ( [0, "0", null].includes(source.duration?.value) ) source.duration.value = "";
- else if ( typeof source.duration?.value === "number" ) source.duration.value = source.duration.value.toString();
- }
-
- /* -------------------------------------------- */
-
- /**
- * Fix issue with some imported range data that uses the format "100/400" in the range field,
- * rather than splitting it between "range.value" & "range.long".
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateRanges(source) {
- if ( !("range" in source) ) return;
- source.range ??= {};
- if ( source.range.units === null ) source.range.units = "";
- if ( typeof source.range.long === "string" ) {
- if ( source.range.long === "" ) source.range.long = null;
- else if ( Number.isNumeric(source.range.long) ) source.range.long = Number(source.range.long);
- }
- if ( typeof source.range.value !== "string" ) return;
- if ( source.range.value === "" ) {
- source.range.value = null;
- return;
- }
- const [value, long] = source.range.value.split("/");
- if ( Number.isNumeric(value) ) source.range.value = Number(value);
- if ( Number.isNumeric(long) ) source.range.long = Number(long);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Ensure blank strings in targets are converted to null.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateTargets(source) {
- if ( !("target" in source) ) return;
- source.target ??= {};
- if ( source.target.value === "" ) source.target.value = null;
- if ( source.target.units === null ) source.target.units = "";
- if ( source.target.type === null ) source.target.type = "";
- }
-
- /* -------------------------------------------- */
-
- /**
- * Ensure a blank string in uses.value is converted to null.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateUses(source) {
- if ( !("uses" in source) ) return;
- source.uses ??= {};
- const value = source.uses.value;
- if ( typeof value === "string" ) {
- if ( value === "" ) source.uses.value = null;
- else if ( Number.isNumeric(value) ) source.uses.value = Number(source.uses.value);
- }
- if ( source.uses.recovery === undefined ) source.uses.recovery = "";
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the consume field.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateConsume(source) {
- if ( !("consume" in source) ) return;
- source.consume ??= {};
- if ( source.consume.type === null ) source.consume.type = "";
- const amount = source.consume.amount;
- if ( typeof amount === "string" ) {
- if ( amount === "" ) source.consume.amount = null;
- else if ( Number.isNumeric(amount) ) source.consume.amount = Number(amount);
- }
- }
-
- /* -------------------------------------------- */
- /* Getters */
- /* -------------------------------------------- */
-
- /**
- * Chat properties for activated effects.
- * @type {string[]}
- */
- get activatedEffectChatProperties() {
- return [
- this.parent.labels.activation + (this.activation.condition ? ` (${this.activation.condition})` : ""),
- this.parent.labels.target,
- this.parent.labels.range,
- this.parent.labels.duration
- ];
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item have an area of effect target?
- * @type {boolean}
- */
- get hasAreaTarget() {
- return this.target.type in CONFIG.DND5E.areaTargetTypes;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item target one or more distinct targets?
- * @type {boolean}
- */
- get hasIndividualTarget() {
- return this.target.type in CONFIG.DND5E.individualTargetTypes;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Is this Item limited in its ability to be used by charges or by recharge?
- * @type {boolean}
- */
- get hasLimitedUses() {
- return !!this.uses.per && (this.uses.max > 0);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item duration accept an associated numeric value or formula?
- * @type {boolean}
- */
- get hasScalarDuration() {
- return this.duration.units in CONFIG.DND5E.scalarTimePeriods;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item range accept an associated numeric value?
- * @type {boolean}
- */
- get hasScalarRange() {
- return this.range.units in CONFIG.DND5E.movementUnits;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item target accept an associated numeric value?
- * @type {boolean}
- */
- get hasScalarTarget() {
- return ![null, "", "self"].includes(this.target.type);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Does the Item have a target?
- * @type {boolean}
- */
- get hasTarget() {
- return !["", null].includes(this.target.type);
- }
-
- }
-
- /**
- * Data model template with information on items that can be attuned and equipped.
- *
- * @property {number} attunement Attunement information as defined in `DND5E.attunementTypes`.
- * @property {boolean} equipped Is this item equipped on its owning actor.
- * @mixin
- */
- class EquippableItemTemplate extends foundry.abstract.DataModel {
- /** @inheritdoc */
- static defineSchema() {
- return {
- attunement: new foundry.data.fields.NumberField({
- required: true, integer: true, initial: CONFIG.DND5E.attunementTypes.NONE, label: "DND5E.Attunement"
- }),
- equipped: new foundry.data.fields.BooleanField({required: true, label: "DND5E.Equipped"})
- };
- }
-
- /* -------------------------------------------- */
- /* Migrations */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- EquippableItemTemplate.#migrateAttunement(source);
- EquippableItemTemplate.#migrateEquipped(source);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the item's attuned boolean to attunement string.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateAttunement(source) {
- if ( (source.attuned === undefined) || (source.attunement !== undefined) ) return;
- source.attunement = source.attuned ? CONFIG.DND5E.attunementTypes.ATTUNED : CONFIG.DND5E.attunementTypes.NONE;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the equipped field.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateEquipped(source) {
- if ( !("equipped" in source) ) return;
- if ( (source.equipped === null) || (source.equipped === undefined) ) source.equipped = false;
- }
-
- /* -------------------------------------------- */
- /* Getters */
- /* -------------------------------------------- */
-
- /**
- * Chat properties for equippable items.
- * @type {string[]}
- */
- get equippableItemChatProperties() {
- const req = CONFIG.DND5E.attunementTypes.REQUIRED;
- return [
- this.attunement === req ? CONFIG.DND5E.attunements[req] : null,
- game.i18n.localize(this.equipped ? "DND5E.Equipped" : "DND5E.Unequipped"),
- ("proficient" in this) ? CONFIG.DND5E.proficiencyLevels[Number(this.proficient)] : null
- ];
- }
- }
-
- /**
- * Data model template with information on physical items.
- *
- * @property {number} quantity Number of items in a stack.
- * @property {number} weight Item's weight in pounds or kilograms (depending on system setting).
- * @property {object} price
- * @property {number} price.value Item's cost in the specified denomination.
- * @property {string} price.denomination Currency denomination used to determine price.
- * @property {string} rarity Item rarity as defined in `DND5E.itemRarity`.
- * @property {boolean} identified Has this item been identified?
- * @mixin
- */
- class PhysicalItemTemplate extends foundry.abstract.DataModel {
- /** @inheritdoc */
- static defineSchema() {
- return {
- quantity: new foundry.data.fields.NumberField({
- required: true, nullable: false, integer: true, initial: 1, min: 0, label: "DND5E.Quantity"
- }),
- weight: new foundry.data.fields.NumberField({
- required: true, nullable: false, initial: 0, min: 0, label: "DND5E.Weight"
- }),
- price: new foundry.data.fields.SchemaField({
- value: new foundry.data.fields.NumberField({
- required: true, nullable: false, initial: 0, min: 0, label: "DND5E.Price"
- }),
- denomination: new foundry.data.fields.StringField({
- required: true, blank: false, initial: "gp", label: "DND5E.Currency"
- })
- }, {label: "DND5E.Price"}),
- rarity: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.Rarity"}),
- identified: new foundry.data.fields.BooleanField({required: true, initial: true, label: "DND5E.Identified"})
- };
- }
-
- /* -------------------------------------------- */
- /* Migrations */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- PhysicalItemTemplate.#migratePrice(source);
- PhysicalItemTemplate.#migrateRarity(source);
- PhysicalItemTemplate.#migrateWeight(source);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the item's price from a single field to an object with currency.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migratePrice(source) {
- if ( !("price" in source) || foundry.utils.getType(source.price) === "Object" ) return;
- source.price = {
- value: Number.isNumeric(source.price) ? Number(source.price) : 0,
- denomination: "gp"
- };
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the item's rarity from freeform string to enum value.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateRarity(source) {
- if ( !("rarity" in source) || CONFIG.DND5E.itemRarity[source.rarity] ) return;
- source.rarity = Object.keys(CONFIG.DND5E.itemRarity).find(key =>
- CONFIG.DND5E.itemRarity[key].toLowerCase() === source.rarity.toLowerCase()
- ) ?? "";
- }
-
- /* -------------------------------------------- */
-
- /**
- * Convert null weights to 0.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateWeight(source) {
- if ( !("weight" in source) ) return;
- if ( (source.weight === null) || (source.weight === undefined) ) source.weight = 0;
- }
- }
-
- /**
- * Data definition for Consumable items.
- * @mixes ItemDescriptionTemplate
- * @mixes PhysicalItemTemplate
- * @mixes EquippableItemTemplate
- * @mixes ActivatedEffectTemplate
- * @mixes ActionTemplate
- *
- * @property {string} consumableType Type of consumable as defined in `DND5E.consumableTypes`.
- * @property {object} uses
- * @property {boolean} uses.autoDestroy Should this item be destroyed when it runs out of uses.
- */
- class ConsumableData extends SystemDataModel.mixin(
- ItemDescriptionTemplate, PhysicalItemTemplate, EquippableItemTemplate, ActivatedEffectTemplate, ActionTemplate
- ) {
- /** @inheritdoc */
- static defineSchema() {
- return this.mergeSchema(super.defineSchema(), {
- consumableType: new foundry.data.fields.StringField({
- required: true, initial: "potion", label: "DND5E.ItemConsumableType"
- }),
- uses: new ActivatedEffectTemplate.ItemUsesField({
- autoDestroy: new foundry.data.fields.BooleanField({required: true, label: "DND5E.ItemDestroyEmpty"})
- }, {label: "DND5E.LimitedUses"})
- });
- }
-
- /* -------------------------------------------- */
- /* Getters */
- /* -------------------------------------------- */
-
- /**
- * Properties displayed in chat.
- * @type {string[]}
- */
- get chatProperties() {
- return [
- CONFIG.DND5E.consumableTypes[this.consumableType],
- this.hasLimitedUses ? `${this.uses.value}/${this.uses.max} ${game.i18n.localize("DND5E.Charges")}` : null
- ];
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get _typeAbilityMod() {
- if ( this.consumableType !== "scroll" ) return null;
- return this.parent?.actor?.system.attributes.spellcasting || "int";
- }
- }
-
- /**
- * Data definition for Backpack items.
- * @mixes ItemDescriptionTemplate
- * @mixes PhysicalItemTemplate
- * @mixes EquippableItemTemplate
- * @mixes CurrencyTemplate
- *
- * @property {object} capacity Information on container's carrying capacity.
- * @property {string} capacity.type Method for tracking max capacity as defined in `DND5E.itemCapacityTypes`.
- * @property {number} capacity.value Total amount of the type this container can carry.
- * @property {boolean} capacity.weightless Does the weight of the items in the container carry over to the actor?
- */
- class ContainerData extends SystemDataModel.mixin(
- ItemDescriptionTemplate, PhysicalItemTemplate, EquippableItemTemplate, CurrencyTemplate
- ) {
- /** @inheritdoc */
- static defineSchema() {
- return this.mergeSchema(super.defineSchema(), {
- capacity: new foundry.data.fields.SchemaField({
- type: new foundry.data.fields.StringField({
- required: true, initial: "weight", blank: false, label: "DND5E.ItemContainerCapacityType"
- }),
- value: new foundry.data.fields.NumberField({
- required: true, min: 0, label: "DND5E.ItemContainerCapacityMax"
- }),
- weightless: new foundry.data.fields.BooleanField({required: true, label: "DND5E.ItemContainerWeightless"})
- }, {label: "DND5E.ItemContainerCapacity"})
- });
- }
- }
-
- /**
- * Data model template for equipment that can be mounted on a vehicle.
- *
- * @property {object} armor Equipment's armor class.
- * @property {number} armor.value Armor class value for equipment.
- * @property {object} hp Equipment's hit points.
- * @property {number} hp.value Current hit point value.
- * @property {number} hp.max Max hit points.
- * @property {number} hp.dt Damage threshold.
- * @property {string} hp.conditions Conditions that are triggered when this equipment takes damage.
- * @mixin
- */
- class MountableTemplate extends foundry.abstract.DataModel {
- /** @inheritdoc */
- static defineSchema() {
- return {
- armor: new foundry.data.fields.SchemaField({
- value: new foundry.data.fields.NumberField({
- required: true, integer: true, min: 0, label: "DND5E.ArmorClass"
- })
- }, {label: "DND5E.ArmorClass"}),
- hp: new foundry.data.fields.SchemaField({
- value: new foundry.data.fields.NumberField({
- required: true, integer: true, min: 0, label: "DND5E.HitPointsCurrent"
- }),
- max: new foundry.data.fields.NumberField({
- required: true, integer: true, min: 0, label: "DND5E.HitPointsMax"
- }),
- dt: new foundry.data.fields.NumberField({
- required: true, integer: true, min: 0, label: "DND5E.DamageThreshold"
- }),
- conditions: new foundry.data.fields.StringField({required: true, label: "DND5E.HealthConditions"})
- }, {label: "DND5E.HitPoints"})
- };
- }
- }
-
- /**
- * Data definition for Equipment items.
- * @mixes ItemDescriptionTemplate
- * @mixes PhysicalItemTemplate
- * @mixes EquippableItemTemplate
- * @mixes ActivatedEffectTemplate
- * @mixes ActionTemplate
- * @mixes MountableTemplate
- *
- * @property {object} armor Armor details and equipment type information.
- * @property {string} armor.type Equipment type as defined in `DND5E.equipmentTypes`.
- * @property {number} armor.value Base armor class or shield bonus.
- * @property {number} armor.dex Maximum dex bonus added to armor class.
- * @property {string} baseItem Base armor as defined in `DND5E.armorIds` for determining proficiency.
- * @property {object} speed Speed granted by a piece of vehicle equipment.
- * @property {number} speed.value Speed granted by this piece of equipment measured in feet or meters
- * depending on system setting.
- * @property {string} speed.conditions Conditions that may affect item's speed.
- * @property {number} strength Minimum strength required to use a piece of armor.
- * @property {boolean} stealth Does this equipment grant disadvantage on stealth checks when used?
- * @property {boolean} proficient Does the owner have proficiency in this piece of equipment?
- */
- class EquipmentData extends SystemDataModel.mixin(
- ItemDescriptionTemplate, PhysicalItemTemplate, EquippableItemTemplate,
- ActivatedEffectTemplate, ActionTemplate, MountableTemplate
- ) {
- /** @inheritdoc */
- static defineSchema() {
- return this.mergeSchema(super.defineSchema(), {
- armor: new foundry.data.fields.SchemaField({
- type: new foundry.data.fields.StringField({
- required: true, initial: "light", label: "DND5E.ItemEquipmentType"
- }),
- value: new foundry.data.fields.NumberField({required: true, integer: true, min: 0, label: "DND5E.ArmorClass"}),
- dex: new foundry.data.fields.NumberField({required: true, integer: true, label: "DND5E.ItemEquipmentDexMod"})
- }, {label: ""}),
- baseItem: new foundry.data.fields.StringField({required: true, label: "DND5E.ItemEquipmentBase"}),
- speed: new foundry.data.fields.SchemaField({
- value: new foundry.data.fields.NumberField({required: true, min: 0, label: "DND5E.Speed"}),
- conditions: new foundry.data.fields.StringField({required: true, label: "DND5E.SpeedConditions"})
- }, {label: "DND5E.Speed"}),
- strength: new foundry.data.fields.NumberField({
- required: true, integer: true, min: 0, label: "DND5E.ItemRequiredStr"
- }),
- stealth: new foundry.data.fields.BooleanField({required: true, label: "DND5E.ItemEquipmentStealthDisav"}),
- proficient: new foundry.data.fields.BooleanField({required: true, initial: true, label: "DND5E.Proficient"})
- });
- }
-
- /* -------------------------------------------- */
- /* Migrations */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- super.migrateData(source);
- EquipmentData.#migrateArmor(source);
- EquipmentData.#migrateStrength(source);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Apply migrations to the armor field.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateArmor(source) {
- if ( !("armor" in source) ) return;
- source.armor ??= {};
- if ( source.armor.type === "bonus" ) source.armor.type = "trinket";
- if ( (typeof source.armor.dex === "string") ) {
- const dex = source.armor.dex;
- if ( dex === "" ) source.armor.dex = null;
- else if ( Number.isNumeric(dex) ) source.armor.dex = Number(dex);
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Ensure blank strength values are migrated to null, and string values are converted to numbers.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateStrength(source) {
- if ( typeof source.strength !== "string" ) return;
- if ( source.strength === "" ) source.strength = null;
- if ( Number.isNumeric(source.strength) ) source.strength = Number(source.strength);
- }
-
- /* -------------------------------------------- */
- /* Getters */
- /* -------------------------------------------- */
-
- /**
- * Properties displayed in chat.
- * @type {string[]}
- */
- get chatProperties() {
- return [
- CONFIG.DND5E.equipmentTypes[this.armor.type],
- this.parent.labels?.armor ?? null,
- this.stealth ? game.i18n.localize("DND5E.StealthDisadvantage") : null
- ];
- }
-
- /* -------------------------------------------- */
-
- /**
- * Is this Item any of the armor subtypes?
- * @type {boolean}
- */
- get isArmor() {
- return this.armor.type in CONFIG.DND5E.armorTypes;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Is this item a separate large object like a siege engine or vehicle component that is
- * usually mounted on fixtures rather than equipped, and has its own AC and HP?
- * @type {boolean}
- */
- get isMountable() {
- return this.armor.type === "vehicle";
- }
-
- }
-
- /**
- * Data definition for Feature items.
- * @mixes ItemDescriptionTemplate
- * @mixes ActivatedEffectTemplate
- * @mixes ActionTemplate
- *
- * @property {object} type
- * @property {string} type.value Category to which this feature belongs.
- * @property {string} type.subtype Feature subtype according to its category.
- * @property {string} requirements Actor details required to use this feature.
- * @property {object} recharge Details on how a feature can roll for recharges.
- * @property {number} recharge.value Minimum number needed to roll on a d6 to recharge this feature.
- * @property {boolean} recharge.charged Does this feature have a charge remaining?
- */
- class FeatData extends SystemDataModel.mixin(
- ItemDescriptionTemplate, ActivatedEffectTemplate, ActionTemplate
- ) {
- /** @inheritdoc */
- static defineSchema() {
- return this.mergeSchema(super.defineSchema(), {
- type: new foundry.data.fields.SchemaField({
- value: new foundry.data.fields.StringField({required: true, label: "DND5E.Type"}),
- subtype: new foundry.data.fields.StringField({required: true, label: "DND5E.Subtype"})
- }, {label: "DND5E.ItemFeatureType"}),
- requirements: new foundry.data.fields.StringField({required: true, nullable: true, label: "DND5E.Requirements"}),
- recharge: new foundry.data.fields.SchemaField({
- value: new foundry.data.fields.NumberField({
- required: true, integer: true, min: 1, label: "DND5E.FeatureRechargeOn"
- }),
- charged: new foundry.data.fields.BooleanField({required: true, label: "DND5E.Charged"})
- }, {label: "DND5E.FeatureActionRecharge"})
- });
- }
-
- /* -------------------------------------------- */
- /* Migrations */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- super.migrateData(source);
- FeatData.#migrateType(source);
- FeatData.#migrateRecharge(source);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Ensure feats have a type object.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateType(source) {
- if ( !("type" in source) ) return;
- if ( !source.type ) source.type = {value: "", subtype: ""};
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate 0 values to null.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateRecharge(source) {
- if ( !("recharge" in source) ) return;
- const value = source.recharge.value;
- if ( (value === 0) || (value === "") ) source.recharge.value = null;
- else if ( (typeof value === "string") && Number.isNumeric(value) ) source.recharge.value = Number(value);
- if ( source.recharge.charged === null ) source.recharge.charged = false;
- }
-
- /* -------------------------------------------- */
- /* Getters */
- /* -------------------------------------------- */
-
- /**
- * Properties displayed in chat.
- * @type {string[]}
- */
- get chatProperties() {
- return [this.requirements];
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get hasLimitedUses() {
- return !!this.recharge.value || super.hasLimitedUses;
- }
- }
-
- /**
- * Data definition for Loot items.
- * @mixes ItemDescriptionTemplate
- * @mixes PhysicalItemTemplate
- */
- class LootData extends SystemDataModel.mixin(ItemDescriptionTemplate, PhysicalItemTemplate) {
-
- /* -------------------------------------------- */
- /* Getters */
- /* -------------------------------------------- */
-
- /**
- * Properties displayed in chat.
- * @type {string[]}
- */
- get chatProperties() {
- return [
- game.i18n.localize(CONFIG.Item.typeLabels.loot),
- this.weight ? `${this.weight} ${game.i18n.localize("DND5E.AbbreviationLbs")}` : null
- ];
- }
- }
-
- /**
- * Data definition for Spell items.
- * @mixes ItemDescriptionTemplate
- * @mixes ActivatedEffectTemplate
- * @mixes ActionTemplate
- *
- * @property {number} level Base level of the spell.
- * @property {string} school Magical school to which this spell belongs.
- * @property {object} components General components and tags for this spell.
- * @property {boolean} components.vocal Does this spell require vocal components?
- * @property {boolean} components.somatic Does this spell require somatic components?
- * @property {boolean} components.material Does this spell require material components?
- * @property {boolean} components.ritual Can this spell be cast as a ritual?
- * @property {boolean} components.concentration Does this spell require concentration?
- * @property {object} materials Details on material components required for this spell.
- * @property {string} materials.value Description of the material components required for casting.
- * @property {boolean} materials.consumed Are these material components consumed during casting?
- * @property {number} materials.cost GP cost for the required components.
- * @property {number} materials.supply Quantity of this component available.
- * @property {object} preparation Details on how this spell is prepared.
- * @property {string} preparation.mode Spell preparation mode as defined in `DND5E.spellPreparationModes`.
- * @property {boolean} preparation.prepared Is the spell currently prepared?
- * @property {object} scaling Details on how casting at higher levels affects this spell.
- * @property {string} scaling.mode Spell scaling mode as defined in `DND5E.spellScalingModes`.
- * @property {string} scaling.formula Dice formula used for scaling.
- */
- class SpellData extends SystemDataModel.mixin(
- ItemDescriptionTemplate, ActivatedEffectTemplate, ActionTemplate
- ) {
- /** @inheritdoc */
- static defineSchema() {
- return this.mergeSchema(super.defineSchema(), {
- level: new foundry.data.fields.NumberField({
- required: true, integer: true, initial: 1, min: 0, label: "DND5E.SpellLevel"
- }),
- school: new foundry.data.fields.StringField({required: true, label: "DND5E.SpellSchool"}),
- components: new MappingField(new foundry.data.fields.BooleanField(), {
- required: true, label: "DND5E.SpellComponents",
- initialKeys: [...Object.keys(CONFIG.DND5E.spellComponents), ...Object.keys(CONFIG.DND5E.spellTags)]
- }),
- materials: new foundry.data.fields.SchemaField({
- value: new foundry.data.fields.StringField({required: true, label: "DND5E.SpellMaterialsDescription"}),
- consumed: new foundry.data.fields.BooleanField({required: true, label: "DND5E.SpellMaterialsConsumed"}),
- cost: new foundry.data.fields.NumberField({
- required: true, initial: 0, min: 0, label: "DND5E.SpellMaterialsCost"
- }),
- supply: new foundry.data.fields.NumberField({
- required: true, initial: 0, min: 0, label: "DND5E.SpellMaterialsSupply"
- })
- }, {label: "DND5E.SpellMaterials"}),
- preparation: new foundry.data.fields.SchemaField({
- mode: new foundry.data.fields.StringField({
- required: true, initial: "prepared", label: "DND5E.SpellPreparationMode"
- }),
- prepared: new foundry.data.fields.BooleanField({required: true, label: "DND5E.SpellPrepared"})
- }, {label: "DND5E.SpellPreparation"}),
- scaling: new foundry.data.fields.SchemaField({
- mode: new foundry.data.fields.StringField({required: true, initial: "none", label: "DND5E.ScalingMode"}),
- formula: new FormulaField({required: true, nullable: true, initial: null, label: "DND5E.ScalingFormula"})
- }, {label: "DND5E.LevelScaling"})
- });
- }
-
- /* -------------------------------------------- */
- /* Migrations */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- super.migrateData(source);
- SpellData.#migrateComponentData(source);
- SpellData.#migrateScaling(source);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the spell's component object to remove any old, non-boolean values.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateComponentData(source) {
- if ( !source.components ) return;
- for ( const [key, value] of Object.entries(source.components) ) {
- if ( typeof value !== "boolean" ) delete source.components[key];
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate spell scaling.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateScaling(source) {
- if ( !("scaling" in source) ) return;
- if ( (source.scaling.mode === "") || (source.scaling.mode === null) ) source.scaling.mode = "none";
- }
-
- /* -------------------------------------------- */
- /* Getters */
- /* -------------------------------------------- */
-
- /**
- * Properties displayed in chat.
- * @type {string[]}
- */
- get chatProperties() {
- return [
- this.parent.labels.level,
- this.parent.labels.components.vsm + (this.parent.labels.materials ? ` (${this.parent.labels.materials})` : ""),
- ...this.parent.labels.components.tags
- ];
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get _typeAbilityMod() {
- return this.parent?.actor?.system.attributes.spellcasting || "int";
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get _typeCriticalThreshold() {
- return this.parent?.actor?.flags.dnd5e?.spellCriticalThreshold ?? Infinity;
- }
-
- }
-
- /**
- * Data definition for Subclass items.
- * @mixes ItemDescriptionTemplate
- *
- * @property {string} identifier Identifier slug for this subclass.
- * @property {string} classIdentifier Identifier slug for the class with which this subclass should be associated.
- * @property {object[]} advancement Advancement objects for this subclass.
- * @property {object} spellcasting Details on subclass's spellcasting ability.
- * @property {string} spellcasting.progression Spell progression granted by class as from `DND5E.spellProgression`.
- * @property {string} spellcasting.ability Ability score to use for spellcasting.
- */
- class SubclassData extends SystemDataModel.mixin(ItemDescriptionTemplate) {
- /** @inheritdoc */
- static defineSchema() {
- return this.mergeSchema(super.defineSchema(), {
- identifier: new IdentifierField({required: true, label: "DND5E.Identifier"}),
- classIdentifier: new IdentifierField({
- required: true, label: "DND5E.ClassIdentifier", hint: "DND5E.ClassIdentifierHint"
- }),
- advancement: new foundry.data.fields.ArrayField(new AdvancementField(), {label: "DND5E.AdvancementTitle"}),
- spellcasting: new foundry.data.fields.SchemaField({
- progression: new foundry.data.fields.StringField({
- required: true, initial: "none", blank: false, label: "DND5E.SpellProgression"
- }),
- ability: new foundry.data.fields.StringField({required: true, label: "DND5E.SpellAbility"})
- }, {label: "DND5E.Spellcasting"})
- });
- }
- }
-
- /**
- * Data definition for Tool items.
- * @mixes ItemDescriptionTemplate
- * @mixes PhysicalItemTemplate
- * @mixes EquippableItemTemplate
- *
- * @property {string} toolType Tool category as defined in `DND5E.toolTypes`.
- * @property {string} baseItem Base tool as defined in `DND5E.toolIds` for determining proficiency.
- * @property {string} ability Default ability when this tool is being used.
- * @property {string} chatFlavor Additional text added to chat when this tool is used.
- * @property {number} proficient Level of proficiency in this tool as defined in `DND5E.proficiencyLevels`.
- * @property {string} bonus Bonus formula added to tool rolls.
- */
- class ToolData extends SystemDataModel.mixin(
- ItemDescriptionTemplate, PhysicalItemTemplate, EquippableItemTemplate
- ) {
- /** @inheritdoc */
- static defineSchema() {
- return this.mergeSchema(super.defineSchema(), {
- toolType: new foundry.data.fields.StringField({required: true, label: "DND5E.ItemToolType"}),
- baseItem: new foundry.data.fields.StringField({required: true, label: "DND5E.ItemToolBase"}),
- ability: new foundry.data.fields.StringField({
- required: true, blank: true, label: "DND5E.DefaultAbilityCheck"
- }),
- chatFlavor: new foundry.data.fields.StringField({required: true, label: "DND5E.ChatFlavor"}),
- proficient: new foundry.data.fields.NumberField({
- required: true, nullable: false, initial: 0, min: 0, label: "DND5E.ItemToolProficiency"
- }),
- bonus: new FormulaField({required: true, label: "DND5E.ItemToolBonus"})
- });
- }
-
- /* -------------------------------------------- */
- /* Migrations */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- super.migrateData(source);
- ToolData.#migrateAbility(source);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the ability field.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateAbility(source) {
- if ( Array.isArray(source.ability) ) source.ability = source.ability[0];
- }
-
- /* -------------------------------------------- */
- /* Getters */
- /* -------------------------------------------- */
-
- /**
- * Properties displayed in chat.
- * @type {string[]}
- */
- get chatProperties() {
- return [CONFIG.DND5E.abilities[this.ability]?.label];
- }
-
- /* -------------------------------------------- */
-
- /**
- * Which ability score modifier is used by this item?
- * @type {string|null}
- */
- get abilityMod() {
- return this.ability || "int";
- }
- }
-
- /**
- * Data definition for Weapon items.
- * @mixes ItemDescriptionTemplate
- * @mixes PhysicalItemTemplate
- * @mixes EquippableItemTemplate
- * @mixes ActivatedEffectTemplate
- * @mixes ActionTemplate
- * @mixes MountableTemplate
- *
- * @property {string} weaponType Weapon category as defined in `DND5E.weaponTypes`.
- * @property {string} baseItem Base weapon as defined in `DND5E.weaponIds` for determining proficiency.
- * @property {object} properties Mapping of various weapon property booleans.
- * @property {boolean} proficient Does the weapon's owner have proficiency?
- */
- class WeaponData extends SystemDataModel.mixin(
- ItemDescriptionTemplate, PhysicalItemTemplate, EquippableItemTemplate,
- ActivatedEffectTemplate, ActionTemplate, MountableTemplate
- ) {
- /** @inheritdoc */
- static defineSchema() {
- return this.mergeSchema(super.defineSchema(), {
- weaponType: new foundry.data.fields.StringField({
- required: true, initial: "simpleM", label: "DND5E.ItemWeaponType"
- }),
- baseItem: new foundry.data.fields.StringField({required: true, blank: true, label: "DND5E.ItemWeaponBase"}),
- properties: new MappingField(new foundry.data.fields.BooleanField(), {
- required: true, initialKeys: CONFIG.DND5E.weaponProperties, label: "DND5E.ItemWeaponProperties"
- }),
- proficient: new foundry.data.fields.BooleanField({required: true, initial: true, label: "DND5E.Proficient"})
- });
- }
-
- /* -------------------------------------------- */
- /* Migrations */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- static migrateData(source) {
- super.migrateData(source);
- WeaponData.#migratePropertiesData(source);
- WeaponData.#migrateProficient(source);
- WeaponData.#migrateWeaponType(source);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the weapons's properties object to remove any old, non-boolean values.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migratePropertiesData(source) {
- if ( !source.properties ) return;
- for ( const [key, value] of Object.entries(source.properties) ) {
- if ( typeof value !== "boolean" ) delete source.properties[key];
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the proficient field to remove non-boolean values.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateProficient(source) {
- if ( typeof source.proficient === "number" ) source.proficient = Boolean(source.proficient);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the weapon type.
- * @param {object} source The candidate source data from which the model will be constructed.
- */
- static #migrateWeaponType(source) {
- if ( source.weaponType === null ) source.weaponType = "simpleM";
- }
-
- /* -------------------------------------------- */
- /* Getters */
- /* -------------------------------------------- */
-
- /**
- * Properties displayed in chat.
- * @type {string[]}
- */
- get chatProperties() {
- return [CONFIG.DND5E.weaponTypes[this.weaponType]];
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get _typeAbilityMod() {
- if ( ["simpleR", "martialR"].includes(this.weaponType) ) return "dex";
-
- const abilities = this.parent?.actor?.system.abilities;
- if ( this.properties.fin && abilities ) {
- return (abilities.dex?.mod ?? 0) >= (abilities.str?.mod ?? 0) ? "dex" : "str";
- }
-
- return null;
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- get _typeCriticalThreshold() {
- return this.parent?.actor?.flags.dnd5e?.weaponCriticalThreshold ?? Infinity;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Is this item a separate large object like a siege engine or vehicle component that is
- * usually mounted on fixtures rather than equipped, and has its own AC and HP?
- * @type {boolean}
- */
- get isMountable() {
- return this.weaponType === "siege";
- }
- }
-
- const config$1 = {
- background: BackgroundData,
- backpack: ContainerData,
- class: ClassData,
- consumable: ConsumableData,
- equipment: EquipmentData,
- feat: FeatData,
- loot: LootData,
- spell: SpellData,
- subclass: SubclassData,
- tool: ToolData,
- weapon: WeaponData
- };
-
- var _module$2 = /*#__PURE__*/Object.freeze({
- __proto__: null,
- ActionTemplate: ActionTemplate,
- ActivatedEffectTemplate: ActivatedEffectTemplate,
- BackgroundData: BackgroundData,
- ClassData: ClassData,
- ConsumableData: ConsumableData,
- ContainerData: ContainerData,
- EquipmentData: EquipmentData,
- EquippableItemTemplate: EquippableItemTemplate,
- FeatData: FeatData,
- ItemDescriptionTemplate: ItemDescriptionTemplate,
- LootData: LootData,
- MountableTemplate: MountableTemplate,
- PhysicalItemTemplate: PhysicalItemTemplate,
- SpellData: SpellData,
- SubclassData: SubclassData,
- ToolData: ToolData,
- WeaponData: WeaponData,
- config: config$1
- });
-
- /**
- * Data definition for Class Summary journal entry pages.
- *
- * @property {string} item UUID of the class item included.
- * @property {object} description
- * @property {string} description.value Introductory description for the class.
- * @property {string} description.additionalHitPoints Additional text displayed beneath the hit points section.
- * @property {string} description.additionalTraits Additional text displayed beneath the traits section.
- * @property {string} description.additionalEquipment Additional text displayed beneath the equipment section.
- * @property {string} description.subclass Introduction to the subclass section.
- * @property {string} subclassHeader Subclass header to replace the default.
- * @property {Set<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,
- 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 {{bar: string[], value: string[]}}
- */
- static getConsumedAttributes(data) {
- const attributes = super.getTrackedAttributes(data);
- attributes.value.push(...Object.keys(CONFIG.DND5E.currencies).map(denom => ["currency", denom]));
- const allowed = CONFIG.DND5E.consumableResources;
- attributes.value = attributes.value.filter(attrs => this._isAllowedAttribute(allowed, attrs));
- return attributes;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Traverse the configured allowed attributes to see if the provided one matches.
- * @param {object} allowed The allowed attributes structure.
- * @param {string[]} attrs The attributes list to test.
- * @returns {boolean} Whether the given attribute is allowed.
- * @private
- */
- static _isAllowedAttribute(allowed, attrs) {
- let allow = allowed;
- for ( const attr of attrs ) {
- if ( allow === undefined ) return false;
- if ( allow === true ) return true;
- if ( allow["*"] !== undefined ) allow = allow["*"];
- else allow = allow[attr];
- }
- return allow !== undefined;
- }
- }
-
- /**
- * Highlight critical success or failure on d20 rolls.
- * @param {ChatMessage} message Message being prepared.
- * @param {HTMLElement} html Rendered contents of the message.
- * @param {object} data Configuration data passed to the message.
- */
- function highlightCriticalSuccessFailure(message, html, data) {
- if ( !message.isRoll || !message.isContentVisible || !message.rolls.length ) return;
-
- // Highlight rolls where the first part is a d20 roll
- let d20Roll = message.rolls.find(r => {
- const d0 = r.dice[0];
- return (d0?.faces === 20) && (d0?.values.length === 1);
- });
- if ( !d20Roll ) return;
- d20Roll = dnd5e.dice.D20Roll.fromRoll(d20Roll);
- const d = d20Roll.dice[0];
-
- const isModifiedRoll = ("success" in d.results[0]) || d.options.marginSuccess || d.options.marginFailure;
- if ( isModifiedRoll ) return;
-
- // Highlight successes and failures
- if ( d20Roll.isCritical ) html.find(".dice-total").addClass("critical");
- else if ( d20Roll.isFumble ) html.find(".dice-total").addClass("fumble");
- else if ( d.options.target ) {
- if ( d20Roll.total >= d.options.target ) html.find(".dice-total").addClass("success");
- else html.find(".dice-total").addClass("failure");
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Optionally hide the display of chat card action buttons which cannot be performed by the user
- * @param {ChatMessage} message Message being prepared.
- * @param {HTMLElement} html Rendered contents of the message.
- * @param {object} data Configuration data passed to the message.
- */
- function displayChatActionButtons(message, html, data) {
- const chatCard = html.find(".dnd5e.chat-card");
- if ( chatCard.length > 0 ) {
- const flavor = html.find(".flavor-text");
- if ( flavor.text() === html.find(".item-name").text() ) flavor.remove();
-
- // If the user is the message author or the actor owner, proceed
- let actor = game.actors.get(data.message.speaker.actor);
- if ( actor && actor.isOwner ) return;
- else if ( game.user.isGM || (data.author.id === game.user.id)) return;
-
- // Otherwise conceal action buttons except for saving throw
- const buttons = chatCard.find("button[data-action]");
- buttons.each((i, btn) => {
- if ( btn.dataset.action === "save" ) return;
- btn.style.display = "none";
- });
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * This function is used to hook into the Chat Log context menu to add additional options to each message
- * These options make it easy to conveniently apply damage to controlled tokens based on the value of a Roll
- *
- * @param {HTMLElement} html The Chat Message being rendered
- * @param {object[]} options The Array of Context Menu options
- *
- * @returns {object[]} The extended options Array including new context choices
- */
- function addChatMessageContextOptions(html, options) {
- let canApply = li => {
- const message = game.messages.get(li.data("messageId"));
- return message?.isRoll && message?.isContentVisible && canvas.tokens?.controlled.length;
- };
- options.push(
- {
- name: game.i18n.localize("DND5E.ChatContextDamage"),
- icon: '<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 => (m.name === macroData.name) && (m.command === macroData.command)
- && m.author.isSelf) || await Macro.create(macroData);
- game.user.assignHotbarMacro(macro, slot);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Find a document of the specified name and type on an assigned or selected actor.
- * @param {string} name Document name to locate.
- * @param {string} documentType Type of embedded document (e.g. "Item" or "ActiveEffect").
- * @returns {Document} Document if found, otherwise nothing.
- */
- function getMacroTarget(name, documentType) {
- let actor;
- const speaker = ChatMessage.getSpeaker();
- if ( speaker.token ) actor = game.actors.tokens[speaker.token];
- actor ??= game.actors.get(speaker.actor);
- if ( !actor ) return ui.notifications.warn(game.i18n.localize("MACRO.5eNoActorSelected"));
-
- const collection = (documentType === "Item") ? actor.items : actor.effects;
- const nameKeyPath = (documentType === "Item") ? "name" : "label";
-
- // Find item in collection
- const documents = collection.filter(i => foundry.utils.getProperty(i, nameKeyPath) === name);
- const type = game.i18n.localize(`DOCUMENT.${documentType}`);
- if ( documents.length === 0 ) {
- return ui.notifications.warn(game.i18n.format("MACRO.5eMissingTargetWarn", { actor: actor.name, type, name }));
- }
- if ( documents.length > 1 ) {
- ui.notifications.warn(game.i18n.format("MACRO.5eMultipleTargetsWarn", { actor: actor.name, type, name }));
- }
- return documents[0];
- }
-
- /* -------------------------------------------- */
-
- /**
- * Trigger an item to roll when a macro is clicked.
- * @param {string} itemName Name of the item on the selected actor to trigger.
- * @returns {Promise<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$a,
- chat: chatMessage,
- combat: combat,
- macro: macro
- });
-
- /**
- * Perform a system migration for the entire World, applying migrations for Actors, Items, and Compendium packs
- * @returns {Promise} A Promise which resolves once the migration is completed
- */
- const migrateWorld = async function() {
- const version = game.system.version;
- ui.notifications.info(game.i18n.format("MIGRATION.5eBegin", {version}), {permanent: true});
-
- const migrationData = await getMigrationData();
-
- // Migrate World Actors
- const actors = game.actors.map(a => [a, true])
- .concat(Array.from(game.actors.invalidDocumentIds).map(id => [game.actors.getInvalid(id), false]));
- for ( const [actor, valid] of actors ) {
- try {
- const source = valid ? actor.toObject() : game.data.actors.find(a => a._id === actor.id);
- const updateData = migrateActorData(source, migrationData);
- if ( !foundry.utils.isEmpty(updateData) ) {
- console.log(`Migrating Actor document ${actor.name}`);
- await actor.update(updateData, {enforceTypes: false, diff: valid});
- }
- } catch(err) {
- err.message = `Failed dnd5e system migration for Actor ${actor.name}: ${err.message}`;
- console.error(err);
- }
- }
-
- // Migrate World Items
- const items = game.items.map(i => [i, true])
- .concat(Array.from(game.items.invalidDocumentIds).map(id => [game.items.getInvalid(id), false]));
- for ( const [item, valid] of items ) {
- try {
- const source = valid ? item.toObject() : game.data.items.find(i => i._id === item.id);
- const updateData = migrateItemData(source, migrationData);
- if ( !foundry.utils.isEmpty(updateData) ) {
- console.log(`Migrating Item document ${item.name}`);
- await item.update(updateData, {enforceTypes: false, diff: valid});
- }
- } catch(err) {
- err.message = `Failed dnd5e system migration for Item ${item.name}: ${err.message}`;
- console.error(err);
- }
- }
-
- // Migrate World Macros
- for ( const m of game.macros ) {
- try {
- const updateData = migrateMacroData(m.toObject(), migrationData);
- if ( !foundry.utils.isEmpty(updateData) ) {
- console.log(`Migrating Macro document ${m.name}`);
- await m.update(updateData, {enforceTypes: false});
- }
- } catch(err) {
- err.message = `Failed dnd5e system migration for Macro ${m.name}: ${err.message}`;
- console.error(err);
- }
- }
-
- // Migrate Actor Override Tokens
- for ( let s of game.scenes ) {
- try {
- const updateData = migrateSceneData(s, migrationData);
- if ( !foundry.utils.isEmpty(updateData) ) {
- console.log(`Migrating Scene document ${s.name}`);
- await s.update(updateData, {enforceTypes: false});
- // If we do not do this, then synthetic token actors remain in cache
- // with the un-updated actorData.
- s.tokens.forEach(t => t._actor = null);
- }
- } catch(err) {
- err.message = `Failed dnd5e system migration for Scene ${s.name}: ${err.message}`;
- console.error(err);
- }
- }
-
- // Migrate World Compendium Packs
- for ( let p of game.packs ) {
- if ( p.metadata.packageType !== "world" ) continue;
- if ( !["Actor", "Item", "Scene"].includes(p.documentName) ) continue;
- await migrateCompendium(p);
- }
-
- // Set the migration as complete
- game.settings.set("dnd5e", "systemMigrationVersion", game.system.version);
- ui.notifications.info(game.i18n.format("MIGRATION.5eComplete", {version}), {permanent: true});
- };
-
- /* -------------------------------------------- */
-
- /**
- * Apply migration rules to all Documents within a single Compendium pack
- * @param {CompendiumCollection} pack Pack to be migrated.
- * @returns {Promise}
- */
- const migrateCompendium = async function(pack) {
- const documentName = pack.documentName;
- if ( !["Actor", "Item", "Scene"].includes(documentName) ) return;
-
- const migrationData = await getMigrationData();
-
- // Unlock the pack for editing
- const wasLocked = pack.locked;
- await pack.configure({locked: false});
-
- // Begin by requesting server-side data model migration and get the migrated content
- await pack.migrate();
- const documents = await pack.getDocuments();
-
- // Iterate over compendium entries - applying fine-tuned migration functions
- for ( let doc of documents ) {
- let updateData = {};
- try {
- switch (documentName) {
- case "Actor":
- updateData = migrateActorData(doc.toObject(), migrationData);
- break;
- case "Item":
- updateData = migrateItemData(doc.toObject(), migrationData);
- break;
- case "Scene":
- updateData = migrateSceneData(doc.toObject(), migrationData);
- break;
- }
-
- // Save the entry, if data was changed
- if ( foundry.utils.isEmpty(updateData) ) continue;
- await doc.update(updateData);
- console.log(`Migrated ${documentName} document ${doc.name} in Compendium ${pack.collection}`);
- }
-
- // Handle migration failures
- catch(err) {
- err.message = `Failed dnd5e system migration for document ${doc.name} in pack ${pack.collection}: ${err.message}`;
- console.error(err);
- }
- }
-
- // Apply the original locked status for the pack
- await pack.configure({locked: wasLocked});
- console.log(`Migrated all ${documentName} documents from Compendium ${pack.collection}`);
- };
-
- /* -------------------------------------------- */
-
- /**
- * Update all compendium packs using the new system data model.
- */
- async function refreshAllCompendiums() {
- for ( const pack of game.packs ) {
- await refreshCompendium(pack);
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Update all Documents in a compendium using the new system data model.
- * @param {CompendiumCollection} pack Pack to refresh.
- */
- async function refreshCompendium(pack) {
- if ( !pack?.documentName ) return;
- dnd5e.moduleArt.suppressArt = true;
- const DocumentClass = CONFIG[pack.documentName].documentClass;
- const wasLocked = pack.locked;
- await pack.configure({locked: false});
- await pack.migrate();
-
- ui.notifications.info(`Beginning to refresh Compendium ${pack.collection}`);
- const documents = await pack.getDocuments();
- for ( const doc of documents ) {
- const data = doc.toObject();
- await doc.delete();
- await DocumentClass.create(data, {keepId: true, keepEmbeddedIds: true, pack: pack.collection});
- }
- await pack.configure({locked: wasLocked});
- dnd5e.moduleArt.suppressArt = false;
- ui.notifications.info(`Refreshed all documents from Compendium ${pack.collection}`);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Apply 'smart' AC migration to a given Actor compendium. This will perform the normal AC migration but additionally
- * check to see if the actor has armor already equipped, and opt to use that instead.
- * @param {CompendiumCollection|string} pack Pack or name of pack to migrate.
- * @returns {Promise}
- */
- const migrateArmorClass = async function(pack) {
- if ( typeof pack === "string" ) pack = game.packs.get(pack);
- if ( pack.documentName !== "Actor" ) return;
- const wasLocked = pack.locked;
- await pack.configure({locked: false});
- const actors = await pack.getDocuments();
- const updates = [];
- const armor = new Set(Object.keys(CONFIG.DND5E.armorTypes));
-
- for ( const actor of actors ) {
- try {
- console.log(`Migrating ${actor.name}...`);
- const src = actor.toObject();
- const update = {_id: actor.id};
-
- // Perform the normal migration.
- _migrateActorAC(src, update);
- // TODO: See if AC migration within DataModel is enough to handle this
- updates.push(update);
-
- // CASE 1: Armor is equipped
- const hasArmorEquipped = actor.itemTypes.equipment.some(e => {
- return armor.has(e.system.armor?.type) && e.system.equipped;
- });
- if ( hasArmorEquipped ) update["system.attributes.ac.calc"] = "default";
-
- // CASE 2: NPC Natural Armor
- else if ( src.type === "npc" ) update["system.attributes.ac.calc"] = "natural";
- } catch(e) {
- console.warn(`Failed to migrate armor class for Actor ${actor.name}`, e);
- }
- }
-
- await Actor.implementation.updateDocuments(updates, {pack: pack.collection});
- await pack.getDocuments(); // Force a re-prepare of all actors.
- await pack.configure({locked: wasLocked});
- console.log(`Migrated the AC of all Actors from Compendium ${pack.collection}`);
- };
-
- /* -------------------------------------------- */
- /* Document Type Migration Helpers */
- /* -------------------------------------------- */
-
- /**
- * Migrate a single Actor document to incorporate latest data model changes
- * Return an Object of updateData to be applied
- * @param {object} actor The actor data object to update
- * @param {object} [migrationData] Additional data to perform the migration
- * @returns {object} The updateData to apply
- */
- const migrateActorData = function(actor, migrationData) {
- const updateData = {};
- _migrateTokenImage(actor, updateData);
- _migrateActorAC(actor, updateData);
-
- // Migrate embedded effects
- if ( actor.effects ) {
- const effects = migrateEffects(actor, migrationData);
- if ( effects.length > 0 ) updateData.effects = effects;
- }
-
- // Migrate Owned Items
- if ( !actor.items ) return updateData;
- const items = actor.items.reduce((arr, i) => {
- // Migrate the Owned Item
- const itemData = i instanceof CONFIG.Item.documentClass ? i.toObject() : i;
- let itemUpdate = migrateItemData(itemData, migrationData);
-
- // Prepared, Equipped, and Proficient for NPC actors
- if ( actor.type === "npc" ) {
- if (foundry.utils.getProperty(itemData.system, "preparation.prepared") === false) itemUpdate["system.preparation.prepared"] = true;
- if (foundry.utils.getProperty(itemData.system, "equipped") === false) itemUpdate["system.equipped"] = true;
- if (foundry.utils.getProperty(itemData.system, "proficient") === false) itemUpdate["system.proficient"] = true;
- }
-
- // Update the Owned Item
- if ( !foundry.utils.isEmpty(itemUpdate) ) {
- itemUpdate._id = itemData._id;
- arr.push(foundry.utils.expandObject(itemUpdate));
- }
-
- // Update tool expertise.
- if ( actor.system.tools ) {
- const hasToolProf = itemData.system.baseItem in actor.system.tools;
- if ( (itemData.type === "tool") && (itemData.system.proficient > 1) && hasToolProf ) {
- updateData[`system.tools.${itemData.system.baseItem}.value`] = itemData.system.proficient;
- }
- }
-
- return arr;
- }, []);
- if ( items.length > 0 ) updateData.items = items;
-
- return updateData;
- };
-
- /* -------------------------------------------- */
-
- /**
- * Migrate a single Item document to incorporate latest data model changes
- *
- * @param {object} item Item data to migrate
- * @param {object} [migrationData] Additional data to perform the migration
- * @returns {object} The updateData to apply
- */
- function migrateItemData(item, migrationData) {
- const updateData = {};
- _migrateDocumentIcon(item, updateData, migrationData);
-
- // Migrate embedded effects
- if ( item.effects ) {
- const effects = migrateEffects(item, migrationData);
- if ( effects.length > 0 ) updateData.effects = effects;
- }
-
- return updateData;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Migrate any active effects attached to the provided parent.
- * @param {object} parent Data of the parent being migrated.
- * @param {object} [migrationData] Additional data to perform the migration.
- * @returns {object[]} Updates to apply on the embedded effects.
- */
- const migrateEffects = function(parent, migrationData) {
- if ( !parent.effects ) return {};
- return parent.effects.reduce((arr, e) => {
- const effectData = e instanceof CONFIG.ActiveEffect.documentClass ? e.toObject() : e;
- let effectUpdate = migrateEffectData(effectData, migrationData);
- if ( !foundry.utils.isEmpty(effectUpdate) ) {
- effectUpdate._id = effectData._id;
- arr.push(foundry.utils.expandObject(effectUpdate));
- }
- return arr;
- }, []);
- };
-
- /* -------------------------------------------- */
-
- /**
- * Migrate the provided active effect data.
- * @param {object} effect Effect data to migrate.
- * @param {object} [migrationData] Additional data to perform the migration.
- * @returns {object} The updateData to apply.
- */
- const migrateEffectData = function(effect, migrationData) {
- const updateData = {};
- _migrateDocumentIcon(effect, updateData, {...migrationData, field: "icon"});
- _migrateEffectArmorClass(effect, updateData);
- return updateData;
- };
-
- /* -------------------------------------------- */
-
- /**
- * Migrate a single Macro document to incorporate latest data model changes.
- * @param {object} macro Macro data to migrate
- * @param {object} [migrationData] Additional data to perform the migration
- * @returns {object} The updateData to apply
- */
- const migrateMacroData = function(macro, migrationData) {
- const updateData = {};
- _migrateDocumentIcon(macro, updateData, migrationData);
- _migrateMacroCommands(macro, updateData);
- return updateData;
- };
-
- /* -------------------------------------------- */
-
- /**
- * Migrate a single Scene document to incorporate changes to the data model of it's actor data overrides
- * Return an Object of updateData to be applied
- * @param {object} scene The Scene data to Update
- * @param {object} [migrationData] Additional data to perform the migration
- * @returns {object} The updateData to apply
- */
- const migrateSceneData = function(scene, migrationData) {
- const tokens = scene.tokens.map(token => {
- const t = token instanceof foundry.abstract.DataModel ? token.toObject() : token;
- const update = {};
- _migrateTokenImage(t, update);
- if ( Object.keys(update).length ) foundry.utils.mergeObject(t, update);
- if ( !game.actors.has(t.actorId) ) t.actorId = null;
- if ( !t.actorId || t.actorLink ) t.actorData = {};
- else if ( !t.actorLink ) {
- const actorData = token.delta?.toObject() ?? foundry.utils.deepClone(t.actorData);
- actorData.type = token.actor?.type;
- const update = migrateActorData(actorData, migrationData);
- if ( game.dnd5e.isV10 ) {
- ["items", "effects"].forEach(embeddedName => {
- if ( !update[embeddedName]?.length ) return;
- const updates = new Map(update[embeddedName].map(u => [u._id, u]));
- t.actorData[embeddedName].forEach(original => {
- const update = updates.get(original._id);
- if ( update ) foundry.utils.mergeObject(original, update);
- });
- delete update[embeddedName];
- });
- foundry.utils.mergeObject(t.actorData, update);
- }
- else t.delta = update;
- }
- return t;
- });
- return {tokens};
- };
-
- /* -------------------------------------------- */
-
- /**
- * Fetch bundled data for large-scale migrations.
- * @returns {Promise<object>} 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,
- migrateSceneData: migrateSceneData,
- migrateWorld: migrateWorld,
- purgeFlags: purgeFlags,
- refreshAllCompendiums: refreshAllCompendiums,
- refreshCompendium: refreshCompendium
- });
-
- /**
- * The DnD5e game system for Foundry Virtual Tabletop
- * A system for playing the fifth edition of the world's most popular role-playing game.
- * Author: Atropos
- * Software License: MIT
- * Content License: https://www.dndbeyond.com/attachments/39j2li89/SRD5.1-CCBY4.0License.pdf
- * Repository: https://github.com/foundryvtt/dnd5e
- * Issue Tracker: https://github.com/foundryvtt/dnd5e/issues
- */
-
- /* -------------------------------------------- */
- /* Define Module Structure */
- /* -------------------------------------------- */
-
- globalThis.dnd5e = {
- applications,
- canvas: canvas$1,
- config: DND5E,
- dataModels,
- dice,
- documents,
- migrations,
- utils
- };
-
- /* -------------------------------------------- */
- /* Foundry VTT Initialization */
- /* -------------------------------------------- */
-
- Hooks.once("init", function() {
- globalThis.dnd5e = game.dnd5e = Object.assign(game.system, globalThis.dnd5e);
- console.log(`DnD5e | Initializing the DnD5e Game System - Version ${dnd5e.version}\n${DND5E.ASCII}`);
-
- // Record Configuration Values
- CONFIG.DND5E = DND5E;
- CONFIG.ActiveEffect.documentClass = ActiveEffect5e;
- CONFIG.Actor.documentClass = Actor5e;
- CONFIG.Item.documentClass = Item5e;
- CONFIG.Token.documentClass = TokenDocument5e;
- CONFIG.Token.objectClass = Token5e;
- CONFIG.time.roundTime = 6;
- CONFIG.Dice.DamageRoll = DamageRoll;
- CONFIG.Dice.D20Roll = D20Roll;
- CONFIG.MeasuredTemplate.defaults.angle = 53.13; // 5e cone RAW should be 53.13 degrees
- CONFIG.ui.combat = CombatTracker5e;
- CONFIG.compatibility.excludePatterns.push(/\bActiveEffect5e#label\b/); // backwards compatibility with v10
- game.dnd5e.isV10 = game.release.generation < 11;
-
- // Configure trackable attributes.
- _configureTrackableAttributes();
-
- // Register System Settings
- registerSystemSettings();
-
- // Validation strictness.
- if ( game.dnd5e.isV10 ) _determineValidationStrictness();
-
- // Configure module art.
- game.dnd5e.moduleArt = new ModuleArt();
-
- // Remove honor & sanity from configuration if they aren't enabled
- if ( !game.settings.get("dnd5e", "honorScore") ) delete DND5E.abilities.hon;
- if ( !game.settings.get("dnd5e", "sanityScore") ) delete DND5E.abilities.san;
-
- // Patch Core Functions
- Combatant.prototype.getInitiativeRoll = getInitiativeRoll;
-
- // Register Roll Extensions
- CONFIG.Dice.rolls.push(D20Roll);
- CONFIG.Dice.rolls.push(DamageRoll);
-
- // Hook up system data types
- const modelType = game.dnd5e.isV10 ? "systemDataModels" : "dataModels";
- CONFIG.Actor[modelType] = config$2;
- CONFIG.Item[modelType] = config$1;
- CONFIG.JournalEntryPage[modelType] = config;
-
- // Register sheet application classes
- Actors.unregisterSheet("core", ActorSheet);
- Actors.registerSheet("dnd5e", ActorSheet5eCharacter, {
- types: ["character"],
- makeDefault: true,
- label: "DND5E.SheetClassCharacter"
- });
- Actors.registerSheet("dnd5e", ActorSheet5eNPC, {
- types: ["npc"],
- makeDefault: true,
- label: "DND5E.SheetClassNPC"
- });
- Actors.registerSheet("dnd5e", ActorSheet5eVehicle, {
- types: ["vehicle"],
- makeDefault: true,
- label: "DND5E.SheetClassVehicle"
- });
- Actors.registerSheet("dnd5e", GroupActorSheet, {
- types: ["group"],
- makeDefault: true,
- label: "DND5E.SheetClassGroup"
- });
-
- Items.unregisterSheet("core", ItemSheet);
- Items.registerSheet("dnd5e", ItemSheet5e, {
- makeDefault: true,
- label: "DND5E.SheetClassItem"
- });
- DocumentSheetConfig.registerSheet(JournalEntryPage, "dnd5e", JournalClassPageSheet, {
- label: "DND5E.SheetClassClassSummary",
- types: ["class"]
- });
-
- // Preload Handlebars helpers & partials
- registerHandlebarsHelpers();
- preloadHandlebarsTemplates();
- });
-
- /**
- * Determine if this is a 'legacy' world with permissive validation, or one where strict validation is enabled.
- * @internal
- */
- function _determineValidationStrictness() {
- SystemDataModel._enableV10Validation = game.settings.get("dnd5e", "strictValidation");
- }
-
- /**
- * Update the world's validation strictness setting based on whether validation errors were encountered.
- * @internal
- */
- async function _configureValidationStrictness() {
- if ( !game.user.isGM ) return;
- const invalidDocuments = game.actors.invalidDocumentIds.size + game.items.invalidDocumentIds.size
- + game.scenes.invalidDocumentIds.size;
- const strictValidation = game.settings.get("dnd5e", "strictValidation");
- if ( invalidDocuments && strictValidation ) {
- await game.settings.set("dnd5e", "strictValidation", false);
- game.socket.emit("reload");
- foundry.utils.debouncedReload();
- }
- }
-
- /**
- * Configure explicit lists of attributes that are trackable on the token HUD and in the combat tracker.
- * @internal
- */
- function _configureTrackableAttributes() {
- const common = {
- bar: [],
- value: [
- ...Object.keys(DND5E.abilities).map(ability => `abilities.${ability}.value`),
- ...Object.keys(DND5E.movementTypes).map(movement => `attributes.movement.${movement}`),
- "attributes.ac.value", "attributes.init.total"
- ]
- };
-
- const creature = {
- bar: [...common.bar, "attributes.hp"],
- value: [
- ...common.value,
- ...Object.keys(DND5E.skills).map(skill => `skills.${skill}.passive`),
- ...Object.keys(DND5E.senses).map(sense => `attributes.senses.${sense}`),
- "attributes.spelldc"
- ]
- };
-
- CONFIG.Actor.trackableAttributes = {
- character: {
- bar: [...creature.bar, "resources.primary", "resources.secondary", "resources.tertiary", "details.xp"],
- value: [...creature.value]
- },
- npc: {
- bar: [...creature.bar, "resources.legact", "resources.legres"],
- value: [...creature.value, "details.cr", "details.spellLevel", "details.xp.value"]
- },
- vehicle: {
- bar: [...common.bar, "attributes.hp"],
- value: [...common.value]
- },
- group: {
- bar: [],
- value: []
- }
- };
- }
-
- /* -------------------------------------------- */
- /* Foundry VTT Setup */
- /* -------------------------------------------- */
-
- /**
- * Prepare attribute lists.
- */
- Hooks.once("setup", function() {
- CONFIG.DND5E.trackableAttributes = expandAttributeList(CONFIG.DND5E.trackableAttributes);
- CONFIG.DND5E.consumableResources = expandAttributeList(CONFIG.DND5E.consumableResources);
- game.dnd5e.moduleArt.registerModuleArt();
-
- // Apply custom compendium styles to the SRD rules compendium.
- if ( !game.dnd5e.isV10 ) {
- const rules = game.packs.get("dnd5e.rules");
- rules.applicationClass = SRDCompendium;
- }
- });
-
- /* --------------------------------------------- */
-
- /**
- * Expand a list of attribute paths into an object that can be traversed.
- * @param {string[]} attributes The initial attributes configuration.
- * @returns {object} The expanded object structure.
- */
- function expandAttributeList(attributes) {
- return attributes.reduce((obj, attr) => {
- foundry.utils.setProperty(obj, attr, true);
- return obj;
- }, {});
- }
-
- /* --------------------------------------------- */
-
- /**
- * Perform one-time pre-localization and sorting of some configuration objects
- */
- Hooks.once("i18nInit", () => performPreLocalization(CONFIG.DND5E));
-
- /* -------------------------------------------- */
- /* Foundry VTT Ready */
- /* -------------------------------------------- */
-
- /**
- * Once the entire VTT framework is initialized, check to see if we should perform a data migration
- */
- Hooks.once("ready", function() {
- if ( game.dnd5e.isV10 ) {
- // Configure validation strictness.
- _configureValidationStrictness();
-
- // Apply custom compendium styles to the SRD rules compendium.
- const rules = game.packs.get("dnd5e.rules");
- rules.apps = [new SRDCompendium(rules)];
- }
-
- // Wait to register hotbar drop hook on ready so that modules could register earlier if they want to
- Hooks.on("hotbarDrop", (bar, data, slot) => {
- if ( ["Item", "ActiveEffect"].includes(data.type) ) {
- create5eMacro(data, slot);
- return false;
- }
- });
-
- // Determine whether a system migration is required and feasible
- if ( !game.user.isGM ) return;
- const cv = game.settings.get("dnd5e", "systemMigrationVersion") || game.world.flags.dnd5e?.version;
- const totalDocuments = game.actors.size + game.scenes.size + game.items.size;
- if ( !cv && totalDocuments === 0 ) return game.settings.set("dnd5e", "systemMigrationVersion", game.system.version);
- if ( cv && !isNewerVersion(game.system.flags.needsMigrationVersion, cv) ) return;
-
- // Perform the migration
- if ( cv && isNewerVersion(game.system.flags.compatibleMigrationVersion, cv) ) {
- ui.notifications.error(game.i18n.localize("MIGRATION.5eVersionTooOldWarning"), {permanent: true});
- }
- migrateWorld();
- });
-
- /* -------------------------------------------- */
- /* Canvas Initialization */
- /* -------------------------------------------- */
-
- Hooks.on("canvasInit", gameCanvas => {
- gameCanvas.grid.diagonalRule = game.settings.get("dnd5e", "diagonalMovement");
- SquareGrid.prototype.measureDistances = measureDistances;
- });
-
- /* -------------------------------------------- */
- /* Other Hooks */
- /* -------------------------------------------- */
-
- Hooks.on("renderChatMessage", onRenderChatMessage);
- Hooks.on("getChatLogEntryContext", addChatMessageContextOptions);
-
- Hooks.on("renderChatLog", (app, html, data) => Item5e.chatListeners(html));
- Hooks.on("renderChatPopout", (app, html, data) => Item5e.chatListeners(html));
- Hooks.on("getActorDirectoryEntryContext", Actor5e.addDirectoryContextOptions);
-
- export { DND5E, applications, canvas$1 as canvas, dataModels, dice, documents, migrations, utils };
- //# sourceMappingURL=dnd5e-compiled.mjs.map
|